From fc286dfe8f09bc072fe2b253fffef872e699347a Mon Sep 17 00:00:00 2001 From: flupkede Date: Tue, 20 Oct 2026 13:48:57 +0200 Subject: [PATCH] fix(csharp): evaluate with the global.json-pinned SDK, not the machine default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dotnet build resolves the SDK pinned by global.json from the muxer; the helper registered MSBuild from whatever SDK was the machine's default. When the default is NEWER than the pin — Isaac's VS2026 RC machine, whose default is the .NET 11 RC against repos pinned to 10.x — every project evaluated under the wrong SDK: duplicate Compile items, unresolvable project references, 11 workspace failures, degraded symbol index — while a plain dotnet build of the same solution succeeds. TryRegisterMsBuild now resolves the pin the way the muxer does: exact version, then highest same major.minor (default rollForward band), then highest same major, registering that SDK's own MSBuild; discovery stays the fallback when nothing matches or the probe fails. Prerelease pins (11.0.0-rc.1) compare on their numeric prefix. Reported on HUSQ.Aprimo (features/243800_Warranty_documents): csharp symbol index degraded, 11 distinct failures. --- CHANGELOG.md | 2 + helpers/csharp/MsbuildRestore.cs | 135 ++++++++++++++++++++ helpers/csharp/Program.cs | 69 ++++++++++ helpers/csharp/tests/MsbuildRestoreTests.cs | 82 ++++++++++++ 4 files changed, 288 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2472a24..f0d32f3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ finalized in place with a date — no renaming/migration step needed. - **Adopting an index on `POST /repos` no longer rebuilds the HNSW graph a second time.** The adoption path's incremental refresh already rebuilds the index once under the job gate; the add-repo task still re-ran a full graph build afterwards, doubling the work (serialized behind the job gate) for an identical result. The extra build now runs only on the force-rebuild path, which builds no index internally. +- **The C# helper now evaluates with the SDK pinned by `global.json`, not the machine's default.** `dotnet build` resolves the pinned SDK from the muxer, but the helper registered MSBuild from the default SDK — on machines whose default is newer than the repo's pin (Visual Studio 2026 RC ships the .NET 11 RC), every project evaluated under the wrong SDK and the symbol index degraded with evaluation failures no actual build shows (duplicate `Compile` items, unresolvable project references). The helper now picks the installed SDK the same way the muxer would (exact pin → same minor → same major) and falls back to discovery only when nothing matches. + ## [1.6.0] - 2026-10-18 ### Fixed diff --git a/helpers/csharp/MsbuildRestore.cs b/helpers/csharp/MsbuildRestore.cs index 9517c0bc..ba855a67 100644 --- a/helpers/csharp/MsbuildRestore.cs +++ b/helpers/csharp/MsbuildRestore.cs @@ -29,6 +29,9 @@ internal static class MsbuildRestore /// vswhere probe bound — generous for a local EXE, still bounded. private const int VsWhereWaitMs = 10_000; + /// dotnet --list-sdks probe bound — same class of local metadata probe. + private const int SdkListWaitMs = 10_000; + /// /// True when a dotnet restore failed on SDK resolution — the /// CLI-visible SDK set cannot target the repo's TFM (NETSDK1045) or @@ -304,4 +307,136 @@ private static IEnumerable EnumerateSolutionProjects(string solutionPath dir = parent; } } + + /// + /// The exact SDK version string pinned by the nearest global.json walking + /// up from the solution dir (the host's own lookup order), or null when + /// the nearest global.json pins nothing. dotnet build resolves + /// exactly this version from the muxer; MSBuildLocator only ever reports + /// the muxer's DEFAULT SDK, so on a machine whose default is newer than + /// the pin (VS2026 RC ships the .NET 11 RC) evaluating with it diverges + /// from every plain build and surfaces as evaluation failures no build + /// shows. + /// + internal static string? GlobalJsonSdkVersion(string solutionDir) + { + var dir = Path.GetFullPath(solutionDir); + while (true) + { + var globalJson = Path.Combine(dir, "global.json"); + if (File.Exists(globalJson)) + { + var match = System.Text.RegularExpressions.Regex.Match( + ReadTextBestEffort(globalJson) ?? "", @"""version""\s*:\s*""(\d[^""]*)"""); + return match.Success ? match.Groups[1].Value : null; + } + var parent = Directory.GetParent(dir)?.FullName; + if (string.IsNullOrEmpty(parent) || string.Equals(parent, dir, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + dir = parent; + } + } + + /// + /// dotnet --list-sdks, bounded like the vswhere probe: a local + /// metadata probe whose death costs one fallback, never the restore. + /// Null on any failure — callers keep their discovery path. + /// + internal static string? ListInstalledSdks() + { + try + { + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = "dotnet", + Arguments = "--list-sdks", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + using var process = System.Diagnostics.Process.Start(psi); + if (process is null) + { + return null; + } + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + _ = process.StandardError.ReadToEndAsync(); + if (!process.WaitForExit(SdkListWaitMs)) + { + try + { + process.Kill(entireProcessTree: true); + } + catch + { + // Already exiting — nothing to bound anymore. + } + return null; + } + return stdoutTask.GetAwaiter().GetResult(); + } + catch + { + return null; + } + } + + /// One dotnet --list-sdks entry: SDK version and its root dir. + internal static IReadOnlyList<(string Version, string Root)> ParseListSdks(string output) + { + var result = new List<(string, string)>(); + foreach (var raw in output.Split('\n')) + { + var match = System.Text.RegularExpressions.Regex.Match( + raw.TrimEnd('\r'), @"^\s*(\d+(?:\.\d+)*(?:-[\w.]+)?)\s+\[(.+)\]\s*$"); + if (match.Success) + { + result.Add((match.Groups[1].Value, match.Groups[2].Value.Trim())); + } + } + return result; + } + + /// + /// Which installed SDK the muxer would resolve for a global.json pin: + /// exact version first, then the highest with the same major.minor (the + /// default rollForward band), then the highest with the same major — + /// null when even the major is absent, leaving the caller on its + /// discovery path. Pre-release labels ("11.0.0-rc.1") compare on their + /// numeric prefix. + /// + internal static (string Version, string Root)? ChoosePinnedSdk( + IReadOnlyList<(string Version, string Root)> installed, string pinned) + { + static Version? Loose(string v) => + Version.TryParse(v.Split('-')[0], out var p) ? p : null; + var pin = Loose(pinned); + if (pin is null) + { + return null; + } + var entries = installed + .Select(e => (Entry: e, V: Loose(e.Version))) + .Where(e => e.V is not null) + .OrderByDescending(e => e.V) + .ToList(); + (string, string)? best = null; + var bestTier = int.MaxValue; + foreach (var e in entries) + { + var v = e.V!; + var tier = + v == pin ? 1 : + v.Major == pin.Major && v.Minor == pin.Minor ? 2 : + v.Major == pin.Major ? 3 : int.MaxValue; + if (tier < bestTier) + { + best = e.Entry; + bestTier = tier; + } + } + return best; + } } diff --git a/helpers/csharp/Program.cs b/helpers/csharp/Program.cs index ca7c855d..d2106190 100644 --- a/helpers/csharp/Program.cs +++ b/helpers/csharp/Program.cs @@ -1161,6 +1161,51 @@ private static string BuildFilteredSolution(string[] lines, HashSet skipLin // ── MSBuild registration ───────────────────────────────────────── + /// + /// Register the MSBuild from the global.json-pinned SDK: pick the + /// installed SDK the muxer would resolve for the pin, verify it actually + /// carries an MSBuild, and register it. False (nothing registered) keeps + /// the discovery path — the pin is an improvement, never a requirement. + /// + private static bool TryRegisterPinnedSdk(string pinned) + { + try + { + var sdksOutput = MsbuildRestore.ListInstalledSdks(); + if (sdksOutput is null) + { + return false; + } + var pick = MsbuildRestore.ChoosePinnedSdk(MsbuildRestore.ParseListSdks(sdksOutput), pinned); + if (pick is null) + { + return false; + } + var msbuildBin = Path.Combine(pick.Value.Root, "MSBuild", "Current", "Bin"); + if (!File.Exists(Path.Combine(msbuildBin, "MSBuild.dll"))) + { + return false; + } + Console.Error.WriteLine( + $"MSBuild: registering global.json-pinned SDK {pick.Value.Version} at {msbuildBin} " + + "(matches what `dotnet build` resolves)."); + MSBuildLocator.RegisterMSBuildPath(msbuildBin); + var msbuildExe = Path.Combine(msbuildBin, "MSBuild.exe"); + if (File.Exists(msbuildExe)) + { + Environment.SetEnvironmentVariable("MSBUILD_EXE_PATH", msbuildExe); + } + return true; + } + catch (Exception ex) + { + Console.Error.WriteLine( + $"[INFO] pinned-SDK registration failed ({ex.GetType().Name}: {ex.Message}) — " + + "falling back to discovery."); + return false; + } + } + /// /// Register the MSBuild instance the workspace will evaluate through. /// Capability-aware since the 2026-10-15 net10 incident: when every @@ -1184,6 +1229,30 @@ private static bool TryRegisterMsBuild( return true; } + // global.json pin first. `dotnet build` resolves the pinned SDK from + // the muxer; MSBuildLocator only reports the muxer's DEFAULT SDK — + // on a machine whose default is newer than the pin (VS2026 RC ships + // the .NET 11 RC), evaluating with it diverges from every plain + // build and surfaces as evaluation failures no build shows. Register + // the pinned SDK's own MSBuild when one matches; discovery stays the + // fallback. + if (solutionPath is not null && File.Exists(solutionPath)) + { + var pinDir = Path.GetDirectoryName(Path.GetFullPath(solutionPath)) ?? "."; + var pinned = MsbuildRestore.GlobalJsonSdkVersion(pinDir); + if (pinned is not null) + { + if (TryRegisterPinnedSdk(pinned)) + { + errorMessage = null; + return true; + } + Console.Error.WriteLine( + $"[INFO] global.json pins SDK {pinned} but no matching installed SDK was found — " + + "falling back to MSBuild discovery."); + } + } + try { var instances = MSBuildLocator diff --git a/helpers/csharp/tests/MsbuildRestoreTests.cs b/helpers/csharp/tests/MsbuildRestoreTests.cs index fb695a64..159e3936 100644 --- a/helpers/csharp/tests/MsbuildRestoreTests.cs +++ b/helpers/csharp/tests/MsbuildRestoreTests.cs @@ -225,6 +225,88 @@ public void NetFrameworkSpellingsDoNotRaiseTheFloor() Assert.Equal(10, MsbuildRestore.RequiredSdkMajorFloor(mixed)); } + // ── global.json pin: the exact SDK `dotnet build` would use ─────── + + [Fact] + public void GlobalJsonSdkVersion_ReturnsTheFullPin() + { + var solutionPath = WriteSolution(("src\\A\\A.csproj", "net10.0")); + var solutionDir = Path.GetDirectoryName(Path.GetFullPath(solutionPath))!; + WriteGlobalJson(Path.Combine(solutionDir, "global.json"), "10.0.100"); + + Assert.Equal("10.0.100", MsbuildRestore.GlobalJsonSdkVersion(solutionDir)); + } + + [Fact] + public void GlobalJsonSdkVersion_NullWhenNothingPins() + { + var solutionPath = WriteSolution(("src\\A\\A.csproj", "net10.0")); + var solutionDir = Path.GetDirectoryName(Path.GetFullPath(solutionPath))!; + + Assert.Null(MsbuildRestore.GlobalJsonSdkVersion(solutionDir)); + } + + [Fact] + public void GlobalJsonSdkVersion_NearestFileWinsAndStopsTheWalk() + { + var solutionPath = WriteSolution(("src\\A\\A.csproj", "net10.0")); + var solutionDir = Path.GetDirectoryName(Path.GetFullPath(solutionPath))!; + // The nearest global.json (the project dir) pins nothing → null, like + // the host itself; the root's pin must NOT be reached past it. + WriteGlobalJson(Path.Combine(solutionDir, "src", "A", "global.json"), "{}"); + WriteGlobalJson(Path.Combine(solutionDir, "global.json"), "9.0.300"); + + Assert.Null(MsbuildRestore.GlobalJsonSdkVersion(Path.Combine(solutionDir, "src", "A"))); + } + + [Fact] + public void ParseListSdks_ParsesVersionsAndRoots_SkipsJunk() + { + var output = string.Join("\r\n", + "8.0.404 [C:\\Program Files\\dotnet\\sdk]", + "", + "10.0.100 [C:\\Program Files\\dotnet\\sdk]", + "11.0.0-rc.1 [C:\\Program Files\\dotnet\\sdk]"); + + var parsed = MsbuildRestore.ParseListSdks(output); + + Assert.Equal(3, parsed.Count); + Assert.Equal("8.0.404", parsed[0].Version); + Assert.Equal("C:\\Program Files\\dotnet\\sdk", parsed[0].Root); + Assert.Equal("11.0.0-rc.1", parsed[2].Version); + } + + [Fact] + public void ChoosePinnedSdk_ExactPinBeatsHigherSameBand() + { + var installed = new List<(string Version, string Root)> + { + ("9.0.300", "r9"), ("10.0.100", "r10a"), ("10.0.200", "r10b"), ("11.0.0-rc.1", "r11"), + }; + + var pick = MsbuildRestore.ChoosePinnedSdk(installed, "10.0.100"); + + Assert.Equal(("10.0.100", "r10a"), pick); + } + + [Fact] + public void ChoosePinnedSdk_DefaultRollForwardBand_ThenMajor_ThenNull() + { + var installed = new List<(string Version, string Root)> + { + ("9.0.300", "r9"), ("10.0.100", "r10a"), ("10.0.200", "r10b"), ("11.0.0-rc.1", "r11"), + }; + + // Default rollForward band: same major.minor, highest patch. + Assert.Equal(("10.0.200", "r10b"), MsbuildRestore.ChoosePinnedSdk(installed, "10.0.50")); + // Band absent: highest with the same major. + Assert.Equal(("9.0.300", "r9"), MsbuildRestore.ChoosePinnedSdk(installed, "9.0.999")); + // Pre-release pin compares on its numeric prefix. + Assert.Equal(("11.0.0-rc.1", "r11"), MsbuildRestore.ChoosePinnedSdk(installed, "11.0.100")); + // Major absent: nothing matches — the caller keeps discovery. + Assert.Null(MsbuildRestore.ChoosePinnedSdk(installed, "12.0.100")); + } + private static void WriteGlobalJson(string path, string version) { var content = version.StartsWith('{')