diff --git a/CLAUDE.md b/CLAUDE.md index 3192272..47b7817 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -126,6 +126,7 @@ dotnet build src/CodingWithCalvin.VSMCP/CodingWithCalvin.VSMCP.csproj - `Services/ServerProcessManager.cs` - Server process lifecycle management - `Services/TestExplorerInterop.cs` - Reflection bridge to Test Explorer (see the file's remarks for why it cannot be a compile-time reference) - `Services/TerminalInterop.cs` - Reflection bridge to the integrated terminal, over the brokered service container +- `Services/CoverageInterop.cs` - Reflection bridge to the code coverage file reader - `Server/Tools/*.cs` - MCP tool definitions ## MCP Tools Available @@ -177,6 +178,14 @@ dotnet build src/CodingWithCalvin.VSMCP/CodingWithCalvin.VSMCP.csproj Terminal output is not captured; the VS terminal is a raw PTY with no exit code. +### Coverage Tools +- `coverage_analyze` - Run all tests with coverage collection +- `coverage_report` - Module/class/method tree with line and block counts +- `coverage_show` - Open the Code Coverage Results window + +Running coverage is edition-gated (Enterprise through VS 2022, all editions from VS 2026); +reading a .coverage file works everywhere. + ## Technology Stack - .NET Framework 4.8 (VSIX) diff --git a/README.md b/README.md index 143db86..6d872ae 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,18 @@ | `test_stats` | Get passed, failed, skipped, and not-run counts | | `test_status` | Get the run state plus current counts | +### 📊 Coverage Tools + +| Tool | Description | +|------|-------------| +| `coverage_analyze` | Run all tests with code coverage collection | +| `coverage_report` | Read results as a module / class / method tree with line and block counts | +| `coverage_show` | Open the Code Coverage Results window | + +> â„šī¸ **Running** coverage needs an edition that supports it — Enterprise only through +> VS 2022, all editions from VS 2026. **Reading** an existing `.coverage` file with +> `coverage_report` works on every edition. + ### đŸ’ģ Terminal Tools | Tool | Description | diff --git a/src/CodingWithCalvin.MCPServer.Server/Program.cs b/src/CodingWithCalvin.MCPServer.Server/Program.cs index 360689c..34c7840 100644 --- a/src/CodingWithCalvin.MCPServer.Server/Program.cs +++ b/src/CodingWithCalvin.MCPServer.Server/Program.cs @@ -103,7 +103,8 @@ static async Task RunServerAsync(string pipeName, string host, int port, string .WithTools() .WithTools() .WithTools() - .WithTools(); + .WithTools() + .WithTools(); var app = builder.Build(); diff --git a/src/CodingWithCalvin.MCPServer.Server/RpcClient.cs b/src/CodingWithCalvin.MCPServer.Server/RpcClient.cs index 5fb9d2c..e85d276 100644 --- a/src/CodingWithCalvin.MCPServer.Server/RpcClient.cs +++ b/src/CodingWithCalvin.MCPServer.Server/RpcClient.cs @@ -65,7 +65,7 @@ public Task> GetAvailableToolsAsync() } var tools = new List(); - var toolTypes = new[] { typeof(Tools.SolutionTools), typeof(Tools.DocumentTools), typeof(Tools.BuildTools), typeof(Tools.NavigationTools), typeof(Tools.DebuggerTools), typeof(Tools.DiagnosticsTools), typeof(Tools.WindowTools), typeof(Tools.TestTools), typeof(Tools.TerminalTools) }; + var toolTypes = new[] { typeof(Tools.SolutionTools), typeof(Tools.DocumentTools), typeof(Tools.BuildTools), typeof(Tools.NavigationTools), typeof(Tools.DebuggerTools), typeof(Tools.DiagnosticsTools), typeof(Tools.WindowTools), typeof(Tools.TestTools), typeof(Tools.TerminalTools), typeof(Tools.CoverageTools) }; foreach (var toolType in toolTypes) { @@ -178,6 +178,11 @@ public Task RunTestsInContextAsync(string target, bool debug) public Task GetTestRunStatusAsync() => Proxy.GetTestRunStatusAsync(); public Task GetTestStatsAsync() => Proxy.GetTestStatsAsync(); + public Task AnalyzeCodeCoverageAsync() => Proxy.AnalyzeCodeCoverageAsync(); + public Task ShowCoverageResultsAsync() => Proxy.ShowCoverageResultsAsync(); + public Task GetCoverageReportAsync(string? coverageFile, string? detail, string? filter) + => Proxy.GetCoverageReportAsync(coverageFile, detail, filter); + public Task CreateTerminalAsync(string? name, string? workingDirectory, string? command) => Proxy.CreateTerminalAsync(name, workingDirectory, command); public Task GetTerminalsAsync() => Proxy.GetTerminalsAsync(); diff --git a/src/CodingWithCalvin.MCPServer.Server/Tools/CoverageTools.cs b/src/CodingWithCalvin.MCPServer.Server/Tools/CoverageTools.cs new file mode 100644 index 0000000..7cad193 --- /dev/null +++ b/src/CodingWithCalvin.MCPServer.Server/Tools/CoverageTools.cs @@ -0,0 +1,62 @@ +using System.ComponentModel; +using System.Text.Json; +using System.Threading.Tasks; +using ModelContextProtocol.Server; + +namespace CodingWithCalvin.MCPServer.Server.Tools; + +[McpServerToolType] +public class CoverageTools +{ + private readonly RpcClient _rpcClient; + private readonly JsonSerializerOptions _jsonOptions; + + public CoverageTools(RpcClient rpcClient) + { + _rpcClient = rpcClient; + _jsonOptions = new JsonSerializerOptions { WriteIndented = true }; + } + + [McpServerTool(Name = "coverage_analyze", Destructive = false)] + [Description("Run all tests with code coverage collection enabled. The run starts asynchronously and this returns immediately; poll test_status until it reports 'Completed', then call coverage_report. Requires an edition of Visual Studio with code coverage: Enterprise only through VS 2022, all editions from VS 2026.")] + public async Task AnalyzeCoverageAsync() + { + var result = await _rpcClient.AnalyzeCodeCoverageAsync(); + + if (!result.Supported) + { + return result.Message ?? "Code coverage is not supported in this edition of Visual Studio."; + } + + return result.Started + ? "Coverage run started. Poll test_status for completion, then call coverage_report." + : result.Message ?? "Failed to start the coverage run."; + } + + [McpServerTool(Name = "coverage_report", ReadOnly = true)] + [Description("Read code coverage results as a module, class and method tree with covered/uncovered line and block counts. Defaults to the newest .coverage file under the solution's TestResults folder. Unlike coverage_analyze, this works on every edition of Visual Studio, so an existing .coverage file produced elsewhere can be read on VS 2022 Community.")] + public async Task GetCoverageReportAsync( + [Description("Detail level: 'summary' for totals per module, 'class' (default) for per-class breakdown, or 'method' for the fully expanded tree.")] string? detail = null, + [Description("Only include modules or classes whose name contains this text, for example 'OrderService'. Case-insensitive.")] string? filter = null, + [Description("Explicit path to a .coverage file. Omit to use the newest one under the solution's TestResults folder.")] string? coverageFile = null) + { + var result = await _rpcClient.GetCoverageReportAsync(coverageFile, detail, filter); + + if (!result.Available) + { + return result.Message ?? "No coverage results are available."; + } + + return JsonSerializer.Serialize(result, _jsonOptions); + } + + [McpServerTool(Name = "coverage_show", Destructive = false, Idempotent = true)] + [Description("Open the Visual Studio Code Coverage Results window so the user can see the results and the editor coverage coloring.")] + public async Task ShowCoverageResultsAsync() + { + var shown = await _rpcClient.ShowCoverageResultsAsync(); + return shown + ? "Code Coverage Results window shown" + : "Could not open the Code Coverage Results window. This edition of Visual Studio may not support code coverage."; + } +} diff --git a/src/CodingWithCalvin.MCPServer.Shared/Models/CoverageModels.cs b/src/CodingWithCalvin.MCPServer.Shared/Models/CoverageModels.cs new file mode 100644 index 0000000..44337e9 --- /dev/null +++ b/src/CodingWithCalvin.MCPServer.Shared/Models/CoverageModels.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; + +namespace CodingWithCalvin.MCPServer.Shared.Models; + +/// +/// Covered and uncovered counts for one node of the coverage tree. +/// +public class CoverageSummary +{ + public int LinesCovered { get; set; } + public int LinesPartiallyCovered { get; set; } + public int LinesNotCovered { get; set; } + public int BlocksCovered { get; set; } + public int BlocksNotCovered { get; set; } + + /// + /// Percentage of lines fully covered. Partially covered lines count against the total, which + /// matches how Visual Studio reports line coverage. + /// + public double LineCoveragePercent { get; set; } + + public double BlockCoveragePercent { get; set; } +} + +public class CoverageMethod +{ + public string Name { get; set; } = string.Empty; + public CoverageSummary Summary { get; set; } = new(); +} + +public class CoverageClass +{ + public string Name { get; set; } = string.Empty; + public string Namespace { get; set; } = string.Empty; + public CoverageSummary Summary { get; set; } = new(); + + /// Populated only when method-level detail is requested. + public List Methods { get; set; } = new(); +} + +public class CoverageModule +{ + public string Name { get; set; } = string.Empty; + public string? Path { get; set; } + public string? TargetFramework { get; set; } + public CoverageSummary Summary { get; set; } = new(); + + /// Populated only when class-level or method-level detail is requested. + public List Classes { get; set; } = new(); +} + +/// +/// A parsed code coverage result set. +/// +public class CoverageReportResult +{ + /// + /// False when no coverage data could be read. Distinct from a report whose counts are all + /// zero, which would mean coverage ran but exercised nothing. + /// + public bool Available { get; set; } + + /// Path of the .coverage file the report was read from. + public string? CoverageFile { get; set; } + + public CoverageSummary Summary { get; set; } = new(); + + public List Modules { get; set; } = new(); + + public string? Message { get; set; } +} + +/// +/// Outcome of asking Visual Studio to start a coverage run. +/// +public class CoverageRunResult +{ + public bool Started { get; set; } + + /// + /// False when this edition of Visual Studio has no code coverage support at all, as opposed + /// to the command being momentarily disabled. + /// + public bool Supported { get; set; } + + public string? Message { get; set; } +} diff --git a/src/CodingWithCalvin.MCPServer.Shared/RpcContracts.cs b/src/CodingWithCalvin.MCPServer.Shared/RpcContracts.cs index c553e0f..8ec913f 100644 --- a/src/CodingWithCalvin.MCPServer.Shared/RpcContracts.cs +++ b/src/CodingWithCalvin.MCPServer.Shared/RpcContracts.cs @@ -88,6 +88,11 @@ public interface IVisualStudioRpc Task CloseTerminalAsync(string terminalId); Task CloseAllTerminalsAsync(); + // Code coverage tools + Task AnalyzeCodeCoverageAsync(); + Task ShowCoverageResultsAsync(); + Task GetCoverageReportAsync(string? coverageFile, string? detail, string? filter); + // Window management tools Task> GetWindowsAsync(); Task ActivateWindowAsync(string caption); diff --git a/src/CodingWithCalvin.MCPServer.Tests/CoverageFileUtilityStandIn.cs b/src/CodingWithCalvin.MCPServer.Tests/CoverageFileUtilityStandIn.cs new file mode 100644 index 0000000..9ef63cf --- /dev/null +++ b/src/CodingWithCalvin.MCPServer.Tests/CoverageFileUtilityStandIn.cs @@ -0,0 +1,34 @@ +using Microsoft.CodeCoverage.IO.Coverage; + +// Declared separately from the data types because it sits in the parent namespace, matching the +// shipped assembly where CoverageFileUtility is in Microsoft.CodeCoverage.IO and the data types +// are in Microsoft.CodeCoverage.IO.Coverage. +namespace Microsoft.CodeCoverage.IO; + +/// +/// Stand-in for the real reader. Mirrors the V1 shape the production code targets: a +/// parameterless constructor and a ReadCoverageFile(string) overload. +/// +public class CoverageFileUtility +{ + /// + /// Data the next read returns. Static because CoverageInterop constructs the utility itself + /// via Activator, so a test has no other way to seed it. Tests within a class run + /// sequentially, so this is safe here. + /// + public static CoverageData? Fixture { get; set; } + + public static string? LastPathRead { get; private set; } + + public CoverageData ReadCoverageFile(string path) + { + LastPathRead = path; + return Fixture ?? new CoverageData(); + } + + // Present so the overload selection in CoverageInterop is exercised against a real ambiguity, + // as it is on the shipped type. + public CoverageData ReadCoverageFile(string path, bool readModules, bool readSkippedMessages) + => throw new System.InvalidOperationException( + "The single-path ReadCoverageFile overload should be selected."); +} diff --git a/src/CodingWithCalvin.MCPServer.Tests/CoverageInteropTests.cs b/src/CodingWithCalvin.MCPServer.Tests/CoverageInteropTests.cs new file mode 100644 index 0000000..d06abd0 --- /dev/null +++ b/src/CodingWithCalvin.MCPServer.Tests/CoverageInteropTests.cs @@ -0,0 +1,333 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using CodingWithCalvin.MCPServer.Services; +using CodingWithCalvin.MCPServer.Shared.Models; +using Microsoft.CodeCoverage.IO; +using Microsoft.CodeCoverage.IO.Coverage; +using Xunit; + +namespace CodingWithCalvin.MCPServer.Tests; + +/// +/// Covers the coverage-report reader and the roll-up arithmetic built on top of it. +/// +/// +/// +/// Microsoft.CodeCoverage.IO.dll cannot be referenced at build time, so +/// loads it from the Visual Studio installation and reads it +/// reflectively. These tests supply the test assembly as the resolver, against stand-ins +/// declared in the genuine namespaces. +/// +/// +/// The arithmetic gets the most attention deliberately. Module and function counts come straight +/// from the reader, but the class level is derived here by grouping functions on their declaring +/// type - the one place a wrong number would be produced silently rather than failing. +/// +/// +/// All tests live in one class because the stand-in reader is seeded through static state and +/// xUnit runs tests within a class sequentially. +/// +/// +public class CoverageInteropTests : IDisposable +{ + private static readonly Func StandInAssembly = () => typeof(CoverageFileUtility).Assembly; + + private readonly string _coverageFile; + + public CoverageInteropTests() + { + // CoverageInterop checks the file exists before reading, so a real one is needed even + // though the stand-in reader ignores its contents. + _coverageFile = Path.Combine(Path.GetTempPath(), $"vsmcp-{Guid.NewGuid():N}.coverage"); + File.WriteAllBytes(_coverageFile, Array.Empty()); + } + + public void Dispose() + { + CoverageFileUtility.Fixture = null; + + try + { + File.Delete(_coverageFile); + } + catch (IOException) + { + // A leftover temp file is not worth failing a test over. + } + } + + [Fact] + public void ReadReport_GroupsFunctionsByDeclaringType() + { + CoverageFileUtility.Fixture = Data( + Module("MyApp.Core.dll", + Function("Add", "MyApp.Core", "Calculator", covered: 6, notCovered: 2), + Function("Subtract", "MyApp.Core", "Calculator", covered: 4, notCovered: 0), + Function("Parse", "MyApp.Core", "Parser", covered: 1, notCovered: 9))); + + var report = Read(CoverageDetail.Class); + + var module = Assert.Single(report.Modules); + Assert.Equal(2, module.Classes.Count); + + var calculator = module.Classes.Single(c => c.Name == "Calculator"); + Assert.Equal("MyApp.Core", calculator.Namespace); + Assert.Equal(10, calculator.Summary.LinesCovered); + Assert.Equal(2, calculator.Summary.LinesNotCovered); + Assert.Equal(83.33, calculator.Summary.LineCoveragePercent); + + var parser = module.Classes.Single(c => c.Name == "Parser"); + Assert.Equal(10, parser.Summary.LinesCovered + parser.Summary.LinesNotCovered); + Assert.Equal(10d, parser.Summary.LineCoveragePercent); + } + + [Fact] + public void ReadReport_RollsClassesUpIntoModuleAndOverallTotals() + { + CoverageFileUtility.Fixture = Data( + Module("A.dll", Function("M1", "N", "C1", covered: 3, notCovered: 1)), + Module("B.dll", Function("M2", "N", "C2", covered: 1, notCovered: 5))); + + var report = Read(CoverageDetail.Class); + + var moduleA = report.Modules.Single(m => m.Name == "A.dll"); + Assert.Equal(3, moduleA.Summary.LinesCovered); + Assert.Equal(1, moduleA.Summary.LinesNotCovered); + + // 3 + 1 covered against 1 + 5 uncovered across both modules. + Assert.Equal(4, report.Summary.LinesCovered); + Assert.Equal(6, report.Summary.LinesNotCovered); + Assert.Equal(40d, report.Summary.LineCoveragePercent); + } + + [Fact] + public void ReadReport_CountsPartiallyCoveredLinesAgainstTheTotal() + { + CoverageFileUtility.Fixture = Data( + Module("A.dll", Function("M", "N", "C", covered: 8, notCovered: 1, partiallyCovered: 1))); + + var summary = Read(CoverageDetail.Class).Summary; + + // A partially covered line is not a covered line: 8 of 10, not 9 of 10 or 8 of 9. + Assert.Equal(80d, summary.LineCoveragePercent); + } + + [Fact] + public void ReadReport_SummaryDetail_OmitsClasses() + { + CoverageFileUtility.Fixture = Data( + Module("A.dll", Function("M", "N", "C", covered: 5, notCovered: 5))); + + var module = Assert.Single(Read(CoverageDetail.Summary).Modules); + + Assert.Empty(module.Classes); + // Totals are still correct even though the breakdown is trimmed. + Assert.Equal(50d, module.Summary.LineCoveragePercent); + } + + [Fact] + public void ReadReport_MethodDetail_IncludesEachMethod() + { + CoverageFileUtility.Fixture = Data( + Module("A.dll", + Function("Add", "N", "C", covered: 2, notCovered: 0), + Function("Remove", "N", "C", covered: 0, notCovered: 4))); + + var klass = Assert.Single(Assert.Single(Read(CoverageDetail.Method).Modules).Classes); + + Assert.Equal(new[] { "Add", "Remove" }, klass.Methods.Select(m => m.Name).OrderBy(n => n)); + Assert.Equal(100d, klass.Methods.Single(m => m.Name == "Add").Summary.LineCoveragePercent); + Assert.Equal(0d, klass.Methods.Single(m => m.Name == "Remove").Summary.LineCoveragePercent); + } + + [Fact] + public void ReadReport_ClassDetail_OmitsMethods() + { + CoverageFileUtility.Fixture = Data( + Module("A.dll", Function("M", "N", "C", covered: 1, notCovered: 1))); + + Assert.Empty(Assert.Single(Assert.Single(Read(CoverageDetail.Class).Modules).Classes).Methods); + } + + [Fact] + public void ReadReport_FilterSelectsMatchingClassOnly() + { + CoverageFileUtility.Fixture = Data( + Module("A.dll", + Function("M", "N", "OrderService", covered: 5, notCovered: 0), + Function("M", "N", "Parser", covered: 0, notCovered: 5))); + + var report = Read(CoverageDetail.Class, filter: "orderservice"); + + var klass = Assert.Single(Assert.Single(report.Modules).Classes); + Assert.Equal("OrderService", klass.Name); + // Totals reflect only what survived the filter. + Assert.Equal(100d, report.Summary.LineCoveragePercent); + } + + [Fact] + public void ReadReport_FilterMatchingModule_KeepsAllOfItsClasses() + { + CoverageFileUtility.Fixture = Data( + Module("MyApp.Core.dll", + Function("M", "N", "OrderService", covered: 1, notCovered: 0), + Function("M", "N", "Parser", covered: 1, notCovered: 0)), + Module("Other.dll", Function("M", "N", "Ignored", covered: 1, notCovered: 0))); + + var report = Read(CoverageDetail.Class, filter: "MyApp.Core"); + + var module = Assert.Single(report.Modules); + Assert.Equal("MyApp.Core.dll", module.Name); + Assert.Equal(2, module.Classes.Count); + } + + [Fact] + public void ReadReport_FilterMatchingNothing_ExplainsWhyItIsEmpty() + { + CoverageFileUtility.Fixture = Data( + Module("A.dll", Function("M", "N", "C", covered: 1, notCovered: 0))); + + var report = Read(CoverageDetail.Class, filter: "nonexistent"); + + Assert.True(report.Available); + Assert.Empty(report.Modules); + Assert.Contains("nonexistent", report.Message); + } + + [Fact] + public void ReadReport_MissingFile_ReportsNotFound() + { + var missing = Path.Combine(Path.GetTempPath(), $"vsmcp-{Guid.NewGuid():N}.coverage"); + + var report = new CoverageInterop(StandInAssembly).ReadReport(missing, CoverageDetail.Class, null); + + Assert.False(report.Available); + Assert.Contains("not found", report.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void ReadReport_ReportsUnavailable_WhenReaderCannotBeLoaded() + { + var report = new CoverageInterop(() => null).ReadReport(_coverageFile, CoverageDetail.Class, null); + + Assert.False(report.Available); + Assert.Equal(CoverageInterop.UnavailableMessage, report.Message); + } + + [Fact] + public void ReadReport_PassesTheRequestedPathToTheReader() + { + CoverageFileUtility.Fixture = Data(Module("A.dll")); + + Read(CoverageDetail.Class); + + Assert.Equal(_coverageFile, CoverageFileUtility.LastPathRead); + } + + [Theory] + [InlineData(0, 0, 0, 0d)] + [InlineData(1, 0, 0, 100d)] + [InlineData(0, 3, 0, 0d)] + [InlineData(1, 2, 0, 33.33)] + public void ApplyPercentages_HandlesEdgeCases( + int covered, + int notCovered, + int partiallyCovered, + double expected) + { + var summary = new CoverageSummary + { + LinesCovered = covered, + LinesNotCovered = notCovered, + LinesPartiallyCovered = partiallyCovered + }; + + CoverageInterop.ApplyPercentages(summary); + + // Zero lines must read as 0%, not a divide-by-zero or NaN. + Assert.Equal(expected, summary.LineCoveragePercent); + } + + [Fact] + public void FindNewestCoverageFile_PicksTheMostRecentlyWritten() + { + var root = Path.Combine(Path.GetTempPath(), $"vsmcp-{Guid.NewGuid():N}"); + var nested = Path.Combine(root, "run-2", "In"); + Directory.CreateDirectory(nested); + + var older = Path.Combine(root, "older.coverage"); + var newer = Path.Combine(nested, "newer.coverage"); + File.WriteAllText(older, string.Empty); + File.WriteAllText(newer, string.Empty); + File.SetLastWriteTimeUtc(older, DateTime.UtcNow.AddHours(-1)); + + try + { + // Visual Studio writes into a generated subdirectory, so the search has to recurse. + Assert.Equal(newer, CoverageInterop.FindNewestCoverageFile(root)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void FindNewestCoverageFile_ReturnsNull_WhenDirectoryAbsent() + { + var missing = Path.Combine(Path.GetTempPath(), $"vsmcp-{Guid.NewGuid():N}"); + + Assert.Null(CoverageInterop.FindNewestCoverageFile(missing)); + Assert.Null(CoverageInterop.FindNewestCoverageFile(null)); + } + + [Fact] + public void TryFindCoverageAssemblyPath_ReturnsNull_WhenIdeDirectoryUnknown() + { + var previous = Environment.GetEnvironmentVariable("VSAPPIDDIR"); + try + { + Environment.SetEnvironmentVariable("VSAPPIDDIR", null); + Assert.Null(CoverageInterop.TryFindCoverageAssemblyPath()); + } + finally + { + Environment.SetEnvironmentVariable("VSAPPIDDIR", previous); + } + } + + private CoverageReportResult Read(CoverageDetail detail, string? filter = null) => + new CoverageInterop(StandInAssembly).ReadReport(_coverageFile, detail, filter); + + private static CoverageData Data(params ModuleWrapper[] modules) => + new() { Modules = modules.ToList() }; + + private static ModuleWrapper Module(string name, params Function[] functions) => new() + { + Name = name, + Path = @"C:\src\bin\" + name, + TargetFramework = "net8.0", + Functions = functions.ToList() + }; + + private static Function Function( + string name, + string namespaceName, + string typeName, + uint covered, + uint notCovered, + uint partiallyCovered = 0) => new() + { + Name = name, + NamespaceName = namespaceName, + TypeName = typeName, + LinesCovered = covered, + LinesNotCovered = notCovered, + LinesPartiallyCovered = partiallyCovered, + BlocksCovered = covered, + BlocksNotCovered = notCovered + }; +} diff --git a/src/CodingWithCalvin.MCPServer.Tests/CoverageStandIns.cs b/src/CodingWithCalvin.MCPServer.Tests/CoverageStandIns.cs new file mode 100644 index 0000000..be3480c --- /dev/null +++ b/src/CodingWithCalvin.MCPServer.Tests/CoverageStandIns.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; + +// Stand-ins for the Visual Studio code coverage reader, declared in the real namespaces so that +// CoverageInterop's reflective lookups resolve against them. +// +// Microsoft.CodeCoverage.IO.dll ships only inside the Visual Studio installation, so the +// production code loads it from disk and reads it reflectively. Supplying the test assembly as +// the resolver lets that path run without Visual Studio. +// +// Shapes are transcribed from the shipped assembly: counts are uint and are inherited from +// CoverageStatistics by both ModuleWrapper's functions and the functions themselves, which is +// why no arithmetic is needed below the class level. +namespace Microsoft.CodeCoverage.IO.Coverage; + +public class CoverageStatistics +{ + public uint BlocksCovered { get; set; } + public uint BlocksNotCovered { get; set; } + public uint LinesCovered { get; set; } + public uint LinesPartiallyCovered { get; set; } + public uint LinesNotCovered { get; set; } +} + +public class Function : CoverageStatistics +{ + public string Name { get; set; } = string.Empty; + public string NamespaceName { get; set; } = string.Empty; + public string TypeName { get; set; } = string.Empty; +} + +public class ModuleWrapper +{ + public string Name { get; set; } = string.Empty; + public string Path { get; set; } = string.Empty; + public string TargetFramework { get; set; } = string.Empty; + public List Functions { get; set; } = new(); +} + +public class CoverageData +{ + public IList Modules { get; set; } = new List(); +} diff --git a/src/CodingWithCalvin.MCPServer/Services/CoverageInterop.cs b/src/CodingWithCalvin.MCPServer/Services/CoverageInterop.cs new file mode 100644 index 0000000..bfdab2a --- /dev/null +++ b/src/CodingWithCalvin.MCPServer/Services/CoverageInterop.cs @@ -0,0 +1,396 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using CodingWithCalvin.MCPServer.Shared.Models; +using CodingWithCalvin.Otel4Vsix; + +namespace CodingWithCalvin.MCPServer.Services; + +/// +/// Reflection bridge to the Visual Studio code coverage file reader. +/// +/// +/// +/// Microsoft.CodeCoverage.IO.dll lives under +/// CommonExtensions\Microsoft\TestWindow\VsTest in the Visual Studio installation and, in +/// contrast to the code coverage feature itself, is present in both VS 2022 and VS 2026 +/// regardless of edition. It is not on NuGet and not in the Microsoft.VisualStudio.SDK +/// metapackage, and $(DevEnvDir) is undefined under dotnet build, so it is loaded +/// from disk and read reflectively - the same constraint documented on +/// and . +/// +/// +/// Unlike those two, this assembly has no binding redirect or codeBase entry in +/// devenv.exe.config, so it is loaded by path rather than by strong name. The path is derived +/// from VSAPPIDDIR, which devenv sets to its own Common7\IDE directory. +/// +/// +/// The V1 CoverageFileUtility is used deliberately: it has a parameterless constructor, +/// whereas CoverageFileUtilityV2 requires an ICoverageFileConfiguration that +/// cannot be implemented without a compile-time reference. Both expose the same underlying data. +/// +/// +/// Per-module and per-function counts come straight from the reader - Function and +/// ModuleWrapper both carry coverage statistics, so nothing is recomputed. Only the class +/// level is derived here, by grouping functions on their declaring type. +/// +/// +internal sealed class CoverageInterop +{ + private const string AssemblyFileName = "Microsoft.CodeCoverage.IO.dll"; + private const string SimpleAssemblyName = "Microsoft.CodeCoverage.IO"; + private const string UtilityTypeName = "Microsoft.CodeCoverage.IO.CoverageFileUtility"; + + internal const string UnavailableMessage = + "The Visual Studio code coverage reader could not be loaded, so coverage results cannot " + + "be parsed in this instance."; + + private readonly Func _assemblyResolver; + + private bool _resolved; + private Type? _utilityType; + private MethodInfo? _readCoverageFile; + + internal CoverageInterop() + : this(TryLoadCoverageAssembly) + { + } + + /// + /// Overload taking an explicit assembly resolver, so that tests can supply stand-in coverage + /// types without a Visual Studio installation. + /// + internal CoverageInterop(Func assemblyResolver) + { + _assemblyResolver = assemblyResolver; + } + + /// + /// Reads a .coverage file and projects it into the module, class and method tree, trimmed to + /// the requested depth. + /// + internal CoverageReportResult ReadReport(string coverageFile, CoverageDetail detail, string? filter) + { + if (string.IsNullOrWhiteSpace(coverageFile) || !File.Exists(coverageFile)) + { + return new CoverageReportResult + { + CoverageFile = coverageFile, + Message = $"Coverage file not found: {coverageFile}" + }; + } + + try + { + if (!TryResolve()) + { + return new CoverageReportResult { CoverageFile = coverageFile, Message = UnavailableMessage }; + } + + var utility = Activator.CreateInstance(_utilityType!); + if (utility == null) + { + return new CoverageReportResult { CoverageFile = coverageFile, Message = UnavailableMessage }; + } + + var data = _readCoverageFile!.Invoke(utility, new object?[] { coverageFile }); + var modules = data?.GetType().GetProperty("Modules")?.GetValue(data) as IEnumerable; + if (modules == null) + { + return new CoverageReportResult { CoverageFile = coverageFile, Message = UnavailableMessage }; + } + + var result = new CoverageReportResult { Available = true, CoverageFile = coverageFile }; + + foreach (var module in modules) + { + var projected = ProjectModule(module, detail, filter); + if (projected != null) + { + result.Modules.Add(projected); + } + } + + result.Summary = Combine(result.Modules.Select(m => m.Summary)); + + if (result.Modules.Count == 0 && !string.IsNullOrWhiteSpace(filter)) + { + result.Message = $"No module or class matched '{filter}'."; + } + + return result; + } + catch (Exception ex) + { + VsixTelemetry.TrackException(ex); + return new CoverageReportResult { CoverageFile = coverageFile, Message = ex.Message }; + } + } + + private static CoverageModule? ProjectModule(object module, CoverageDetail detail, string? filter) + { + var name = GetString(module, "Name"); + var moduleMatches = Matches(name, filter); + + var functions = module.GetType().GetProperty("Functions")?.GetValue(module) as IEnumerable; + var classes = functions == null + ? new List() + : GroupIntoClasses(functions, detail, moduleMatches ? null : filter); + + // A module survives when it matches the filter itself, or when one of its classes does. + if (!moduleMatches && classes.Count == 0) + { + return null; + } + + var projected = new CoverageModule + { + Name = name, + Path = GetString(module, "Path"), + TargetFramework = GetString(module, "TargetFramework"), + Summary = Combine(classes.Select(c => c.Summary)) + }; + + if (detail != CoverageDetail.Summary) + { + projected.Classes = classes; + } + + return projected; + } + + private static List GroupIntoClasses( + IEnumerable functions, + CoverageDetail detail, + string? filter) + { + var grouped = new Dictionary(StringComparer.Ordinal); + + foreach (var function in functions) + { + var typeName = GetString(function, "TypeName"); + var namespaceName = GetString(function, "NamespaceName"); + var key = namespaceName + "." + typeName; + + if (!Matches(key, filter) && !Matches(typeName, filter)) + { + continue; + } + + if (!grouped.TryGetValue(key, out var entry)) + { + entry = new CoverageClass { Name = typeName, Namespace = namespaceName }; + grouped[key] = entry; + } + + var summary = ReadStatistics(function); + + if (detail == CoverageDetail.Method) + { + entry.Methods.Add(new CoverageMethod + { + Name = GetString(function, "Name"), + Summary = summary + }); + } + + Accumulate(entry.Summary, summary); + } + + foreach (var entry in grouped.Values) + { + ApplyPercentages(entry.Summary); + } + + return grouped.Values + .OrderBy(c => c.Namespace, StringComparer.Ordinal) + .ThenBy(c => c.Name, StringComparer.Ordinal) + .ToList(); + } + + /// + /// Reads the counts a coverage node inherits from CoverageStatistics. + /// + private static CoverageSummary ReadStatistics(object node) + { + var summary = new CoverageSummary + { + LinesCovered = GetInt(node, "LinesCovered"), + LinesPartiallyCovered = GetInt(node, "LinesPartiallyCovered"), + LinesNotCovered = GetInt(node, "LinesNotCovered"), + BlocksCovered = GetInt(node, "BlocksCovered"), + BlocksNotCovered = GetInt(node, "BlocksNotCovered") + }; + + ApplyPercentages(summary); + return summary; + } + + internal static void Accumulate(CoverageSummary target, CoverageSummary addition) + { + target.LinesCovered += addition.LinesCovered; + target.LinesPartiallyCovered += addition.LinesPartiallyCovered; + target.LinesNotCovered += addition.LinesNotCovered; + target.BlocksCovered += addition.BlocksCovered; + target.BlocksNotCovered += addition.BlocksNotCovered; + } + + internal static CoverageSummary Combine(IEnumerable summaries) + { + var combined = new CoverageSummary(); + + foreach (var summary in summaries) + { + Accumulate(combined, summary); + } + + ApplyPercentages(combined); + return combined; + } + + /// + /// Partially covered lines count against the total rather than towards it, matching how + /// Visual Studio reports line coverage. + /// + internal static void ApplyPercentages(CoverageSummary summary) + { + var totalLines = summary.LinesCovered + summary.LinesPartiallyCovered + summary.LinesNotCovered; + summary.LineCoveragePercent = Percentage(summary.LinesCovered, totalLines); + + var totalBlocks = summary.BlocksCovered + summary.BlocksNotCovered; + summary.BlockCoveragePercent = Percentage(summary.BlocksCovered, totalBlocks); + } + + private static double Percentage(int covered, int total) => + total == 0 ? 0d : Math.Round(covered * 100d / total, 2); + + private static bool Matches(string value, string? filter) => + string.IsNullOrWhiteSpace(filter) + || value.IndexOf(filter!, StringComparison.OrdinalIgnoreCase) >= 0; + + private static string GetString(object target, string property) => + target.GetType().GetProperty(property)?.GetValue(target) as string ?? string.Empty; + + private static int GetInt(object target, string property) + { + var value = target.GetType().GetProperty(property)?.GetValue(target); + + return value switch + { + uint unsigned => unsigned > int.MaxValue ? int.MaxValue : (int)unsigned, + int signed => signed, + _ => 0 + }; + } + + private bool TryResolve() + { + if (_resolved) + { + return _utilityType != null && _readCoverageFile != null; + } + + _resolved = true; + + var assembly = _assemblyResolver(); + _utilityType = assembly?.GetType(UtilityTypeName); + + // ReadCoverageFile is overloaded; the single-path overload is the one wanted here. + _readCoverageFile = _utilityType?.GetMethod("ReadCoverageFile", new[] { typeof(string) }); + + return _utilityType != null && _readCoverageFile != null; + } + + private static Assembly? TryLoadCoverageAssembly() + { + try + { + var loaded = AppDomain.CurrentDomain.GetAssemblies() + .FirstOrDefault(a => string.Equals( + a.GetName().Name, + SimpleAssemblyName, + StringComparison.OrdinalIgnoreCase)); + if (loaded != null) + { + return loaded; + } + + var path = TryFindCoverageAssemblyPath(); + return path == null ? null : Assembly.LoadFrom(path); + } + catch (Exception ex) + { + VsixTelemetry.TrackException(ex); + return null; + } + } + + /// + /// Locates the reader inside the running Visual Studio. VSAPPIDDIR points at Common7\IDE. + /// + internal static string? TryFindCoverageAssemblyPath() + { + try + { + var ideDirectory = Environment.GetEnvironmentVariable("VSAPPIDDIR"); + if (string.IsNullOrWhiteSpace(ideDirectory)) + { + return null; + } + + var path = Path.GetFullPath(Path.Combine( + ideDirectory, + "CommonExtensions", + "Microsoft", + "TestWindow", + "VsTest", + AssemblyFileName)); + + return File.Exists(path) ? path : null; + } + catch (Exception ex) + { + VsixTelemetry.TrackException(ex); + return null; + } + } + + /// + /// Finds the most recent .coverage file beneath a directory. Visual Studio writes coverage + /// results under the solution's TestResults folder, but the exact subdirectory carries a + /// generated name, so the newest file is the only reliable handle on "the run that just + /// finished". + /// + internal static string? FindNewestCoverageFile(string? searchRoot) + { + if (string.IsNullOrWhiteSpace(searchRoot) || !Directory.Exists(searchRoot)) + { + return null; + } + + try + { + return new DirectoryInfo(searchRoot) + .GetFiles("*.coverage", SearchOption.AllDirectories) + .OrderByDescending(f => f.LastWriteTimeUtc) + .FirstOrDefault() + ?.FullName; + } + catch (Exception ex) + { + VsixTelemetry.TrackException(ex); + return null; + } + } +} + +/// How deep a coverage report should be projected. +internal enum CoverageDetail +{ + Summary, + Class, + Method +} diff --git a/src/CodingWithCalvin.MCPServer/Services/IVisualStudioService.cs b/src/CodingWithCalvin.MCPServer/Services/IVisualStudioService.cs index 000f7c5..6540efd 100644 --- a/src/CodingWithCalvin.MCPServer/Services/IVisualStudioService.cs +++ b/src/CodingWithCalvin.MCPServer/Services/IVisualStudioService.cs @@ -83,6 +83,11 @@ public interface IVisualStudioService Task CloseTerminalAsync(string terminalId); Task CloseAllTerminalsAsync(); + // Code coverage tools + Task AnalyzeCodeCoverageAsync(); + Task ShowCoverageResultsAsync(); + Task GetCoverageReportAsync(string? coverageFile, string? detail, string? filter); + Task> GetWindowsAsync(); Task ActivateWindowAsync(string caption); Task ShowToolWindowAsync(string name); diff --git a/src/CodingWithCalvin.MCPServer/Services/RpcServer.cs b/src/CodingWithCalvin.MCPServer/Services/RpcServer.cs index 3d2b91c..d49d0c3 100644 --- a/src/CodingWithCalvin.MCPServer/Services/RpcServer.cs +++ b/src/CodingWithCalvin.MCPServer/Services/RpcServer.cs @@ -287,6 +287,11 @@ public Task RunTestsInContextAsync(string target, bool debug) public Task GetTestRunStatusAsync() => _vsService.GetTestRunStatusAsync(); public Task GetTestStatsAsync() => _vsService.GetTestStatsAsync(); + public Task AnalyzeCodeCoverageAsync() => _vsService.AnalyzeCodeCoverageAsync(); + public Task ShowCoverageResultsAsync() => _vsService.ShowCoverageResultsAsync(); + public Task GetCoverageReportAsync(string? coverageFile, string? detail, string? filter) + => _vsService.GetCoverageReportAsync(coverageFile, detail, filter); + public Task CreateTerminalAsync(string? name, string? workingDirectory, string? command) => _vsService.CreateTerminalAsync(name, workingDirectory, command); public Task GetTerminalsAsync() => _vsService.GetTerminalsAsync(); diff --git a/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs b/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs index 76c9b7c..fa8aa66 100644 --- a/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs +++ b/src/CodingWithCalvin.MCPServer/Services/VisualStudioService.cs @@ -2792,6 +2792,153 @@ public async Task GetTestStatsAsync() return TestExplorer.GetStats(); } + private const string AnalyzeCoverageCommand = "Test.AnalyzeCodeCoverageForAllTests"; + private const string CoverageResultsCommand = "Test.CodeCoverageResults"; + + private const string CoverageUnsupportedMessage = + "This edition of Visual Studio has no code coverage support. Code coverage was limited to " + + "Enterprise through VS 2022; it is available in all editions from VS 2026."; + + private CoverageInterop? _coverage; + + private CoverageInterop Coverage => _coverage ??= new CoverageInterop(); + + public async Task AnalyzeCodeCoverageAsync() + { + using var activity = VsixTelemetry.Tracer.StartActivity("AnalyzeCodeCoverage"); + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + var dte = await GetDteAsync(); + + try + { + // An unknown command means the edition lacks coverage entirely; a known but disabled + // one means it is momentarily unavailable. Those need different advice. + var command = TryGetCommand(dte, AnalyzeCoverageCommand); + if (command == null) + { + return new CoverageRunResult { Message = CoverageUnsupportedMessage }; + } + + if (!command.IsAvailable) + { + return new CoverageRunResult + { + Supported = true, + Message = "Code coverage is not available right now. A build or test run may be " + + "in progress, or the solution may have no discovered tests." + }; + } + + // Coverage runs the tests, so this returns as soon as the run is queued. Poll + // test_status for completion before reading results. + dte.ExecuteCommand(AnalyzeCoverageCommand); + + return new CoverageRunResult { Started = true, Supported = true }; + } + catch (Exception ex) + { + activity?.SetStatus(ActivityStatusCode.Error, ex.Message); + activity?.RecordException(ex); + return new CoverageRunResult { Supported = true, Message = ex.Message }; + } + } + + public async Task ShowCoverageResultsAsync() + { + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + var dte = await GetDteAsync(); + + try + { + if (!IsCommandAvailable(dte, CoverageResultsCommand)) + { + return false; + } + + dte.ExecuteCommand(CoverageResultsCommand); + return true; + } + catch (Exception ex) + { + VsixTelemetry.TrackException(ex); + return false; + } + } + + public async Task GetCoverageReportAsync( + string? coverageFile, + string? detail, + string? filter) + { + using var activity = VsixTelemetry.Tracer.StartActivity("GetCoverageReport"); + await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(); + + var resolved = string.IsNullOrWhiteSpace(coverageFile) + ? CoverageInterop.FindNewestCoverageFile(GetTestResultsDirectory()) + : NormalizePath(coverageFile!); + + if (resolved == null) + { + return new CoverageReportResult + { + Message = "No .coverage file was found under the solution's TestResults folder. " + + "Run coverage_analyze first, or pass an explicit coverageFile path." + }; + } + + return Coverage.ReadReport(resolved, ParseDetail(detail), filter); + } + + private static CoverageDetail ParseDetail(string? detail) => detail?.ToLowerInvariant() switch + { + "method" => CoverageDetail.Method, + "summary" => CoverageDetail.Summary, + _ => CoverageDetail.Class + }; + + private string? GetTestResultsDirectory() + { + ThreadHelper.ThrowIfNotOnUIThread(); + + try + { + var dte = ServiceProvider.GetService(typeof(DTE)) as DTE2; + var solutionFile = dte?.Solution?.FullName; + if (string.IsNullOrEmpty(solutionFile)) + { + return null; + } + + var solutionDirectory = Path.GetDirectoryName(solutionFile); + return solutionDirectory == null + ? null + : Path.Combine(solutionDirectory, "TestResults"); + } + catch (Exception ex) + { + VsixTelemetry.TrackException(ex); + return null; + } + } + + /// + /// Returns the command when Visual Studio knows about it, or null when it does not exist in + /// this installation. Commands.Item throws for unknown names. + /// + private static Command? TryGetCommand(DTE2 dte, string name) + { + ThreadHelper.ThrowIfNotOnUIThread(); + + try + { + return dte.Commands.Item(name); + } + catch (Exception) + { + return null; + } + } + private TerminalInterop? _terminal; private TerminalInterop Terminal => _terminal ??= new TerminalInterop(GetServiceBroker);