diff --git a/.gitignore b/.gitignore
index 290e6e6..5336d20 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
# Build Artifacts
obj
bin
+artifacts
# Visual Studio
.vs
diff --git a/Directory.Build.props b/Directory.Build.props
index a21f0bc..6c2102b 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -8,6 +8,16 @@
true
11
enable
+
+ true
+
+ $(NoWarn);WTG1005
+
+ direct
4.0.0.0
@@ -20,7 +30,6 @@
true
AllEnabledByDefault
- $(MSBuildThisFileDirectory)bin
@@ -28,4 +37,13 @@
Properties\stylecop.json
-
\ No newline at end of file
+
+
+
+
+
+
+
diff --git a/src/BulkAnalysisRunner.sln b/src/BulkAnalysisRunner.sln
index 5b7d410..ec7d1eb 100644
--- a/src/BulkAnalysisRunner.sln
+++ b/src/BulkAnalysisRunner.sln
@@ -1,36 +1,78 @@
-
-Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 17
-VisualStudioVersion = 17.0.31912.275
-MinimumVisualStudioVersion = 15.0.26124.0
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WTG.BulkAnalysis.Runner", "WTG.BulkAnalysis.Runner\WTG.BulkAnalysis.Runner.csproj", "{CC915472-4531-448B-8D01-9FD9D036D451}"
-EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WTG.BulkAnalysis.Core", "WTG.BulkAnalysis.Core\WTG.BulkAnalysis.Core.csproj", "{EF81C8B4-A10D-4606-801E-D3DD02E1BFBE}"
-EndProject
-Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WTG.BulkAnalysis.Test", "WTG.BulkAnalysis.Test\WTG.BulkAnalysis.Test.csproj", "{F440B628-0C1E-4C5C-9BDF-EA0D62219678}"
-EndProject
-Global
- GlobalSection(SolutionConfigurationPlatforms) = preSolution
- Debug|Any CPU = Debug|Any CPU
- Release|Any CPU = Release|Any CPU
- EndGlobalSection
- GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {CC915472-4531-448B-8D01-9FD9D036D451}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {CC915472-4531-448B-8D01-9FD9D036D451}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {CC915472-4531-448B-8D01-9FD9D036D451}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {CC915472-4531-448B-8D01-9FD9D036D451}.Release|Any CPU.Build.0 = Release|Any CPU
- {EF81C8B4-A10D-4606-801E-D3DD02E1BFBE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {EF81C8B4-A10D-4606-801E-D3DD02E1BFBE}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {EF81C8B4-A10D-4606-801E-D3DD02E1BFBE}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {EF81C8B4-A10D-4606-801E-D3DD02E1BFBE}.Release|Any CPU.Build.0 = Release|Any CPU
- {F440B628-0C1E-4C5C-9BDF-EA0D62219678}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {F440B628-0C1E-4C5C-9BDF-EA0D62219678}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {F440B628-0C1E-4C5C-9BDF-EA0D62219678}.Release|Any CPU.ActiveCfg = Release|Any CPU
- EndGlobalSection
- GlobalSection(SolutionProperties) = preSolution
- HideSolutionNode = FALSE
- EndGlobalSection
- GlobalSection(ExtensibilityGlobals) = postSolution
- SolutionGuid = {B22E70E0-1827-42B3-9A87-68C964733344}
- EndGlobalSection
-EndGlobal
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31912.275
+MinimumVisualStudioVersion = 15.0.26124.0
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WTG.BulkAnalysis.Runner", "WTG.BulkAnalysis.Runner\WTG.BulkAnalysis.Runner.csproj", "{CC915472-4531-448B-8D01-9FD9D036D451}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WTG.BulkAnalysis.Core", "WTG.BulkAnalysis.Core\WTG.BulkAnalysis.Core.csproj", "{EF81C8B4-A10D-4606-801E-D3DD02E1BFBE}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WTG.BulkAnalysis.Test", "WTG.BulkAnalysis.Test\WTG.BulkAnalysis.Test.csproj", "{F440B628-0C1E-4C5C-9BDF-EA0D62219678}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WTG.BulkAnalysis.Mcp", "WTG.BulkAnalysis.Mcp\WTG.BulkAnalysis.Mcp.csproj", "{E6CC7782-A8F0-41AE-A9F9-3BF4563D30C0}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {CC915472-4531-448B-8D01-9FD9D036D451}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {CC915472-4531-448B-8D01-9FD9D036D451}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {CC915472-4531-448B-8D01-9FD9D036D451}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {CC915472-4531-448B-8D01-9FD9D036D451}.Debug|x64.Build.0 = Debug|Any CPU
+ {CC915472-4531-448B-8D01-9FD9D036D451}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {CC915472-4531-448B-8D01-9FD9D036D451}.Debug|x86.Build.0 = Debug|Any CPU
+ {CC915472-4531-448B-8D01-9FD9D036D451}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {CC915472-4531-448B-8D01-9FD9D036D451}.Release|Any CPU.Build.0 = Release|Any CPU
+ {CC915472-4531-448B-8D01-9FD9D036D451}.Release|x64.ActiveCfg = Release|Any CPU
+ {CC915472-4531-448B-8D01-9FD9D036D451}.Release|x64.Build.0 = Release|Any CPU
+ {CC915472-4531-448B-8D01-9FD9D036D451}.Release|x86.ActiveCfg = Release|Any CPU
+ {CC915472-4531-448B-8D01-9FD9D036D451}.Release|x86.Build.0 = Release|Any CPU
+ {EF81C8B4-A10D-4606-801E-D3DD02E1BFBE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {EF81C8B4-A10D-4606-801E-D3DD02E1BFBE}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {EF81C8B4-A10D-4606-801E-D3DD02E1BFBE}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {EF81C8B4-A10D-4606-801E-D3DD02E1BFBE}.Debug|x64.Build.0 = Debug|Any CPU
+ {EF81C8B4-A10D-4606-801E-D3DD02E1BFBE}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {EF81C8B4-A10D-4606-801E-D3DD02E1BFBE}.Debug|x86.Build.0 = Debug|Any CPU
+ {EF81C8B4-A10D-4606-801E-D3DD02E1BFBE}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {EF81C8B4-A10D-4606-801E-D3DD02E1BFBE}.Release|Any CPU.Build.0 = Release|Any CPU
+ {EF81C8B4-A10D-4606-801E-D3DD02E1BFBE}.Release|x64.ActiveCfg = Release|Any CPU
+ {EF81C8B4-A10D-4606-801E-D3DD02E1BFBE}.Release|x64.Build.0 = Release|Any CPU
+ {EF81C8B4-A10D-4606-801E-D3DD02E1BFBE}.Release|x86.ActiveCfg = Release|Any CPU
+ {EF81C8B4-A10D-4606-801E-D3DD02E1BFBE}.Release|x86.Build.0 = Release|Any CPU
+ {F440B628-0C1E-4C5C-9BDF-EA0D62219678}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F440B628-0C1E-4C5C-9BDF-EA0D62219678}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F440B628-0C1E-4C5C-9BDF-EA0D62219678}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {F440B628-0C1E-4C5C-9BDF-EA0D62219678}.Debug|x64.Build.0 = Debug|Any CPU
+ {F440B628-0C1E-4C5C-9BDF-EA0D62219678}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {F440B628-0C1E-4C5C-9BDF-EA0D62219678}.Debug|x86.Build.0 = Debug|Any CPU
+ {F440B628-0C1E-4C5C-9BDF-EA0D62219678}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F440B628-0C1E-4C5C-9BDF-EA0D62219678}.Release|x64.ActiveCfg = Release|Any CPU
+ {F440B628-0C1E-4C5C-9BDF-EA0D62219678}.Release|x64.Build.0 = Release|Any CPU
+ {F440B628-0C1E-4C5C-9BDF-EA0D62219678}.Release|x86.ActiveCfg = Release|Any CPU
+ {F440B628-0C1E-4C5C-9BDF-EA0D62219678}.Release|x86.Build.0 = Release|Any CPU
+ {E6CC7782-A8F0-41AE-A9F9-3BF4563D30C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E6CC7782-A8F0-41AE-A9F9-3BF4563D30C0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E6CC7782-A8F0-41AE-A9F9-3BF4563D30C0}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {E6CC7782-A8F0-41AE-A9F9-3BF4563D30C0}.Debug|x64.Build.0 = Debug|Any CPU
+ {E6CC7782-A8F0-41AE-A9F9-3BF4563D30C0}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {E6CC7782-A8F0-41AE-A9F9-3BF4563D30C0}.Debug|x86.Build.0 = Debug|Any CPU
+ {E6CC7782-A8F0-41AE-A9F9-3BF4563D30C0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E6CC7782-A8F0-41AE-A9F9-3BF4563D30C0}.Release|Any CPU.Build.0 = Release|Any CPU
+ {E6CC7782-A8F0-41AE-A9F9-3BF4563D30C0}.Release|x64.ActiveCfg = Release|Any CPU
+ {E6CC7782-A8F0-41AE-A9F9-3BF4563D30C0}.Release|x64.Build.0 = Release|Any CPU
+ {E6CC7782-A8F0-41AE-A9F9-3BF4563D30C0}.Release|x86.ActiveCfg = Release|Any CPU
+ {E6CC7782-A8F0-41AE-A9F9-3BF4563D30C0}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {B22E70E0-1827-42B3-9A87-68C964733344}
+ EndGlobalSection
+EndGlobal
diff --git a/src/WTG.BulkAnalysis.Core/AnalysisSession.cs b/src/WTG.BulkAnalysis.Core/AnalysisSession.cs
new file mode 100644
index 0000000..4a9784b
--- /dev/null
+++ b/src/WTG.BulkAnalysis.Core/AnalysisSession.cs
@@ -0,0 +1,270 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.MSBuild;
+
+namespace WTG.BulkAnalysis.Core
+{
+ ///
+ /// A stateful, in-memory analysis session over a single solution or project. The workspace and
+ /// analyzers are loaded once (an expensive operation) and reused across analyze/fix calls.
+ /// Callers must register an MSBuild instance (e.g. via MSBuildLocator) before opening a session.
+ ///
+ public sealed class AnalysisSession : IDisposable
+ {
+ AnalysisSession(MSBuildWorkspace workspace, AnalyzerCache cache, AnalysisSessionOptions options, ILog log)
+ {
+ this.workspace = workspace;
+ this.cache = cache;
+ this.options = options;
+ this.log = log;
+ }
+
+ public string TargetPath => options.Path;
+
+ public ImmutableArray ProjectNames => workspace.CurrentSolution.Projects
+ .Where(p => p.Language == LanguageNames.CSharp)
+ .Select(p => p.Name)
+ .OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
+ .ToImmutableArray();
+
+ public static async Task OpenAsync(AnalysisSessionOptions options, ILog log, CancellationToken cancellationToken)
+ {
+ if (options == null)
+ {
+ throw new ArgumentNullException(nameof(options));
+ }
+
+ if (log == null)
+ {
+ throw new ArgumentNullException(nameof(log));
+ }
+
+ var properties = new Dictionary()
+ {
+ { "Configuration", options.Configuration },
+ };
+
+ var workspace = MSBuildWorkspace.Create(properties);
+
+ try
+ {
+ await LoadAsync(workspace, options.Path, log, cancellationToken).ConfigureAwait(false);
+ Processor.ConfigureWorkspace(workspace);
+
+ var cache = AnalyzerCache.CreateForDiscovery(options.LoadDir ?? string.Empty, options.LoadList);
+ return new AnalysisSession(workspace, cache, options, log);
+ }
+ catch
+ {
+ workspace.Dispose();
+ throw;
+ }
+ }
+
+ ///
+ /// Runs every loaded analyzer over the C# projects and returns the resulting diagnostics.
+ /// When is null all diagnostics are returned (discovery);
+ /// otherwise the result is limited to the given rule ids.
+ ///
+ public async Task> AnalyzeAsync(ImmutableHashSet? ruleIds, CancellationToken cancellationToken)
+ {
+ await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ return await AnalyzeCoreAsync(workspace.CurrentSolution, ruleIds, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ gate.Release();
+ }
+ }
+
+ ///
+ /// Applies fix-all operations for the requested rule ids until no further changes are made,
+ /// writing the results to disk, and reports what changed and what remains.
+ ///
+ public async Task ApplyFixesAsync(ImmutableHashSet ruleIds, CancellationToken cancellationToken)
+ {
+ if (ruleIds == null || ruleIds.Count == 0)
+ {
+ throw new ArgumentException("At least one rule id is required.", nameof(ruleIds));
+ }
+
+ await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ var before = workspace.CurrentSolution;
+
+ // Reuse the proven CLI fix loop (analyze -> fix-all -> repeat until stable), scoped to the requested rules.
+ var context = new RunContext(
+ ImmutableArray.Empty,
+ applyFixes: true,
+ ruleIds,
+ options.LoadDir ?? string.Empty,
+ options.LoadList,
+ log,
+ reporter: null,
+ options.Configuration,
+ debug: false,
+ cancellationToken);
+
+ var processor = new SolutionProcessor(context, cache, workspace);
+ var operationsApplied = await processor.ProcessSolutionAsync().ConfigureAwait(false);
+
+ var after = workspace.CurrentSolution;
+ var changedFiles = GetChangedFiles(before, after);
+
+ var remaining = await AnalyzeCoreAsync(after, ruleIds, cancellationToken).ConfigureAwait(false);
+ var remainingByRule = remaining
+ .GroupBy(d => d.Id)
+ .ToImmutableDictionary(g => g.Key, g => g.Count());
+
+ return new FixResult(operationsApplied, changedFiles, remainingByRule);
+ }
+ finally
+ {
+ gate.Release();
+ }
+ }
+
+ async Task> AnalyzeCoreAsync(Solution solution, ImmutableHashSet? ruleIds, CancellationToken cancellationToken)
+ {
+ var builder = ImmutableArray.CreateBuilder();
+
+ foreach (var project in solution.Projects)
+ {
+ if (project.Language != LanguageNames.CSharp)
+ {
+ continue;
+ }
+
+ var analyzers = cache.GetAnalyzers(project);
+
+ if (analyzers.Length == 0)
+ {
+ continue;
+ }
+
+ var compilation = await project.GetCompilationAsync(cancellationToken).ConfigureAwait(false);
+
+ if (compilation == null)
+ {
+ continue;
+ }
+
+ var providers = cache.GetAllCodeFixProviders(project);
+
+ // Pass the project's AnalyzerOptions so the analyzer driver honours the project's
+ // .editorconfig and global analyzer config (severities and suppressions) and sees its
+ // AdditionalFiles, matching what the build would report.
+ var diagnostics = await compilation
+ .WithAnalyzers(analyzers, CreateAnalyzerOptions(project))
+ .GetAnalyzerDiagnosticsAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ foreach (var diagnostic in diagnostics)
+ {
+ if (ruleIds != null && !ruleIds.Contains(diagnostic.Id))
+ {
+ continue;
+ }
+
+ builder.Add(DiagnosticInfo.Create(diagnostic, project.Name, providers.ContainsKey(diagnostic.Id)));
+ }
+ }
+
+ return builder.ToImmutable();
+ }
+
+ static async Task LoadAsync(MSBuildWorkspace workspace, string path, ILog log, CancellationToken cancellationToken)
+ {
+ try
+ {
+ if (path.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase))
+ {
+ await workspace.OpenProjectAsync(path, cancellationToken: cancellationToken).ConfigureAwait(false);
+ }
+ else if (path.EndsWith(".sln", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".slnx", StringComparison.OrdinalIgnoreCase))
+ {
+ await workspace.OpenSolutionAsync(path, cancellationToken: cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ throw new InvalidConfigurationException("Unsupported target '" + path + "'. Expected a .sln, .slnx or .csproj path.");
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (InvalidConfigurationException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ throw new WorkspaceLoadException("Error loading '" + path + "'.", ex);
+ }
+
+ foreach (var diagnostic in workspace.Diagnostics)
+ {
+ switch (diagnostic.Kind)
+ {
+ case WorkspaceDiagnosticKind.Failure:
+ log.WriteLine(diagnostic.Message, LogLevel.Error);
+ break;
+
+ case WorkspaceDiagnosticKind.Warning:
+ log.WriteLine(diagnostic.Message, LogLevel.Warning);
+ break;
+ }
+ }
+ }
+
+ static ImmutableArray GetChangedFiles(Solution before, Solution after)
+ {
+ var files = new SortedSet(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var projectChange in after.GetChanges(before).GetProjectChanges())
+ {
+ foreach (var documentId in projectChange.GetChangedDocuments().Concat(projectChange.GetAddedDocuments()))
+ {
+ var path = after.GetDocument(documentId)?.FilePath;
+
+ if (!string.IsNullOrEmpty(path))
+ {
+ files.Add(path!);
+ }
+ }
+ }
+
+ return files.ToImmutableArray();
+ }
+
+ public void Dispose()
+ {
+ workspace.Dispose();
+ gate.Dispose();
+ }
+
+ readonly MSBuildWorkspace workspace;
+ readonly AnalyzerCache cache;
+ readonly AnalysisSessionOptions options;
+ readonly ILog log;
+ readonly SemaphoreSlim gate = new SemaphoreSlim(1, 1);
+
+ static CompilationWithAnalyzersOptions CreateAnalyzerOptions(Project project) => new CompilationWithAnalyzersOptions(
+ project.AnalyzerOptions,
+ onAnalyzerException: null,
+ concurrentAnalysis: true,
+ logAnalyzerExecutionTime: false);
+ }
+}
diff --git a/src/WTG.BulkAnalysis.Core/AnalysisSessionOptions.cs b/src/WTG.BulkAnalysis.Core/AnalysisSessionOptions.cs
new file mode 100644
index 0000000..0ea617c
--- /dev/null
+++ b/src/WTG.BulkAnalysis.Core/AnalysisSessionOptions.cs
@@ -0,0 +1,31 @@
+using System.Collections.Immutable;
+
+namespace WTG.BulkAnalysis.Core
+{
+ public sealed class AnalysisSessionOptions
+ {
+ public AnalysisSessionOptions(
+ string path,
+ string configuration,
+ string? loadDir,
+ ImmutableArray loadList)
+ {
+ Path = path;
+ Configuration = configuration;
+ LoadDir = loadDir;
+ LoadList = loadList.IsDefault ? ImmutableArray.Empty : loadList;
+ }
+
+ /// Path to the solution (.sln/.slnx) or project (.csproj) to open.
+ public string Path { get; }
+
+ /// The build configuration to load, e.g. "Debug" or "Release".
+ public string Configuration { get; }
+
+ /// Optional directory to load analyzer/code-fix assemblies from instead of the project's references.
+ public string? LoadDir { get; }
+
+ /// Optional explicit list of analyzer assemblies to load. When empty, the project's references are used.
+ public ImmutableArray LoadList { get; }
+ }
+}
diff --git a/src/WTG.BulkAnalysis.Core/AnalyzerAssemblyResolver.cs b/src/WTG.BulkAnalysis.Core/AnalyzerAssemblyResolver.cs
new file mode 100644
index 0000000..b840685
--- /dev/null
+++ b/src/WTG.BulkAnalysis.Core/AnalyzerAssemblyResolver.cs
@@ -0,0 +1,49 @@
+using System;
+using System.IO;
+using System.Reflection;
+
+namespace WTG.BulkAnalysis.Core
+{
+ ///
+ /// Analyzer assemblies are loaded with , which does not probe
+ /// for an assembly's dependencies. This resolves them from the requesting assembly's own directory so
+ /// analyzers that ship a separate dependency (e.g. a *.Utils.dll) can run instead of throwing at
+ /// execution time (which surfaces as AD0001). Call once at host start-up.
+ ///
+ public static class AnalyzerAssemblyResolver
+ {
+ public static void Register()
+ {
+ if (registered)
+ {
+ return;
+ }
+
+ registered = true;
+ AppDomain.CurrentDomain.AssemblyResolve += OnAssemblyResolve;
+ }
+
+ static Assembly? OnAssemblyResolve(object? sender, ResolveEventArgs args)
+ {
+ var requester = args.RequestingAssembly;
+
+ if (requester == null || string.IsNullOrEmpty(requester.Location))
+ {
+ return null;
+ }
+
+ var directory = Path.GetDirectoryName(requester.Location);
+ var name = new AssemblyName(args.Name).Name;
+
+ if (directory == null || name == null)
+ {
+ return null;
+ }
+
+ var candidate = Path.Combine(directory, name + ".dll");
+ return File.Exists(candidate) ? Assembly.LoadFile(candidate) : null;
+ }
+
+ static bool registered;
+ }
+}
diff --git a/src/WTG.BulkAnalysis.Core/CodeFixException.cs b/src/WTG.BulkAnalysis.Core/CodeFixException.cs
index 0914773..afaf390 100644
--- a/src/WTG.BulkAnalysis.Core/CodeFixException.cs
+++ b/src/WTG.BulkAnalysis.Core/CodeFixException.cs
@@ -1,5 +1,7 @@
using System;
+#if NETFRAMEWORK
using System.Runtime.Serialization;
+#endif
namespace WTG.BulkAnalysis.Core
{
@@ -20,9 +22,11 @@ public CodeFixException(string message, Exception innerException)
{
}
+#if NETFRAMEWORK
protected CodeFixException(SerializationInfo serializationInfo, StreamingContext streamingContext)
: base(serializationInfo, streamingContext)
{
}
+#endif
}
}
diff --git a/src/WTG.BulkAnalysis.Core/DiagnosticInfo.cs b/src/WTG.BulkAnalysis.Core/DiagnosticInfo.cs
new file mode 100644
index 0000000..819130a
--- /dev/null
+++ b/src/WTG.BulkAnalysis.Core/DiagnosticInfo.cs
@@ -0,0 +1,77 @@
+using System.Globalization;
+using Microsoft.CodeAnalysis;
+
+namespace WTG.BulkAnalysis.Core
+{
+ public sealed class DiagnosticInfo
+ {
+ public DiagnosticInfo(
+ string id,
+ string title,
+ string message,
+ string severity,
+ string category,
+ string projectName,
+ string? filePath,
+ int startLine,
+ int startColumn,
+ int endLine,
+ int endColumn,
+ bool hasCodeFix)
+ {
+ Id = id;
+ Title = title;
+ Message = message;
+ Severity = severity;
+ Category = category;
+ ProjectName = projectName;
+ FilePath = filePath;
+ StartLine = startLine;
+ StartColumn = startColumn;
+ EndLine = endLine;
+ EndColumn = endColumn;
+ HasCodeFix = hasCodeFix;
+ }
+
+ public string Id { get; }
+ public string Title { get; }
+ public string Message { get; }
+ public string Severity { get; }
+ public string Category { get; }
+ public string ProjectName { get; }
+
+ /// The source file the diagnostic points at, or null for project-level diagnostics.
+ public string? FilePath { get; }
+
+ // Positions are 1-based for human/agent friendliness (Roslyn reports 0-based), or 0 when not in source.
+ public int StartLine { get; }
+ public int StartColumn { get; }
+ public int EndLine { get; }
+ public int EndColumn { get; }
+
+ /// Whether at least one loaded code fix provider can fix this diagnostic's rule.
+ public bool HasCodeFix { get; }
+
+ public static DiagnosticInfo Create(Diagnostic diagnostic, string projectName, bool hasCodeFix)
+ {
+ var inSource = diagnostic.Location.IsInSource;
+ var span = diagnostic.Location.GetLineSpan();
+ var start = span.StartLinePosition;
+ var end = span.EndLinePosition;
+
+ return new DiagnosticInfo(
+ diagnostic.Id,
+ diagnostic.Descriptor.Title.ToString(CultureInfo.CurrentCulture),
+ diagnostic.GetMessage(CultureInfo.CurrentCulture),
+ diagnostic.Severity.ToString(),
+ diagnostic.Descriptor.Category,
+ projectName,
+ inSource ? span.Path : null,
+ inSource ? start.Line + 1 : 0,
+ inSource ? start.Character + 1 : 0,
+ inSource ? end.Line + 1 : 0,
+ inSource ? end.Character + 1 : 0,
+ hasCodeFix);
+ }
+ }
+}
diff --git a/src/WTG.BulkAnalysis.Core/FixResult.cs b/src/WTG.BulkAnalysis.Core/FixResult.cs
new file mode 100644
index 0000000..60f1135
--- /dev/null
+++ b/src/WTG.BulkAnalysis.Core/FixResult.cs
@@ -0,0 +1,26 @@
+using System.Collections.Immutable;
+
+namespace WTG.BulkAnalysis.Core
+{
+ public sealed class FixResult
+ {
+ public FixResult(
+ int operationsApplied,
+ ImmutableArray changedFiles,
+ ImmutableDictionary remainingByRule)
+ {
+ OperationsApplied = operationsApplied;
+ ChangedFiles = changedFiles;
+ RemainingByRule = remainingByRule;
+ }
+
+ /// Number of fix-all operations applied to the workspace (and written to disk).
+ public int OperationsApplied { get; }
+
+ /// Absolute paths of the source files that were changed on disk.
+ public ImmutableArray ChangedFiles { get; }
+
+ /// For each requested rule id, the number of diagnostics still remaining after fixing.
+ public ImmutableDictionary RemainingByRule { get; }
+ }
+}
diff --git a/src/WTG.BulkAnalysis.Core/Internal/AnalyzerCache.cs b/src/WTG.BulkAnalysis.Core/Internal/AnalyzerCache.cs
index 7afe424..5c237b3 100644
--- a/src/WTG.BulkAnalysis.Core/Internal/AnalyzerCache.cs
+++ b/src/WTG.BulkAnalysis.Core/Internal/AnalyzerCache.cs
@@ -14,14 +14,20 @@ namespace WTG.BulkAnalysis.Core
abstract class AnalyzerCache
{
public static AnalyzerCache Create(ImmutableHashSet diagnosticIds, string loadDir, ImmutableArray loadList)
+ => Create(diagnosticIds, loadDir, loadList, discoverAll: false);
+
+ public static AnalyzerCache CreateForDiscovery(string loadDir, ImmutableArray loadList)
+ => Create(ImmutableHashSet.Empty, loadDir, loadList, discoverAll: true);
+
+ static AnalyzerCache Create(ImmutableHashSet diagnosticIds, string loadDir, ImmutableArray loadList, bool discoverAll)
{
if (loadList.Length > 0)
{
- return new Explicit(diagnosticIds, loadDir, loadList);
+ return new Explicit(diagnosticIds, loadDir, loadList, discoverAll);
}
else
{
- return new Implicit(diagnosticIds, loadDir);
+ return new Implicit(diagnosticIds, loadDir, discoverAll);
}
}
@@ -32,27 +38,55 @@ static IEnumerable Get(string assemblyPath, Predicate filter)
{
var assembly = Assembly.LoadFile(assemblyPath);
- foreach (var type in assembly.GetTypes())
+ // Discovery loads every referenced analyzer assembly; some may be built against a different
+ // Roslyn version and fail to fully load. Use the types that did load and skip the rest.
+ Type?[] types;
+
+ try
+ {
+ types = assembly.GetTypes();
+ }
+ catch (ReflectionTypeLoadException ex)
+ {
+ types = ex.Types;
+ }
+
+ var result = new List();
+
+ foreach (var type in types)
{
- if (!type.IsAbstract && type.IsSubclassOf(typeof(T)))
+ if (type == null || type.IsAbstract || !type.IsSubclassOf(typeof(T)))
+ {
+ continue;
+ }
+
+ T instance;
+
+ try
+ {
+ instance = (T)Activator.CreateInstance(type)!;
+ }
+ catch (Exception)
{
- var instance = (T)Activator.CreateInstance(type);
+ continue;
+ }
- if (filter(instance))
- {
- yield return instance;
- }
+ if (filter(instance))
+ {
+ result.Add(instance);
}
}
+
+ return result;
}
sealed class Implicit : AnalyzerCache
{
- public Implicit(ImmutableHashSet diagnosticIds, string loadDir)
+ public Implicit(ImmutableHashSet diagnosticIds, string loadDir, bool discoverAll)
{
this.loadDir = loadDir;
- analyzerFilter = a => a.SupportedDiagnostics.Any(x => diagnosticIds.Contains(x.Id));
- providerFilter = p => p.FixableDiagnosticIds.Any(diagnosticIds.Contains);
+ analyzerFilter = a => discoverAll || a.SupportedDiagnostics.Any(x => diagnosticIds.Contains(x.Id));
+ providerFilter = p => discoverAll || p.FixableDiagnosticIds.Any(diagnosticIds.Contains);
analyzerLookup = new ConcurrentDictionary>();
providerLookup = new ConcurrentDictionary>();
}
@@ -142,10 +176,10 @@ static IEnumerable Remap(IEnumerable source, string loadDir)
sealed class Explicit : AnalyzerCache
{
- public Explicit(ImmutableHashSet diagnosticIds, string loadDir, ImmutableArray loadList)
+ public Explicit(ImmutableHashSet diagnosticIds, string loadDir, ImmutableArray loadList, bool discoverAll)
{
- Predicate analyzerFilter = a => a.SupportedDiagnostics.Any(x => diagnosticIds.Contains(x.Id));
- Predicate providerFilter = p => p.FixableDiagnosticIds.Any(diagnosticIds.Contains);
+ Predicate analyzerFilter = a => discoverAll || a.SupportedDiagnostics.Any(x => diagnosticIds.Contains(x.Id));
+ Predicate providerFilter = p => discoverAll || p.FixableDiagnosticIds.Any(diagnosticIds.Contains);
var paths = PrefixPaths(loadDir, loadList);
@@ -155,7 +189,7 @@ public Explicit(ImmutableHashSet diagnosticIds, string loadDir, Immutabl
from path in paths
from provider in Get(path, providerFilter)
from id in provider.FixableDiagnosticIds
- where diagnosticIds.Contains(id)
+ where discoverAll || diagnosticIds.Contains(id)
group provider by id into g
select g,
x => x.Key,
diff --git a/src/WTG.BulkAnalysis.Core/Internal/SolutionProcessor.cs b/src/WTG.BulkAnalysis.Core/Internal/SolutionProcessor.cs
index 21246d7..2fd213e 100644
--- a/src/WTG.BulkAnalysis.Core/Internal/SolutionProcessor.cs
+++ b/src/WTG.BulkAnalysis.Core/Internal/SolutionProcessor.cs
@@ -18,7 +18,7 @@ public SolutionProcessor(RunContext context, AnalyzerCache cache, Workspace work
this.workspace = workspace;
}
- public async Task ProcessSolutionAsync()
+ public async Task ProcessSolutionAsync()
{
var operationsCounter = 0;
var numPreviousDiagnostics = 0;
@@ -67,6 +67,8 @@ public async Task ProcessSolutionAsync()
{
context.Log.WriteFormatted($" - Applied {operationsCounter} fix-all operations to resolve errors.", LogLevel.Info);
}
+
+ return operationsCounter;
}
async Task ApplyFixesAsync(Solution solution, ImmutableDictionary> diagnostics)
@@ -176,10 +178,16 @@ async Task> GetProjectAnalyzerDiagnosticsAsync(Projec
return ImmutableArray.Empty;
}
+ // Pass the project's AnalyzerOptions so editorconfig / global analyzer config severities and
+ // suppressions (and AdditionalFiles) are honoured, matching the build's effective results.
var compilationWithAnalyzers = compilation
.WithAnalyzers(
analyzers,
- EmptyCompilationWithAnalyzersOptions);
+ new CompilationWithAnalyzersOptions(
+ project.AnalyzerOptions,
+ onAnalyzerException: null,
+ concurrentAnalysis: true,
+ logAnalyzerExecutionTime: false));
var diagnostics = await compilationWithAnalyzers
.GetAllDiagnosticsAsync(context.CancellationToken)
@@ -224,12 +232,6 @@ IEnumerable GetApplicableFixProviders(Project project, Immutabl
static readonly ImmutableList EmptyCodeFixProviderList = ImmutableList.Create();
- static readonly CompilationWithAnalyzersOptions EmptyCompilationWithAnalyzersOptions = new CompilationWithAnalyzersOptions(
- new AnalyzerOptions(ImmutableArray.Create()),
- null,
- true,
- false);
-
readonly RunContext context;
readonly AnalyzerCache cache;
readonly Workspace workspace;
diff --git a/src/WTG.BulkAnalysis.Core/InvalidConfigurationException.cs b/src/WTG.BulkAnalysis.Core/InvalidConfigurationException.cs
index 98554c1..5d6ac20 100644
--- a/src/WTG.BulkAnalysis.Core/InvalidConfigurationException.cs
+++ b/src/WTG.BulkAnalysis.Core/InvalidConfigurationException.cs
@@ -1,5 +1,7 @@
using System;
+#if NETFRAMEWORK
using System.Runtime.Serialization;
+#endif
namespace WTG.BulkAnalysis.Core
{
@@ -20,9 +22,11 @@ public InvalidConfigurationException(string message, Exception innerException)
{
}
+#if NETFRAMEWORK
protected InvalidConfigurationException(SerializationInfo info, StreamingContext context)
: base(info, context)
{
}
+#endif
}
}
diff --git a/src/WTG.BulkAnalysis.Core/Processor.cs b/src/WTG.BulkAnalysis.Core/Processor.cs
index 1db9220..c03e58d 100644
--- a/src/WTG.BulkAnalysis.Core/Processor.cs
+++ b/src/WTG.BulkAnalysis.Core/Processor.cs
@@ -117,7 +117,7 @@ static async Task LoadSolutionIntoNewWorkspace(RunContext context, st
}
}
- static void ConfigureWorkspace(Workspace workspace)
+ internal static void ConfigureWorkspace(Workspace workspace)
{
Solution solution;
diff --git a/src/WTG.BulkAnalysis.Core/SolutionLocator.cs b/src/WTG.BulkAnalysis.Core/SolutionLocator.cs
index de2bf11..0576a10 100644
--- a/src/WTG.BulkAnalysis.Core/SolutionLocator.cs
+++ b/src/WTG.BulkAnalysis.Core/SolutionLocator.cs
@@ -48,12 +48,14 @@ where IsPathPrefix(solutionPath, pathToBranch)
throw new InvalidConfigurationException("Directory does not exist, '" + pathToBranch + "'.");
}
- pathToBranch = Path.GetDirectoryName(pathToBranch);
+ var parent = Path.GetDirectoryName(pathToBranch);
- if (pathToBranch == null)
+ if (parent == null)
{
throw new InvalidConfigurationException("Build.xml could be found.");
}
+
+ pathToBranch = parent;
}
}
@@ -77,6 +79,7 @@ static IEnumerable Load(string path)
throw new InvalidConfigurationException("Error reading '" + path + "': Invalid root element.");
}
+ // Build.xml stores Windows-style separators; normalise so paths resolve on any platform.
return
from solutionsElement in root.Elements()
where solutionsElement.Name.LocalName == "Solutions"
@@ -84,7 +87,7 @@ from solutionElement in solutionsElement.Elements()
where solutionElement.Name.LocalName == "Solution"
let filename = solutionElement.Attribute("Filename")?.Value
where filename != null
- select filename;
+ select filename.Replace('\\', Path.DirectorySeparatorChar);
}
static bool IsPathPrefix(string path, string prefix)
diff --git a/src/WTG.BulkAnalysis.Core/WTG.BulkAnalysis.Core.csproj b/src/WTG.BulkAnalysis.Core/WTG.BulkAnalysis.Core.csproj
index dd2f9a5..19934b4 100644
--- a/src/WTG.BulkAnalysis.Core/WTG.BulkAnalysis.Core.csproj
+++ b/src/WTG.BulkAnalysis.Core/WTG.BulkAnalysis.Core.csproj
@@ -1,16 +1,20 @@
- net472
+ net472;net10.0
-
+
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/src/WTG.BulkAnalysis.Core/WorkspaceLoadException.cs b/src/WTG.BulkAnalysis.Core/WorkspaceLoadException.cs
index 211df7c..9045f2b 100644
--- a/src/WTG.BulkAnalysis.Core/WorkspaceLoadException.cs
+++ b/src/WTG.BulkAnalysis.Core/WorkspaceLoadException.cs
@@ -1,5 +1,7 @@
using System;
+#if NETFRAMEWORK
using System.Runtime.Serialization;
+#endif
namespace WTG.BulkAnalysis.Core
{
@@ -20,9 +22,11 @@ public WorkspaceLoadException(string message, Exception innerException)
{
}
+#if NETFRAMEWORK
protected WorkspaceLoadException(SerializationInfo serializationInfo, StreamingContext streamingContext)
: base(serializationInfo, streamingContext)
{
}
+#endif
}
}
diff --git a/src/WTG.BulkAnalysis.Mcp/AnalysisTools.cs b/src/WTG.BulkAnalysis.Mcp/AnalysisTools.cs
new file mode 100644
index 0000000..e928c5a
--- /dev/null
+++ b/src/WTG.BulkAnalysis.Mcp/AnalysisTools.cs
@@ -0,0 +1,250 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.ComponentModel;
+using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using ModelContextProtocol.Server;
+using WTG.BulkAnalysis.Core;
+
+namespace WTG.BulkAnalysis.Mcp
+{
+ [McpServerToolType]
+ public static class AnalysisTools
+ {
+ [McpServerTool(Name = "open_analysis")]
+ [Description("Open a solution or project for analysis and keep it loaded in memory for subsequent calls. " +
+ "Accepts a .sln/.slnx/.csproj path, or a directory/branch containing a Build.xml to discover solutions. " +
+ "Returns a sessionId used by the other tools.")]
+ public static async Task OpenAnalysisAsync(
+ SessionManager sessions,
+ ILoggerFactory loggerFactory,
+ [Description("Path to a .sln/.slnx/.csproj file, or a directory/branch containing a Build.xml.")] string path,
+ [Description("Build configuration to load (e.g. Debug or Release).")] string configuration = "Debug",
+ [Description("Optional directory to load analyzer/code-fix assemblies from instead of the project's references.")] string? loadDir = null,
+ [Description("Optional ';'-separated list of analyzer assemblies to load.")] string? load = null,
+ [Description("Optional regex to filter discovered solutions when a directory/branch is given.")] string? filter = null,
+ CancellationToken cancellationToken = default)
+ {
+ var full = Path.GetFullPath(path);
+ string resolved;
+
+ if (IsProjectOrSolutionFile(full))
+ {
+ resolved = full;
+ }
+ else
+ {
+ var found = SolutionLocator.Locate(full, BuildFilter(filter));
+
+ if (found.Length == 0)
+ {
+ return new OpenResult(null, path, Array.Empty(), null, "No solutions were found. Provide a .sln/.csproj path, or a directory containing a Build.xml.");
+ }
+
+ if (found.Length > 1)
+ {
+ return new OpenResult(null, path, Array.Empty(), found, "Multiple solutions were found. Re-run with a specific .sln path or a 'filter' regex.");
+ }
+
+ resolved = found[0];
+ }
+
+ var loadList = string.IsNullOrWhiteSpace(load)
+ ? ImmutableArray.Empty
+ : load.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToImmutableArray();
+
+ var options = new AnalysisSessionOptions(resolved, configuration, loadDir, loadList);
+ var log = new McpLog(loggerFactory.CreateLogger("WTG.BulkAnalysis"));
+
+ var session = await AnalysisSession.OpenAsync(options, log, cancellationToken).ConfigureAwait(false);
+ var projects = session.ProjectNames;
+ var id = sessions.Add(session);
+
+ return new OpenResult(id, resolved, projects, null, $"Opened with {projects.Length} C# project(s).");
+ }
+
+ [McpServerTool(Name = "list_diagnostics")]
+ [Description("Run all analyzers and return a grouped summary of the diagnostics. Each group reports its count " +
+ "and whether a code fix provider is available (hasCodeFix), so you can tell what is bulk-fixable. " +
+ "By default only Warning and Error are returned (what a build surfaces); pass minSeverity to include " +
+ "lower-severity (Info/Hidden) suggestions.")]
+ public static async Task ListDiagnosticsAsync(
+ SessionManager sessions,
+ [Description("Session id from open_analysis.")] string sessionId,
+ [Description("Optional ';'-separated rule ids to limit the results to.")] string? ruleIds = null,
+ [Description("Minimum severity: Hidden, Info, Warning or Error. Defaults to Warning (build-actionable).")] string minSeverity = "Warning",
+ [Description("How to group results: rule (default), severity, project or fixability.")] string groupBy = "rule",
+ CancellationToken cancellationToken = default)
+ {
+ var session = Get(sessions, sessionId);
+ var diagnostics = await session.AnalyzeAsync(ParseIds(ruleIds), cancellationToken).ConfigureAwait(false);
+ var filtered = ApplyMinSeverity(diagnostics, minSeverity);
+
+ var groups = GroupDiagnostics(filtered, groupBy);
+ return new ListDiagnosticsResult(filtered.Length, groupBy, groups);
+ }
+
+ [McpServerTool(Name = "get_diagnostics")]
+ [Description("List the individual occurrences (file, line, column, message) for a single rule id, paged.")]
+ public static async Task GetDiagnosticsAsync(
+ SessionManager sessions,
+ [Description("Session id from open_analysis.")] string sessionId,
+ [Description("The rule id to list occurrences for.")] string ruleId,
+ [Description("Number of occurrences to skip (for paging).")] int skip = 0,
+ [Description("Maximum number of occurrences to return.")] int take = 50,
+ CancellationToken cancellationToken = default)
+ {
+ var session = Get(sessions, sessionId);
+ var ids = ImmutableHashSet.Create(StringComparer.Ordinal, ruleId);
+ var diagnostics = await session.AnalyzeAsync(ids, cancellationToken).ConfigureAwait(false);
+
+ var occurrences = diagnostics
+ .OrderBy(d => d.FilePath, StringComparer.OrdinalIgnoreCase)
+ .ThenBy(d => d.StartLine)
+ .ThenBy(d => d.StartColumn)
+ .Skip(Math.Max(0, skip))
+ .Take(Math.Max(0, take))
+ .Select(d => new DiagnosticOccurrence(d.Id, d.Severity, d.Message, d.ProjectName, d.FilePath, d.StartLine, d.StartColumn, d.EndLine, d.EndColumn, d.HasCodeFix))
+ .ToArray();
+
+ return new GetDiagnosticsResult(ruleId, diagnostics.Length, skip, take, occurrences);
+ }
+
+ [McpServerTool(Name = "apply_fixes")]
+ [Description("Apply fix-all code fixes for the given rule ids until no further changes are made, writing the " +
+ "results to disk. Only rules that have a code fix provider can be fixed. Returns what changed and what remains.")]
+ public static async Task ApplyFixesAsync(
+ SessionManager sessions,
+ [Description("Session id from open_analysis.")] string sessionId,
+ [Description("';'-separated rule ids to fix.")] string ruleIds,
+ CancellationToken cancellationToken = default)
+ {
+ var session = Get(sessions, sessionId);
+ var ids = ParseIds(ruleIds);
+
+ if (ids == null || ids.Count == 0)
+ {
+ throw new ArgumentException("At least one rule id is required.", nameof(ruleIds));
+ }
+
+ var result = await session.ApplyFixesAsync(ids, cancellationToken).ConfigureAwait(false);
+ return new ApplyFixesResult(result.OperationsApplied, result.ChangedFiles, result.RemainingByRule);
+ }
+
+ [McpServerTool(Name = "close_analysis")]
+ [Description("Close an analysis session and release its workspace from memory.")]
+ public static CloseResult CloseAnalysis(
+ SessionManager sessions,
+ [Description("Session id from open_analysis.")] string sessionId)
+ {
+ if (sessions.Remove(sessionId, out var session))
+ {
+ session.Dispose();
+ return new CloseResult(true, "Session closed.");
+ }
+
+ return new CloseResult(false, $"No open session with id '{sessionId}'.");
+ }
+
+ static AnalysisSession Get(SessionManager sessions, string sessionId)
+ {
+ if (!sessions.TryGet(sessionId, out var session))
+ {
+ throw new ArgumentException($"No open session with id '{sessionId}'. Call open_analysis first.", nameof(sessionId));
+ }
+
+ return session;
+ }
+
+ static ImmutableHashSet? ParseIds(string? ruleIds)
+ {
+ if (string.IsNullOrWhiteSpace(ruleIds))
+ {
+ return null;
+ }
+
+ return ruleIds
+ .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .ToImmutableHashSet(StringComparer.Ordinal);
+ }
+
+ static Func? BuildFilter(string? filter)
+ {
+ if (string.IsNullOrWhiteSpace(filter))
+ {
+ return null;
+ }
+
+ var regex = new Regex(filter, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
+ return regex.IsMatch;
+ }
+
+ static bool IsProjectOrSolutionFile(string path)
+ => path.EndsWith(".sln", StringComparison.OrdinalIgnoreCase)
+ || path.EndsWith(".slnx", StringComparison.OrdinalIgnoreCase)
+ || path.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase);
+
+ static ImmutableArray ApplyMinSeverity(ImmutableArray diagnostics, string? minSeverity)
+ {
+ if (string.IsNullOrWhiteSpace(minSeverity))
+ {
+ return diagnostics;
+ }
+
+ var threshold = SeverityRank(minSeverity);
+ return diagnostics.Where(d => SeverityRank(d.Severity) >= threshold).ToImmutableArray();
+ }
+
+ static int SeverityRank(string severity) => severity.ToUpperInvariant() switch
+ {
+ "ERROR" => 3,
+ "WARNING" => 2,
+ "INFO" => 1,
+ _ => 0,
+ };
+
+ static IReadOnlyList GroupDiagnostics(ImmutableArray diagnostics, string groupBy)
+ {
+ switch ((groupBy ?? "rule").ToUpperInvariant())
+ {
+ case "SEVERITY":
+ return diagnostics
+ .GroupBy(d => d.Severity, StringComparer.OrdinalIgnoreCase)
+ .Select(g => new DiagnosticGroup(g.Key, null, g.Key, null, g.Count(), g.Any(d => d.HasCodeFix)))
+ .OrderByDescending(g => SeverityRank(g.Key))
+ .ToArray();
+
+ case "PROJECT":
+ return diagnostics
+ .GroupBy(d => d.ProjectName, StringComparer.OrdinalIgnoreCase)
+ .Select(g => new DiagnosticGroup(g.Key, null, null, null, g.Count(), g.Any(d => d.HasCodeFix)))
+ .OrderByDescending(g => g.Count)
+ .ToArray();
+
+ case "FIXABILITY":
+ return diagnostics
+ .GroupBy(d => d.HasCodeFix)
+ .Select(g => new DiagnosticGroup(g.Key ? "fixable" : "not-fixable", null, null, null, g.Count(), g.Key))
+ .OrderByDescending(g => g.HasCodeFix)
+ .ToArray();
+
+ default:
+ return diagnostics
+ .GroupBy(d => d.Id, StringComparer.Ordinal)
+ .Select(g =>
+ {
+ var first = g.First();
+ return new DiagnosticGroup(g.Key, first.Title, first.Severity, first.Category, g.Count(), first.HasCodeFix);
+ })
+ .OrderByDescending(g => g.Count)
+ .ThenBy(g => g.Key, StringComparer.Ordinal)
+ .ToArray();
+ }
+ }
+ }
+}
diff --git a/src/WTG.BulkAnalysis.Mcp/Contracts.cs b/src/WTG.BulkAnalysis.Mcp/Contracts.cs
new file mode 100644
index 0000000..e2b05ff
--- /dev/null
+++ b/src/WTG.BulkAnalysis.Mcp/Contracts.cs
@@ -0,0 +1,59 @@
+using System.Collections.Generic;
+
+namespace WTG.BulkAnalysis.Mcp
+{
+ /// Result of opening an analysis session.
+ public sealed record OpenResult(
+ string? SessionId,
+ string Target,
+ IReadOnlyList Projects,
+ IReadOnlyList? CandidateSolutions,
+ string Message);
+
+ /// A grouped summary of diagnostics.
+ public sealed record DiagnosticGroup(
+ string Key,
+ string? Title,
+ string? Severity,
+ string? Category,
+ int Count,
+ bool HasCodeFix);
+
+ /// Result of listing/grouping diagnostics.
+ public sealed record ListDiagnosticsResult(
+ int Total,
+ string GroupBy,
+ IReadOnlyList Groups);
+
+ /// A single diagnostic occurrence.
+ public sealed record DiagnosticOccurrence(
+ string Id,
+ string Severity,
+ string Message,
+ string ProjectName,
+ string? FilePath,
+ int StartLine,
+ int StartColumn,
+ int EndLine,
+ int EndColumn,
+ bool HasCodeFix);
+
+ /// Paged list of individual diagnostic occurrences for a rule.
+ public sealed record GetDiagnosticsResult(
+ string RuleId,
+ int Total,
+ int Skip,
+ int Take,
+ IReadOnlyList Occurrences);
+
+ /// Result of applying fixes.
+ public sealed record ApplyFixesResult(
+ int OperationsApplied,
+ IReadOnlyList ChangedFiles,
+ IReadOnlyDictionary RemainingByRule);
+
+ /// Result of closing a session.
+ public sealed record CloseResult(
+ bool Closed,
+ string Message);
+}
diff --git a/src/WTG.BulkAnalysis.Mcp/McpLog.cs b/src/WTG.BulkAnalysis.Mcp/McpLog.cs
new file mode 100644
index 0000000..c30320d
--- /dev/null
+++ b/src/WTG.BulkAnalysis.Mcp/McpLog.cs
@@ -0,0 +1,37 @@
+using System;
+using System.Globalization;
+using Microsoft.Extensions.Logging;
+using WTG.BulkAnalysis.Core;
+using CoreLogLevel = WTG.BulkAnalysis.Core.LogLevel;
+using MsLogLevel = Microsoft.Extensions.Logging.LogLevel;
+
+namespace WTG.BulkAnalysis.Mcp
+{
+ /// Bridges the engine's to an (which writes to stderr).
+ public sealed class McpLog : ILog
+ {
+ public McpLog(ILogger logger)
+ {
+ this.logger = logger;
+ }
+
+ public void WriteFormatted(FormattableString message, CoreLogLevel level = CoreLogLevel.Normal)
+ => logger.Log(Map(level), "{Message}", message.ToString(CultureInfo.CurrentCulture));
+
+ public void WriteLine(string message, CoreLogLevel level = CoreLogLevel.Normal)
+ => logger.Log(Map(level), "{Message}", message);
+
+ public void WriteLine()
+ {
+ }
+
+ static MsLogLevel Map(CoreLogLevel level) => level switch
+ {
+ CoreLogLevel.Error => MsLogLevel.Error,
+ CoreLogLevel.Warning => MsLogLevel.Warning,
+ _ => MsLogLevel.Information,
+ };
+
+ readonly ILogger logger;
+ }
+}
diff --git a/src/WTG.BulkAnalysis.Mcp/Program.cs b/src/WTG.BulkAnalysis.Mcp/Program.cs
new file mode 100644
index 0000000..ab7d0e4
--- /dev/null
+++ b/src/WTG.BulkAnalysis.Mcp/Program.cs
@@ -0,0 +1,42 @@
+using System.Threading.Tasks;
+using Microsoft.Build.Locator;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using WTG.BulkAnalysis.Core;
+using WTG.BulkAnalysis.Mcp;
+
+// Resolve analyzer dependencies (loaded via Assembly.LoadFile) from their own directory.
+AnalyzerAssemblyResolver.Register();
+
+// Register the installed .NET SDK's MSBuild before any project is opened so MSBuildWorkspace
+// (Roslyn 5.x) can locate it.
+if (!MSBuildLocator.IsRegistered)
+{
+ MSBuildLocator.RegisterDefaults();
+}
+
+await Server.RunAsync(args).ConfigureAwait(false);
+
+namespace WTG.BulkAnalysis.Mcp
+{
+ static class Server
+ {
+ public static async Task RunAsync(string[] args)
+ {
+ var builder = Host.CreateApplicationBuilder(args);
+
+ // stdout is reserved for the MCP protocol over stdio; route all logging to stderr.
+ builder.Logging.AddConsole(options => options.LogToStandardErrorThreshold = Microsoft.Extensions.Logging.LogLevel.Trace);
+
+ builder.Services.AddSingleton();
+ builder.Services
+ .AddMcpServer()
+ .WithStdioServerTransport()
+ .WithToolsFromAssembly();
+
+ using var host = builder.Build();
+ await host.RunAsync().ConfigureAwait(false);
+ }
+ }
+}
diff --git a/src/WTG.BulkAnalysis.Mcp/README.md b/src/WTG.BulkAnalysis.Mcp/README.md
new file mode 100644
index 0000000..875b109
--- /dev/null
+++ b/src/WTG.BulkAnalysis.Mcp/README.md
@@ -0,0 +1,65 @@
+# Bulk Analysis Runner — MCP server
+
+A local [Model Context Protocol](https://modelcontextprotocol.io) server (stdio, `net10.0`) that
+lets LLMs / AI agents drive the Bulk Analysis Runner engine: **discover** analyzer diagnostics in a
+solution or project, **group / classify** them, and **apply code fixes** (including bulk FixAll
+operations) for the rules that have a code fix provider.
+
+It wraps the same `WTG.BulkAnalysis.Core` engine used by the CLI, exposed through a stateful
+in-memory `AnalysisSession` so the (expensive) workspace load happens once per session and is reused
+across calls.
+
+## Tools
+
+| Tool | Purpose |
+| --- | --- |
+| `open_analysis` | Open a `.sln`/`.slnx`/`.csproj` (or a directory/branch with a `Build.xml`) and return a `sessionId`. Keeps the workspace + analyzers loaded in memory. |
+| `list_diagnostics` | Run all analyzers and return a grouped summary (`groupBy`: `rule` \| `severity` \| `project` \| `fixability`). Each group reports its `count` and `hasCodeFix` so you can see what is bulk-fixable. Optional `ruleIds` / `minSeverity` filters. |
+| `get_diagnostics` | List the individual occurrences (file, line, column, message) for one rule id, paged (`skip`/`take`). |
+| `apply_fixes` | Run FixAll for the given `;`-separated `ruleIds` until stable, writing changes to disk. Returns operations applied, changed files, and remaining counts. |
+| `close_analysis` | Dispose a session and release its workspace. |
+
+Re-running `list_diagnostics` re-analyzes the current (in-memory, fix-updated) workspace, so it
+reflects fixes applied in the same session. To pick up edits made outside the session, re-open it.
+
+## Running
+
+Build once, then point your MCP client at the built assembly (using a pre-built DLL keeps the
+protocol's stdout clean — avoid `dotnet run`, which writes build output to stdout):
+
+```bash
+dotnet build src/WTG.BulkAnalysis.Mcp -c Release
+```
+
+Builds use the artifacts output layout, so the server lands at
+`artifacts/bin/WTG.BulkAnalysis.Mcp/release/BulkAnalysisRunner.Mcp.dll` (each project gets its own
+isolated folder). For a standalone deployment, `dotnet publish src/WTG.BulkAnalysis.Mcp -c Release -o
+` produces a self-contained folder you can point at instead.
+
+### Claude Code / VS Code (`.mcp.json` or `.vscode/mcp.json`)
+
+```json
+{
+ "mcpServers": {
+ "bulk-analysis": {
+ "type": "stdio",
+ "command": "dotnet",
+ "args": [
+ "/absolute/path/to/BulkAnalysisRunner/artifacts/bin/WTG.BulkAnalysis.Mcp/release/BulkAnalysisRunner.Mcp.dll"
+ ]
+ }
+ }
+}
+```
+
+Then, from the agent: `open_analysis` on a solution, `list_diagnostics` to triage, `apply_fixes`
+for a fixable rule, and `close_analysis` when done.
+
+## Requirements & notes
+
+- **.NET 10 SDK** must be installed; the server registers the SDK's MSBuild via `MSBuildLocator`.
+- The engine uses **Roslyn 5.x** to match the .NET 10 SDK's compiler, so it parses modern C#
+ (e.g. C# 14). The reported diagnostics reflect the project's effective `.editorconfig` / global
+ analyzer config, and `list_diagnostics` defaults to Warning+ to match what a build surfaces.
+- First-time `open_analysis` on a large solution can take a few minutes (MSBuild evaluates the
+ project graph out-of-process); subsequent calls in the same session reuse the loaded workspace.
diff --git a/src/WTG.BulkAnalysis.Mcp/SessionManager.cs b/src/WTG.BulkAnalysis.Mcp/SessionManager.cs
new file mode 100644
index 0000000..3f31030
--- /dev/null
+++ b/src/WTG.BulkAnalysis.Mcp/SessionManager.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Collections.Concurrent;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.Threading;
+using WTG.BulkAnalysis.Core;
+
+namespace WTG.BulkAnalysis.Mcp
+{
+ /// Owns the lifetime of the open instances for the server.
+ public sealed class SessionManager : IDisposable
+ {
+ public string Add(AnalysisSession session)
+ {
+ var id = "session-" + Interlocked.Increment(ref counter).ToString(CultureInfo.InvariantCulture);
+ sessions[id] = session;
+ return id;
+ }
+
+ public bool TryGet(string id, [NotNullWhen(true)] out AnalysisSession? session)
+ => sessions.TryGetValue(id, out session);
+
+ public bool Remove(string id, [NotNullWhen(true)] out AnalysisSession? session)
+ => sessions.TryRemove(id, out session);
+
+ public void Dispose()
+ {
+ foreach (var session in sessions.Values)
+ {
+ session.Dispose();
+ }
+
+ sessions.Clear();
+ }
+
+ readonly ConcurrentDictionary sessions = new ConcurrentDictionary(StringComparer.Ordinal);
+ int counter;
+ }
+}
diff --git a/src/WTG.BulkAnalysis.Mcp/WTG.BulkAnalysis.Mcp.csproj b/src/WTG.BulkAnalysis.Mcp/WTG.BulkAnalysis.Mcp.csproj
new file mode 100644
index 0000000..31664f5
--- /dev/null
+++ b/src/WTG.BulkAnalysis.Mcp/WTG.BulkAnalysis.Mcp.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net10.0
+ Exe
+ BulkAnalysisRunner.Mcp
+ WTG.BulkAnalysis.Mcp
+ disable
+
+ Default
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/WTG.BulkAnalysis.Runner/WTG.BulkAnalysis.Runner.csproj b/src/WTG.BulkAnalysis.Runner/WTG.BulkAnalysis.Runner.csproj
index 557d1ac..4e2b5ff 100644
--- a/src/WTG.BulkAnalysis.Runner/WTG.BulkAnalysis.Runner.csproj
+++ b/src/WTG.BulkAnalysis.Runner/WTG.BulkAnalysis.Runner.csproj
@@ -8,7 +8,7 @@
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/src/WTG.BulkAnalysis.Test/AnalysisSessionTest.cs b/src/WTG.BulkAnalysis.Test/AnalysisSessionTest.cs
new file mode 100644
index 0000000..bdd55a0
--- /dev/null
+++ b/src/WTG.BulkAnalysis.Test/AnalysisSessionTest.cs
@@ -0,0 +1,148 @@
+using System;
+using System.Collections.Immutable;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Build.Locator;
+using WTG.BulkAnalysis.Core;
+
+namespace WTG.BulkAnalysis.Test;
+
+[NonParallelizable]
+public class AnalysisSessionTest
+{
+ [Test]
+ public async Task ReportsAFixableRuleInAModernProject()
+ {
+ // SDK analyzer injection for the fixture project only works when the host process is .NET Core/.NET 5+.
+ // On net472, MSBuildWorkspace does not populate AnalyzerReferences with the SDK's bundled analyzers,
+ // so the analysis returns no diagnostics and this assertion would trivially fail.
+ Assume.That(Environment.Version.Major >= 5, "SDK analyzer injection requires a .NET 5+ host process.");
+
+ // The fixture uses a C# 12 collection expression; if the engine's Roslyn were too old to parse
+ // it, analysis would be disrupted and CA1822 would not be found here.
+ var path = CreateFixture(suppressCA1822: false);
+
+ var diagnostics = await AnalyzeAsync(path).ConfigureAwait(false);
+
+ var ca1822 = diagnostics.Where(d => d.Id == "CA1822").ToArray();
+ Assert.That(ca1822, Is.Not.Empty, "Expected CA1822 to be reported in the modern fixture project.");
+ Assert.That(ca1822.All(d => d.HasCodeFix), Is.True);
+ }
+
+ [Test]
+ public async Task HonoursGlobalAnalyzerConfigSuppression()
+ {
+ // Same fixture, but with a global analyzer config (as WTG.BuildConfiguration delivers one)
+ // suppressing CA1822. It must not be reported.
+ var path = CreateFixture(suppressCA1822: true);
+
+ var diagnostics = await AnalyzeAsync(path).ConfigureAwait(false);
+
+ Assert.That(diagnostics.Any(d => d.Id == "CA1822"), Is.False, "CA1822 is suppressed by the global analyzer config and must not be reported.");
+ }
+
+ static async Task> AnalyzeAsync(string projectPath)
+ {
+ var options = new AnalysisSessionOptions(projectPath, "Debug", loadDir: null, ImmutableArray.Empty);
+ using var session = await AnalysisSession.OpenAsync(options, NullLog.Instance, CancellationToken.None).ConfigureAwait(false);
+ return await session.AnalyzeAsync(ImmutableHashSet.Create("CA1822"), CancellationToken.None).ConfigureAwait(false);
+ }
+
+ string CreateFixture(bool suppressCA1822)
+ {
+ var dir = Path.Combine(temporaryDirectory, suppressCA1822 ? "suppressed" : "plain");
+ Directory.CreateDirectory(dir);
+
+ var globalConfig = suppressCA1822
+ ? " " + Environment.NewLine
+ : string.Empty;
+
+ File.WriteAllText(
+ Path.Combine(dir, "Fixture.csproj"),
+ "" + Environment.NewLine +
+ " " + Environment.NewLine +
+ " net8.0" + Environment.NewLine +
+ " enable" + Environment.NewLine +
+ " AllEnabledByDefault" + Environment.NewLine +
+ " true" + Environment.NewLine +
+ " " + Environment.NewLine +
+ globalConfig +
+ "" + Environment.NewLine);
+
+ File.WriteAllText(
+ Path.Combine(dir, "Calc.cs"),
+ "namespace Fixture;" + Environment.NewLine + Environment.NewLine +
+ "public class Calc" + Environment.NewLine +
+ "{" + Environment.NewLine +
+ " static readonly int[] Numbers = [1, 2, 3];" + Environment.NewLine +
+ " public int Total() => Numbers.Length;" + Environment.NewLine +
+ "}" + Environment.NewLine);
+
+ if (suppressCA1822)
+ {
+ File.WriteAllText(
+ Path.Combine(dir, "suppress.globalconfig"),
+ "is_global = true" + Environment.NewLine +
+ "dotnet_diagnostic.CA1822.severity = none" + Environment.NewLine);
+ }
+
+ Restore(dir);
+ return Path.Combine(dir, "Fixture.csproj");
+ }
+
+ static void Restore(string directory)
+ {
+ var info = new ProcessStartInfo("dotnet", "restore")
+ {
+ WorkingDirectory = directory,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ };
+
+ using var process = Process.Start(info)!;
+ process.WaitForExit();
+ Assert.That(process.ExitCode, Is.Zero, "Fixture restore failed.");
+ }
+
+ [OneTimeSetUp]
+ public void OneTimeSetUp()
+ {
+ AnalyzerAssemblyResolver.Register();
+
+ if (!MSBuildLocator.IsRegistered)
+ {
+ MSBuildLocator.RegisterDefaults();
+ }
+ }
+
+ [SetUp]
+ public void Setup()
+ {
+ temporaryDirectory = Path.Combine(Path.GetTempPath(), "WTG.BulkAnalysis.AnalysisSessionTest", Guid.NewGuid().ToString());
+ Directory.CreateDirectory(temporaryDirectory);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ if (temporaryDirectory != null)
+ {
+ Directory.Delete(temporaryDirectory, recursive: true);
+ temporaryDirectory = null!;
+ }
+ }
+
+ string temporaryDirectory = null!;
+
+ sealed class NullLog : ILog
+ {
+ public static readonly NullLog Instance = new NullLog();
+ public void WriteFormatted(FormattableString message, LogLevel level = LogLevel.Normal) { }
+ public void WriteLine(string message, LogLevel level = LogLevel.Normal) { }
+ public void WriteLine() { }
+ }
+}
diff --git a/src/WTG.BulkAnalysis.Test/DiagnosticInfoTest.cs b/src/WTG.BulkAnalysis.Test/DiagnosticInfoTest.cs
new file mode 100644
index 0000000..280b879
--- /dev/null
+++ b/src/WTG.BulkAnalysis.Test/DiagnosticInfoTest.cs
@@ -0,0 +1,69 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Text;
+using WTG.BulkAnalysis.Core;
+
+namespace WTG.BulkAnalysis.Test;
+
+public class DiagnosticInfoTest
+{
+ [Test]
+ public void MapsFieldsAndConvertsPositionsToOneBased()
+ {
+ // 'class C' starts at line 2, column 1 (0-based: line 1, char 0).
+ var tree = CSharpSyntaxTree.ParseText("\nclass C\n{\n}\n", path: "Widget.cs");
+ var start = tree.GetText().Lines[1].Start;
+ var location = Location.Create(tree, TextSpan.FromBounds(start, start + "class C".Length));
+
+ var descriptor = new DiagnosticDescriptor(
+ "WTG9999",
+ "The title",
+ "The message {0}",
+ "Naming",
+ DiagnosticSeverity.Warning,
+ isEnabledByDefault: true);
+
+ var diagnostic = Diagnostic.Create(descriptor, location, "detail");
+
+ var info = DiagnosticInfo.Create(diagnostic, "MyProject", hasCodeFix: true);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(info.Id, Is.EqualTo("WTG9999"));
+ Assert.That(info.Title, Is.EqualTo("The title"));
+ Assert.That(info.Message, Is.EqualTo("The message detail"));
+ Assert.That(info.Severity, Is.EqualTo("Warning"));
+ Assert.That(info.Category, Is.EqualTo("Naming"));
+ Assert.That(info.ProjectName, Is.EqualTo("MyProject"));
+ Assert.That(info.FilePath, Is.EqualTo("Widget.cs"));
+ Assert.That(info.HasCodeFix, Is.True);
+
+ // Roslyn reports 0-based positions; DiagnosticInfo exposes 1-based.
+ Assert.That(info.StartLine, Is.EqualTo(2));
+ Assert.That(info.StartColumn, Is.EqualTo(1));
+ });
+ }
+
+ [Test]
+ public void RepresentsNonSourceDiagnosticsWithoutAFilePath()
+ {
+ var descriptor = new DiagnosticDescriptor(
+ "WTG0001",
+ "No location",
+ "No location",
+ "Usage",
+ DiagnosticSeverity.Error,
+ isEnabledByDefault: true);
+
+ var diagnostic = Diagnostic.Create(descriptor, Location.None);
+
+ var info = DiagnosticInfo.Create(diagnostic, "MyProject", hasCodeFix: false);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(info.FilePath, Is.Null);
+ Assert.That(info.StartLine, Is.EqualTo(0));
+ Assert.That(info.HasCodeFix, Is.False);
+ });
+ }
+}
diff --git a/src/WTG.BulkAnalysis.Test/SolutionLocatorTest.cs b/src/WTG.BulkAnalysis.Test/SolutionLocatorTest.cs
index cb6809a..b4644cf 100644
--- a/src/WTG.BulkAnalysis.Test/SolutionLocatorTest.cs
+++ b/src/WTG.BulkAnalysis.Test/SolutionLocatorTest.cs
@@ -12,12 +12,12 @@ public void ReadsSolutionListFromBuildXml()
var solutions = SolutionLocator.Locate(temporaryDirectory, solutionFilter: null);
Assert.That(
- solutions.ToArray(),
+ solutions.Select(Normalize).ToArray(),
Is.EqualTo(new[]
{
Path.Combine("src", "BulkAnalysisRunner.sln"),
"AnotherSln.sln",
- }.Select(path => Path.Combine(temporaryDirectory, path))));
+ }.Select(path => Normalize(Path.Combine(temporaryDirectory, path)))));
}
[Test]
@@ -31,11 +31,11 @@ public void ReadsApplicableSolutionListFromParentBuildXmlWhenValid()
var solutions = SolutionLocator.Locate(subdirPath, solutionFilter: null);
Assert.That(
- solutions.ToArray(),
+ solutions.Select(Normalize).ToArray(),
Is.EqualTo(new[]
{
Path.Combine("src", "BulkAnalysisRunner.sln")
- }.Select(path => Path.Combine(temporaryDirectory, path))));
+ }.Select(path => Normalize(Path.Combine(temporaryDirectory, path)))));
}
[Test]
@@ -65,6 +65,9 @@ public void ReadsApplicableSolutionListFromParentBuildXmlWhenNoMatch(string subd
Assert.That(solutions, Is.Empty);
}
+ // Build.xml uses Windows-style separators; normalise so the comparison is cross-platform.
+ static string Normalize(string path) => path.Replace('\\', '/');
+
IFileProvider fileProvider;
string temporaryDirectory;
diff --git a/src/WTG.BulkAnalysis.Test/WTG.BulkAnalysis.Test.csproj b/src/WTG.BulkAnalysis.Test/WTG.BulkAnalysis.Test.csproj
index bca34ee..171e8bf 100644
--- a/src/WTG.BulkAnalysis.Test/WTG.BulkAnalysis.Test.csproj
+++ b/src/WTG.BulkAnalysis.Test/WTG.BulkAnalysis.Test.csproj
@@ -1,10 +1,14 @@
- net472
+ net472;net10.0
enable
enable
false
+ Default
+
+ true
@@ -14,6 +18,7 @@
+