From 03d0ee3bbba4a4117bc936494a8b46ae5a32c054 Mon Sep 17 00:00:00 2001 From: "Calvin A. Allen" Date: Thu, 3 Sep 2026 15:10:26 -0400 Subject: [PATCH] feat(tools): add code coverage tools Adds three MCP tools: coverage_analyze, coverage_report and coverage_show. Reading results goes through Microsoft.CodeCoverage.IO rather than Microsoft.CodeCoverage.VisualStudio.Contracts. The contracts assembly ships only in VS 2026 and exposes coverage per file and per line, with no class or module rollup. Microsoft.CodeCoverage.IO ships in both VS 2022 and VS 2026 under CommonExtensions\Microsoft\TestWindow\VsTest, in every edition, and its CoverageFileUtility yields modules whose functions carry NamespaceName and TypeName. Grouping functions by declaring type produces the module, class and method tree the original request asked for, on both Visual Studio versions. ModuleWrapper and Function both derive from CoverageStatistics, so per-module and per-function counts come straight from the reader. Only the class level is derived here. 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. Unlike the TestWindow and Terminal assemblies, this one has no binding redirect or codeBase entry in devenv.exe.config, so it is loaded by path from VSAPPIDDIR rather than by strong name. Running coverage stays edition-gated: Enterprise only through VS 2022, all editions from VS 2026. coverage_analyze distinguishes an unknown command, which means the edition has no coverage support, from a known but disabled one, which means it is momentarily unavailable, because those need different advice. coverage_report defaults to the newest .coverage file beneath the solution's TestResults folder and accepts an explicit path. Detail level is selectable because a method-level tree over a large solution is far too large to return by default. --- CLAUDE.md | 9 + README.md | 12 + .../Program.cs | 3 +- .../RpcClient.cs | 7 +- .../Tools/CoverageTools.cs | 62 +++ .../Models/CoverageModels.cs | 87 ++++ .../RpcContracts.cs | 5 + .../CoverageFileUtilityStandIn.cs | 34 ++ .../CoverageInteropTests.cs | 333 +++++++++++++++ .../CoverageStandIns.cs | 42 ++ .../Services/CoverageInterop.cs | 396 ++++++++++++++++++ .../Services/IVisualStudioService.cs | 5 + .../Services/RpcServer.cs | 5 + .../Services/VisualStudioService.cs | 147 +++++++ 14 files changed, 1145 insertions(+), 2 deletions(-) create mode 100644 src/CodingWithCalvin.MCPServer.Server/Tools/CoverageTools.cs create mode 100644 src/CodingWithCalvin.MCPServer.Shared/Models/CoverageModels.cs create mode 100644 src/CodingWithCalvin.MCPServer.Tests/CoverageFileUtilityStandIn.cs create mode 100644 src/CodingWithCalvin.MCPServer.Tests/CoverageInteropTests.cs create mode 100644 src/CodingWithCalvin.MCPServer.Tests/CoverageStandIns.cs create mode 100644 src/CodingWithCalvin.MCPServer/Services/CoverageInterop.cs 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);