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 @@ +