Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
135 changes: 135 additions & 0 deletions helpers/csharp/MsbuildRestore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ internal static class MsbuildRestore
/// <summary>vswhere probe bound — generous for a local EXE, still bounded.</summary>
private const int VsWhereWaitMs = 10_000;

/// <summary><c>dotnet --list-sdks</c> probe bound — same class of local metadata probe.</summary>
private const int SdkListWaitMs = 10_000;

/// <summary>
/// True when a <c>dotnet restore</c> failed on SDK resolution — the
/// CLI-visible SDK set cannot target the repo's TFM (NETSDK1045) or
Expand Down Expand Up @@ -304,4 +307,136 @@ private static IEnumerable<string> EnumerateSolutionProjects(string solutionPath
dir = parent;
}
}

/// <summary>
/// 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. <c>dotnet build</c> 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.
/// </summary>
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;
}
}

/// <summary>
/// <c>dotnet --list-sdks</c>, 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.
/// </summary>
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;
}
}

/// <summary>One <c>dotnet --list-sdks</c> entry: SDK version and its root dir.</summary>
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;
}

/// <summary>
/// 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.
/// </summary>
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;
}
}
69 changes: 69 additions & 0 deletions helpers/csharp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1161,6 +1161,51 @@ private static string BuildFilteredSolution(string[] lines, HashSet<int> skipLin

// ── MSBuild registration ─────────────────────────────────────────

/// <summary>
/// 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.
/// </summary>
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;
}
}

/// <summary>
/// Register the MSBuild instance the workspace will evaluate through.
/// Capability-aware since the 2026-10-15 net10 incident: when every
Expand All @@ -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
Expand Down
82 changes: 82 additions & 0 deletions helpers/csharp/tests/MsbuildRestoreTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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('{')
Expand Down
Loading