diff --git a/AGENTS.develop.md b/AGENTS.develop.md index 3ba2eddf..b68a6d40 100644 --- a/AGENTS.develop.md +++ b/AGENTS.develop.md @@ -241,6 +241,7 @@ Never `unwrap_or_default()` a store error on a search path — "no results" and ## Changelog highlights (recent) +- **v1.5.31 (pending)** — scip-csharp VS-MSBuild restore fallback: NETSDK1045-class CLI SDK gaps (hollow SDK dir, net10-only-in-VS) retry via vswhere + `MSBuild -t:restore`, and MSBuild registration picks a VS instance when no CLI SDK can target the repo - **v1.5.31 (pending)** — serve TUI status: `Indexing` only for real text indexing (pure SCIP rebuilds drive the C#/TS columns only); resting repos read `Idle` again - **v1.5.16 (pending)** — C# restore-pending retries 20×60s → 3 attempts with exponential backoff; restore exhaustion renders as yellow C#⚠ partial on every serve path instead of a red error - **v1.5.6 (pending)** — scip-csharp demotes MSBuild check advisories + BuildHost shutdown chatter from index warnings diff --git a/CHANGELOG.md b/CHANGELOG.md index b6f950d3..724cab8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ finalized in place with a date — no renaming/migration step needed. ### Changed +- **scip-csharp retries the solution restore with the VS MSBuild when the CLI SDK set cannot target the repo.** When `dotnet restore` fails on SDK resolution (NETSDK1045 — CLI SDKs older than the repo's TFM — or an unsatisfiable `global.json`; detection reads stdout AND stderr, because NETSDK1045 prints to stdout on current hosts), the helper locates the newest VS MSBuild via vswhere and retries the restore once with `MSBuild -t:restore` — VS ships its own MSBuild + SDK targets independent of `C:\Program Files\dotnet`, so it restores solutions the CLI cannot even evaluate. Timeout keeps the existing pending-marker/gated-exit-75 contract (a hung VS restore is never killed); on VS failure the per-project fallback still runs. The workspace-load MSBuild registration is now capability-aware too: when every CLI SDK instance is older than the repo's raw-text TFM floor (max over all `TargetFrameworks` + the nearest `global.json` major), the highest VS instance is registered instead — otherwise an SDK 8 instance would evaluate net10 projects into certain NETSDK1045 load failures even with fresh assets. On healthy machines the choice is unchanged (single-SDK machines byte-identical; multi-SDK machines now deterministically take the highest qualifying SDK, was first-enumerated). Motivated by the 2026-10-15 field incident: a hollow `10.0.101` SDK dir left the CLI blind to net10 on a machine where VS built fine, degrading every net10 repo's symbol index to "0 projects loaded". + - **The serve TUI status column no longer shows `Indexing` for a pure symbol (SCIP) rebuild.** Only text-indexing flows (startup warmup, file-watcher batches, explicit reindex) light the general repo label; a C#/TypeScript symbol rebuild — including a long restore-pending retry loop — drives only the language columns, so a busy C# pass can no longer read as "the normal index is stuck behind the SCIP index". A queued phase-2 rebuild stays visible in the C# column only. The `/status` wire vocabulary is unchanged. A repo whose index is on disk but which is not loaded renders `Idle` again — the interim `idx` rendering read as active indexing on done, non-SCIP repos. ## [1.5.30] diff --git a/helpers/csharp/MsbuildRestore.cs b/helpers/csharp/MsbuildRestore.cs new file mode 100644 index 00000000..b011207d --- /dev/null +++ b/helpers/csharp/MsbuildRestore.cs @@ -0,0 +1,305 @@ +namespace ScipCsharp; + +/// +/// One installable MSBuild candidate for the capability-aware instance +/// choice: either the MSBuild of a .NET SDK install (CLI-visible) or of a +/// Visual Studio instance (bundled with VS, independent of +/// C:\Program Files\dotnet). Dependency-free so the decision logic +/// is unit-testable without spinning up real installs. +/// +internal readonly record struct MsbuildCandidate(Version Version, bool IsDotNetSdk); + +internal static class MsbuildRestore +{ + /// + /// vswhere ships in the fixed VS Installer location on every Windows + /// machine; probing it anywhere else just multiplies failure modes. + /// + private const string VsWherePath = @"C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe"; + + // NETSDK1045: "The current .NET SDK does not support targeting .NET X.0". + private const string SdkDoesNotSupportTargeting = "does not support targeting"; + // Current hosts: "A compatible .NET SDK was not found" (global.json + // unsatisfiable); legacy hosts worded it "A compatible installed .NET + // SDK for global.json at ... was not found." Match both — neither + // phrasing contains the other. + private const string SdkGlobalJsonNotFoundModern = "compatible .NET SDK"; + private const string SdkGlobalJsonNotFoundLegacy = "compatible installed .NET SDK"; + + /// vswhere probe bound — generous for a local EXE, still bounded. + private const int VsWhereWaitMs = 10_000; + + /// + /// True when a dotnet restore failed on SDK resolution — the + /// CLI-visible SDK set cannot target the repo's TFM (NETSDK1045) or + /// cannot satisfy global.json — rather than on packages. These are the + /// failures a Visual Studio install can still recover: VS ships its own + /// MSBuild + SDK targets, blind spots of C:\Program Files\dotnet + /// included (2026-10-15 incident: a hollow 10.0.101 SDK dir left the + /// CLI unable to target net10 while VS built fine). Feed/auth failures + /// (NU11xx) deliberately do NOT match — VS restores through the same + /// NuGet stack and would fail identically, so retrying those buys a + /// second slow failure. + /// + internal static bool IsSdkResolutionFailure(string restoreOutput) => + restoreOutput.Contains(SdkDoesNotSupportTargeting, StringComparison.OrdinalIgnoreCase) + || restoreOutput.Contains(SdkGlobalJsonNotFoundModern, StringComparison.OrdinalIgnoreCase) + || restoreOutput.Contains(SdkGlobalJsonNotFoundLegacy, StringComparison.OrdinalIgnoreCase); + + /// + /// Full path of the newest VS MSBuild via vswhere, or null when vswhere, + /// VS, or the MSBuild component is absent (non-Windows included). + /// Best effort by design: a probe failure must never break the restore + /// path it is meant to improve. -prerelease includes Insiders + /// channels; -find (vswhere ≥ 2.8) returns MSBuild.exe paths + /// directly, newest instance only via -latest. + /// + internal static string? LocateVsMsbuild() + { + if (!OperatingSystem.IsWindows() || !File.Exists(VsWherePath)) + { + return null; + } + try + { + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = VsWherePath, + Arguments = "-latest -prerelease -products * -requires Microsoft.Component.MSBuild " + + "-find MSBuild\\**\\Bin\\MSBuild.exe", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + using var process = System.Diagnostics.Process.Start(psi); + if (process is null) + { + return null; + } + // Drain async, THEN bounded-wait — and KILL on timeout: unlike a + // restore (never killed — its work must survive), vswhere is a + // local metadata probe whose death costs one failed probe. The + // kill also closes the pipes, so the stdout read cannot block. + var stdoutTask = process.StandardOutput.ReadToEndAsync(); + _ = process.StandardError.ReadToEndAsync(); + if (!process.WaitForExit(VsWhereWaitMs)) + { + try + { + process.Kill(entireProcessTree: true); + } + catch + { + // Already exiting — nothing to bound anymore. + } + return null; + } + var path = ParseFirstVswherePath(stdoutTask.GetAwaiter().GetResult()); + return path is not null && File.Exists(path) ? path : null; + } + catch + { + return null; + } + } + + /// First non-blank line of vswhere -find output. + internal static string? ParseFirstVswherePath(string vswhereOutput) + { + foreach (var raw in vswhereOutput.Split('\n')) + { + var line = raw.Trim().TrimEnd('\r'); + if (line.Length > 0) + { + return line; + } + } + return null; + } + + /// + /// The highest .NET major the solution's projects provably target: + /// max over every .csproj's first TargetFramework(s) raw-text + /// match (raw text — this runs BEFORE any MSBuild evaluation, which is + /// the whole point) and over global.json's pinned SDK version when one + /// sits at or above the solution dir. null when nothing parseable was + /// found — callers must then keep the default instance choice, because + /// an unknown floor proves nothing. + /// + internal static int? RequiredSdkMajorFloor(string? solutionPath) + { + if (solutionPath is null || !File.Exists(solutionPath)) + { + return null; + } + var solutionDir = Path.GetDirectoryName(Path.GetFullPath(solutionPath)) ?? "."; + int? floor = null; + foreach (var projectPath in EnumerateSolutionProjects(solutionPath, solutionDir)) + { + var major = TargetFrameworkMajor(ReadTextBestEffort(projectPath)); + if (major is not null && (floor is null || major > floor)) + { + floor = major; + } + } + var globalJsonMajor = GlobalJsonSdkMajor(solutionDir); + if (globalJsonMajor is not null && (floor is null || globalJsonMajor > floor)) + { + floor = globalJsonMajor; + } + return floor; + } + + /// + /// Which install to register MSBuild from: the highest .NET SDK instance + /// that can target (matching the + /// "register the highest available MSBuild instance" intent, unlike the + /// historical first-match), falling back to the highest VS instance when + /// no SDK instance qualifies — VS bundles its own SDK targets, so it can + /// evaluate TFMs the installed CLI SDKs cannot. The VS path is the + /// documented net472-BuildHost-crash risk, accepted ONLY when the SDK + /// path is certain to fail the load anyway. Returns the index of the + /// chosen candidate within and whether it + /// is an SDK instance (for logging the flip explicitly). + /// + internal static (int Index, bool IsSdk) ChooseMsBuildInstance( + IReadOnlyList candidates, + int? requiredMajor) + { + var sdkCandidates = candidates + .Select((c, i) => (Candidate: c, Index: i)) + .Where(e => e.Candidate.IsDotNetSdk) + .OrderByDescending(e => e.Candidate.Version) + .ToList(); + if (sdkCandidates.Count > 0) + { + var qualifying = requiredMajor is null + ? sdkCandidates[0] + : sdkCandidates.FirstOrDefault(e => e.Candidate.Version.Major >= requiredMajor); + if (!qualifying.Equals(default)) + { + return (qualifying.Index, true); + } + } + // No SDK instance can target the repo (or none exists): highest + // instance overall — a VS install when present, else whatever the + // SDK set offered (preserves the old behaviour as the last resort). + var overall = candidates + .Select((c, i) => (Candidate: c, Index: i)) + .OrderByDescending(e => e.Candidate.Version) + .First(); + return (overall.Index, overall.Candidate.IsDotNetSdk); + } + + private static IEnumerable EnumerateSolutionProjects(string solutionPath, string solutionDir) + { + string[] lines; + try + { + lines = File.ReadAllLines(solutionPath); + } + catch + { + yield break; + } + // Project("{GUID}") = "Name", "Relative\Path.csproj", "{GUID}" + foreach (var line in lines) + { + var trimmed = line.Trim(); + if (!trimmed.StartsWith("Project(", StringComparison.Ordinal)) + { + continue; + } + var parts = trimmed.Split(','); + if (parts.Length < 2) + { + continue; + } + var pathPart = parts[1].Trim(); + if (pathPart.Length < 2 || !pathPart.StartsWith('"') || !pathPart.EndsWith('"')) + { + continue; + } + var relative = pathPart[1..^1]; + if (relative.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)) + { + yield return Path.GetFullPath( + relative.Replace('\\', Path.DirectorySeparatorChar), Path.GetFullPath(solutionDir)); + } + } + } + + private static string? ReadTextBestEffort(string path) + { + try + { + return File.Exists(path) ? File.ReadAllText(path) : null; + } + catch + { + return null; + } + } + + /// + /// The HIGHEST netX.Y major in raw project text — a floor wants the max, + /// so a net8.0;net10.0 multi-target floors at 10 (unlike the EOL + /// skip-guard, whose first-TFM stance serves a different purpose). + /// netstandard/netcoreapp deliberately do not match — they load under + /// any modern SDK, and EOL ones are skipped anyway. The minor dot is + /// REQUIRED: the extension-less .NET Framework spellings (net48, + /// net472) must not raise the floor — an SDK loads those from reference + /// assemblies, and a "48" floor would flip healthy SDK+VS machines to + /// the VS instance for no reason. + /// + private static int? TargetFrameworkMajor(string? projectText) + { + if (projectText is null) + { + return null; + } + var match = System.Text.RegularExpressions.Regex.Match( + projectText, @"([^<]+) floor)) + { + floor = major; + } + } + return floor; + } + + /// + /// Major of the SDK version pinned by the nearest global.json walking + /// up from the solution dir — NuGet's own lookup order. rollForward + /// never lowers the floor, so the pinned major is the safe bound. + /// + private static int? GlobalJsonSdkMajor(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 && int.TryParse(match.Groups[1].Value, out var major) ? major : null; + } + var parent = Directory.GetParent(dir)?.FullName; + if (string.IsNullOrEmpty(parent) || string.Equals(parent, dir, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + dir = parent; + } + } +} diff --git a/helpers/csharp/Program.cs b/helpers/csharp/Program.cs index 846d97da..b1fce4fd 100644 --- a/helpers/csharp/Program.cs +++ b/helpers/csharp/Program.cs @@ -86,7 +86,7 @@ private static async Task RunServeAsync(string[] args) var parsed = ParseServeArgs(args); if (parsed is null) return 1; - if (!TryRegisterMsBuild(out var regErr)) { await Console.Error.WriteLineAsync(regErr).ConfigureAwait(false); return 1; } + if (!TryRegisterMsBuild(parsed, out var regErr)) { await Console.Error.WriteLineAsync(regErr).ConfigureAwait(false); return 1; } // A resident workspace must not come up mid-restore: the host CACHES // its find-refs answers, so degraded answers from a workspace loaded @@ -167,7 +167,7 @@ private static async Task RunIndexAsync(string[] args) var parsed = ParseIndexArgs(args); if (parsed is null) return 1; - if (!TryRegisterMsBuild(out var regErr)) { await Console.Error.WriteLineAsync(regErr).ConfigureAwait(false); return 1; } + if (!TryRegisterMsBuild(parsed.Value.SolutionPath, out var regErr)) { await Console.Error.WriteLineAsync(regErr).ConfigureAwait(false); return 1; } using var workspace = CreateTolerantWorkspace(); @@ -238,7 +238,7 @@ private static async Task RunFindRefsAsync(string[] args) var parsed = ParseFindRefsArgs(args); if (parsed is null) return 1; - if (!TryRegisterMsBuild(out var regErr)) { await Console.Error.WriteLineAsync(regErr).ConfigureAwait(false); return 1; } + if (!TryRegisterMsBuild(parsed.Value.SolutionPath, out var regErr)) { await Console.Error.WriteLineAsync(regErr).ConfigureAwait(false); return 1; } using var workspace = CreateTolerantWorkspace(); @@ -299,7 +299,7 @@ private static async Task RunBatchFindRefsAsync(string[] args) var parsed = ParseBatchFindRefsArgs(args); if (parsed is null) return 1; - if (!TryRegisterMsBuild(out var regErr)) { await Console.Error.WriteLineAsync(regErr).ConfigureAwait(false); return 1; } + if (!TryRegisterMsBuild(parsed.Value.SolutionPath, out var regErr)) { await Console.Error.WriteLineAsync(regErr).ConfigureAwait(false); return 1; } using var workspace = CreateTolerantWorkspace(); @@ -534,8 +534,51 @@ private static List RestoreSolution( { return []; } - var stderr = stderrDrain.GetAwaiter().GetResult(); - var nuDetail = RestorePolicy.ExtractRestoreErrors(stderr); + // NETSDK1045 and friends print to STDOUT on current dotnet hosts + // (NU11xx detail may land on either stream) — classify over both. + var restoreOutput = stderrDrain.GetAwaiter().GetResult() + + Environment.NewLine + stdoutDrain.GetAwaiter().GetResult(); + var nuDetail = RestorePolicy.ExtractRestoreErrors(restoreOutput); + if (MsbuildRestore.IsSdkResolutionFailure(restoreOutput)) + { + // The CLI SDK set cannot even target this solution — packages + // were never the problem, so the per-project fallback (same + // dotnet, same SDK wall) can only re-fail per project. A + // Visual Studio install ships its own MSBuild + SDK targets + // and can restore the full solution in one go. + var vsMsbuild = MsbuildRestore.LocateVsMsbuild(); + if (vsMsbuild is null) + { + Console.Error.WriteLine( + "[INFO] no VS MSBuild found via vswhere — the CLI SDK gap cannot be worked around."); + } + else + { + Console.Error.WriteLine( + "[INFO] the CLI .NET SDK set cannot target this solution — retrying the restore " + + $"with VS MSBuild at '{vsMsbuild}'."); + switch (TryRestoreSolutionWithVsMsbuild( + vsMsbuild, solutionPath, timeoutSeconds, gateRestorePending)) + { + case VsRestoreOutcome.Restored: + Console.Error.WriteLine( + "[INFO] VS MSBuild restore succeeded — full solution assets restored."); + return []; + case VsRestoreOutcome.StillRunningUngated: + // The VS restore keeps writing obj/ in the + // background; per-project dotnet restores would + // race those writes, so degrade with an honest + // per-project reason instead. + return loadable.Select(e => new ProjectRestoreFailure( + Path.GetFileNameWithoutExtension(e.RelativePath), + e.RelativePath, + "not attempted — a VS MSBuild restore is still running")).ToList(); + } + // Failed → the per-project fallback below still runs: in a + // mixed-TFM solution it recovers the projects the CLI CAN + // target. + } + } var fallback = RestorePerProjectFallback( solutionPath, solutionDir: Path.GetDirectoryName(solutionPath) ?? ".", loadable, timeoutSeconds, gateRestorePending); @@ -579,6 +622,83 @@ private static List RestoreSolution( } } + /// Outcome of the one-shot VS-MSBuild solution restore. + private enum VsRestoreOutcome + { + Restored, + Failed, + /// Timed out ungated: the restore keeps running, serve degrades. + StillRunningUngated, + } + + /// + /// One bounded attempt to restore the solution with a VS MSBuild instead + /// of the CLI dotnet — the fallback for SDK-resolution failures, where + /// the machine's Visual Studio can do what its CLI SDK set cannot. Same + /// contract as the dotnet restore above: never killed on timeout (the + /// marker gates later runs), gated commands exit + /// instead of building a degraded + /// index. -nr:false because the helper is a short-lived subprocess + /// on a hot serve loop and must not leave MSBuild worker nodes behind. + /// + private static VsRestoreOutcome TryRestoreSolutionWithVsMsbuild( + string msbuildPath, + string solutionPath, + int timeoutSeconds, + bool gateRestorePending) + { + try + { + var psi = new ProcessStartInfo + { + FileName = msbuildPath, + Arguments = $"\"{solutionPath}\" -t:restore -nologo -v:q -nr:false", + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + }; + using var process = Process.Start(psi); + if (process is null) + { + return VsRestoreOutcome.Failed; + } + _ = process.StandardOutput.ReadToEndAsync(); + var stderrDrain = process.StandardError.ReadToEndAsync(); + if (!process.WaitForExit(timeoutSeconds * 1000)) + { + RestorePendingMarker.Write( + solutionPath, process.Id, process.StartTime.ToUniversalTime()); + if (gateRestorePending) + { + Console.Error.WriteLine( + "[RESTORE-PENDING] the VS MSBuild restore is still running — exiting " + + $"{ExitCodes.RestorePending} without building an index; retry when it finishes."); + throw new RestorePendingException(); + } + Console.Error.WriteLine(RestorePolicy.StillRunningWarning(timeoutSeconds, "VS MSBuild restore")); + return VsRestoreOutcome.StillRunningUngated; + } + if (process.ExitCode == 0) + { + return VsRestoreOutcome.Restored; + } + Console.Error.WriteLine( + "[INFO] VS MSBuild restore failed" + + RestorePolicy.ExtractRestoreErrors(stderrDrain.GetAwaiter().GetResult())); + return VsRestoreOutcome.Failed; + } + catch (RestorePendingException) + { + throw; + } + catch (Exception ex) + { + Console.Error.WriteLine( + $"[INFO] VS MSBuild restore could not start ({ex.GetType().Name}: {ex.Message})."); + return VsRestoreOutcome.Failed; + } + } + /// /// The per-project fallback after a failed solution restore: NuGet is /// all-or-nothing per restore, so one dead package fails every project — @@ -1015,10 +1135,19 @@ private static string BuildFilteredSolution(string[] lines, HashSet skipLin // ── MSBuild registration ───────────────────────────────────────── /// - /// Register the highest available MSBuild instance. - /// Returns true on success; sets on failure. + /// Register the MSBuild instance the workspace will evaluate through. + /// Capability-aware since the 2026-10-15 net10 incident: when every + /// CLI-visible .NET SDK is older than the repo's target frameworks + /// (raw TargetFramework scan + global.json floor via + /// ), an SDK instance + /// would fail every project with NETSDK1045 during evaluation — in that + /// case the highest VS instance wins instead, because VS bundles its own + /// SDK targets and can evaluate TFMs the CLI cannot. Returns true on + /// success; sets on failure. /// - private static bool TryRegisterMsBuild([System.Diagnostics.CodeAnalysis.NotNullWhen(false)] out string? errorMessage) + private static bool TryRegisterMsBuild( + string? solutionPath, + [System.Diagnostics.CodeAnalysis.NotNullWhen(false)] out string? errorMessage) { // Guard against double-registration in case the function is ever called twice // in the same process lifetime (e.g. future test harnesses, daemon mode). @@ -1045,10 +1174,22 @@ private static bool TryRegisterMsBuild([System.Diagnostics.CodeAnalysis.NotNullW // Prefer .NET SDK instances over Visual Studio instances. // The VS BuildHost (net472) crashes with TypeInitializationException - // when loading solutions that contain certain project types. - var sdkInstance = instances - .FirstOrDefault(i => i.DiscoveryType == DiscoveryType.DotNetSdk); - var instance = sdkInstance ?? instances.OrderByDescending(i => i.Version).First(); + // when loading solutions that contain certain project types — so the + // VS instance is only chosen when no SDK instance can even target + // the repo (certain NETSDK1045 load failure either way). + var requiredMajor = MsbuildRestore.RequiredSdkMajorFloor(solutionPath); + var candidates = instances + .Select(i => new MsbuildCandidate(i.Version, i.DiscoveryType == DiscoveryType.DotNetSdk)) + .ToList(); + var (index, isSdk) = MsbuildRestore.ChooseMsBuildInstance(candidates, requiredMajor); + var instance = instances[index]; + if (!isSdk) + { + Console.Error.WriteLine( + $"[INFO] no CLI .NET SDK instance can target net{requiredMajor} projects of " + + $"'{solutionPath}' — registering the VS MSBuild '{instance.Name}' v{instance.Version} " + + "instead (VS-bundled SDK targets)."); + } Console.Error.WriteLine( $"MSBuild: registering '{instance.Name}' v{instance.Version} at {instance.MSBuildPath}"); diff --git a/helpers/csharp/RestorePolicy.cs b/helpers/csharp/RestorePolicy.cs index 7eeb9fd9..6b638818 100644 --- a/helpers/csharp/RestorePolicy.cs +++ b/helpers/csharp/RestorePolicy.cs @@ -233,18 +233,19 @@ public static int RestoreTimeoutSeconds() } /// - /// The line emitted when the bounded wait expires while - /// dotnet restore is still running — serve mode only: gated - /// commands exit instead. The - /// restore is NOT killed: it keeps running in the background (the child - /// outlives the helper), serve keeps answering from the existing - /// workspace, and any later index/find-refs run is gated by the restore - /// marker until the fresh assets land. INFO, never WARN: restore state - /// must not drive the caller-facing warning icon. Pinned by test so a - /// "killed" wording cannot sneak back in. + /// The line emitted when the bounded wait expires while a restore is + /// still running — serve mode only: gated commands exit + /// instead. The restore is NOT + /// killed: it keeps running in the background (the child outlives this + /// helper), serve keeps answering from the existing workspace, and any + /// later index/find-refs run is gated by the restore marker until the + /// fresh assets land. INFO, never WARN: restore state must not drive the + /// caller-facing warning icon. Pinned by test so a "killed" wording + /// cannot sneak back in. names the restore + /// flavor still running (dotnet restore, or the VS MSBuild fallback). /// - public static string StillRunningWarning(int seconds) => - $"[INFO] dotnet restore still running after {seconds}s — serve keeps answering from the " + + public static string StillRunningWarning(int seconds, string tool = "dotnet restore") => + $"[INFO] {tool} still running after {seconds}s — serve keeps answering from the " + "existing workspace (degraded symbols) while restore continues in the background; " + "index and find-refs runs are gated until it finishes and the next reindex picks up " + $"the fresh assets. Raise with {TimeoutEnvName} if this solution legitimately needs longer."; diff --git a/helpers/csharp/tests/MsbuildRestoreTests.cs b/helpers/csharp/tests/MsbuildRestoreTests.cs new file mode 100644 index 00000000..fb695a64 --- /dev/null +++ b/helpers/csharp/tests/MsbuildRestoreTests.cs @@ -0,0 +1,235 @@ +using ScipCsharp; +using Xunit; + +namespace ScipCsharp.Tests; + +/// +/// Unit tests for the VS-MSBuild restore fallback's pure logic: SDK- +/// resolution failure detection (the feature's trigger — pinned against +/// the exact host wordings seen live on 2026-10-15, incl. NETSDK1045 +/// printing to stdout), vswhere -find output parsing, the capability-aware +/// MSBuild instance choice, and the repo TFM floor scan. +/// +public class MsbuildRestoreTests : IDisposable +{ + private readonly string _root; + + public MsbuildRestoreTests() + { + _root = Path.Combine(Path.GetTempPath(), "cs-msbuild-restore-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_root); + } + + public void Dispose() + { + Directory.Delete(_root, recursive: true); + } + + // ── IsSdkResolutionFailure ──────────────────────────────────────── + + [Fact] + public void Netsdk1045StdoutWordingTriggers() + { + // Verbatim shape of the live net99.0 probe output (stdout), the + // 2026-10-15 incident's failure mode. + var stdout = + "C:\\Program Files\\dotnet\\sdk\\8.0.425\\Sdks\\Microsoft.NET.Sdk\\targets\\" + + "Microsoft.NET.TargetFrameworkInference.targets(166,5): error NETSDK1045: " + + "The current .NET SDK does not support targeting .NET 10.0. Either target " + + ".NET 8.0 or lower, or use a version of the .NET SDK that supports .NET 10.0."; + + Assert.True(MsbuildRestore.IsSdkResolutionFailure(stdout)); + } + + [Theory] + [InlineData("A compatible .NET SDK was not found. Install or update the .NET SDK.")] // current host + [InlineData("A compatible installed .NET SDK for global.json at [C:\\repo\\global.json] was not found.")] // legacy host + public void GlobalJsonUnsatisfiableWordingsTriggerBothHostGenerations(string line) + { + Assert.True(MsbuildRestore.IsSdkResolutionFailure(line)); + } + + [Theory] + [InlineData("error NU1101: Unable to find package Vendor.Internal.Base. No packages exist with this id in source(s): private-feed.")] + [InlineData("App.Core.csproj: error NU1301: Unable to load the service index for source https://pkgs.dev.azure.com/org/_packaging/private-feed/nuget/v3/index.json.")] + [InlineData("")] + public void FeedAndAuthFailuresDoNotTrigger(string line) + { + // VS restores through the same NuGet stack — retrying those buys a + // second slow failure, so they must fall through to per-project. + Assert.False(MsbuildRestore.IsSdkResolutionFailure(line)); + } + + // ── ParseFirstVswherePath ───────────────────────────────────────── + + [Fact] + public void VswhereParsingSkipsBlankLines() + { + var output = "\r\nC:\\Program Files\\Microsoft Visual Studio\\18\\Professional\\MSBuild\\Current\\Bin\\MSBuild.exe\r\n\r\n"; + + Assert.Equal( + "C:\\Program Files\\Microsoft Visual Studio\\18\\Professional\\MSBuild\\Current\\Bin\\MSBuild.exe", + MsbuildRestore.ParseFirstVswherePath(output)); + } + + [Fact] + public void VswhereParsingEmptyOutputYieldsNull() + { + Assert.Null(MsbuildRestore.ParseFirstVswherePath("")); + } + + // ── ChooseMsBuildInstance ───────────────────────────────────────── + + private static MsbuildCandidate Sdk(int major) => new(new Version(major, 0), IsDotNetSdk: true); + + private static MsbuildCandidate Vs(int major) => new(new Version(major, 0), IsDotNetSdk: false); + + [Fact] + public void NoFloorKeepsHighestSdk() + { + var candidates = new[] { Sdk(8), Sdk(10), Vs(18) }; + + var (index, isSdk) = MsbuildRestore.ChooseMsBuildInstance(candidates, requiredMajor: null); + + Assert.Equal((1, true), (index, isSdk)); + } + + [Fact] + public void QualifyingSdkBeatsNewerVs() + { + var candidates = new[] { Sdk(10), Vs(18) }; + + var (index, isSdk) = MsbuildRestore.ChooseMsBuildInstance(candidates, requiredMajor: 10); + + Assert.Equal((0, true), (index, isSdk)); + } + + [Fact] + public void SdkOlderThanFloorFallsToVs() + { + // The incident layout: CLI SDK 8 only, repo targets net10, VS 18 present. + var candidates = new[] { Sdk(8), Vs(18) }; + + var (index, isSdk) = MsbuildRestore.ChooseMsBuildInstance(candidates, requiredMajor: 10); + + Assert.Equal((1, false), (index, isSdk)); + } + + [Fact] + public void SdkOlderThanFloorWithoutVsKeepsSdkAsLastResort() + { + var candidates = new[] { Sdk(8) }; + + var (index, isSdk) = MsbuildRestore.ChooseMsBuildInstance(candidates, requiredMajor: 10); + + Assert.Equal((0, true), (index, isSdk)); + } + + [Fact] + public void HighestQualifyingSdkWinsAmongSdkSiblings() + { + var candidates = new[] { Sdk(8), Sdk(10), Sdk(11) }; + + var (index, isSdk) = MsbuildRestore.ChooseMsBuildInstance(candidates, requiredMajor: 10); + + Assert.Equal((2, true), (index, isSdk)); + } + + // ── RequiredSdkMajorFloor ───────────────────────────────────────── + + private string WriteSolution(params (string RelativePath, string TargetFrameworks)[] projects) + { + var solutionLines = new List { "Microsoft Visual Studio Solution File, Format Version 12.00", "Global" }; + foreach (var (relative, tfms) in projects) + { + var projectPath = Path.Combine(_root, relative.Replace('\\', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(projectPath)!); + File.WriteAllText( + projectPath, + $"\n \n {tfms}\n \n\n"); + solutionLines.Insert( + 1, + $"Project(\"{{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}}\") = \"P\", \"{relative}\", \"{{GUID}}\""); + } + var solutionPath = Path.Combine(_root, "Repo.sln"); + File.WriteAllLines(solutionPath, solutionLines); + return solutionPath; + } + + [Fact] + public void MultiTargetListFloorsAtHighestTfm() + { + // net8.0;net10.0 must floor at 10 — first-TFM (the pre-fix stance) + // would floor at 8 and register an SDK 8 instance that cannot + // evaluate the net10 half (the exact incident class). The pinned + // 8.x SDK below must NOT lower it. + var solutionPath = WriteSolution(("src\\A\\A.csproj", "net8.0;net10.0")); + WriteGlobalJson(Path.Combine(_root, "global.json"), "8.0.100"); + + Assert.Equal(10, MsbuildRestore.RequiredSdkMajorFloor(solutionPath)); + } + + [Fact] + public void FloorTakesMaxAcrossProjectsAndGlobalJson() + { + var solutionPath = WriteSolution( + ("src\\A\\A.csproj", "net8.0"), + ("src\\B\\B.csproj", "net10.0")); + WriteGlobalJson(Path.Combine(_root, "global.json"), "10.0.303"); + + Assert.Equal(10, MsbuildRestore.RequiredSdkMajorFloor(solutionPath)); + } + + [Fact] + public void GlobalJsonAboveSolutionDirRaisesTheFloor() + { + // NuGet's own lookup order: the sln and its projects sit in a + // subdir, global.json at the parent — the walk-up must find the + // pinned 11 over the projects' net8. + var solutionPath = WriteSolution(("src\\A\\A.csproj", "net8.0")); + var repoDir = Path.Combine(_root, "repo"); + Directory.CreateDirectory(repoDir); + Directory.Move(Path.Combine(_root, "src"), Path.Combine(repoDir, "src")); + File.Move(solutionPath, Path.Combine(repoDir, "Repo.sln")); + WriteGlobalJson(Path.Combine(_root, "global.json"), "11.0.100"); + + Assert.Equal(11, MsbuildRestore.RequiredSdkMajorFloor(Path.Combine(repoDir, "Repo.sln"))); + } + + [Fact] + public void NonNetTargetsAndMissingSolutionYieldNoFloor() + { + // The local global.json pins nothing parseable, so no ancestor + // stray file can perturb the null expectation. + var solutionPath = WriteSolution(("src\\A\\A.csproj", "netstandard2.0")); + WriteGlobalJson(Path.Combine(_root, "global.json"), "{}"); + + Assert.Null(MsbuildRestore.RequiredSdkMajorFloor(solutionPath)); + Assert.Null(MsbuildRestore.RequiredSdkMajorFloor(null)); + Assert.Null(MsbuildRestore.RequiredSdkMajorFloor(Path.Combine(_root, "absent.sln"))); + } + + [Fact] + public void NetFrameworkSpellingsDoNotRaiseTheFloor() + { + // net48/net472 (extension-less) must not become a "48" floor: an + // SDK loads them from reference assemblies, and a 48 floor would + // flip healthy SDK+VS machines to the VS instance for no reason. + // The pre-fix regex (^net(\d+)(?:\.|$)) matched net48 as major 48. + var net48Only = WriteSolution(("src\\Legacy\\Legacy.csproj", "net48")); + + Assert.Null(MsbuildRestore.RequiredSdkMajorFloor(net48Only)); + + var mixed = WriteSolution(("src\\Mixed\\Mixed.csproj", "net48;net10.0")); + + Assert.Equal(10, MsbuildRestore.RequiredSdkMajorFloor(mixed)); + } + + private static void WriteGlobalJson(string path, string version) + { + var content = version.StartsWith('{') + ? version + : $"{{ \"sdk\": {{ \"version\": \"{version}\" }} }}"; + File.WriteAllText(path, content); + } +} diff --git a/helpers/csharp/tests/ScipCsharp.Tests.csproj b/helpers/csharp/tests/ScipCsharp.Tests.csproj index 49ac02bc..bbd732b5 100644 --- a/helpers/csharp/tests/ScipCsharp.Tests.csproj +++ b/helpers/csharp/tests/ScipCsharp.Tests.csproj @@ -47,6 +47,7 @@ +