diff --git a/Roslyn.slnx b/Roslyn.slnx
index 66545a51b8fe8..1ed1f08313c37 100644
--- a/Roslyn.slnx
+++ b/Roslyn.slnx
@@ -542,6 +542,8 @@
+
+
diff --git a/eng/Packages.props b/eng/Packages.props
index 4cfb755db62ed..a47c77b87eb1f 100644
--- a/eng/Packages.props
+++ b/eng/Packages.props
@@ -184,6 +184,7 @@
-->
+
diff --git a/eng/config/test/Desktop/app.config b/eng/config/test/Desktop/app.config
index 5d5bfd354f04b..07353f558f6a5 100644
--- a/eng/config/test/Desktop/app.config
+++ b/eng/config/test/Desktop/app.config
@@ -2,6 +2,18 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/AnalyzerConfigFileFilterTests.cs b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/AnalyzerConfigFileFilterTests.cs
new file mode 100644
index 0000000000000..a05aa26bb8d2c
--- /dev/null
+++ b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/AnalyzerConfigFileFilterTests.cs
@@ -0,0 +1,167 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.Build.Framework;
+using Moq;
+using Xunit;
+
+namespace Microsoft.NET.ProjectData.Tasks.Tests;
+
+public class AnalyzerConfigFileFilterTests
+{
+ [Theory]
+ [InlineData("/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_10_default.globalconfig")]
+ [InlineData("/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_11_recommended_warnaserror.globalconfig")]
+ [InlineData("/Sdks/Microsoft.NET.Sdk/codestyle/cs/build/config/analysislevelstyle_default.globalconfig")]
+ public void IsSdkAnalyzerConfigFilePath_RecognizesSdkGlobalConfigs(string portablePath)
+ {
+ Assert.True(AnalyzerConfigFileFilter.IsSdkAnalyzerConfigFilePath(portablePath));
+ }
+
+ [Fact]
+ public void Prepare_FiltersSdkGlobalConfigsWhenPolicyIsAvailable()
+ {
+ string projectDir = Path.Combine(Path.GetTempPath(), "proj");
+ (CachePathResolver resolver, string dotnetRoot) = MakeSyntheticResolver(projectDir);
+ string analysisConfig = Path.Combine(dotnetRoot, "sdk", "10.0.202", "Sdks", "Microsoft.NET.Sdk", "analyzers", "build", "config", "analysislevel_10_default.globalconfig");
+ string styleConfig = Path.Combine(dotnetRoot, "sdk", "10.0.202", "Sdks", "Microsoft.NET.Sdk", "codestyle", "cs", "build", "config", "analysislevelstyle_default.globalconfig");
+ string projectConfig = Path.Combine(projectDir, "Directory.Build.globalconfig");
+
+ List prepared = AnalyzerConfigFileFilter.Prepare(
+ [analysisConfig, styleConfig, projectConfig],
+ resolver,
+ sourceFiles: null,
+ filterSdkAnalyzerConfigFiles: true);
+
+ Assert.Single(prepared);
+ Assert.Equal("Directory.Build.globalconfig", prepared[0]);
+ }
+
+ [Fact]
+ public void Prepare_IgnoresNullSourceItems()
+ {
+ string projectDir = Path.Combine(Path.GetTempPath(), "proj");
+ var resolver = new CachePathResolver(projectDir, [], [], null);
+ string projectConfig = Path.Combine(projectDir, "Directory.Build.globalconfig");
+
+ List prepared = AnalyzerConfigFileFilter.Prepare(
+ [projectConfig],
+ resolver,
+ sourceFiles: [null!, MakeItem(Path.Combine(projectDir, "Program.cs"))],
+ filterSdkAnalyzerConfigFiles: false);
+
+ Assert.Equal(["Directory.Build.globalconfig"], prepared);
+ }
+
+ [Fact]
+ public void Prepare_StopsAtNearestRootEditorConfig()
+ {
+ string tempDir = Path.Combine(Path.GetTempPath(), "lscache-editorconfig-root-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ string repoDir = Path.Combine(tempDir, "parent", "repo");
+ string nestedWorktreeDir = Path.Combine(repoDir, "worktrees", "nested");
+ string projectDir = Path.Combine(nestedWorktreeDir, "src", "App");
+ Directory.CreateDirectory(projectDir);
+
+ string parentConfig = Path.Combine(tempDir, "parent", ".editorconfig");
+ string repoConfig = Path.Combine(repoDir, ".editorconfig");
+ string nestedConfig = Path.Combine(nestedWorktreeDir, ".editorconfig");
+ string projectConfig = Path.Combine(projectDir, ".editorconfig");
+ string sourceFile = Path.Combine(projectDir, "Program.cs");
+ string generatedConfig = Path.Combine(projectDir, "obj", "Debug", "net8.0", "App.GeneratedMSBuildEditorConfig.editorconfig");
+ Directory.CreateDirectory(Path.GetDirectoryName(generatedConfig)!);
+
+ File.WriteAllText(parentConfig, "[*.cs]\ndotnet_diagnostic.PARENT9999.severity = error\n");
+ File.WriteAllText(repoConfig, "root = true\n\n[*.cs]\ndotnet_diagnostic.REPO0001.severity = warning\n");
+ File.WriteAllText(nestedConfig, "root = true\n\n[*.cs]\ndotnet_diagnostic.NESTED0001.severity = silent\n");
+ File.WriteAllText(projectConfig, "[*.cs]\ndotnet_diagnostic.PROJECT0001.severity = silent\n");
+ File.WriteAllText(sourceFile, "Console.WriteLine(\"Hello\");\n");
+ File.WriteAllText(generatedConfig, "is_global = true\n");
+
+ var resolver = new CachePathResolver(projectDir, [], [], null);
+
+ List prepared = AnalyzerConfigFileFilter.Prepare(
+ [parentConfig, repoConfig, nestedConfig, projectConfig, generatedConfig],
+ resolver,
+ sourceFiles: [MakeItem(sourceFile)],
+ filterSdkAnalyzerConfigFiles: false);
+
+ Assert.DoesNotContain(resolver.ToPortable(parentConfig), prepared);
+ Assert.DoesNotContain(resolver.ToPortable(repoConfig), prepared);
+ Assert.Contains(resolver.ToPortable(nestedConfig), prepared);
+ Assert.Contains(resolver.ToPortable(projectConfig), prepared);
+ Assert.Contains(resolver.ToPortable(generatedConfig), prepared);
+ }
+ finally
+ {
+ try { Directory.Delete(tempDir, recursive: true); } catch { }
+ }
+ }
+
+ [Fact]
+ public void Prepare_KeepsAncestorEditorConfigForLinkedSourceOutsideRoot()
+ {
+ string tempDir = Path.Combine(Path.GetTempPath(), "lscache-linked-editorconfig-root-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ string parentDir = Path.Combine(tempDir, "parent");
+ string repoDir = Path.Combine(parentDir, "repo");
+ string nestedWorktreeDir = Path.Combine(repoDir, "worktrees", "nested");
+ string projectDir = Path.Combine(nestedWorktreeDir, "src", "App");
+ string linkedSourceDir = Path.Combine(parentDir, "shared");
+ Directory.CreateDirectory(projectDir);
+ Directory.CreateDirectory(linkedSourceDir);
+
+ string parentConfig = Path.Combine(parentDir, ".editorconfig");
+ string repoConfig = Path.Combine(repoDir, ".editorconfig");
+ string nestedConfig = Path.Combine(nestedWorktreeDir, ".editorconfig");
+ string projectConfig = Path.Combine(projectDir, ".editorconfig");
+ string projectSource = Path.Combine(projectDir, "Program.cs");
+ string linkedSource = Path.Combine(linkedSourceDir, "Shared.cs");
+
+ File.WriteAllText(parentConfig, "[*.cs]\ndotnet_diagnostic.PARENT9999.severity = error\n");
+ File.WriteAllText(repoConfig, "root = true\n\n[*.cs]\ndotnet_diagnostic.REPO0001.severity = warning\n");
+ File.WriteAllText(nestedConfig, "root = true\n\n[*.cs]\ndotnet_diagnostic.NESTED0001.severity = silent\n");
+ File.WriteAllText(projectConfig, "[*.cs]\ndotnet_diagnostic.PROJECT0001.severity = silent\n");
+ File.WriteAllText(projectSource, "Console.WriteLine(\"Hello\");\n");
+ File.WriteAllText(linkedSource, "public class Shared { }\n");
+
+ var resolver = new CachePathResolver(projectDir, [], [], null);
+
+ List prepared = AnalyzerConfigFileFilter.Prepare(
+ [parentConfig, repoConfig, nestedConfig, projectConfig],
+ resolver,
+ sourceFiles: [MakeItem(projectSource), MakeItem(linkedSource)],
+ filterSdkAnalyzerConfigFiles: false);
+
+ Assert.Contains(resolver.ToPortable(parentConfig), prepared);
+ Assert.DoesNotContain(resolver.ToPortable(repoConfig), prepared);
+ Assert.Contains(resolver.ToPortable(nestedConfig), prepared);
+ Assert.Contains(resolver.ToPortable(projectConfig), prepared);
+ }
+ finally
+ {
+ try { Directory.Delete(tempDir, recursive: true); } catch { }
+ }
+ }
+
+ private static ITaskItem MakeItem(string identity)
+ {
+ var mock = new Mock();
+ mock.Setup(i => i.ItemSpec).Returns(identity);
+ return mock.Object;
+ }
+
+ private static (CachePathResolver Resolver, string DotNetRoot) MakeSyntheticResolver(string projectDir)
+ {
+ string dotnetRoot = Path.Combine(projectDir, "fakedotnet") + Path.DirectorySeparatorChar;
+ var resolver = new CachePathResolver(
+ projectDir: projectDir,
+ nugetFolders: [Path.Combine(projectDir, "fakenuget") + Path.DirectorySeparatorChar],
+ dotnetRoots: [dotnetRoot],
+ netFxRefRoot: null);
+ return (resolver, dotnetRoot);
+ }
+}
diff --git a/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/CachePathResolverTests.cs b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/CachePathResolverTests.cs
new file mode 100644
index 0000000000000..149b294cdc3dc
--- /dev/null
+++ b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/CachePathResolverTests.cs
@@ -0,0 +1,274 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Xunit;
+
+namespace Microsoft.NET.ProjectData.Tasks.Tests;
+
+public class CachePathResolverTests
+{
+ // Helper: create a resolver with explicit roots so tests are hermetic.
+ private static CachePathResolver Make(
+ string projectDir,
+ string[]? nugetFolders = null,
+ string[]? dotnetRoots = null,
+ string? netFxRefRoot = null)
+ => new CachePathResolver(
+ projectDir,
+ nugetFolders ?? [],
+ dotnetRoots ?? [],
+ netFxRefRoot);
+
+ private static string TempPath(params string[] parts)
+ {
+ string path = Path.GetTempPath();
+ foreach (string part in parts)
+ path = Path.Combine(path, part);
+ return path;
+ }
+
+ [Fact]
+ public void ToPortable_NuGetPath_Substitutes()
+ {
+ string nuget = CachePathResolver.NormalizeFolderPath(Path.Combine(Path.GetTempPath(), "packages"));
+ CachePathResolver resolver = Make(@"C:\project", nugetFolders: [nuget]);
+
+ string result = resolver.ToPortable(Path.Combine(nuget, "Newtonsoft.Json", "13.0.3", "lib", "netstandard2.0", "Newtonsoft.Json.dll").TrimEnd(Path.DirectorySeparatorChar));
+
+ Assert.StartsWith("/", result);
+ Assert.Contains("Newtonsoft.Json", result);
+ Assert.DoesNotContain("\\", result);
+ }
+
+ [Fact]
+ public void ToPortable_DotNetRootPath_Substitutes()
+ {
+ string dotnet = CachePathResolver.NormalizeFolderPath(TempPath("dotnet"));
+ CachePathResolver resolver = Make(TempPath("project"), dotnetRoots: [dotnet]);
+
+ string result = resolver.ToPortable(Path.Combine(dotnet, "shared", "Microsoft.NETCore.App", "8.0.0", "System.dll").TrimEnd(Path.DirectorySeparatorChar));
+
+ Assert.StartsWith("/", result);
+ Assert.Contains("System.dll", result);
+ }
+
+ [Fact]
+ public void TryGetDotNetRootFromHostPath_ReturnsContainingDirectory()
+ {
+ string dotnetRoot = TempPath("dotnet");
+ string hostPath = Path.Combine(dotnetRoot, OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet");
+
+ Assert.Equal(Path.GetFullPath(dotnetRoot), CachePathResolver.TryGetDotNetRootFromHostPath(hostPath));
+ }
+
+ [Fact]
+ public void TryGetDotNetRootFromHostPath_RejectsRelativePath()
+ {
+ Assert.Null(CachePathResolver.TryGetDotNetRootFromHostPath("dotnet"));
+ }
+
+ [Fact]
+ public void TryGetDotNetRootFromSdkPath_ReturnsParentOfSdkDirectory()
+ {
+ string dotnetRoot = TempPath("dotnet");
+ string sdkPath = Path.Combine(dotnetRoot, "sdk", "11.0.100");
+
+ Assert.Equal(Path.GetFullPath(dotnetRoot), CachePathResolver.TryGetDotNetRootFromSdkPath(sdkPath));
+ }
+
+ [Fact]
+ public void TryGetDotNetRootFromSdkPath_AcceptsMixedCaseSdkDirectory()
+ {
+ string dotnetRoot = TempPath("dotnet");
+ string sdkPath = Path.Combine(dotnetRoot, "SdK", "11.0.100");
+
+ Assert.Equal(Path.GetFullPath(dotnetRoot), CachePathResolver.TryGetDotNetRootFromSdkPath(sdkPath));
+ }
+
+ [Fact]
+ public void TryGetDotNetRootFromSdkPath_RejectsUnrelatedDirectory()
+ {
+ Assert.Null(CachePathResolver.TryGetDotNetRootFromSdkPath(TempPath("project", "bin")));
+ }
+
+ [Fact]
+ public void ToPortable_ProjectRelativePath_ReturnsForwardSlashRelative()
+ {
+ string projectDir = TempPath("project", "src");
+ CachePathResolver resolver = Make(projectDir);
+
+ string result = resolver.ToPortable(Path.Combine(projectDir, "Program.cs"));
+
+ Assert.Equal("Program.cs", result);
+ }
+
+ [Fact]
+ public void ToPortable_ParentRelativePath_ReturnsDoubleDot()
+ {
+ string projectDir = TempPath("project", "src");
+ CachePathResolver resolver = Make(projectDir);
+
+ string result = resolver.ToPortable(TempPath("project", "Common.cs"));
+
+ Assert.Equal("../Common.cs", result);
+ }
+
+ [Fact]
+ public void MakePortable_NoAbsolutePath_Unchanged()
+ {
+ CachePathResolver resolver = Make(@"C:\project");
+
+ string result = resolver.MakePortable("/nologo");
+
+ Assert.Equal("/nologo", result);
+ }
+
+ [Fact]
+ public void MakePortable_RelativeBackslashPath_NormalizedToForwardSlash()
+ {
+ // Csc emits relative /out: and /refout: arguments with backslashes on Windows.
+ // MakePortable must normalize them so the output is cross-platform identical.
+ CachePathResolver resolver = Make(@"C:\project");
+
+ string result = resolver.MakePortable(@"/out:obj\Debug\net8.0\MyApp.dll");
+
+ Assert.Equal("/out:obj/Debug/net8.0/MyApp.dll", result);
+ }
+
+ [Fact]
+ public void MakePortable_RefOutBackslashPath_NormalizedToForwardSlash()
+ {
+ CachePathResolver resolver = Make(@"C:\project");
+
+ string result = resolver.MakePortable(@"/refout:obj\Debug\net8.0\refint\MyApp.dll");
+
+ Assert.Equal("/refout:obj/Debug/net8.0/refint/MyApp.dll", result);
+ }
+
+ [Fact]
+ public void MakePortable_PlainBackslashText_NormalizedToForwardSlash()
+ {
+ CachePathResolver resolver = Make(@"C:\project");
+
+ string result = resolver.MakePortable(@"some\relative\path.txt");
+
+ Assert.Equal("some/relative/path.txt", result);
+ }
+
+ [Fact]
+ public void MakePortable_EmbeddedNuGetPath_Substitutes()
+ {
+ string nuget = CachePathResolver.NormalizeFolderPath(TempPath("Users", "user", ".nuget", "packages"));
+ CachePathResolver resolver = Make(TempPath("project"), nugetFolders: [nuget]);
+
+ string result = resolver.MakePortable("/doc:" + Path.Combine(nuget, "foo", "1.0", "lib", "net8.0", "foo.xml").TrimEnd(Path.DirectorySeparatorChar));
+
+ Assert.StartsWith("/doc:/", result);
+ }
+
+ [Fact]
+ public void MakePortable_WindowsDrivePath_EmitsPathSentinel()
+ {
+ CachePathResolver resolver = Make(@"C:\project");
+
+ string result = resolver.MakePortable(@"-out:C:\project\bin\Debug\app.dll");
+
+ Assert.StartsWith("-out:", result);
+ Assert.Contains("bin/Debug/app.dll", result);
+ }
+
+ [Fact]
+ public void FindSharedDirPrefix_CommonDir_ReturnsWithTrailingSlash()
+ {
+ string? prefix = CachePathResolver.FindSharedDirPrefix(
+ "/newtonsoft.json/13.0.3/lib/net8.0/Newtonsoft.Json.dll",
+ "/newtonsoft.json/13.0.3/lib/net8.0/Newtonsoft.Json.xml");
+
+ Assert.Equal("/newtonsoft.json/13.0.3/lib/net8.0/", prefix);
+ }
+
+ [Fact]
+ public void FindSharedDirPrefix_NoDirInCommon_ReturnsNull()
+ {
+ string? prefix = CachePathResolver.FindSharedDirPrefix(
+ "/foo/1.0/lib.dll",
+ "/shared/app.dll");
+
+ Assert.Null(prefix);
+ }
+
+ [Fact]
+ public void MakeRelative_SubDir_ReturnsRelative()
+ {
+ string result = CachePathResolver.MakeRelative(@"C:\project\src", @"C:\project\src\Program.cs");
+ Assert.Equal("Program.cs", result);
+ }
+
+ [Fact]
+ public void MakeRelative_ParentDir_ReturnsDoubleDot()
+ {
+ string result = CachePathResolver.MakeRelative(@"C:\project\src", @"C:\project\Shared.cs");
+ Assert.Equal("../Shared.cs", result);
+ }
+
+ [Fact]
+ public void ToPortable_DotnetSdkPath_RewritesToNetSdk()
+ {
+ string dotnet = CachePathResolver.NormalizeFolderPath(Path.Combine(Path.GetTempPath(), "dotnet"));
+ CachePathResolver resolver = Make(@"C:\project", dotnetRoots: [dotnet]);
+
+ string analyzer = Path.Combine(dotnet, "sdk", "10.0.202", "Sdks", "Microsoft.NET.Sdk", "analyzers", "Microsoft.CodeAnalysis.NetAnalyzers.dll").TrimEnd(Path.DirectorySeparatorChar);
+ string result = resolver.ToPortable(analyzer);
+
+ // Expect the version segment dropped — anyone reading the cache binds the
+ // SDK version they care about and the resolver expands against it.
+ Assert.Equal("/Sdks/Microsoft.NET.Sdk/analyzers/Microsoft.CodeAnalysis.NetAnalyzers.dll", result);
+ }
+
+ [Fact]
+ public void ToPortable_DotnetNonSdkPath_StaysUnderDotnetSentinel()
+ {
+ // /packs/... and /host/... must NOT be rewritten — they aren't
+ // the per-version SDK content the rewrite is targeting.
+ string dotnet = CachePathResolver.NormalizeFolderPath(Path.Combine(Path.GetTempPath(), "dotnet"));
+ CachePathResolver resolver = Make(@"C:\project", dotnetRoots: [dotnet]);
+
+ string pack = Path.Combine(dotnet, "packs", "Microsoft.NETCore.App.Ref", "10.0.7", "ref", "net10.0", "System.Runtime.dll").TrimEnd(Path.DirectorySeparatorChar);
+ string result = resolver.ToPortable(pack);
+
+ Assert.StartsWith("/packs/", result);
+ Assert.DoesNotContain("", result);
+ }
+
+ [Fact]
+ public void MakePortable_EmbeddedDotnetSdkPath_RewritesToNetSdk()
+ {
+ // Property values and command-line args may embed an absolute SDK path
+ // (e.g. /globalconfig:/sdk/10.0.202/...). The embedded form
+ // must be rewritten the same way as standalone paths.
+ string dotnet = CachePathResolver.NormalizeFolderPath(Path.Combine(Path.GetTempPath(), "dotnet"));
+ CachePathResolver resolver = Make(@"C:\project", dotnetRoots: [dotnet]);
+
+ string analyzerCfg = Path.Combine(dotnet, "sdk", "10.0.202", "Sdks", "Microsoft.NET.Sdk", "analyzers", "build", "config", "analysislevel_10_default.globalconfig");
+ string text = "/globalconfig:" + analyzerCfg;
+ string result = resolver.MakePortable(text);
+
+ Assert.Equal("/globalconfig:/Sdks/Microsoft.NET.Sdk/analyzers/build/config/analysislevel_10_default.globalconfig", result);
+ }
+
+ [Fact]
+ public void RewriteSdkPath_NonSdkPortable_LeavesAlone()
+ {
+ Assert.Equal("/packs/Foo/1.0/file", CachePathResolver.RewriteSdkPath("/packs/Foo/1.0/file"));
+ Assert.Equal("/foo", CachePathResolver.RewriteSdkPath("/foo"));
+ Assert.Equal("anything", CachePathResolver.RewriteSdkPath("anything"));
+ }
+
+ [Fact]
+ public void RewriteSdkPath_SdkPortable_DropsVersion()
+ {
+ Assert.Equal("/Sdks/X/Y.dll",
+ CachePathResolver.RewriteSdkPath("/sdk/10.0.202/Sdks/X/Y.dll"));
+ }
+}
diff --git a/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/ForwardCompatTests.cs b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/ForwardCompatTests.cs
new file mode 100644
index 0000000000000..696f975dba2fb
--- /dev/null
+++ b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/ForwardCompatTests.cs
@@ -0,0 +1,1096 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Text;
+using System.Text.RegularExpressions;
+using Xunit;
+
+namespace Microsoft.NET.ProjectData.Tasks.Tests;
+
+///
+/// Tests for forward/backward compatibility of the .lscache format: an older writer that
+/// re-generates a file authored by a newer minor version must carry the unknown data through
+/// losslessly (so the file does not churn between team members), while the reader simply ignores it.
+///
+/// The tests deliberately use a that injects fake
+/// unknown sections / properties / item metadata and a fake higher minor version. There is
+/// no real forward-compat field yet, so this is the only way to validate the machinery — and it is
+/// the durable harness a future field-addition reuses: when a field becomes "known", flip it out of
+/// the builder's unknown set and these same tests guard the transition.
+///
+public class ForwardCompatTests
+{
+ private const int CurrentMajor = 2;
+
+ // --- Reusable fake-future fixture ------------------------------------------------------------
+
+ ///
+ /// Synthesizes single- and multi-target .lscache content with optional forward-compatible
+ /// (unknown-to-the-current-writer) additions. Lines are assembled explicitly so leading-space
+ /// significant constructs (indentation-compressed paths, @metadata) stay byte-exact.
+ ///
+ internal static class FutureCacheBuilder
+ {
+ public const string UnknownSectionName = "futureSection";
+ public const string UnknownSectionPayload = "future-section-payload";
+ public const string UnknownProperty = "FutureProp=onward";
+ public const string UnknownMetadata = "@futureMeta=42";
+
+ /// A self-consistent single-target cache body (headerless, LF-terminated).
+ public static string SingleTarget(
+ string version = "version=2",
+ bool unknownSection = false,
+ bool unknownProperty = false,
+ bool unknownMetadata = false)
+ {
+ var lines = new List
+ {
+ version,
+ string.Empty,
+ "[project]",
+ "project=Sample.csproj",
+ "language=C#",
+ "lastDtbSucceeded",
+ string.Empty,
+ "[properties]",
+ "AssemblyName=Sample",
+ };
+ if (unknownProperty) lines.Add(UnknownProperty);
+ lines.Add("TargetFramework=net8.0");
+ lines.Add(string.Empty);
+ lines.Add("[sourceFiles]");
+ lines.Add("Program.cs");
+ if (unknownMetadata) lines.Add(" " + UnknownMetadata);
+ lines.Add("Helpers/");
+ lines.Add(" Util.cs");
+ if (unknownSection)
+ {
+ lines.Add(string.Empty);
+ lines.Add("[" + UnknownSectionName + "]");
+ lines.Add(UnknownSectionPayload);
+ }
+
+ return string.Join("\n", lines) + "\n";
+ }
+ }
+
+ // Replaces the leading version header line with , mirroring how
+ // a newer-minor writer stamps a file that carries data this (older) writer does not understand.
+ private static string BumpPrimaryVersion(string text, string newVersionLine)
+ {
+ int nl = text.IndexOf('\n');
+ return nl < 0 ? newVersionLine : newVersionLine + text.Substring(nl);
+ }
+
+ // --- ForwardCompat.PreserveUnknownData: unit tests -------------------------------------------
+
+ [Fact]
+ public void Preserves_Unknown_Section()
+ {
+ string existing = FutureCacheBuilder.SingleTarget(version: "version=2.3", unknownSection: true);
+ string candidate = FutureCacheBuilder.SingleTarget();
+
+ string merged = ForwardCompat.PreserveUnknownData(existing, candidate, CurrentMajor);
+
+ Assert.Contains("[" + FutureCacheBuilder.UnknownSectionName + "]", merged);
+ Assert.Contains(FutureCacheBuilder.UnknownSectionPayload, merged);
+ // Known content is untouched.
+ Assert.Contains("AssemblyName=Sample", merged);
+ Assert.Contains("Helpers/\n Util.cs", merged);
+ }
+
+ // Multiple unknown sections must round-trip in the newer writer's file order, NOT be re-sorted.
+ // Re-sorting would move sections relative to where the newer minor put them, so an older minor
+ // and a newer minor would fight over the layout and churn the file on every alternating write.
+ // Encounter order here is z-before-a (the reverse of ordinal), so a stray re-sort would flip it.
+ [Fact]
+ public void Preserves_Unknown_Sections_In_File_Order_Not_Resorted()
+ {
+ string existing = string.Join("\n", new[]
+ {
+ "version=2.3",
+ "",
+ "[project]",
+ "project=Sample.csproj",
+ "language=C#",
+ "lastDtbSucceeded",
+ "",
+ "[zSection]",
+ "z-payload",
+ "",
+ "[aSection]",
+ "a-payload",
+ }) + "\n";
+ string candidate = FutureCacheBuilder.SingleTarget();
+
+ string merged = ForwardCompat.PreserveUnknownData(existing, candidate, CurrentMajor);
+
+ int z = merged.IndexOf("[zSection]", StringComparison.Ordinal);
+ int a = merged.IndexOf("[aSection]", StringComparison.Ordinal);
+ Assert.True(z >= 0 && a >= 0, "both unknown sections must be preserved");
+ Assert.True(z < a, "unknown sections must keep their original file order, not be re-sorted");
+ }
+
+ [Fact]
+ public void Preserves_Unknown_Section_Between_Known_Sections_In_Same_Gap()
+ {
+ string existing = string.Join("\n", new[]
+ {
+ "version=2.3",
+ "",
+ "[project]",
+ "project=Sample.csproj",
+ "language=C#",
+ "",
+ "[properties]",
+ "AssemblyName=Sample",
+ "",
+ "[futureCopyToOutputItems]",
+ "content.txt",
+ "",
+ "[projectReferences]",
+ "Referenced.csproj",
+ "",
+ "[capabilities]",
+ "CSharp",
+ }) + "\n";
+ string candidate = string.Join("\n", new[]
+ {
+ "version=2",
+ "",
+ "[project]",
+ "project=Sample.csproj",
+ "language=C#",
+ "",
+ "[properties]",
+ "AssemblyName=Sample",
+ "",
+ "[projectReferences]",
+ "Referenced.csproj",
+ "",
+ "[capabilities]",
+ "CSharp",
+ }) + "\n";
+
+ string merged = ForwardCompat.PreserveUnknownData(existing, candidate, CurrentMajor);
+
+ int properties = merged.IndexOf("[properties]", StringComparison.Ordinal);
+ int unknown = merged.IndexOf("[futureCopyToOutputItems]", StringComparison.Ordinal);
+ int projectReferences = merged.IndexOf("[projectReferences]", StringComparison.Ordinal);
+ int capabilities = merged.IndexOf("[capabilities]", StringComparison.Ordinal);
+ Assert.True(properties >= 0 && unknown >= 0 && projectReferences >= 0 && capabilities >= 0, "all sections must be present");
+ Assert.True(properties < unknown, "the unknown section should remain after its preceding known section");
+ Assert.True(unknown < projectReferences, "the unknown section should remain before its following known section instead of moving to the segment end");
+ Assert.True(projectReferences < capabilities, "later known sections should not move ahead of the preserved unknown section's original gap");
+ }
+
+ [Fact]
+ public void Preserves_Multiple_Unknown_Sections_In_Same_Gap_In_File_Order()
+ {
+ string existing = string.Join("\n", new[]
+ {
+ "version=2.3",
+ "",
+ "[project]",
+ "project=Sample.csproj",
+ "language=C#",
+ "",
+ "[properties]",
+ "AssemblyName=Sample",
+ "",
+ "[futureCopyToOutputItems]",
+ "content.txt",
+ "",
+ "[futureUpToDateCheckBuilt]",
+ "bin/Debug/Sample.dll",
+ "",
+ "[projectReferences]",
+ "Referenced.csproj",
+ "",
+ "[capabilities]",
+ "CSharp",
+ }) + "\n";
+ string candidate = string.Join("\n", new[]
+ {
+ "version=2",
+ "",
+ "[project]",
+ "project=Sample.csproj",
+ "language=C#",
+ "",
+ "[properties]",
+ "AssemblyName=Sample",
+ "",
+ "[projectReferences]",
+ "Referenced.csproj",
+ "",
+ "[capabilities]",
+ "CSharp",
+ }) + "\n";
+
+ string merged = ForwardCompat.PreserveUnknownData(existing, candidate, CurrentMajor);
+
+ int copyToOutputItems = merged.IndexOf("[futureCopyToOutputItems]", StringComparison.Ordinal);
+ int upToDateCheckBuilt = merged.IndexOf("[futureUpToDateCheckBuilt]", StringComparison.Ordinal);
+ int projectReferences = merged.IndexOf("[projectReferences]", StringComparison.Ordinal);
+ Assert.True(copyToOutputItems >= 0 && upToDateCheckBuilt >= 0 && projectReferences >= 0, "all sections must be present");
+ Assert.True(copyToOutputItems < upToDateCheckBuilt, "unknown sections in the same gap must keep their original encounter order");
+ Assert.True(upToDateCheckBuilt < projectReferences, "all unknown sections in the gap should stay before the following known section");
+ }
+
+ [Fact]
+ public void Preserves_Unknown_Section_Before_First_Known_Section_In_PerSliceSegment()
+ {
+ string existing = string.Join("\n", new[]
+ {
+ "version=2.3",
+ "[project]",
+ "language=C#",
+ "",
+ "[sliceDimensions]",
+ "TargetFramework=net8.0",
+ "---",
+ "[futureSection]",
+ "future-payload",
+ "",
+ "[project]",
+ "language=C#",
+ "",
+ "[sliceDimensions]",
+ "TargetFramework=net9.0",
+ }) + "\n";
+ string candidate = string.Join("\n", new[]
+ {
+ "version=2",
+ "[project]",
+ "language=C#",
+ "",
+ "[sliceDimensions]",
+ "TargetFramework=net8.0",
+ "---",
+ "[project]",
+ "language=C#",
+ "",
+ "[sliceDimensions]",
+ "TargetFramework=net9.0",
+ }) + "\n";
+
+ string merged = ForwardCompat.PreserveUnknownData(existing, candidate, CurrentMajor);
+
+ int separator = merged.IndexOf("---", StringComparison.Ordinal);
+ int futureSection = merged.IndexOf("[futureSection]", StringComparison.Ordinal);
+ int secondProject = merged.IndexOf("[project]", separator, StringComparison.Ordinal);
+ int secondProjectLanguage = merged.IndexOf("language=C#", secondProject, StringComparison.Ordinal);
+ Assert.True(separator >= 0 && futureSection >= 0 && secondProject >= 0 && secondProjectLanguage >= 0, "the second slice and unknown section must be present");
+ Assert.True(separator < futureSection, "the unknown section should be inserted after the slice separator");
+ Assert.True(futureSection < secondProject, "the unknown section should stay before the following known section header");
+ Assert.True(secondProject < secondProjectLanguage, "the known section's content should stay under its own header");
+ }
+
+ // The last item in the file carries unknown @metadata AND there is an unknown whole section to
+ // append. Both anchor at the file's last content line; the metadata must stay attached to its
+ // item, with the appended section AFTER it — otherwise the @metadata would re-parse under the
+ // appended section on the next read (wrong item/section).
+ [Fact]
+ public void Preserves_Last_Item_Metadata_Before_Appended_Unknown_Section()
+ {
+ string existing = string.Join("\n", new[]
+ {
+ "version=2.1",
+ "",
+ "[project]",
+ "project=Sample.csproj",
+ "language=C#",
+ "lastDtbSucceeded",
+ "",
+ "[sourceFiles]",
+ "Program.cs",
+ " @futureMeta=42",
+ "",
+ "[futureSection]",
+ "future-payload",
+ }) + "\n";
+ string candidate = string.Join("\n", new[]
+ {
+ "version=2",
+ "",
+ "[project]",
+ "project=Sample.csproj",
+ "language=C#",
+ "lastDtbSucceeded",
+ "",
+ "[sourceFiles]",
+ "Program.cs",
+ }) + "\n";
+
+ string merged = ForwardCompat.PreserveUnknownData(existing, candidate, CurrentMajor);
+
+ int meta = merged.IndexOf("@futureMeta=42", StringComparison.Ordinal);
+ int section = merged.IndexOf("[futureSection]", StringComparison.Ordinal);
+ Assert.True(meta >= 0 && section >= 0, "both the metadata and the unknown section must be preserved");
+ Assert.True(meta < section, "the item's @metadata must stay before the appended unknown section");
+ Assert.Contains("Program.cs\n @futureMeta=42", merged);
+ }
+
+ // Two unknown @metadata lines on one item, in non-ordinal order (@zMeta before @aMeta). A newer
+ // writer emits them in its own order; an older writer must preserve that file order, not re-sort
+ // by content (which would churn the cache in mixed-version teams).
+ [Fact]
+ public void Preserves_Unknown_Metadata_In_File_Order_Not_Resorted()
+ {
+ string existing = string.Join("\n", new[]
+ {
+ "version=2.1",
+ "",
+ "[project]",
+ "project=Sample.csproj",
+ "language=C#",
+ "lastDtbSucceeded",
+ "",
+ "[sourceFiles]",
+ "Program.cs",
+ " @zMeta=1",
+ " @aMeta=2",
+ }) + "\n";
+ string candidate = string.Join("\n", new[]
+ {
+ "version=2",
+ "",
+ "[project]",
+ "project=Sample.csproj",
+ "language=C#",
+ "lastDtbSucceeded",
+ "",
+ "[sourceFiles]",
+ "Program.cs",
+ }) + "\n";
+
+ string merged = ForwardCompat.PreserveUnknownData(existing, candidate, CurrentMajor);
+
+ int z = merged.IndexOf("@zMeta=1", StringComparison.Ordinal);
+ int a = merged.IndexOf("@aMeta=2", StringComparison.Ordinal);
+ Assert.True(z >= 0 && a >= 0, "both unknown metadata lines must be preserved");
+ Assert.True(z < a, "unknown metadata must keep its original file order, not be re-sorted");
+ }
+
+ [Fact]
+ public void Preserves_Unknown_Property_In_Sorted_Position()
+ {
+ string existing = FutureCacheBuilder.SingleTarget(version: "version=2.3", unknownProperty: true);
+ string candidate = FutureCacheBuilder.SingleTarget();
+
+ string merged = ForwardCompat.PreserveUnknownData(existing, candidate, CurrentMajor);
+
+ // Inserted into the already-sorted [properties] block between AssemblyName and TargetFramework.
+ Assert.Contains("AssemblyName=Sample\nFutureProp=onward\nTargetFramework=net8.0", merged);
+ }
+
+ // Regression: when the candidate has NO [properties] section and the chosen anchor section is
+ // header-only, its LastLineIndex is -1. Reassemble drops insertions keyed at -1, so the created
+ // [properties] block must anchor on the header line instead — otherwise the preserved unknown
+ // property is silently lost.
+ [Fact]
+ public void Preserves_Unknown_Property_When_Candidate_Lacks_Properties_And_Anchor_Is_HeaderOnly()
+ {
+ string existing = string.Join("\n", new[]
+ {
+ "version=2.1",
+ "",
+ "[project]",
+ "project=Sample.csproj",
+ "language=C#",
+ "lastDtbSucceeded",
+ "",
+ "[properties]",
+ "FutureProp=onward",
+ }) + "\n";
+ // Degenerate candidate: a header-only [project] (LastLineIndex == -1) and no [properties].
+ string candidate = "version=2\n\n[project]\n";
+
+ string merged = ForwardCompat.PreserveUnknownData(existing, candidate, CurrentMajor);
+
+ Assert.Contains("[properties]", merged);
+ Assert.Contains("FutureProp=onward", merged);
+ }
+
+ // Defensive: a candidate segment with no sections at all must neither throw (the old code indexed
+ // Sections[Count - 1]) nor drop the preserved property (no anchor → segment last content line).
+ [Fact]
+ public void Preserves_Unknown_Property_When_Candidate_Has_No_Sections()
+ {
+ string existing = string.Join("\n", new[]
+ {
+ "version=2.1",
+ "",
+ "[properties]",
+ "FutureProp=onward",
+ }) + "\n";
+ string candidate = "version=2\n";
+
+ string merged = ForwardCompat.PreserveUnknownData(existing, candidate, CurrentMajor);
+
+ Assert.Contains("FutureProp=onward", merged);
+ }
+
+ [Fact]
+ public void Preserves_Unknown_Metadata_On_Matching_Item()
+ {
+ string existing = FutureCacheBuilder.SingleTarget(version: "version=2.3", unknownMetadata: true);
+ string candidate = FutureCacheBuilder.SingleTarget();
+
+ string merged = ForwardCompat.PreserveUnknownData(existing, candidate, CurrentMajor);
+
+ // Reattached at metadata indentation (one space) directly under Program.cs.
+ Assert.Contains("Program.cs\n @futureMeta=42", merged);
+ }
+
+ [Fact]
+ public void Drops_Unknown_Metadata_When_Item_Removed_From_Candidate()
+ {
+ // Existing has @futureMeta on a source file the candidate no longer lists.
+ string existing = string.Join("\n", new[]
+ {
+ "version=2.3",
+ string.Empty,
+ "[project]",
+ "language=C#",
+ string.Empty,
+ "[sourceFiles]",
+ "Gone.cs",
+ " @futureMeta=42",
+ }) + "\n";
+ string candidate = string.Join("\n", new[]
+ {
+ "version=2",
+ string.Empty,
+ "[project]",
+ "language=C#",
+ string.Empty,
+ "[sourceFiles]",
+ "Program.cs",
+ }) + "\n";
+
+ string merged = ForwardCompat.PreserveUnknownData(existing, candidate, CurrentMajor);
+
+ Assert.DoesNotContain("@futureMeta", merged);
+ Assert.DoesNotContain("Gone.cs", merged);
+ }
+
+ [Fact]
+ public void Does_Not_Resurrect_Known_Data_The_Current_Build_Did_Not_Produce()
+ {
+ // The cross-environment hazard the preservation design must NOT introduce: env A built the
+ // project, so its cache lists a generated source (Generated.g.cs) and a known [properties]
+ // key (RootNamespace). Env B regenerates the cache WITHOUT those outputs — e.g. the project
+ // was not (fully) built there, so the generated file and the property are legitimately
+ // absent. Both are data the current writer KNOWS HOW TO EMIT, so the freshly generated
+ // candidate is authoritative. Preservation only carries forward data the writer CANNOT
+ // regenerate; it must never splice known-but-currently-absent values back in, or it would
+ // mask the fact that env B's build produced nothing and resurrect stale outputs.
+ string existing = string.Join("\n", new[]
+ {
+ "version=2",
+ string.Empty,
+ "[project]",
+ "project=Sample.csproj",
+ "language=C#",
+ "lastDtbSucceeded",
+ string.Empty,
+ "[properties]",
+ "AssemblyName=Sample",
+ "RootNamespace=Sample.App", // known property present in A, absent in B
+ "TargetFramework=net8.0",
+ string.Empty,
+ "[sourceFiles]",
+ "Program.cs",
+ "Generated.g.cs", // known item: a generated output present only in A
+ }) + "\n";
+ string candidate = string.Join("\n", new[]
+ {
+ "version=2",
+ string.Empty,
+ "[project]",
+ "project=Sample.csproj",
+ "language=C#",
+ "lastDtbSucceeded",
+ string.Empty,
+ "[properties]",
+ "AssemblyName=Sample",
+ "TargetFramework=net8.0",
+ string.Empty,
+ "[sourceFiles]",
+ "Program.cs",
+ }) + "\n";
+
+ string merged = ForwardCompat.PreserveUnknownData(existing, candidate, CurrentMajor);
+
+ // Nothing the current writer can emit is resurrected: the candidate wins verbatim (same
+ // reference, since there is no forward-compatible data to splice).
+ Assert.Same(candidate, merged);
+ Assert.DoesNotContain("Generated.g.cs", merged);
+ Assert.DoesNotContain("RootNamespace", merged);
+ }
+
+ [Fact]
+ public void Preserves_Known_Metadata_Name_Reused_On_A_Different_Item_Type()
+ {
+ // Forward-compat hazard the per-section metadata gate exists to close: a newer minor reuses
+ // an EXISTING metadata name (@link — today emitted only on [sourceFiles]/Compile items) on a
+ // DIFFERENT item type ([metadataReferences]/MetadataReference, which never emits @link). The
+ // older writer does not produce @link there, so it must treat it as unknown and carry it
+ // forward. A flattened "known metadata" union would mistake it for regenerable data and drop
+ // it (resurfacing the exact churn this feature prevents).
+ string existing = string.Join("\n", new[]
+ {
+ "version=2.4",
+ string.Empty,
+ "[project]",
+ "project=Sample.csproj",
+ "language=C#",
+ string.Empty,
+ "[metadataReferences]",
+ "Ref.dll",
+ " @link=carried/forward",
+ }) + "\n";
+ string candidate = string.Join("\n", new[]
+ {
+ "version=2",
+ string.Empty,
+ "[project]",
+ "project=Sample.csproj",
+ "language=C#",
+ string.Empty,
+ "[metadataReferences]",
+ "Ref.dll",
+ }) + "\n";
+
+ string merged = ForwardCompat.PreserveUnknownData(existing, candidate, CurrentMajor);
+
+ Assert.Contains("Ref.dll\n @link=carried/forward", merged);
+ }
+
+ [Fact]
+ public void Drops_Metadata_That_Is_Known_For_The_Section()
+ {
+ // The flip side of the test above: @aliases IS known for [metadataReferences], so an existing
+ // @aliases the candidate did not regenerate is data the writer KNOWS HOW TO EMIT and must not
+ // resurrect (the candidate is authoritative — the reference legitimately has no alias now).
+ string existing = string.Join("\n", new[]
+ {
+ "version=2.4",
+ string.Empty,
+ "[project]",
+ "project=Sample.csproj",
+ "language=C#",
+ string.Empty,
+ "[metadataReferences]",
+ "Ref.dll",
+ " @aliases=Old",
+ }) + "\n";
+ string candidate = string.Join("\n", new[]
+ {
+ "version=2",
+ string.Empty,
+ "[project]",
+ "project=Sample.csproj",
+ "language=C#",
+ string.Empty,
+ "[metadataReferences]",
+ "Ref.dll",
+ }) + "\n";
+
+ string merged = ForwardCompat.PreserveUnknownData(existing, candidate, CurrentMajor);
+
+ Assert.DoesNotContain("@aliases", merged);
+ }
+
+ [Fact]
+ public void Ignores_Unknown_Data_When_Existing_Major_Differs()
+ {
+ string existing = FutureCacheBuilder.SingleTarget(version: "version=3.0", unknownSection: true, unknownProperty: true, unknownMetadata: true);
+ string candidate = FutureCacheBuilder.SingleTarget();
+
+ string merged = ForwardCompat.PreserveUnknownData(existing, candidate, CurrentMajor);
+
+ // A different major is an incompatible format: nothing is preserved and the candidate is
+ // returned unchanged (the writer then overwrites the file wholesale).
+ Assert.Same(candidate, merged);
+ }
+
+ [Fact]
+ public void Returns_Same_Reference_When_Nothing_To_Preserve()
+ {
+ string existing = FutureCacheBuilder.SingleTarget();
+ string candidate = FutureCacheBuilder.SingleTarget();
+
+ string merged = ForwardCompat.PreserveUnknownData(existing, candidate, CurrentMajor);
+
+ Assert.Same(candidate, merged);
+ }
+
+ [Fact]
+ public void Carries_Forward_Higher_Minor_Version_Stamp()
+ {
+ // Existing is a newer minor with no other unknown data; the stamp must be carried forward so
+ // the file does not flip-flop minors as different versions open it.
+ string existing = FutureCacheBuilder.SingleTarget(version: "version=2.5");
+ string candidate = FutureCacheBuilder.SingleTarget(version: "version=2");
+
+ string merged = ForwardCompat.PreserveUnknownData(existing, candidate, CurrentMajor);
+
+ Assert.StartsWith("version=2.5\n", merged);
+ }
+
+ [Fact]
+ public void Does_Not_Downgrade_Version_Stamp()
+ {
+ // Existing is an OLDER (or equal) minor: keep the candidate's own stamp.
+ string existing = FutureCacheBuilder.SingleTarget(version: "version=2", unknownSection: true);
+ string candidate = FutureCacheBuilder.SingleTarget(version: "version=2");
+
+ string merged = ForwardCompat.PreserveUnknownData(existing, candidate, CurrentMajor);
+
+ Assert.StartsWith("version=2\n", merged);
+ }
+
+ [Theory]
+ // existing newer minor, same major → gate open (preserve can carry data forward)
+ [InlineData("version=2.4", "version=2", 2, true)]
+ [InlineData("version=2.1", "version=2.0", 2, true)]
+ // same or older minor → gate closed (nothing this writer can't already produce)
+ [InlineData("version=2", "version=2", 2, false)]
+ [InlineData("version=2.0", "version=2", 2, false)]
+ [InlineData("version=2", "version=2.4", 2, false)]
+ // present-but-unorderable existing minor (multi-part / non-numeric) → gate OPEN, conservatively,
+ // so a newer writer that stamps a non-integer minor never causes us to skip preservation
+ [InlineData("version=2.0.5", "version=2", 2, true)]
+ [InlineData("version=2.3-preview", "version=2", 2, true)]
+ // different / unparseable major → gate closed (incompatible or no header)
+ [InlineData("version=3.0", "version=2", 2, false)]
+ [InlineData("garbage", "version=2", 2, false)]
+ public void ExistingHasNewerMinor_MirrorsPreservationGate(string existingVersion, string candidateVersion, int currentMajor, bool expected)
+ {
+ // The byte-level pre-check must agree with PreserveUnknownData's own version gate: it opens
+ // (returns true) for exactly — and only — the existing-newer-minor, same-major case.
+ byte[] existing = Encoding.UTF8.GetBytes(FutureCacheBuilder.SingleTarget(version: existingVersion));
+ byte[] candidate = Encoding.UTF8.GetBytes(FutureCacheBuilder.SingleTarget(version: candidateVersion));
+
+ bool gate = ForwardCompat.ExistingHasNewerMinor(existing, candidate, currentMajor);
+
+ Assert.Equal(expected, gate);
+
+ // Soundness: the gate must never be more restrictive than the actual transform. Whenever the
+ // gate is closed, PreserveUnknownData must return the candidate unchanged (no data to carry).
+ if (!gate)
+ {
+ string existingText = Encoding.UTF8.GetString(existing);
+ string candidateText = Encoding.UTF8.GetString(candidate);
+ Assert.Same(candidateText, ForwardCompat.PreserveUnknownData(existingText, candidateText, currentMajor));
+ }
+ }
+
+ [Fact]
+ public void ExistingHasNewerMinor_SkipsLeadingCommentLines()
+ {
+ // A leading comment line must not hide the version header from the byte probe.
+ byte[] existing = Encoding.UTF8.GetBytes("# leading comment\nversion=2.4\n");
+ byte[] candidate = Encoding.UTF8.GetBytes(FutureCacheBuilder.SingleTarget(version: "version=2"));
+
+ Assert.True(ForwardCompat.ExistingHasNewerMinor(existing, candidate, CurrentMajor));
+ }
+
+ [Fact]
+ public void ExistingHasNewerMinor_SkipsLeadingLegacyHashLine()
+ {
+ // A legacy committed cache still leads with a hash= header before version=. The byte probe
+ // must skip it (like the reader) so a newer-minor legacy file is still recognized for
+ // preservation instead of being treated as an unparseable header (which would drop data).
+ byte[] existing = Encoding.UTF8.GetBytes(
+ "hash=0000000000000000000000000000000000000000000000000000000000000000\nversion=2.4\n");
+ byte[] candidate = Encoding.UTF8.GetBytes(FutureCacheBuilder.SingleTarget(version: "version=2"));
+
+ Assert.True(ForwardCompat.ExistingHasNewerMinor(existing, candidate, CurrentMajor));
+ }
+
+ [Fact]
+ public void Preserves_All_Levels_Together_Deterministically()
+ {
+ string existing = FutureCacheBuilder.SingleTarget(version: "version=2.4", unknownSection: true, unknownProperty: true, unknownMetadata: true);
+ string candidate = FutureCacheBuilder.SingleTarget();
+
+ string first = ForwardCompat.PreserveUnknownData(existing, candidate, CurrentMajor);
+ // Feeding the merged output back in as the existing file is a no-op: byte-stable, no churn.
+ string second = ForwardCompat.PreserveUnknownData(first, candidate, CurrentMajor);
+
+ Assert.Equal(first, second);
+ Assert.Contains("[futureSection]", first);
+ Assert.Contains("FutureProp=onward", first);
+ Assert.Contains("Program.cs\n @futureMeta=42", first);
+ Assert.StartsWith("version=2.4\n", first);
+ }
+
+ [Fact]
+ public void Preserves_Unknown_Data_When_Existing_Minor_Is_Malformed()
+ {
+ // A newer writer that stamps a non-integer / multi-part minor (e.g. "2.0.5", "2.3-preview")
+ // must not cause an older writer to silently drop its unknown data. The gate treats an
+ // unorderable minor conservatively (preserve), so the future data survives.
+ string existing = FutureCacheBuilder.SingleTarget(version: "version=2.0.5", unknownSection: true, unknownProperty: true);
+ string candidate = FutureCacheBuilder.SingleTarget(version: "version=2");
+
+ string merged = ForwardCompat.PreserveUnknownData(existing, candidate, CurrentMajor);
+
+ Assert.Contains("[" + FutureCacheBuilder.UnknownSectionName + "]", merged);
+ Assert.Contains(FutureCacheBuilder.UnknownProperty, merged);
+ }
+
+ [Fact]
+ public void Preserves_PerSlice_Metadata_On_The_Correct_Slice_Only()
+ {
+ // Multi-TFM attribution: both slices list the same item path (Program.cs) but carry DISTINCT
+ // unknown metadata. Segments are matched by their [sliceDimensions] identity, so each slice's
+ // forward-compat metadata must land back on THAT slice's item — never bleed across slices,
+ // even though the item paths collide.
+ string slice(string tfm, string? meta) => string.Join("\n", new[]
+ {
+ "[project]",
+ "language=C#",
+ string.Empty,
+ "[sliceDimensions]",
+ $"TargetFramework={tfm}",
+ string.Empty,
+ "[sourceFiles]",
+ "Program.cs",
+ }.Concat(meta is null ? Array.Empty() : new[] { " " + meta }));
+
+ string existing = "version=2.4\n" + slice("net8.0", "@futureMetaA=8") + "\n---\n" + slice("net9.0", "@futureMetaB=9") + "\n";
+ string candidate = "version=2\n" + slice("net8.0", null) + "\n---\n" + slice("net9.0", null) + "\n";
+
+ string merged = ForwardCompat.PreserveUnknownData(existing, candidate, CurrentMajor);
+
+ // Each metadatum appears exactly once, attached under its own slice.
+ int metaACount = Regex.Matches(merged, "@futureMetaA").Count;
+ int metaBCount = Regex.Matches(merged, "@futureMetaB").Count;
+ Assert.Equal(1, metaACount);
+ Assert.Equal(1, metaBCount);
+
+ int net8 = merged.IndexOf("TargetFramework=net8.0", StringComparison.Ordinal);
+ int net9 = merged.IndexOf("TargetFramework=net9.0", StringComparison.Ordinal);
+ int metaA = merged.IndexOf("@futureMetaA", StringComparison.Ordinal);
+ int metaB = merged.IndexOf("@futureMetaB", StringComparison.Ordinal);
+
+ // @futureMetaA belongs to the net8.0 segment, @futureMetaB to the net9.0 segment.
+ Assert.InRange(metaA, net8, net9);
+ Assert.True(metaB > net9);
+ }
+
+ // --- End-to-end through the writer (single-TFM path) -----------------------------------------
+
+ [Fact]
+ public void AtomicWriteStreamed_PreservesUnknownData_AndStripsLegacyHash()
+ {
+ string dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ string outputPath = Path.Combine(dir, "test.lscache");
+ try
+ {
+ Directory.CreateDirectory(dir);
+
+ // On disk: a file a NEWER minor authored, still carrying a legacy hash header.
+ string newer = FutureCacheBuilder.SingleTarget(version: "version=2.4", unknownSection: true, unknownProperty: true, unknownMetadata: true);
+ File.WriteAllText(outputPath, $"hash={new string('0', 64)}\n{newer}", new UTF8Encoding(false));
+
+ // The current (older) writer regenerates the candidate it knows about.
+ string candidate = FutureCacheBuilder.SingleTarget();
+ ProjectDataWriter.AtomicWriteStreamed(outputPath, w => w.Write(candidate));
+
+ string result = File.ReadAllText(outputPath);
+
+ Assert.DoesNotContain("hash=", result);
+ Assert.StartsWith("version=2.4\n", result); // newer minor stamp carried forward
+ Assert.Contains("[futureSection]", result);
+ Assert.Contains("FutureProp=onward", result);
+ Assert.Contains("Program.cs\n @futureMeta=42", result);
+ Assert.Contains("AssemblyName=Sample", result);
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void AtomicWriteStreamed_IsNoOp_OnRepeatedWrite_WithPreservedData()
+ {
+ string dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ string outputPath = Path.Combine(dir, "test.lscache");
+ try
+ {
+ Directory.CreateDirectory(dir);
+ string newer = FutureCacheBuilder.SingleTarget(version: "version=2.4", unknownSection: true, unknownProperty: true, unknownMetadata: true);
+ File.WriteAllText(outputPath, $"hash={new string('0', 64)}\n{newer}", new UTF8Encoding(false));
+
+ string candidate = FutureCacheBuilder.SingleTarget();
+ ProjectDataWriter.AtomicWriteStreamed(outputPath, w => w.Write(candidate)); // migrates + preserves
+ DateTime afterMigration = File.GetLastWriteTimeUtc(outputPath);
+
+ Thread.Sleep(1100);
+ ProjectDataWriter.AtomicWriteStreamed(outputPath, w => w.Write(candidate)); // must skip
+
+ Assert.Equal(afterMigration, File.GetLastWriteTimeUtc(outputPath));
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void AtomicWriteStreamed_OverwritesIncompatibleMajor()
+ {
+ string dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ string outputPath = Path.Combine(dir, "test.lscache");
+ try
+ {
+ Directory.CreateDirectory(dir);
+ string future = FutureCacheBuilder.SingleTarget(version: "version=3.0", unknownSection: true);
+ File.WriteAllText(outputPath, future, new UTF8Encoding(false));
+
+ string candidate = FutureCacheBuilder.SingleTarget();
+ ProjectDataWriter.AtomicWriteStreamed(outputPath, w => w.Write(candidate));
+
+ string result = File.ReadAllText(outputPath);
+ Assert.Equal(candidate, result);
+ Assert.DoesNotContain("futureSection", result);
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+
+ // --- End-to-end through the merger (multi-TFM path) ------------------------------------------
+
+ [Fact]
+ public void Merge_PreservesUnknownData_FromExistingMergedFile()
+ {
+ string dir = Path.Combine(Path.GetTempPath(), "lscache-fc-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ Directory.CreateDirectory(dir);
+
+ string net8Dir = Path.Combine(dir, "obj", "Debug", "net8.0");
+ string net9Dir = Path.Combine(dir, "obj", "Debug", "net9.0");
+ Directory.CreateDirectory(net8Dir);
+ Directory.CreateDirectory(net9Dir);
+
+ // Both slices share Program.cs and a shared property, so the merged file has a shared block.
+ string slice(string tfm) => string.Join("\n", new[]
+ {
+ "[project]",
+ "language=C#",
+ "[sliceDimensions]",
+ $"TargetFramework={tfm}",
+ "[properties]",
+ "AssemblyName=Sample",
+ "[sourceFiles]",
+ "Program.cs",
+ }) + "\n";
+ File.WriteAllText(Path.Combine(net8Dir, "Sample.csproj.slice"), slice("net8.0"));
+ File.WriteAllText(Path.Combine(net9Dir, "Sample.csproj.slice"), slice("net9.0"));
+
+ string outPath = Path.Combine(dir, "out.lscache");
+
+ // First merge to capture the canonical merged shape, then inject unknown data into the
+ // shared block AND bump the stamp to a newer minor — exactly how a newer writer would
+ // have authored this file (forward-compatible data always rides a newer-minor stamp).
+ ProjectDataMerger.Merge(outPath, Path.Combine(dir, "obj", "**", "Sample.csproj.slice"), "net8.0;net9.0");
+ string merged = File.ReadAllText(outPath).Replace("\r\n", "\n");
+
+ string newer = BumpPrimaryVersion(merged, "version=2.4")
+ .Replace("AssemblyName=Sample", "AssemblyName=Sample\nFutureProp=onward")
+ .Replace("Program.cs\n", "Program.cs\n @futureMeta=42\n");
+ File.WriteAllText(outPath, newer, new UTF8Encoding(false));
+
+ // Re-merge with the current (older) writer: it must carry the unknown data forward.
+ ProjectDataMerger.Merge(outPath, Path.Combine(dir, "obj", "**", "Sample.csproj.slice"), "net8.0;net9.0");
+ string result = File.ReadAllText(outPath).Replace("\r\n", "\n");
+
+ Assert.Contains("FutureProp=onward", result);
+ Assert.Contains("Program.cs\n @futureMeta=42", result);
+ Assert.DoesNotContain("hash=", result);
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void Merge_PreservesUnknownSection_InPerTargetSlice()
+ {
+ string dir = Path.Combine(Path.GetTempPath(), "lscache-fc-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ Directory.CreateDirectory(dir);
+ string net8Dir = Path.Combine(dir, "obj", "Debug", "net8.0");
+ string net9Dir = Path.Combine(dir, "obj", "Debug", "net9.0");
+ Directory.CreateDirectory(net8Dir);
+ Directory.CreateDirectory(net9Dir);
+
+ // Distinct command-line arguments keep the slices from collapsing into a shared block,
+ // so each TargetFramework gets its own segment.
+ string slice(string tfm, string arg) => string.Join("\n", new[]
+ {
+ "[project]",
+ "language=C#",
+ "[sliceDimensions]",
+ $"TargetFramework={tfm}",
+ "[commandLineArguments]",
+ arg,
+ }) + "\n";
+ File.WriteAllText(Path.Combine(net8Dir, "Sample.csproj.slice"), slice("net8.0", "/define:NET8"));
+ File.WriteAllText(Path.Combine(net9Dir, "Sample.csproj.slice"), slice("net9.0", "/define:NET9"));
+
+ string outPath = Path.Combine(dir, "out.lscache");
+ ProjectDataMerger.Merge(outPath, Path.Combine(dir, "obj", "**", "Sample.csproj.slice"), "net8.0;net9.0");
+ string merged = File.ReadAllText(outPath).Replace("\r\n", "\n");
+
+ // Inject an unknown section AND bump the stamp to a newer minor — forward-compatible data
+ // is only ever authored by a newer-minor writer, which stamps the file accordingly.
+ int net9Index = merged.IndexOf("TargetFramework=net9.0", StringComparison.Ordinal);
+ Assert.True(net9Index >= 0);
+ string newer = BumpPrimaryVersion(merged, "version=2.4") + "\n[futureSection]\nfuture-section-payload\n";
+ File.WriteAllText(outPath, newer, new UTF8Encoding(false));
+
+ ProjectDataMerger.Merge(outPath, Path.Combine(dir, "obj", "**", "Sample.csproj.slice"), "net8.0;net9.0");
+ string result = File.ReadAllText(outPath).Replace("\r\n", "\n");
+
+ Assert.Contains("[futureSection]", result);
+ Assert.Contains("future-section-payload", result);
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+
+ // --- Guard: the generated .props allow-list must match ProjectProperties.All ----------------
+
+ [Fact]
+ public void GeneratedProps_Match_ProjectProperties_All()
+ {
+ // The writer's [properties] allow-list is every schema property, generated into BOTH
+ // ProjectProperties.All (C#, consumed here and by forward-compat) and the committed
+ // Microsoft.NET.ProjectData.Schema.props (_ProjectDataProperties, consumed by MSBuild). They
+ // are generated from the same schema by different generators (C# source generator vs the Node
+ // script), so this guard fails the build if one was regenerated and the other was not — i.e.
+ // it catches a stale committed .props.
+ string propsPath = Path.Combine(AppContext.BaseDirectory, "Microsoft.NET.ProjectData.Schema.props");
+ Assert.True(File.Exists(propsPath), $"Generated props file not found at {propsPath}.");
+
+ string props = File.ReadAllText(propsPath);
+ var fromProps = new HashSet(
+ Regex.Matches(props, "_ProjectDataProperties Include=\"(?[^\"]+)\"")
+ .Select(m => m.Groups["name"].Value),
+ StringComparer.Ordinal);
+
+ var all = new HashSet(ProjectProperties.All, StringComparer.Ordinal);
+
+ Assert.NotEmpty(fromProps);
+ Assert.True(
+ fromProps.SetEquals(all),
+ "Microsoft.NET.ProjectData.Schema.props is out of sync with ProjectProperties.All. " +
+ "Regenerate with `node tools/generate-schema-types.js`.\n" +
+ $"In .props but not All: {string.Join(", ", fromProps.Except(all))}\n" +
+ $"In All but not .props: {string.Join(", ", all.Except(fromProps))}");
+ }
+
+ // --- Guard: growing the emittable set must bump the writer's version minor -------------------
+
+ [Fact]
+ public void AddingEmittableField_RequiresMinorBump()
+ {
+ // Soundness guard for the forward-compat scan's minor gate (ForwardCompat.PreserveUnknownData):
+ // the gate SKIPS the unknown-data scan whenever the existing file's minor <= the writer's
+ // minor, trusting that a same-or-older minor cannot contain anything the writer does not
+ // already emit. That holds ONLY if the writer's version minor is bumped every time the
+ // emittable set (sections / [properties] keys / item @metadata) grows. This test fails the
+ // moment that set changes without a matching minor bump, so the gate can never silently drop
+ // forward-compatible data.
+ //
+ // If this fails because you intentionally changed the emittable set: bump the minor in the
+ // cache schema (which flows to CacheFormat.VersionHeader) and update ExpectedMinor +
+ // ExpectedEmittableHash below to the values printed in the failure message.
+ const int ExpectedMinor = 2;
+ const string ExpectedEmittableHash = "4F7E8056D1E53C2805BA30975E889F762D97752B24BD69F77CFFF6233D1604E6";
+
+ // Metadata is hashed AS item-type:key pairs (not a flattened union) so that reusing an
+ // existing metadata NAME on a different item type — which leaves ProjectItems.AllMetadata
+ // unchanged — still changes this hash and forces a minor bump. That closes the gap where the
+ // per-section preservation gate would otherwise be trusted to skip data it cannot regenerate.
+ IEnumerable metadataPairs = ProjectItems.MetadataByItemType
+ .SelectMany(kv => kv.Value.Select(m => $"{kv.Key}:{m}"));
+
+ string joined = string.Join(
+ "|",
+ CacheFormat.Sections.All
+ .Concat(ForwardCompat.KnownPropertyKeys)
+ .Concat(metadataPairs)
+ .Select(s => s.ToLowerInvariant())
+ .OrderBy(s => s, StringComparer.Ordinal));
+ string actualHash = Convert.ToHexString(
+ System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(joined)));
+
+ Assert.True(
+ ForwardCompat.TryParseVersion(CacheFormat.VersionHeader, out _, out int actualMinor),
+ $"CacheFormat.VersionHeader '{CacheFormat.VersionHeader}' is not a valid version header.");
+
+ string detail = $"Emittable set ({joined.Length} chars):\n{joined}\n" +
+ $"Re-pin with: ExpectedMinor={actualMinor}, ExpectedEmittableHash=\"{actualHash}\".";
+
+ if (actualHash == ExpectedEmittableHash)
+ {
+ Assert.Equal(ExpectedMinor, actualMinor);
+ return;
+ }
+
+ Assert.True(
+ actualMinor > ExpectedMinor,
+ "The emittable cache field set changed but the writer's version minor was NOT bumped " +
+ $"(still {actualMinor}). Adding forward-compatible fields REQUIRES a minor bump so older " +
+ $"writers preserve them instead of silently dropping them.\n{detail}");
+
+ Assert.Fail(
+ $"The emittable cache field set changed and the minor was correctly bumped to {actualMinor}. " +
+ $"Re-pin this guard so steady-state runs pass again.\n{detail}");
+ }
+
+ [Fact]
+ public void EveryEmittedMetadataKey_IsKnownForItsSection()
+ {
+ // Forward-compat judges "known @metadata" per cache section via the schema-generated
+ // CacheFormat.Sections.MetadataBySection (built from each section's itemType link). If a
+ // future schema change gives metadata to an item type whose section lacks an itemType link,
+ // that metadata would be absent from MetadataBySection and the section would be treated as
+ // emitting no metadata. That is the SAFE direction (the metadata is then preserved rather than
+ // dropped), but this guard makes the omission explicit so the section->itemType links stay
+ // complete and the per-section gate keeps dropping genuinely-known metadata.
+ var knownAcrossSections = new HashSet(
+ CacheFormat.Sections.MetadataBySection.Values.SelectMany(m => m),
+ StringComparer.OrdinalIgnoreCase);
+
+ string[] missing = ProjectItems.MetadataByItemType.Values
+ .SelectMany(m => m)
+ .Where(meta => !knownAcrossSections.Contains(meta))
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .OrderBy(s => s, StringComparer.Ordinal)
+ .ToArray();
+
+ Assert.True(
+ missing.Length == 0,
+ "Metadata key(s) the writer emits are not reachable through any cache section's itemType " +
+ $"link (CacheFormat.Sections.MetadataBySection): {string.Join(", ", missing)}. Add the " +
+ "\"itemType\" link to the owning section in server/src/Microsoft.NET.ProjectData.Generators/project-data-schema.json.");
+ }
+}
diff --git a/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/LscacheInvariants.cs b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/LscacheInvariants.cs
new file mode 100644
index 0000000000000..64fad41b2058b
--- /dev/null
+++ b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/LscacheInvariants.cs
@@ -0,0 +1,217 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Xunit;
+
+namespace Microsoft.NET.ProjectData.Tasks.Tests;
+
+///
+/// Invariants that any well-formed .lscache file produced by the
+/// ProjectDataBuild targets and ProjectDataWriter must satisfy.
+/// Used by writer regression tests to guard against bug shapes that produce
+/// internally inconsistent caches, regardless of the specific input that
+/// surfaced the bug.
+///
+internal static class LscacheInvariants
+{
+ ///
+ /// Every line in the [sdkAnalyzerConfigPolicy] section refers to an
+ /// SDK analyzer pack subfolder (e.g. Microsoft.NET.Sdk/analyzers or
+ /// Microsoft.NET.Sdk/codestyle/cs|...). For each such line there
+ /// must be at least one analyzer DLL listed under the matching
+ /// /Sdks/<sdkPack>/<segment>/ path in
+ /// [analyzerReferences] — otherwise the cache claims to apply
+ /// analyzer configuration to DLLs the SDK never produced.
+ ///
+ ///
+ /// Guards against regressing the
+ /// Gate [sdkAnalyzerConfigPolicy] lines on the SDK's analyzer-pack
+ /// property gates fix. The fix gated emission on
+ /// EnableNETAnalyzers and EnforceCodeStyleInBuild to
+ /// mirror the SDK's own conditions; any future code path that bypasses
+ /// those gates (in the targets file, the writer, or a new policy type)
+ /// will produce orphan policy lines that this assertion catches.
+ ///
+ public static void AssertNoOrphanAnalyzerPolicyLines(string lscacheContent)
+ {
+ IReadOnlyList policyLines = ExtractSection(lscacheContent, "sdkAnalyzerConfigPolicy", preserveIndent: false);
+ if (policyLines.Count == 0)
+ {
+ return;
+ }
+
+ // [analyzerReferences] encodes paths as an indented tree where children
+ // share their parent's prefix (e.g. parent `/Sdks/Microsoft.NET.Sdk/`,
+ // children `analyzers/`, `codestyle/cs/`). Rebuild the flat list of
+ // full file paths so the invariant can be checked with a simple
+ // substring match regardless of how the writer compresses prefixes.
+ IReadOnlyList analyzerReferencePaths = ReconstructIndentedTreePaths(
+ ExtractSection(lscacheContent, "analyzerReferences", preserveIndent: true));
+ string analyzerReferencesFlat = string.Join("\n", analyzerReferencePaths);
+
+ List orphans = [];
+ foreach (string line in policyLines)
+ {
+ // Policy lines look like "Microsoft.NET.Sdk/analyzers" or
+ // "Microsoft.NET.Sdk/codestyle/cs|AnalysisLevel=...". Strip the
+ // argument segment after '|' and look for a matching SDK pack
+ // subfolder in [analyzerReferences].
+ int barIndex = line.IndexOf('|');
+ string folderKey = barIndex >= 0 ? line[..barIndex] : line;
+ string expectedFragment = $"/Sdks/{folderKey}/";
+ if (!analyzerReferencesFlat.Contains(expectedFragment))
+ {
+ orphans.Add(line);
+ }
+ }
+
+ Assert.True(
+ orphans.Count == 0,
+ $"Found {orphans.Count} orphan [sdkAnalyzerConfigPolicy] line(s) without matching analyzer DLLs in [analyzerReferences]: {string.Join("; ", orphans)}");
+ }
+
+ private static IReadOnlyList ExtractSection(string lscacheContent, string sectionName, bool preserveIndent)
+ {
+ string[] lines = lscacheContent.Replace("\r\n", "\n").Split('\n');
+ List result = [];
+ bool inSection = false;
+ string sectionHeader = $"[{sectionName}]";
+ foreach (string line in lines)
+ {
+ if (line.StartsWith('['))
+ {
+ inSection = line == sectionHeader;
+ continue;
+ }
+
+ if (!inSection || line.Length == 0)
+ {
+ continue;
+ }
+
+ result.Add(preserveIndent ? line : line.TrimStart());
+ }
+
+ return result;
+ }
+
+ private static IReadOnlyList ReconstructIndentedTreePaths(IReadOnlyList indentedLines)
+ {
+ // Each line's leading-space count is its depth in the tree. A line ending
+ // in '/' is a directory: its full path becomes the prefix for any deeper
+ // lines until we return to that depth or shallower. A line not ending in
+ // '/' is a file: emit its reconstructed full path.
+ Stack<(int Depth, string Prefix)> stack = new();
+ List filePaths = [];
+ foreach (string raw in indentedLines)
+ {
+ int depth = 0;
+ while (depth < raw.Length && raw[depth] == ' ')
+ {
+ depth++;
+ }
+
+ string text = raw[depth..];
+ while (stack.Count > 0 && stack.Peek().Depth >= depth)
+ {
+ stack.Pop();
+ }
+
+ string prefix = stack.Count > 0 ? stack.Peek().Prefix : string.Empty;
+ string full = prefix + text;
+ if (text.EndsWith('/'))
+ {
+ stack.Push((depth, full));
+ }
+ else
+ {
+ filePaths.Add(full);
+ }
+ }
+
+ return filePaths;
+ }
+}
+
+public sealed class LscacheInvariantsTests
+{
+ [Fact]
+ public void AssertNoOrphanAnalyzerPolicyLines_NoPolicySection_Passes()
+ {
+ // Trivial: a cache with no policy section can't have orphan lines.
+ const string content = "[someOtherSection]\nfoo=bar\n";
+ LscacheInvariants.AssertNoOrphanAnalyzerPolicyLines(content);
+ }
+
+ [Fact]
+ public void AssertNoOrphanAnalyzerPolicyLines_EveryPolicyLineHasMatchingReference_Passes()
+ {
+ const string content = """
+ [sdkAnalyzerConfigPolicy]
+ Microsoft.NET.Sdk/analyzers
+ Microsoft.NET.Sdk/codestyle/cs|AnalysisLevel=latest
+ [analyzerReferences]
+ /Sdks/Microsoft.NET.Sdk/analyzers/
+ Microsoft.CodeAnalysis.NetAnalyzers.dll
+ /Sdks/Microsoft.NET.Sdk/codestyle/cs/
+ Microsoft.CodeAnalysis.CSharp.CodeStyle.dll
+ """;
+ LscacheInvariants.AssertNoOrphanAnalyzerPolicyLines(content);
+ }
+
+ [Fact]
+ public void AssertNoOrphanAnalyzerPolicyLines_OrphanAnalyzerLine_Throws()
+ {
+ // Mirrors the exact pre-fix bug shape: policy line exists but the
+ // corresponding analyzer pack subfolder has no DLLs in [analyzerReferences].
+ const string content = """
+ [sdkAnalyzerConfigPolicy]
+ Microsoft.NET.Sdk/analyzers
+ [analyzerReferences]
+ /Sdks/Microsoft.NET.Sdk/codestyle/cs/
+ Microsoft.CodeAnalysis.CSharp.CodeStyle.dll
+ """;
+ Xunit.Sdk.XunitException ex = Assert.ThrowsAny(
+ () => LscacheInvariants.AssertNoOrphanAnalyzerPolicyLines(content));
+ Assert.Contains("Microsoft.NET.Sdk/analyzers", ex.Message);
+ }
+
+ [Fact]
+ public void AssertNoOrphanAnalyzerPolicyLines_OrphanCodeStyleLine_Throws()
+ {
+ const string content = """
+ [sdkAnalyzerConfigPolicy]
+ Microsoft.NET.Sdk/codestyle/cs|AnalysisLevel=latest
+ [analyzerReferences]
+ /Sdks/Microsoft.NET.Sdk/analyzers/
+ Microsoft.CodeAnalysis.NetAnalyzers.dll
+ """;
+ Xunit.Sdk.XunitException ex = Assert.ThrowsAny(
+ () => LscacheInvariants.AssertNoOrphanAnalyzerPolicyLines(content));
+ Assert.Contains("Microsoft.NET.Sdk/codestyle/cs", ex.Message);
+ }
+
+ [Fact]
+ public void AssertNoOrphanAnalyzerPolicyLines_CompressedTreeWithSharedParent_Passes()
+ {
+ // The writer compresses common path prefixes in [analyzerReferences].
+ // When both analyzers/ and codestyle/cs/ are present under the same SDK
+ // pack, they are emitted as siblings under a shared parent line. The
+ // invariant must reconstruct the tree before substring-matching the
+ // folder key, otherwise it would spuriously fail on this real-world
+ // output shape.
+ const string content = """
+ [sdkAnalyzerConfigPolicy]
+ Microsoft.NET.Sdk/analyzers
+ Microsoft.NET.Sdk/codestyle/cs|AnalysisLevel=latest
+ [analyzerReferences]
+ /Sdks/Microsoft.NET.Sdk/
+ analyzers/
+ Microsoft.CodeAnalysis.NetAnalyzers.dll
+ codestyle/cs/
+ Microsoft.CodeAnalysis.CSharp.CodeStyle.dll
+ """;
+ LscacheInvariants.AssertNoOrphanAnalyzerPolicyLines(content);
+ }
+}
diff --git a/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/Microsoft.NET.ProjectData.Tasks.Tests.csproj b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/Microsoft.NET.ProjectData.Tasks.Tests.csproj
new file mode 100644
index 0000000000000..1b3772be7fa4b
--- /dev/null
+++ b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/Microsoft.NET.ProjectData.Tasks.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ Exe
+
+ Microsoft.NET.ProjectData.Tasks.UnitTests
+ Microsoft.NET.ProjectData.Tasks.Tests
+ false
+ false
+ false
+
+ $(NoWarn);RS0030
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/ProjectDataMergerTests.cs b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/ProjectDataMergerTests.cs
new file mode 100644
index 0000000000000..0857a898c8c41
--- /dev/null
+++ b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/ProjectDataMergerTests.cs
@@ -0,0 +1,748 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Xunit;
+
+namespace Microsoft.NET.ProjectData.Tasks.Tests;
+
+public class ProjectDataMergerTests
+{
+ private const string SliceNet8 = "[project]\nlanguage=C#\n[sliceDimensions]\nTargetFramework=net8.0\n";
+ private const string SliceNet9 = "[project]\nlanguage=C#\n[sliceDimensions]\nTargetFramework=net9.0\n";
+ private const string SliceNet472 = "[project]\nlanguage=C#\n[sliceDimensions]\nTargetFramework=net472\n";
+
+ private static string MakeTempDir()
+ {
+ string dir = Path.Combine(Path.GetTempPath(), "lscache-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(dir);
+ return dir;
+ }
+
+ private static string CreateSliceDir(string baseDir, string tfm)
+ {
+ string dir = Path.Combine(baseDir, "obj", "Debug", tfm);
+ Directory.CreateDirectory(dir);
+ return dir;
+ }
+
+ private static int CountOccurrences(string content, string value)
+ {
+ int count = 0;
+ int index = 0;
+ while ((index = content.IndexOf(value, index, StringComparison.Ordinal)) >= 0)
+ {
+ count++;
+ index += value.Length;
+ }
+
+ return count;
+ }
+
+ private static string GetSliceBlock(string content, string targetFramework)
+ {
+ foreach (string block in content.Split("\n---\n", StringSplitOptions.None).Skip(1))
+ {
+ if (block.Contains($"TargetFramework={targetFramework}\n", StringComparison.Ordinal))
+ return block;
+ }
+
+ throw new InvalidOperationException($"Could not find slice block for {targetFramework}.\n{content}");
+ }
+
+ [Fact]
+ public void Merge_SingleSlice_CreatesFileWithBanner()
+ {
+ string dir = MakeTempDir();
+ try
+ {
+ string sliceDir = CreateSliceDir(dir, "net8.0");
+ File.WriteAllText(Path.Combine(sliceDir, "Sample.csproj.slice"), SliceNet8);
+ string outPath = Path.Combine(dir, "out.lscache");
+
+ int count = ProjectDataMerger.Merge(outPath, Path.Combine(dir, "obj", "**", "Sample.csproj.slice"));
+
+ Assert.Equal(1, count);
+ Assert.True(File.Exists(outPath));
+ string content = File.ReadAllText(outPath).Replace("\r\n", "\n");
+ string[] lines = content.Split('\n');
+ Assert.Equal("version=2.2", lines[0]);
+ Assert.DoesNotContain("hash=", content);
+ Assert.Contains("# This file caches", content);
+ Assert.DoesNotContain("TargetFramework=net8.0", content);
+ Assert.DoesNotContain("\n---\n", content);
+ Assert.DoesNotContain("[sliceDimensions]", content);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public void Merge_PreservesSlicesAfterSuccess()
+ {
+ string dir = MakeTempDir();
+ try
+ {
+ string sliceDir = CreateSliceDir(dir, "net8.0");
+ string slicePath = Path.Combine(sliceDir, "Sample.csproj.slice");
+ File.WriteAllText(slicePath, SliceNet8);
+ string outPath = Path.Combine(dir, "out.lscache");
+
+ ProjectDataMerger.Merge(outPath, Path.Combine(dir, "obj", "**", "Sample.csproj.slice"));
+
+ Assert.True(File.Exists(slicePath), "slice should be preserved so later builds can skip when it is up-to-date");
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public void Merge_ExplicitSliceFiles_IgnoresUnlistedStaleSlice()
+ {
+ string dir = MakeTempDir();
+ try
+ {
+ string net8Slice = Path.Combine(CreateSliceDir(dir, "net8.0"), "Sample.csproj.slice");
+ string staleNet9Slice = Path.Combine(CreateSliceDir(dir, "net9.0"), "Sample.csproj.slice");
+ File.WriteAllText(net8Slice, SliceNet8);
+ File.WriteAllText(staleNet9Slice, SliceNet9);
+ string outPath = Path.Combine(dir, "out.lscache");
+
+ int count = ProjectDataMerger.Merge(outPath, [net8Slice], "net8.0");
+
+ Assert.Equal(1, count);
+ string content = File.ReadAllText(outPath).Replace("\r\n", "\n");
+ Assert.DoesNotContain("\n---\n", content);
+ Assert.DoesNotContain("TargetFramework=net9.0", content);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public void Merge_PreserveExistingSlices_KeepsUnevaluatedSliceFromOutput()
+ {
+ string dir = MakeTempDir();
+ try
+ {
+ string net10Slice = Path.Combine(CreateSliceDir(dir, "net10.0"), "Sample.csproj.slice");
+ File.WriteAllText(net10Slice, "[project]\nlanguage=C#\nlastDtbSucceeded\n[sliceDimensions]\nTargetFramework=net10.0\n[commandLineArguments]\n/langversion:preview\n");
+ string outPath = Path.Combine(dir, "out.lscache");
+ File.WriteAllText(
+ outPath,
+ """
+ hash=0000000000000000000000000000000000000000000000000000000000000000
+ version=2
+
+ # Existing committed cache.
+
+ [project]
+ project=Sample.csproj
+ language=C#
+ lastDtbSucceeded
+
+ [commandLineArguments]
+ /noconfig
+
+ ---
+
+ [project]
+ primary
+
+ [sliceDimensions]
+ TargetFramework=net10.0
+
+ ---
+
+ [project]
+
+ [sliceDimensions]
+ TargetFramework=net472
+
+ [metadataReferences]
+ /v4.7.2/
+ mscorlib.dll
+ """);
+
+ int count = ProjectDataMerger.Merge(outPath, [net10Slice], "net10.0", preserveExistingSlices: true);
+
+ Assert.Equal(1, count);
+ string content = File.ReadAllText(outPath).Replace("\r\n", "\n");
+ Assert.Contains("TargetFramework=net10.0", content);
+ Assert.Contains("/langversion:preview", content);
+ Assert.DoesNotContain("/noconfig", GetSliceBlock(content, "net10.0"));
+
+ string preservedNetFrameworkBlock = GetSliceBlock(content, "net472");
+ Assert.Contains("/v4.7.2/", preservedNetFrameworkBlock);
+ Assert.Contains(" mscorlib.dll", preservedNetFrameworkBlock);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public void Merge_PreserveExistingSlices_DoesNotPreserveSlicesFromIncompatibleMajorVersion()
+ {
+ string dir = MakeTempDir();
+ try
+ {
+ string net10Slice = Path.Combine(CreateSliceDir(dir, "net10.0"), "Sample.csproj.slice");
+ File.WriteAllText(net10Slice, "[project]\nlanguage=C#\nlastDtbSucceeded\n[sliceDimensions]\nTargetFramework=net10.0\n[commandLineArguments]\n/langversion:preview\n");
+ string outPath = Path.Combine(dir, "out.lscache");
+
+ // The existing merged cache is a FUTURE major (version=3): its grammar may differ from this
+ // writer's, so its slices must NOT be parsed-and-re-emitted under the current version= header.
+ File.WriteAllText(
+ outPath,
+ """
+ version=3
+
+ [project]
+ project=Sample.csproj
+ language=C#
+ lastDtbSucceeded
+
+ ---
+
+ [project]
+
+ [sliceDimensions]
+ TargetFramework=net472
+
+ [metadataReferences]
+ /v4.7.2/
+ future_major_payload.dll
+ """);
+
+ int count = ProjectDataMerger.Merge(outPath, [net10Slice], "net10.0", preserveExistingSlices: true);
+
+ Assert.Equal(1, count);
+ string content = File.ReadAllText(outPath).Replace("\r\n", "\n");
+ Assert.StartsWith("version=2", content);
+ Assert.Contains("/langversion:preview", content);
+ // The incompatible-major slice content must be gone — not re-emitted under version=2.
+ Assert.DoesNotContain("future_major_payload.dll", content);
+ Assert.DoesNotContain("TargetFramework=net472", content);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public void Merge_NoSlices_ReturnsZero()
+ {
+ string dir = MakeTempDir();
+ try
+ {
+ Directory.CreateDirectory(Path.Combine(dir, "obj"));
+ string outPath = Path.Combine(dir, "out.lscache");
+
+ int count = ProjectDataMerger.Merge(outPath, Path.Combine(dir, "obj", "**", "Sample.csproj.slice"));
+
+ Assert.Equal(0, count);
+ Assert.False(File.Exists(outPath));
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public void Merge_TwoSlices_AddsSeparator()
+ {
+ string dir = MakeTempDir();
+ try
+ {
+ File.WriteAllText(Path.Combine(CreateSliceDir(dir, "net8.0"), "Sample.csproj.slice"), SliceNet8);
+ File.WriteAllText(Path.Combine(CreateSliceDir(dir, "net9.0"), "Sample.csproj.slice"), SliceNet9);
+ string outPath = Path.Combine(dir, "out.lscache");
+
+ int count = ProjectDataMerger.Merge(outPath, Path.Combine(dir, "obj", "**", "Sample.csproj.slice"));
+
+ Assert.Equal(2, count);
+ string content = File.ReadAllText(outPath).Replace("\r\n", "\n");
+ Assert.Contains("TargetFramework=net8.0", content);
+ Assert.Contains("TargetFramework=net9.0", content);
+
+ int sepCount = 0;
+ int idx = 0;
+ while ((idx = content.IndexOf("\n---\n", idx, StringComparison.Ordinal)) >= 0)
+ {
+ sepCount++;
+ idx += 5;
+ }
+ // Shared section + 2 per-TFM sections = 2 separators
+ Assert.Equal(2, sepCount);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public void Merge_SortsSlicesByPath()
+ {
+ string dir = MakeTempDir();
+ try
+ {
+ // Write net9.0 first, then net472 — output should sort alphabetically by path
+ File.WriteAllText(Path.Combine(CreateSliceDir(dir, "net9.0"), "Sample.csproj.slice"), SliceNet9 + "MARK_NET9\n");
+ File.WriteAllText(Path.Combine(CreateSliceDir(dir, "net472"), "Sample.csproj.slice"), SliceNet472 + "MARK_NET472\n");
+ string outPath = Path.Combine(dir, "out.lscache");
+
+ ProjectDataMerger.Merge(outPath, Path.Combine(dir, "obj", "**", "Sample.csproj.slice"));
+
+ string content = File.ReadAllText(outPath);
+ int idx472 = content.IndexOf("MARK_NET472", StringComparison.Ordinal);
+ int idx9 = content.IndexOf("MARK_NET9", StringComparison.Ordinal);
+ Assert.True(idx472 >= 0 && idx9 >= 0);
+ Assert.True(idx472 < idx9, "net472 slice should come before net9.0");
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Theory]
+ [InlineData("net8.0;netstandard2.0", "net8.0", "netstandard2.0")]
+ [InlineData("netstandard2.0;net8.0", "netstandard2.0", "net8.0")]
+ public void Merge_PrimaryFollowsFirstNonNetFrameworkTargetFramework(string targetFrameworks, string expectedPrimary, string expectedNonPrimary)
+ {
+ string dir = MakeTempDir();
+ try
+ {
+ string sliceNet8 = "[project]\nlanguage=C#\n[sliceDimensions]\nTargetFramework=net8.0\n";
+ string sliceNetStandard = "[project]\nlanguage=C#\n[sliceDimensions]\nTargetFramework=netstandard2.0\n";
+
+ File.WriteAllText(Path.Combine(CreateSliceDir(dir, "net8.0"), "Sample.csproj.slice"), sliceNet8);
+ File.WriteAllText(Path.Combine(CreateSliceDir(dir, "netstandard2.0"), "Sample.csproj.slice"), sliceNetStandard);
+ string outPath = Path.Combine(dir, "out.lscache");
+
+ ProjectDataMerger.Merge(outPath, Path.Combine(dir, "obj", "**", "Sample.csproj.slice"), targetFrameworks);
+
+ string content = File.ReadAllText(outPath).Replace("\r\n", "\n");
+ Assert.Equal(1, CountOccurrences(content, "\nprimary\n"));
+ Assert.Contains("\nprimary\n", GetSliceBlock(content, expectedPrimary));
+ Assert.DoesNotContain("\nprimary\n", GetSliceBlock(content, expectedNonPrimary));
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public void Merge_PrimaryPrefersNetCoreAppOverNetFramework()
+ {
+ string dir = MakeTempDir();
+ try
+ {
+ File.WriteAllText(Path.Combine(CreateSliceDir(dir, "net472"), "Sample.csproj.slice"), SliceNet472);
+ File.WriteAllText(Path.Combine(CreateSliceDir(dir, "net8.0"), "Sample.csproj.slice"), SliceNet8);
+ string outPath = Path.Combine(dir, "out.lscache");
+
+ ProjectDataMerger.Merge(outPath, Path.Combine(dir, "obj", "**", "Sample.csproj.slice"), "net472;net8.0");
+
+ string content = File.ReadAllText(outPath).Replace("\r\n", "\n");
+ Assert.Equal(1, CountOccurrences(content, "\nprimary\n"));
+ Assert.Contains("\nprimary\n", GetSliceBlock(content, "net8.0"));
+ Assert.DoesNotContain("\nprimary\n", GetSliceBlock(content, "net472"));
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public void Merge_PrimaryMarkerOmittedForSingleSliceBecauseReaderFallsBackToFirstSlice()
+ {
+ // Single-slice output intentionally omits ``primary``: the data-model reader's
+ // ``ToProjectDto`` treats ``slices[0]`` as primary when no slice carries the
+ // marker. Skipping the marker keeps the cache stable when a project transitions
+ // from multi-targeting to single-targeting.
+ string dir = MakeTempDir();
+ try
+ {
+ File.WriteAllText(Path.Combine(CreateSliceDir(dir, "net472"), "Sample.csproj.slice"), SliceNet472);
+ string outPath = Path.Combine(dir, "out.lscache");
+
+ ProjectDataMerger.Merge(outPath, Path.Combine(dir, "obj", "**", "Sample.csproj.slice"), "net472");
+
+ string content = File.ReadAllText(outPath).Replace("\r\n", "\n");
+ Assert.Equal(0, CountOccurrences(content, "\nprimary\n"));
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public void Merge_ThreeSlices_TwoSeparators()
+ {
+ string dir = MakeTempDir();
+ try
+ {
+ File.WriteAllText(Path.Combine(CreateSliceDir(dir, "net8.0"), "Sample.csproj.slice"), SliceNet8);
+ File.WriteAllText(Path.Combine(CreateSliceDir(dir, "net9.0"), "Sample.csproj.slice"), SliceNet9);
+ File.WriteAllText(Path.Combine(CreateSliceDir(dir, "net472"), "Sample.csproj.slice"), SliceNet472);
+ string outPath = Path.Combine(dir, "out.lscache");
+
+ int count = ProjectDataMerger.Merge(outPath, Path.Combine(dir, "obj", "**", "Sample.csproj.slice"));
+
+ Assert.Equal(3, count);
+ string content = File.ReadAllText(outPath).Replace("\r\n", "\n");
+
+ int sepCount = 0;
+ int idx = 0;
+ while ((idx = content.IndexOf("\n---\n", idx, StringComparison.Ordinal)) >= 0)
+ {
+ sepCount++;
+ idx += 5;
+ }
+ // Shared section + 3 per-TFM sections = 3 separators
+ Assert.Equal(3, sepCount);
+ Assert.Contains("TargetFramework=net472", content);
+ Assert.Contains("TargetFramework=net8.0", content);
+ Assert.Contains("TargetFramework=net9.0", content);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public void Merge_OverwritesExistingOutput()
+ {
+ string dir = MakeTempDir();
+ try
+ {
+ File.WriteAllText(Path.Combine(CreateSliceDir(dir, "net8.0"), "Sample.csproj.slice"), SliceNet8);
+ string outPath = Path.Combine(dir, "out.lscache");
+ File.WriteAllText(outPath, "OLD_CONTENT");
+
+ ProjectDataMerger.Merge(outPath, Path.Combine(dir, "obj", "**", "Sample.csproj.slice"));
+
+ string content = File.ReadAllText(outPath);
+ Assert.DoesNotContain("OLD_CONTENT", content);
+ Assert.Contains("[project]", content);
+ Assert.DoesNotContain("\n---\n", content);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public void Merge_DeduplicatesSharedContent()
+ {
+ string dir = MakeTempDir();
+ try
+ {
+ // Two slices with mostly identical content but different TFMs and one different property.
+ string slice8 = "[project]\nlanguage=C#\nlastDtbSucceeded\n" +
+ "[sliceDimensions]\nTargetFramework=net8.0\n" +
+ "[properties]\nAssemblyName=MyApp\nTargetPath=bin/net8.0/MyApp.dll\n" +
+ "[sourceFiles]\nProgram.cs\nHelper.cs\n" +
+ "[metadataReferences]\nSystem.Runtime.dll\nSystem.Collections.dll\n";
+ string slice9 = "[project]\nlanguage=C#\nlastDtbSucceeded\n" +
+ "[sliceDimensions]\nTargetFramework=net9.0\n" +
+ "[properties]\nAssemblyName=MyApp\nTargetPath=bin/net9.0/MyApp.dll\n" +
+ "[sourceFiles]\nProgram.cs\nHelper.cs\n" +
+ "[metadataReferences]\nSystem.Runtime.dll\nSystem.Collections.dll\nSystem.Text.Json.dll\n";
+
+ File.WriteAllText(Path.Combine(CreateSliceDir(dir, "net8.0"), "Sample.csproj.slice"), slice8);
+ File.WriteAllText(Path.Combine(CreateSliceDir(dir, "net9.0"), "Sample.csproj.slice"), slice9);
+ string outPath = Path.Combine(dir, "out.lscache");
+
+ ProjectDataMerger.Merge(outPath, Path.Combine(dir, "obj", "**", "Sample.csproj.slice"));
+
+ string content = File.ReadAllText(outPath).Replace("\r\n", "\n");
+
+ // Shared properties should appear before the first --- separator.
+ int firstSep = content.IndexOf("\n---\n", StringComparison.Ordinal);
+ string sharedPart = content.Substring(0, firstSep);
+ Assert.Contains("AssemblyName=MyApp", sharedPart);
+ Assert.Contains("Program.cs", sharedPart);
+ Assert.Contains("Helper.cs", sharedPart);
+ Assert.Contains("System.Runtime.dll", sharedPart);
+ Assert.Contains("System.Collections.dll", sharedPart);
+ Assert.Contains("lastDtbSucceeded", sharedPart);
+
+ // TFM-specific content should only be in per-TFM sections.
+ Assert.DoesNotContain("TargetFramework=", sharedPart);
+ string perTfmPart = content.Substring(firstSep);
+ Assert.Contains("TargetPath=bin/net8.0/MyApp.dll", perTfmPart);
+ Assert.Contains("TargetPath=bin/net9.0/MyApp.dll", perTfmPart);
+ Assert.Contains("System.Text.Json.dll", perTfmPart);
+
+ // Shared content should NOT be repeated in per-TFM sections.
+ // Count occurrences of "Program.cs" in the whole file — should be exactly 1.
+ int programCount = 0;
+ int searchIdx = 0;
+ while ((searchIdx = content.IndexOf("Program.cs", searchIdx, StringComparison.Ordinal)) >= 0)
+ {
+ programCount++;
+ searchIdx += 10;
+ }
+ Assert.Equal(1, programCount);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public void ParseSlice_ParsesAllSections()
+ {
+ string slice = "[project]\nlanguage=C#\nlastDtbSucceeded\nprimary\n" +
+ "[sliceDimensions]\nTargetFramework=net8.0\n" +
+ "[properties]\nAssemblyName=Test\nTargetPath=bin/Test.dll\n" +
+ "[commandLineArguments]\n/noconfig\n/unsafe-\n" +
+ "[sourceFiles]\nProgram.cs\n" +
+ "[metadataReferences]\nSystem.dll\n" +
+ "[analyzerReferences]\nMyAnalyzer.dll\n";
+
+ ProjectDataMerger.SliceData data = ProjectDataMerger.ParseSlice(slice);
+
+ Assert.Contains("lastDtbSucceeded", data.ProjectLines);
+ Assert.DoesNotContain("primary", data.ProjectLines);
+ Assert.True(data.IsPrimary);
+ Assert.Single(data.SliceDimensions);
+ Assert.Equal("TargetFramework=net8.0", data.SliceDimensions[0]);
+ Assert.Equal(2, data.Properties.Count);
+ Assert.Equal(2, data.ListSections["commandLineArguments"].Count);
+ Assert.Single(data.ListSections["sourceFiles"]);
+ Assert.Single(data.ListSections["metadataReferences"]);
+ Assert.Single(data.ListSections["analyzerReferences"]);
+ }
+
+ [Fact]
+ public void Merge_IsStableWhenSharedSdkAnalyzerConfigPolicyIsReParsed()
+ {
+ // Round-trip stability guard for the shared block's ``[sdkAnalyzerConfigPolicy]``
+ // entries. ``ProjectDataMerger.ParseSlice`` calls ``CanonicalizeSdkAnalyzerConfigPolicyLine``
+ // while parsing the merged file's shared block, where ``GetTargetFramework()``
+ // returns ``null``. The canonicalizer is currently a no-op under null TFM, so a
+ // line that lands in the shared block (because every per-TFM slice produced the
+ // same value) survives a parse/emit round-trip unchanged. Pin that contract — if
+ // the canonicalizer ever mutates already-canonical data under null TFM, this test
+ // catches it before the cache starts thrashing on round-trip.
+ string dir = MakeTempDir();
+ try
+ {
+ string sharedPolicy = "Microsoft.NET.Sdk/analyzers|AnalysisMode=Default";
+ string sliceNet8 =
+ "[project]\nlanguage=C#\n" +
+ "[sliceDimensions]\nTargetFramework=net8.0\n" +
+ "[sdkAnalyzerConfigPolicy]\n" + sharedPolicy + "\n";
+ string sliceNet9 =
+ "[project]\nlanguage=C#\n" +
+ "[sliceDimensions]\nTargetFramework=net9.0\n" +
+ "[sdkAnalyzerConfigPolicy]\n" + sharedPolicy + "\n";
+
+ File.WriteAllText(Path.Combine(CreateSliceDir(dir, "net8.0"), "Sample.csproj.slice"), sliceNet8);
+ File.WriteAllText(Path.Combine(CreateSliceDir(dir, "net9.0"), "Sample.csproj.slice"), sliceNet9);
+ string outPath = Path.Combine(dir, "out.lscache");
+
+ ProjectDataMerger.Merge(outPath, Path.Combine(dir, "obj", "**", "Sample.csproj.slice"), "net8.0;net9.0");
+ string firstMerge = File.ReadAllText(outPath).Replace("\r\n", "\n");
+
+ // Sanity-check the shared block actually contains the policy line.
+ Assert.Contains($"\n{sharedPolicy}\n", firstMerge);
+
+ // The merged file is headerless now (no leading hash line); the whole file is the
+ // banner + structured content produced by ``WriteMergedContent``.
+ string firstMergeBody = firstMerge;
+
+ // Round-trip the merged file: parse it back to ``SliceData`` (which canonicalizes
+ // the shared block under null TFM) and re-emit through ``WriteMergedContent``.
+ // The result must be byte-identical to the original ``WriteMergedContent`` output.
+ List reparsed = ProjectDataMerger.ParseMergedContent(firstMerge);
+ using var sw = new StringWriter { NewLine = "\n" };
+ ProjectDataMerger.WriteMergedContent(sw, reparsed, "net8.0;net9.0");
+
+ Assert.Equal(firstMergeBody, sw.ToString());
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public void Merge_IndentedSourceFilesUnderTfmSpecificDir_AreNotHoistedToShared()
+ {
+ // Regression test for: a child line ' ConsoleApp2.AssemblyInfo.cs' from
+ // the per-TFM compressed group
+ // obj/Debug/net10.0/
+ // ConsoleApp2.AssemblyInfo.cs
+ // ConsoleApp2.GlobalUsings.g.cs
+ // was being hoisted into the shared block (line-level intersection) without
+ // its parent prefix line, producing a corrupt cache file like
+ // [sourceFiles]
+ // ConsoleApp2.AssemblyInfo.cs <-- orphaned indented line
+ // ConsoleApp2.GlobalUsings.g.cs
+ // Program.cs
+ // The fix groups indent-0 lines with their indented continuations and
+ // intersects at the group level.
+ string dir = MakeTempDir();
+ try
+ {
+ string slice10 = "[project]\nlanguage=C#\n[sliceDimensions]\nTargetFramework=net10.0\n" +
+ "[sourceFiles]\nProgram.cs\nobj/Debug/net10.0/\n ConsoleApp2.AssemblyInfo.cs\n ConsoleApp2.GlobalUsings.g.cs\n";
+ string slice9 = "[project]\nlanguage=C#\n[sliceDimensions]\nTargetFramework=net9.0\n" +
+ "[sourceFiles]\nProgram.cs\nobj/Debug/net9.0/\n ConsoleApp2.AssemblyInfo.cs\n ConsoleApp2.GlobalUsings.g.cs\n";
+
+ File.WriteAllText(Path.Combine(CreateSliceDir(dir, "net10.0"), "Sample.csproj.slice"), slice10);
+ File.WriteAllText(Path.Combine(CreateSliceDir(dir, "net9.0"), "Sample.csproj.slice"), slice9);
+ string outPath = Path.Combine(dir, "out.lscache");
+
+ ProjectDataMerger.Merge(outPath, Path.Combine(dir, "obj", "**", "Sample.csproj.slice"));
+
+ string content = File.ReadAllText(outPath).Replace("\r\n", "\n");
+ int firstSep = content.IndexOf("\n---\n", StringComparison.Ordinal);
+ string sharedPart = content.Substring(0, firstSep);
+ string perTfmPart = content.Substring(firstSep);
+
+ // Program.cs is the only line legitimately shared: it has no parent prefix.
+ Assert.Contains("\nProgram.cs\n", sharedPart);
+
+ // The orphaned indented child lines must not appear in the shared block
+ // — they would be meaningless without their per-TFM directory header.
+ Assert.DoesNotContain("\n ConsoleApp2.AssemblyInfo.cs\n", sharedPart);
+ Assert.DoesNotContain("\n ConsoleApp2.GlobalUsings.g.cs\n", sharedPart);
+ Assert.DoesNotContain("obj/Debug/net10.0/", sharedPart);
+ Assert.DoesNotContain("obj/Debug/net9.0/", sharedPart);
+
+ // Each per-TFM block keeps its complete group: prefix line + children together.
+ Assert.Contains("obj/Debug/net10.0/\n ConsoleApp2.AssemblyInfo.cs\n ConsoleApp2.GlobalUsings.g.cs\n", perTfmPart);
+ Assert.Contains("obj/Debug/net9.0/\n ConsoleApp2.AssemblyInfo.cs\n ConsoleApp2.GlobalUsings.g.cs\n", perTfmPart);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public void Merge_IndentedGroupIdenticalAcrossTfms_IsShared()
+ {
+ // When both slices share the *same* compressed group (same prefix line +
+ // same children), the entire group should be hoisted to the shared block.
+ string dir = MakeTempDir();
+ try
+ {
+ string sliceA = "[project]\nlanguage=C#\n[sliceDimensions]\nTargetFramework=net8.0\n" +
+ "[metadataReferences]\n/\n foo/1.0/lib/net8.0/Foo.dll\n bar/2.0/lib/net8.0/Bar.dll\n";
+ string sliceB = "[project]\nlanguage=C#\n[sliceDimensions]\nTargetFramework=net9.0\n" +
+ "[metadataReferences]\n/\n foo/1.0/lib/net8.0/Foo.dll\n bar/2.0/lib/net8.0/Bar.dll\n";
+
+ File.WriteAllText(Path.Combine(CreateSliceDir(dir, "net8.0"), "Sample.csproj.slice"), sliceA);
+ File.WriteAllText(Path.Combine(CreateSliceDir(dir, "net9.0"), "Sample.csproj.slice"), sliceB);
+ string outPath = Path.Combine(dir, "out.lscache");
+
+ ProjectDataMerger.Merge(outPath, Path.Combine(dir, "obj", "**", "Sample.csproj.slice"));
+
+ string content = File.ReadAllText(outPath).Replace("\r\n", "\n");
+ int firstSep = content.IndexOf("\n---\n", StringComparison.Ordinal);
+ string sharedPart = content.Substring(0, firstSep);
+ string perTfmPart = content.Substring(firstSep);
+
+ // The whole group (header + both children) appears once, in shared.
+ Assert.Contains("[metadataReferences]\n/\n foo/1.0/lib/net8.0/Foo.dll\n bar/2.0/lib/net8.0/Bar.dll\n", sharedPart);
+ Assert.DoesNotContain("[metadataReferences]", perTfmPart);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public void Merge_FrameworkPacksSection_IsPropagatedAndDeduplicated()
+ {
+ string dir = MakeTempDir();
+ try
+ {
+ string sliceA = "[project]\nlanguage=C#\n[sliceDimensions]\nTargetFramework=net8.0\n" +
+ "[frameworkPacks]\nMicrosoft.NETCore.App.Ref\n";
+ string sliceB = "[project]\nlanguage=C#\n[sliceDimensions]\nTargetFramework=net9.0\n" +
+ "[frameworkPacks]\nMicrosoft.NETCore.App.Ref\nMicrosoft.AspNetCore.App.Ref\n";
+
+ File.WriteAllText(Path.Combine(CreateSliceDir(dir, "net8.0"), "Sample.csproj.slice"), sliceA);
+ File.WriteAllText(Path.Combine(CreateSliceDir(dir, "net9.0"), "Sample.csproj.slice"), sliceB);
+ string outPath = Path.Combine(dir, "out.lscache");
+
+ ProjectDataMerger.Merge(outPath, Path.Combine(dir, "obj", "**", "Sample.csproj.slice"));
+
+ string content = File.ReadAllText(outPath).Replace("\r\n", "\n");
+ int firstSep = content.IndexOf("\n---\n", StringComparison.Ordinal);
+ string sharedPart = content.Substring(0, firstSep);
+ string perTfmPart = content.Substring(firstSep);
+
+ // NETCore is shared; AspNetCore is per-TFM.
+ Assert.Contains("[frameworkPacks]\nMicrosoft.NETCore.App.Ref\n", sharedPart);
+ Assert.DoesNotContain("Microsoft.AspNetCore.App.Ref", sharedPart);
+ Assert.Contains("Microsoft.AspNetCore.App.Ref", perTfmPart);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public void Merge_NetFxRefMetadataReferences_ArePropagatedAndDeduplicated()
+ {
+ string dir = MakeTempDir();
+ try
+ {
+ string sliceA = "[project]\nlanguage=C#\n[sliceDimensions]\nTargetFramework=net472\n" +
+ "[metadataReferences]\n/v4.7.2/mscorlib.dll\n";
+ string sliceB = "[project]\nlanguage=C#\n[sliceDimensions]\nTargetFramework=net472-windows\n" +
+ "[metadataReferences]\n/v4.7.2/mscorlib.dll\n/v4.7.2/System.dll\n";
+
+ File.WriteAllText(Path.Combine(CreateSliceDir(dir, "net472"), "Sample.csproj.slice"), sliceA);
+ File.WriteAllText(Path.Combine(CreateSliceDir(dir, "net472-windows"), "Sample.csproj.slice"), sliceB);
+ string outPath = Path.Combine(dir, "out.lscache");
+
+ ProjectDataMerger.Merge(outPath, Path.Combine(dir, "obj", "**", "Sample.csproj.slice"));
+
+ string content = File.ReadAllText(outPath).Replace("\r\n", "\n");
+ int firstSep = content.IndexOf("\n---\n", StringComparison.Ordinal);
+ string sharedPart = content.Substring(0, firstSep);
+ string perTfmPart = content.Substring(firstSep);
+
+ Assert.Contains("[metadataReferences]\n/v4.7.2/mscorlib.dll\n", sharedPart);
+ Assert.DoesNotContain("/v4.7.2/System.dll", sharedPart);
+ Assert.Contains("/v4.7.2/System.dll", perTfmPart);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public void Merge_SdkAnalyzerSections_ArePropagatedAndDeduplicated()
+ {
+ string dir = MakeTempDir();
+ try
+ {
+ string sliceA = "[project]\nlanguage=C#\n[sliceDimensions]\nTargetFramework=net10.0\n" +
+ "[sdkAnalyzerPacks]\nMicrosoft.NET.ILLink.Tasks\n" +
+ "[sdkAnalyzerConfigPolicy]\nMicrosoft.NET.Sdk/analyzers\n";
+ string sliceB = "[project]\nlanguage=C#\n[sliceDimensions]\nTargetFramework=net11.0\n" +
+ "[sdkAnalyzerPacks]\nMicrosoft.NET.ILLink.Tasks\nAnother.Sdk.AnalyzerPack\n" +
+ "[sdkAnalyzerConfigPolicy]\nMicrosoft.NET.Sdk/analyzers\nMicrosoft.NET.Sdk/codestyle/cs\n";
+
+ File.WriteAllText(Path.Combine(CreateSliceDir(dir, "net10.0"), "Sample.csproj.slice"), sliceA);
+ File.WriteAllText(Path.Combine(CreateSliceDir(dir, "net11.0"), "Sample.csproj.slice"), sliceB);
+ string outPath = Path.Combine(dir, "out.lscache");
+
+ ProjectDataMerger.Merge(outPath, Path.Combine(dir, "obj", "**", "Sample.csproj.slice"));
+
+ string content = File.ReadAllText(outPath).Replace("\r\n", "\n");
+ int firstSep = content.IndexOf("\n---\n", StringComparison.Ordinal);
+ string sharedPart = content.Substring(0, firstSep);
+ string perTfmPart = content.Substring(firstSep);
+
+ Assert.Contains("[sdkAnalyzerPacks]\nMicrosoft.NET.ILLink.Tasks\n", sharedPart);
+ Assert.DoesNotContain("Another.Sdk.AnalyzerPack", sharedPart);
+ Assert.Contains("Another.Sdk.AnalyzerPack", perTfmPart);
+ Assert.Contains("[sdkAnalyzerConfigPolicy]\nMicrosoft.NET.Sdk/analyzers\n", sharedPart);
+ Assert.DoesNotContain("Microsoft.NET.Sdk/codestyle/cs", sharedPart);
+ Assert.Contains("Microsoft.NET.Sdk/codestyle/cs", perTfmPart);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+
+ [Fact]
+ public void Merge_SdkAnalyzerConfigPolicy_CanonicalizesNumericDefaultAndLatest()
+ {
+ string dir = MakeTempDir();
+ try
+ {
+ string sliceA = "[project]\nlanguage=C#\n[sliceDimensions]\nTargetFramework=net10.0\n" +
+ "[properties]\nTargetFramework=net10.0\nTargetFrameworkIdentifier=.NETCoreApp\nTargetFrameworkVersion=v10.0\n" +
+ "[sdkAnalyzerConfigPolicy]\nMicrosoft.NET.Sdk/analyzers|AnalysisLevel=10.0\nMicrosoft.NET.Sdk/codestyle/cs|AnalysisLevel=Latest|AnalysisMode=Default\n";
+ string sliceB = "[project]\nlanguage=C#\n[sliceDimensions]\nTargetFramework=net8.0\n" +
+ "[properties]\nTargetFramework=net8.0\nTargetFrameworkIdentifier=.NETCoreApp\nTargetFrameworkVersion=v8.0\n" +
+ "[sdkAnalyzerConfigPolicy]\nMicrosoft.NET.Sdk/analyzers\nMicrosoft.NET.Sdk/codestyle/cs\n";
+
+ File.WriteAllText(Path.Combine(CreateSliceDir(dir, "net10.0"), "Sample.csproj.slice"), sliceA);
+ File.WriteAllText(Path.Combine(CreateSliceDir(dir, "net8.0"), "Sample.csproj.slice"), sliceB);
+ string outPath = Path.Combine(dir, "out.lscache");
+
+ ProjectDataMerger.Merge(outPath, Path.Combine(dir, "obj", "**", "Sample.csproj.slice"));
+
+ string content = File.ReadAllText(outPath).Replace("\r\n", "\n");
+ Assert.DoesNotContain("AnalysisLevel=10.0", content);
+ Assert.Contains("Microsoft.NET.Sdk/analyzers\n", content);
+ Assert.Contains("Microsoft.NET.Sdk/codestyle/cs|AnalysisMode=Default\n", content);
+ Assert.DoesNotContain("AnalysisLevel=Latest", content);
+ }
+ finally { Directory.Delete(dir, recursive: true); }
+ }
+}
diff --git a/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/ProjectDataWriterTests.cs b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/ProjectDataWriterTests.cs
new file mode 100644
index 0000000000000..c3100fb6ef2f7
--- /dev/null
+++ b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/ProjectDataWriterTests.cs
@@ -0,0 +1,2324 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Text;
+using Microsoft.Build.Framework;
+using Moq;
+using Xunit;
+
+namespace Microsoft.NET.ProjectData.Tasks.Tests;
+
+public class ProjectDataWriterTests
+{
+ private static string Build(
+ string? projectPath = null,
+ bool writeHeader = true,
+ bool isPrimary = false,
+ bool lastDtbSucceeded = false,
+ ITaskItem[]? sliceDimensions = null,
+ ITaskItem[]? properties = null,
+ string[]? commandLineArguments = null,
+ ITaskItem[]? sourceFiles = null,
+ ITaskItem[]? metadataReferences = null,
+ ITaskItem[]? analyzerReferences = null,
+ string[]? analyzerConfigFiles = null,
+ string[]? additionalFiles = null,
+ ITaskItem[]? embeddedResources = null,
+ ITaskItem[]? projectReferences = null,
+ string[]? capabilities = null,
+ ITaskItem[]? sdkKnownAnalyzerPacks = null,
+ ITaskItem[]? sdkAnalyzerConfigPolicy = null,
+ Action? duplicateItemReporter = null)
+ => ProjectDataWriter.BuildContent(
+ projectPath ?? Path.Combine(Path.GetTempPath(), "projectdata-writer-tests", "App.csproj"), writeHeader, isPrimary, lastDtbSucceeded,
+ sliceDimensions, properties, commandLineArguments,
+ sourceFiles, metadataReferences, analyzerReferences,
+ analyzerConfigFiles, additionalFiles, embeddedResources, projectReferences, capabilities, sdkKnownAnalyzerPacks, sdkAnalyzerConfigPolicy, duplicateItemReporter);
+
+ private static ITaskItem MakeItem(
+ string identity,
+ string? value = null,
+ string? nuGetPackageId = null,
+ string? nuGetPackageVersion = null,
+ string? frameworkReferenceName = null,
+ string? referenceOutputAssembly = null)
+ {
+ var mock = new Mock();
+ mock.Setup(i => i.ItemSpec).Returns(identity);
+ mock.Setup(i => i.GetMetadata("Value")).Returns(value ?? string.Empty);
+ mock.Setup(i => i.GetMetadata("NuGetPackageId")).Returns(nuGetPackageId ?? string.Empty);
+ mock.Setup(i => i.GetMetadata("NuGetPackageVersion")).Returns(nuGetPackageVersion ?? string.Empty);
+ mock.Setup(i => i.GetMetadata("FrameworkReferenceName")).Returns(frameworkReferenceName ?? string.Empty);
+ mock.Setup(i => i.GetMetadata("Aliases")).Returns(string.Empty);
+ mock.Setup(i => i.GetMetadata("EmbedInteropTypes")).Returns(string.Empty);
+ mock.Setup(i => i.GetMetadata("Link")).Returns(string.Empty);
+ mock.Setup(i => i.GetMetadata("ReferenceOutputAssembly")).Returns(referenceOutputAssembly ?? string.Empty);
+ return mock.Object;
+ }
+
+ private static ITaskItem MakeSdkKnownAnalyzerPack(string packageId, string targetFramework, string packageVersion)
+ {
+ var mock = new Mock();
+ mock.Setup(i => i.ItemSpec).Returns(packageId);
+ mock.Setup(i => i.GetMetadata("PackageId")).Returns(packageId);
+ mock.Setup(i => i.GetMetadata("PackageVersion")).Returns(packageVersion);
+ mock.Setup(i => i.GetMetadata("TargetFramework")).Returns(targetFramework);
+ return mock.Object;
+ }
+
+ private static ITaskItem MakeSdkAnalyzerConfigPolicy(params (string Name, string Value)[] metadata)
+ {
+ Dictionary values = metadata.ToDictionary(static item => item.Name, static item => item.Value, StringComparer.OrdinalIgnoreCase);
+ Mock mock = new Mock();
+ mock.Setup(i => i.ItemSpec).Returns("Microsoft.NET.Sdk");
+ mock.Setup(i => i.GetMetadata(It.IsAny())).Returns((string name) => values.TryGetValue(name, out string? value) ? value : string.Empty);
+ return mock.Object;
+ }
+
+ private static ITaskItem MakeDefaultSdkAnalyzerConfigPolicy(string analysisLevel = "latest", string effectiveAnalysisLevel = "10.0")
+ => MakeSdkAnalyzerConfigPolicy(
+ ("Language", "C#"),
+ ("EnableNETAnalyzers", "true"),
+ ("EnforceCodeStyleInBuild", "true"),
+ ("AnalysisLevel", analysisLevel),
+ ("AnalysisLevelStyle", analysisLevel),
+ ("EffectiveAnalysisLevel", effectiveAnalysisLevel),
+ ("EffectiveAnalysisLevelStyle", effectiveAnalysisLevel),
+ ("MicrosoftCodeAnalysisNetAnalyzersRulesVersion", effectiveAnalysisLevel.Split('.')[0]));
+
+ [Fact]
+ public void BuildSdkAnalyzerConfigPolicy_OmitsNetAnalyzersLine_WhenEnableNETAnalyzersFalse()
+ {
+ // netstandard2.0 default: EnableNETAnalyzers=false, EnforceCodeStyleInBuild=false.
+ // SDK does not add NetAnalyzer DLLs to @(Analyzer), so we must not emit the
+ // `Microsoft.NET.Sdk/analyzers|...` policy line either (orphan policy entry).
+ ITaskItem item = MakeSdkAnalyzerConfigPolicy(
+ ("Language", "C#"),
+ ("EnableNETAnalyzers", "false"),
+ ("EnforceCodeStyleInBuild", "false"),
+ ("AnalysisLevel", "latest"),
+ ("EffectiveAnalysisLevel", "4.0"));
+
+ SortedSet policies = ProjectDataWriter.BuildSdkAnalyzerConfigPolicy([item], new ProjectDataWriter.TargetFramework("netstandard2.0", ".NETStandard", "v2.0"));
+
+ Assert.Empty(policies);
+ }
+
+ [Fact]
+ public void BuildSdkAnalyzerConfigPolicy_EmitsNetAnalyzersLineOnly_WhenOnlyEnableNETAnalyzersTrue()
+ {
+ // netcoreapp default with codestyle suppressed: only NetAnalyzers policy expected.
+ ITaskItem item = MakeSdkAnalyzerConfigPolicy(
+ ("Language", "C#"),
+ ("EnableNETAnalyzers", "true"),
+ ("EnforceCodeStyleInBuild", "false"),
+ ("AnalysisLevel", "latest"),
+ ("EffectiveAnalysisLevel", "10.0"));
+
+ SortedSet policies = ProjectDataWriter.BuildSdkAnalyzerConfigPolicy([item], new ProjectDataWriter.TargetFramework("net10.0", ".NETCoreApp", "v10.0"));
+
+ Assert.Single(policies);
+ Assert.Contains(policies, p => p.StartsWith("Microsoft.NET.Sdk/analyzers", StringComparison.Ordinal));
+ Assert.DoesNotContain(policies, p => p.StartsWith("Microsoft.NET.Sdk/codestyle/", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void BuildSdkAnalyzerConfigPolicy_EmitsCodeStyleLineOnly_WhenOnlyEnforceCodeStyleInBuildTrue()
+ {
+ // Repo case (e.g. vs-validation test asset on netstandard2.0): codestyle DLLs included
+ // via Directory.Build.props setting EnforceCodeStyleInBuild=true, but NetAnalyzers stay
+ // off because EffectiveAnalysisLevel<5.0 leaves EnableNETAnalyzers=false.
+ ITaskItem item = MakeSdkAnalyzerConfigPolicy(
+ ("Language", "C#"),
+ ("EnableNETAnalyzers", "false"),
+ ("EnforceCodeStyleInBuild", "true"),
+ ("AnalysisLevel", "latest"),
+ ("AnalysisLevelStyle", "latest"),
+ ("EffectiveAnalysisLevel", "4.0"),
+ ("EffectiveAnalysisLevelStyle", "4.0"));
+
+ SortedSet policies = ProjectDataWriter.BuildSdkAnalyzerConfigPolicy([item], new ProjectDataWriter.TargetFramework("netstandard2.0", ".NETStandard", "v2.0"));
+
+ Assert.Single(policies);
+ Assert.DoesNotContain(policies, p => p.StartsWith("Microsoft.NET.Sdk/analyzers", StringComparison.Ordinal));
+ Assert.Contains(policies, p => p.StartsWith("Microsoft.NET.Sdk/codestyle/cs", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void BuildSdkAnalyzerConfigPolicy_EmitsBothLines_WhenBothPropertiesTrue()
+ {
+ // Modern netcoreapp default: both NetAnalyzers and CodeStyle policies.
+ SortedSet policies = ProjectDataWriter.BuildSdkAnalyzerConfigPolicy(
+ [MakeDefaultSdkAnalyzerConfigPolicy()], new ProjectDataWriter.TargetFramework("net10.0", ".NETCoreApp", "v10.0"));
+
+ Assert.Equal(2, policies.Count);
+ Assert.Contains(policies, p => p.StartsWith("Microsoft.NET.Sdk/analyzers", StringComparison.Ordinal));
+ Assert.Contains(policies, p => p.StartsWith("Microsoft.NET.Sdk/codestyle/cs", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void WriteHeader_EmitsVersionAndBanner()
+ {
+ string content = Build();
+
+ Assert.StartsWith("version=2.2", content);
+ Assert.Contains("aka.ms/lscache", content);
+ Assert.Contains("dotnet.projectsystem.cacheInProjectFolder", content);
+ }
+
+ [Fact]
+ public void WriteHeader_False_NoVersionLine()
+ {
+ string content = Build(writeHeader: false);
+
+ Assert.DoesNotContain("version=2", content);
+ Assert.StartsWith("[project]", content.TrimStart('\r', '\n'));
+ }
+
+ [Fact]
+ public void WriteProjectSection_EmitsProjectPrimaryAndDtbFlags()
+ {
+ string content = Build(isPrimary: true, lastDtbSucceeded: true);
+
+ string normalized = content.Replace("\r\n", "\n");
+ Assert.Contains("[project]\nproject=App.csproj\nlanguage=C#\n", normalized);
+ Assert.Contains("\nprimary\n", normalized);
+ Assert.Contains("\nlastDtbSucceeded\n", normalized);
+ }
+
+ [Fact]
+ public void WriteProjectSection_OmitsDtbFlag_WhenFalse()
+ {
+ string content = Build(lastDtbSucceeded: false);
+
+ string normalized = content.Replace("\r\n", "\n");
+ Assert.DoesNotContain("\nprimary\n", normalized);
+ Assert.DoesNotContain("\nlastDtbSucceeded\n", normalized);
+ }
+
+ [Fact]
+ public void ProjectReferences_EmitsReferenceOutputAssemblyFalseMetadata()
+ {
+ string projectPath = Path.Combine(Path.GetTempPath(), "projectdata-writer-tests", "App.csproj");
+ string referencePath = Path.Combine(Path.GetDirectoryName(projectPath)!, "BuildOnly", "BuildOnly.csproj");
+ string content = Build(
+ projectPath,
+ projectReferences:
+ [
+ MakeItem(referencePath, referenceOutputAssembly: "false"),
+ MakeItem(Path.Combine(Path.GetDirectoryName(projectPath)!, "Library", "Library.csproj")),
+ ]);
+
+ string normalized = content.Replace("\r\n", "\n");
+ Assert.Contains(
+ "[projectReferences]\nBuildOnly/BuildOnly.csproj\n @ReferenceOutputAssembly=false\nLibrary/Library.csproj\n",
+ normalized);
+ Assert.DoesNotContain("@ReferenceOutputAssembly=true", normalized);
+ }
+
+ [Fact]
+ public void WritePropertiesSection_SortedOrdinalIgnoreCase()
+ {
+ ITaskItem[] props = [
+ MakeItem("ZProperty", "z"),
+ MakeItem("aProperty", "a"),
+ MakeItem("MProperty", "m"),
+ ];
+ string content = Build(properties: props);
+
+ int aIdx = content.IndexOf("aProperty=", StringComparison.OrdinalIgnoreCase);
+ int mIdx = content.IndexOf("MProperty=", StringComparison.OrdinalIgnoreCase);
+ int zIdx = content.IndexOf("ZProperty=", StringComparison.OrdinalIgnoreCase);
+ Assert.True(aIdx < mIdx && mIdx < zIdx, "Properties should be sorted OrdinalIgnoreCase");
+ }
+
+ [Fact]
+ public void WritePropertiesSection_EmptySkipped()
+ {
+ string content = Build(properties: []);
+
+ Assert.DoesNotContain("[properties]", content);
+ }
+
+ [Fact]
+ public void WritePropertiesSection_SkipsEmptyAndWhitespaceValues()
+ {
+ ITaskItem[] props = [
+ MakeItem("HasValue", "real"),
+ MakeItem("EmptyValue", ""),
+ MakeItem("WhitespaceValue", " "),
+ ];
+ string content = Build(properties: props);
+
+ Assert.Contains("HasValue=real", content);
+ Assert.DoesNotContain("EmptyValue=", content);
+ Assert.DoesNotContain("WhitespaceValue=", content);
+ }
+
+ [Fact]
+ public void WritePropertiesSection_SkipsUndefinedSentinel()
+ {
+ ITaskItem[] props = [
+ MakeItem("AssemblyName", "Foo"),
+ MakeItem("SolutionPath", "*Undefined*"),
+ ];
+ string content = Build(properties: props);
+
+ Assert.Contains("AssemblyName=Foo", content);
+ Assert.DoesNotContain("SolutionPath=", content);
+ }
+
+ [Fact]
+ public void WritePropertiesSection_ExcludesSolutionPath_EvenWhenValueIsReal()
+ {
+ ITaskItem[] props = [
+ MakeItem("AssemblyName", "Foo"),
+ MakeItem("SolutionPath", @"C:\Users\dev\MySolution.sln"),
+ ];
+ string content = Build(properties: props);
+
+ Assert.Contains("AssemblyName=Foo", content);
+ Assert.DoesNotContain("SolutionPath=", content);
+ }
+
+ [Fact]
+ public void WritePropertiesSection_HeaderOmittedWhenAllValuesSkipped()
+ {
+ ITaskItem[] props = [
+ MakeItem("EmptyA", ""),
+ MakeItem("EmptyB", " "),
+ MakeItem("Sentinel", "*Undefined*"),
+ ];
+ string content = Build(properties: props);
+
+ Assert.DoesNotContain("[properties]", content);
+ }
+
+ [Fact]
+ public void WriteCommandLineArgSection_FiltersFileArgs()
+ {
+ string[] args = [
+ "/nologo",
+ "/langversion:preview",
+ "/reference:C:\\ref\\System.dll", // excluded
+ "/analyzer:C:\\Analyzers\\Foo.dll", // excluded
+ "C:\\project\\Program.cs", // bare path, excluded
+ "/Users/dev/project/Generated.cs", // Unix absolute source path, excluded
+ "GeneratedFromRsp.cs", // already-portable source path, excluded
+ "/doc:obj/Debug/App.xml", // output path, preserved
+ "/out:obj/Debug/App.dll", // output path, preserved
+ "/refout:obj/Debug/refint/App.dll", // output path, preserved
+ "/pdb:obj/Debug/App.pdb", // output path, preserved
+ ];
+ string content = Build(commandLineArguments: args);
+
+ string normalized = content.Replace("\r\n", "\n");
+ Assert.Contains("[commandLineArguments]", normalized);
+ Assert.Contains("/nologo", normalized);
+ Assert.Contains("/langversion:preview", normalized);
+ Assert.DoesNotContain("/reference:", normalized);
+ Assert.DoesNotContain("/analyzer:", normalized);
+ Assert.DoesNotContain("Program.cs\n", normalized.Substring(normalized.IndexOf("[commandLineArguments]")));
+ Assert.DoesNotContain("Generated.cs\n", normalized.Substring(normalized.IndexOf("[commandLineArguments]")));
+ Assert.DoesNotContain("GeneratedFromRsp.cs\n", normalized.Substring(normalized.IndexOf("[commandLineArguments]")));
+ Assert.Contains("/doc:obj/Debug/App.xml", normalized);
+ Assert.Contains("/out:obj/Debug/App.dll", normalized);
+ Assert.Contains("/refout:obj/Debug/refint/App.dll", normalized);
+ Assert.Contains("/pdb:obj/Debug/App.pdb", normalized);
+ }
+
+ [Fact]
+ public void WriteCommandLineArgSection_FiltersPlatformArgs()
+ {
+ string content = Build(commandLineArguments: ["/nologo", "/platform:AnyCPU", "-platform:x86", "/langversion:preview"]);
+
+ string normalized = content.Replace("\r\n", "\n");
+ Assert.Contains("/nologo", normalized);
+ Assert.Contains("/langversion:preview", normalized);
+ Assert.DoesNotContain("/platform:", normalized);
+ Assert.DoesNotContain("-platform:", normalized);
+ }
+
+ [Fact]
+ public void WriteCommandLineArgSection_OrderPreserved()
+ {
+ string[] args = ["/langversion:preview", "/nologo", "/nullable+"];
+ string content = Build(commandLineArguments: args);
+
+ int lIdx = content.IndexOf("/langversion");
+ int nIdx = content.IndexOf("/nologo");
+ int qIdx = content.IndexOf("/nullable");
+ Assert.True(lIdx < nIdx && nIdx < qIdx, "Argument order should be preserved");
+ }
+
+ [Fact]
+ public void WriteCommandLineArgSection_ExcludesMachineSpecificArgs()
+ {
+ string[] args = [
+ "/nologo",
+ "/preferreduilang:en", // machine-specific, excluded
+ "/langversion:preview",
+ "-preferreduilang:de", // dash-prefix variant, excluded
+ "/nullable+",
+ ];
+ string content = Build(commandLineArguments: args);
+
+ string normalized = content.Replace("\r\n", "\n");
+ Assert.Contains("/nologo", normalized);
+ Assert.Contains("/langversion:preview", normalized);
+ Assert.Contains("/nullable+", normalized);
+ Assert.DoesNotContain("preferreduilang", normalized);
+ }
+
+ [Fact]
+ public void WriteCommandLineArgSection_NormalizesNetCoreAppNoWarn8002()
+ {
+ string content = Build(
+ properties: [MakeItem("TargetFrameworkIdentifier", ".NETCoreApp")],
+ commandLineArguments: ["/nologo", "/nowarn:1701,1702"]);
+
+ string normalized = content.Replace("\r\n", "\n");
+ Assert.Contains("/nowarn:1701,1702,8002", normalized);
+ }
+
+ [Fact]
+ public void WriteCommandLineArgSection_DoesNotDuplicateNoWarn8002()
+ {
+ string content = Build(
+ properties: [MakeItem("TargetFrameworkIdentifier", ".NETCoreApp")],
+ commandLineArguments: ["/nowarn:1701,8002,1702"]);
+
+ string normalized = content.Replace("\r\n", "\n");
+ Assert.Contains("/nowarn:1701,8002,1702", normalized);
+ Assert.DoesNotContain("/nowarn:1701,8002,1702,8002", normalized);
+ }
+
+ [Fact]
+ public void WriteCommandLineArgSection_DoesNotNormalizeNonNetCoreAppNoWarn8002()
+ {
+ string content = Build(
+ properties: [MakeItem("TargetFrameworkIdentifier", ".NETStandard")],
+ commandLineArguments: ["/nowarn:1701,1702"]);
+
+ string normalized = content.Replace("\r\n", "\n");
+ Assert.Contains("/nowarn:1701,1702", normalized);
+ Assert.DoesNotContain("8002", normalized);
+ }
+
+ [Theory]
+ [InlineData("/platform:AnyCPU")]
+ [InlineData("/platform:x86")]
+ [InlineData("/platform:x64")]
+ [InlineData("/platform:anycpu32bitpreferred")]
+ [InlineData("-platform:arm64")]
+ public void WriteCommandLineArgSection_SkipsNetFrameworkPlatform(string platformArgument)
+ {
+ string content = Build(
+ properties: [MakeItem("TargetFrameworkIdentifier", ".NETFramework")],
+ commandLineArguments: ["/nologo", platformArgument]);
+
+ string normalized = content.Replace("\r\n", "\n");
+ Assert.Contains("/nologo", normalized);
+ Assert.DoesNotContain(platformArgument, normalized);
+ }
+
+ [Fact]
+ public void WriteCommandLineArgSection_SkipsNetCoreAppPlatform()
+ {
+ string content = Build(
+ properties: [MakeItem("TargetFrameworkIdentifier", ".NETCoreApp")],
+ commandLineArguments: ["/platform:x64"]);
+
+ string normalized = content.Replace("\r\n", "\n");
+ Assert.DoesNotContain("/platform:x64", normalized);
+ }
+
+ [Fact]
+ public void EmitCompressed_SharedPrefix_CollapsesSingleChildChain()
+ {
+ var paths = new List
+ {
+ "/foo/1.0/lib/net8.0/a.dll",
+ "/foo/1.0/lib/net8.0/b.dll",
+ };
+ var sb = new StringBuilder();
+ ProjectDataWriter.EmitCompressed(sb, paths, 0);
+ string result = sb.ToString().Replace("\r\n", "\n");
+
+ // Single-child directory chain collapses onto one header line; the two
+ // sibling files nest under it.
+ string expected =
+ "/foo/1.0/lib/net8.0/\n" +
+ " a.dll\n" +
+ " b.dll\n";
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void EmitCompressed_NoSharedPrefix_EmitsFlatCollapsedSingletonsInOrder()
+ {
+ var paths = new List { "/a.dll", "/b.dll" };
+ var sb = new StringBuilder();
+ ProjectDataWriter.EmitCompressed(sb, paths, 0);
+ string result = sb.ToString().Replace("\r\n", "\n");
+
+ // A path that is a single linear directory chain ending in one file
+ // collapses back to one file line.
+ string expected =
+ "/b.dll\n" +
+ "/a.dll\n";
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void EmitCompressed_SiblingFileAndSubdirectory_EmitsDirectoriesFirstThenFiles()
+ {
+ var paths = new List
+ {
+ "Contracts/CultureInfoFormatter.cs",
+ "Contracts/DataModel/DataModelReadiness.cs",
+ "Contracts/DataModel/Project.cs",
+ "Contracts/EnvironmentMutationResult.cs",
+ };
+ paths.Sort(StringComparer.OrdinalIgnoreCase);
+ var sb = new StringBuilder();
+ ProjectDataWriter.EmitCompressed(sb, paths, 0);
+ string result = sb.ToString().Replace("\r\n", "\n");
+
+ // Single Contracts/ block; directories first then files, with each group sorted.
+ string expected =
+ "Contracts/\n" +
+ " DataModel/\n" +
+ " DataModelReadiness.cs\n" +
+ " Project.cs\n" +
+ " CultureInfoFormatter.cs\n" +
+ " EnvironmentMutationResult.cs\n";
+ Assert.Equal(expected, result);
+ Assert.Equal(1, CountOccurrences(result, "Contracts/\n"));
+ }
+
+ [Fact]
+ public void EmitCompressed_SingleFileDeepPath_CollapsesToFileLine()
+ {
+ var paths = new List { "a/b/c/only.cs" };
+ var sb = new StringBuilder();
+ ProjectDataWriter.EmitCompressed(sb, paths, 0);
+ string result = sb.ToString().Replace("\r\n", "\n");
+
+ // Single-child directory chain ending in one file collapses to one file line.
+ string expected = "a/b/c/only.cs\n";
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void EmitCompressed_CollapsedDirectoryEntries_SortWithDirectories()
+ {
+ var paths = new List
+ {
+ "/system.drawing.common/10.0.5/lib/net10.0/System.Drawing.Common.dll",
+ "/system.drawing.common/10.0.5/lib/net10.0/System.Private.Windows.Core.dll",
+ "/google.protobuf/3.22.5/lib/net5.0/Google.Protobuf.dll",
+ "/microsoft.dotnet.cecil/0.11.5-preview.26160.112/lib/netstandard2.0/Mono.Cecil.dll",
+ "/microsoft.dotnet.cecil/0.11.5-preview.26160.112/lib/netstandard2.0/Mono.Cecil.Rocks.dll",
+ };
+ paths.Sort(StringComparer.OrdinalIgnoreCase);
+ var sb = new StringBuilder();
+ ProjectDataWriter.EmitCompressed(sb, paths, 0);
+ string result = sb.ToString().Replace("\r\n", "\n");
+
+ string expected =
+ "/\n" +
+ " google.protobuf/3.22.5/lib/net5.0/Google.Protobuf.dll\n" +
+ " microsoft.dotnet.cecil/0.11.5-preview.26160.112/lib/netstandard2.0/\n" +
+ " Mono.Cecil.dll\n" +
+ " Mono.Cecil.Rocks.dll\n" +
+ " system.drawing.common/10.0.5/lib/net10.0/\n" +
+ " System.Drawing.Common.dll\n" +
+ " System.Private.Windows.Core.dll\n";
+ Assert.Equal(expected, result);
+ }
+
+ [Fact]
+ public void EmitCompressed_RemovingFile_DoesNotReshapeOtherBranches()
+ {
+ var withExtra = new List
+ {
+ "alpha/one.cs",
+ "alpha/two.cs",
+ "beta/three.cs",
+ };
+ var withoutExtra = new List
+ {
+ "alpha/one.cs",
+ "beta/three.cs",
+ };
+ withExtra.Sort(StringComparer.OrdinalIgnoreCase);
+ withoutExtra.Sort(StringComparer.OrdinalIgnoreCase);
+
+ var sb1 = new StringBuilder();
+ ProjectDataWriter.EmitCompressed(sb1, withExtra, 0);
+ var sb2 = new StringBuilder();
+ ProjectDataWriter.EmitCompressed(sb2, withoutExtra, 0);
+ string r1 = sb1.ToString().Replace("\r\n", "\n");
+ string r2 = sb2.ToString().Replace("\r\n", "\n");
+
+ // beta/ has a single file in both shapes and collapses identically.
+ Assert.Contains("beta/three.cs\n", r1);
+ Assert.Contains("beta/three.cs\n", r2);
+ // alpha/ remains grouped while it has two files, and collapses to a file
+ // line when only one file remains.
+ Assert.Equal(1, CountOccurrences(r1, "alpha/\n"));
+ Assert.Contains("alpha/one.cs\n", r2);
+ }
+
+ [Fact]
+ public void EmitCompressed_RoundTripsThroughReader()
+ {
+ var paths = new List
+ {
+ "Contracts/CultureInfoFormatter.cs",
+ "Contracts/DataModel/DataModelReadiness.cs",
+ "Contracts/DataModel/Project.cs",
+ "Contracts/EnvironmentMutationResult.cs",
+ "Program.cs",
+ "/foo/1.0/lib/net8.0/Foo.dll",
+ "/foo/1.0/lib/net8.0/Bar.dll",
+ };
+ paths.Sort(StringComparer.OrdinalIgnoreCase);
+ var sb = new StringBuilder();
+ ProjectDataWriter.EmitCompressed(sb, paths, 0);
+ string emitted = sb.ToString().Replace("\r\n", "\n");
+
+ // Inline expansion mirroring CacheFileReader.ExpandCompressedPaths so the
+ // writer-tests project does not need to depend on the cache-reader assembly.
+ var stack = new Stack<(int Indent, string Prefix)>();
+ var rebuilt = new List();
+ foreach (string raw in emitted.Split('\n', StringSplitOptions.RemoveEmptyEntries))
+ {
+ int indent = 0;
+ while (indent < raw.Length && raw[indent] == ' ') indent++;
+ string content = raw[indent..];
+ while (stack.Count > 0 && stack.Peek().Indent >= indent) stack.Pop();
+ string prefix = stack.Count > 0 ? stack.Peek().Prefix : "";
+ if (content.Length > 0 && content[^1] == '/')
+ stack.Push((indent, prefix + content));
+ else
+ rebuilt.Add(prefix + content);
+ }
+ rebuilt.Sort(StringComparer.OrdinalIgnoreCase);
+ Assert.Equal(paths, rebuilt);
+ }
+
+ private static int CountOccurrences(string haystack, string needle)
+ {
+ int count = 0;
+ int idx = 0;
+ while ((idx = haystack.IndexOf(needle, idx, StringComparison.Ordinal)) >= 0)
+ {
+ count++;
+ idx += needle.Length;
+ }
+ return count;
+ }
+
+ [Fact]
+ public void BuildContent_UsesLfLineEndings()
+ {
+ string content = Build(commandLineArguments: ["/noconfig"]);
+
+ Assert.Contains("\n", content);
+ Assert.DoesNotContain("\r\n", content);
+ }
+
+ [Fact]
+ public void AtomicWrite_TmpFileGoneAfterSuccess()
+ {
+ string dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ string outputPath = Path.Combine(dir, "test.lscache");
+ try
+ {
+ ProjectDataWriter.AtomicWrite(outputPath, "hello");
+
+ Assert.True(File.Exists(outputPath));
+ Assert.Empty(Directory.GetFiles(dir, "*.tmp"));
+ Assert.Equal("hello", File.ReadAllText(outputPath));
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void AtomicWrite_PlacesTempFileInRequestedDirectory_NotNextToOutput()
+ {
+ string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ string outputDir = Path.Combine(root, "src");
+ string tempDir = Path.Combine(root, "obj"); // does not exist yet
+ string outputPath = Path.Combine(outputDir, "test.lscache");
+ try
+ {
+ Directory.CreateDirectory(outputDir);
+
+ ProjectDataWriter.AtomicWrite(outputPath, "hello", tempDir);
+
+ // The requested temp directory is created and used, so the transient .tmp side-file never
+ // appears next to the (committed, watched) output file.
+ Assert.True(Directory.Exists(tempDir));
+ Assert.Empty(Directory.GetFiles(outputDir, "*.tmp"));
+ Assert.Empty(Directory.GetFiles(tempDir, "*.tmp"));
+ Assert.Equal("hello", File.ReadAllText(outputPath));
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void AtomicWriteStreamed_PlacesTempFileInRequestedDirectory_NotNextToOutput()
+ {
+ string root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ string outputDir = Path.Combine(root, "src");
+ string tempDir = Path.Combine(root, "obj"); // does not exist yet
+ string outputPath = Path.Combine(outputDir, "test.lscache");
+ try
+ {
+ Directory.CreateDirectory(outputDir);
+
+ ProjectDataWriter.AtomicWriteStreamed(outputPath, writer => writer.Write("version=2\n"), tempDir);
+
+ Assert.True(Directory.Exists(tempDir));
+ Assert.Empty(Directory.GetFiles(outputDir, "*.tmp"));
+ Assert.Empty(Directory.GetFiles(tempDir, "*.tmp"));
+ Assert.Equal("version=2\n", File.ReadAllText(outputPath));
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void AtomicWrite_FallsBackToOutputDirectory_WhenTempDirectoryUnusable()
+ {
+ string dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ string outputPath = Path.Combine(dir, "test.lscache");
+ try
+ {
+ Directory.CreateDirectory(dir);
+
+ // An unusable temp directory (invalid path) must not throw: the write degrades to the
+ // output directory and still succeeds.
+ ProjectDataWriter.AtomicWrite(outputPath, "hello", "bad\0dir");
+
+ Assert.Equal("hello", File.ReadAllText(outputPath));
+ Assert.Empty(Directory.GetFiles(dir, "*.tmp"));
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void AtomicWrite_ReplacesExistingFile()
+ {
+ string dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ string outputPath = Path.Combine(dir, "test.lscache");
+ try
+ {
+ Directory.CreateDirectory(dir);
+ File.WriteAllText(outputPath, "old");
+
+ ProjectDataWriter.AtomicWrite(outputPath, "new");
+
+ Assert.Empty(Directory.GetFiles(dir, "*.tmp"));
+ Assert.Equal("new", File.ReadAllText(outputPath));
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void AtomicWrite_SkipsRewrite_WhenContentMatches()
+ {
+ string dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ string outputPath = Path.Combine(dir, "test.lscache");
+ try
+ {
+ ProjectDataWriter.AtomicWrite(outputPath, "same");
+ DateTime lastWriteTime = File.GetLastWriteTimeUtc(outputPath);
+
+ Thread.Sleep(1100);
+ ProjectDataWriter.AtomicWrite(outputPath, "same");
+
+ Assert.Equal(lastWriteTime, File.GetLastWriteTimeUtc(outputPath));
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void AtomicWriteStreamed_SkipsRewrite_WhenOnlyMinorVersionIncreases()
+ {
+ string dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ string outputPath = Path.Combine(dir, "test.lscache");
+ const string ExistingContent = "version=2.1\n[project]\nlanguage=C#\n";
+ try
+ {
+ Directory.CreateDirectory(dir);
+ File.WriteAllText(outputPath, ExistingContent, new UTF8Encoding(false));
+ File.SetLastWriteTimeUtc(outputPath, new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc));
+ DateTime lastWriteTime = File.GetLastWriteTimeUtc(outputPath);
+
+ ProjectDataWriter.AtomicWriteStreamed(outputPath, writer =>
+ {
+ writer.WriteLine("version=2.2");
+ writer.WriteLine("[project]");
+ writer.WriteLine("language=C#");
+ });
+
+ Assert.Equal(ExistingContent, File.ReadAllText(outputPath));
+ Assert.Equal(lastWriteTime, File.GetLastWriteTimeUtc(outputPath));
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void AtomicWriteStreamed_Rewrites_WhenNewMinorAddsData()
+ {
+ string dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ string outputPath = Path.Combine(dir, "test.lscache");
+ try
+ {
+ Directory.CreateDirectory(dir);
+ File.WriteAllText(outputPath, "version=2.1\n[properties]\nAssemblyName=Sample\n", new UTF8Encoding(false));
+
+ ProjectDataWriter.AtomicWriteStreamed(outputPath, writer =>
+ {
+ writer.WriteLine("version=2.2");
+ writer.WriteLine("[properties]");
+ writer.WriteLine("AssemblyName=Sample");
+ writer.WriteLine("IsTestProject=true");
+ });
+
+ Assert.Equal(
+ "version=2.2\n[properties]\nAssemblyName=Sample\nIsTestProject=true\n",
+ File.ReadAllText(outputPath));
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void AtomicWrite_StripsLegacyHashHeader_ThenStable()
+ {
+ string dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ string outputPath = Path.Combine(dir, "test.lscache");
+ try
+ {
+ Directory.CreateDirectory(dir);
+ // A pre-migration file still carries a leading "hash=" line.
+ File.WriteAllText(outputPath, $"hash={new string('0', 64)}\nsame", new UTF8Encoding(false));
+
+ // First write strips the legacy header even though the body is unchanged.
+ ProjectDataWriter.AtomicWrite(outputPath, "same");
+ Assert.Equal("same", File.ReadAllText(outputPath));
+
+ // Second write is a no-op now that the header is gone.
+ DateTime lastWriteTime = File.GetLastWriteTimeUtc(outputPath);
+ Thread.Sleep(1100);
+ ProjectDataWriter.AtomicWrite(outputPath, "same");
+ Assert.Equal(lastWriteTime, File.GetLastWriteTimeUtc(outputPath));
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void AtomicWrite_NormalizesLineEndingsBeforeWriting()
+ {
+ string dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ string outputPath = Path.Combine(dir, "test.lscache");
+ try
+ {
+ ProjectDataWriter.AtomicWrite(outputPath, "hello\r\nworld\r\n");
+
+ Assert.Equal("hello\nworld\n", File.ReadAllText(outputPath));
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void AtomicWriteStreamed_UsesLfLineEndings_NoHashHeader()
+ {
+ string dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ string outputPath = Path.Combine(dir, "test.lscache");
+ try
+ {
+ ProjectDataWriter.AtomicWriteStreamed(outputPath, writer =>
+ {
+ writer.WriteLine("version=2");
+ writer.WriteLine("[project]");
+ });
+
+ Assert.Equal("version=2\n[project]\n", File.ReadAllText(outputPath));
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+
+ [Theory]
+ [InlineData("a\r\nb\r\nc", "a\nb\nc")] // CRLF
+ [InlineData("a\rb\rc", "a\nb\nc")] // lone CR
+ [InlineData("a\r\r\nb", "a\n\nb")] // CR immediately followed by CRLF
+ [InlineData("\r\n\r\n", "\n\n")] // only line endings
+ [InlineData("no-endings-here", "no-endings-here")] // fast path: no CR at all
+ [InlineData("", "")] // empty
+ public void AtomicWriteStreamed_NormalizesEmbeddedLineEndings(string input, string expected)
+ {
+ string dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ string outputPath = Path.Combine(dir, "test.lscache");
+ try
+ {
+ ProjectDataWriter.AtomicWriteStreamed(outputPath, writer => writer.Write(input));
+
+ Assert.Equal(expected, File.ReadAllText(outputPath));
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void AtomicWriteStreamed_NormalizesAndGrowsBeyondInitialBuffer()
+ {
+ // Render well past the pooled writer's initial capacity so the buffer-growth path is
+ // exercised alongside in-place line-ending normalization.
+ var sb = new StringBuilder();
+ for (int i = 0; i < 5000; i++)
+ sb.Append("reference/path/segment/Some.Package.Name.").Append(i).Append(".dll\r\n");
+ string input = sb.ToString();
+ string expected = input.Replace("\r\n", "\n");
+
+ string dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ string outputPath = Path.Combine(dir, "test.lscache");
+ try
+ {
+ ProjectDataWriter.AtomicWriteStreamed(outputPath, writer => writer.Write(input));
+
+ Assert.Equal(expected, File.ReadAllText(outputPath));
+ Assert.True(input.Length > 4096, "input should exceed the initial pooled buffer to exercise growth");
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void AtomicWriteStreamed_StripsLegacyHashHeader()
+ {
+ string dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
+ string outputPath = Path.Combine(dir, "test.lscache");
+ string body = "version=2\n[project]\n";
+ try
+ {
+ Directory.CreateDirectory(dir);
+ File.WriteAllText(outputPath, $"hash={new string('0', 64)}\n{body}", new UTF8Encoding(false));
+
+ ProjectDataWriter.AtomicWriteStreamed(outputPath, writer =>
+ {
+ writer.WriteLine("version=2");
+ writer.WriteLine("[project]");
+ });
+
+ Assert.Equal(body, File.ReadAllText(outputPath));
+ }
+ finally
+ {
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void WriteSourceFileSection_EmitsLinkMetadata()
+ {
+ var item = new Mock();
+ item.Setup(i => i.ItemSpec).Returns(@"C:\project\Shared\Feature.cs");
+ item.Setup(i => i.GetMetadata("Link")).Returns(@"Linked\Feature.cs");
+
+ string content = Build(sourceFiles: [item.Object]);
+ string normalized = content.Replace("\r\n", "\n");
+
+ Assert.Contains("[sourceFiles]", normalized);
+ Assert.Contains("Shared/Feature.cs\n", normalized);
+ Assert.Contains(" @link=Linked/Feature.cs\n", normalized);
+ }
+
+ [Fact]
+ public void WriteSourceFileSection_OmitsLinkMetadata_WhenLinkIsEmpty()
+ {
+ string content = Build(sourceFiles: [MakeItem(@"C:\project\Program.cs")]);
+ string normalized = content.Replace("\r\n", "\n");
+
+ Assert.Contains("[sourceFiles]", normalized);
+ Assert.Contains("Program.cs\n", normalized);
+ Assert.DoesNotContain("@link=", normalized);
+ }
+
+ [Fact]
+ public void WriteSourceFileSection_DedupesItemsCollapsingToSamePortableForm()
+ {
+ // MSBuild evaluation can produce two ``ITaskItem``s with different ``ItemSpec``s
+ // that resolve to the same portable form (e.g. a wildcard ````
+ // plus an explicit ```` with custom metadata, or two
+ // items differing only in casing on a case-insensitive file system). Without
+ // deduplication the same path is emitted twice — bloating the cache and causing
+ // the reader to materialize duplicate ``CachedSourceFile`` entries.
+ string projectPath = Path.Combine(Path.GetTempPath(), "projectdata-writer-tests", "App.csproj");
+ string projectDir = Path.GetDirectoryName(projectPath)!;
+ ITaskItem first = MakeItem(Path.Combine(projectDir, "Program.cs"));
+ ITaskItem duplicate = MakeItem(Path.Combine(projectDir, "program.cs"));
+ List diagnostics = [];
+
+ string content = Build(projectPath: projectPath, sourceFiles: [first, duplicate], duplicateItemReporter: diagnostics.Add);
+ string normalized = content.Replace("\r\n", "\n");
+
+ int matches = CountOccurrencesInSection(normalized, "[sourceFiles]", "Program.cs");
+ Assert.Equal(OperatingSystem.IsLinux() ? 2 : 1, matches);
+ if (OperatingSystem.IsLinux())
+ {
+ Assert.Empty(diagnostics);
+ }
+ else
+ {
+ ProjectDataDuplicateItemDiagnostic diagnostic = Assert.Single(diagnostics);
+ Assert.Equal(projectPath, diagnostic.ProjectFilePath);
+ Assert.Equal("sourceFiles", diagnostic.Section);
+ Assert.EndsWith("Program.cs", diagnostic.ItemSpec, StringComparison.OrdinalIgnoreCase);
+ }
+ }
+
+ [Fact]
+ public void WriteMetadataRefSection_DedupesItemsCollapsingToSamePortableForm()
+ {
+ // Same concern as the source-file case: two ``MetadataReference`` items that
+ // collapse to the same portable form should be emitted only once. While
+ // ``PrepareMetadataRefs`` upstream handles most dedup, ``EmitMetadataRefSection``
+ // is the wire-format boundary and must not assume its caller dedups.
+ string projectDir = Path.GetDirectoryName(Path.Combine(Path.GetTempPath(), "projectdata-writer-tests", "App.csproj"))!;
+ ITaskItem first = MakeItem(Path.Combine(projectDir, "ref", "Foo.dll"));
+ ITaskItem duplicate = MakeItem(Path.Combine(projectDir, "ref", "foo.dll"));
+
+ string content = Build(metadataReferences: [first, duplicate]);
+ string normalized = content.Replace("\r\n", "\n");
+
+ int matches = CountOccurrencesInSection(normalized, "[metadataReferences]", "Foo.dll");
+ Assert.Equal(OperatingSystem.IsLinux() ? 2 : 1, matches);
+ }
+
+ [Fact]
+ public void WriteAnalyzerReferenceSection_DedupesItemsCollapsingToSamePortableForm()
+ {
+ string projectDir = Path.GetDirectoryName(Path.Combine(Path.GetTempPath(), "projectdata-writer-tests", "App.csproj"))!;
+ ITaskItem first = MakeItem(Path.Combine(projectDir, "analyzers", "PolyType.SourceGenerator.dll"));
+ ITaskItem duplicate = MakeItem(Path.Combine(projectDir, "analyzers", "PolyType.SourceGenerator.dll"));
+
+ string content = Build(analyzerReferences: [first, duplicate]);
+ string normalized = content.Replace("\r\n", "\n");
+
+ int matches = CountOccurrencesInSection(normalized, "[analyzerReferences]", "PolyType.SourceGenerator.dll");
+ Assert.Equal(1, matches);
+ }
+
+ [Fact]
+ public void WriteAnalyzerReferenceSection_ReportsDuplicateItems()
+ {
+ string projectPath = Path.Combine(Path.GetTempPath(), "projectdata-writer-tests", "App.csproj");
+ string projectDir = Path.GetDirectoryName(projectPath)!;
+ ITaskItem first = MakeItem(Path.Combine(projectDir, "analyzers", "PolyType.SourceGenerator.dll"));
+ ITaskItem duplicate = MakeItem(Path.Combine(projectDir, "analyzers", "PolyType.SourceGenerator.dll"));
+ List diagnostics = [];
+
+ Build(projectPath: projectPath, analyzerReferences: [first, duplicate], duplicateItemReporter: diagnostics.Add);
+
+ ProjectDataDuplicateItemDiagnostic diagnostic = Assert.Single(diagnostics);
+ Assert.Equal(projectPath, diagnostic.ProjectFilePath);
+ Assert.Equal("analyzerReferences", diagnostic.Section);
+ Assert.EndsWith("analyzers/PolyType.SourceGenerator.dll", diagnostic.ItemSpec, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void WriteAnalyzerReferenceSection_UsesPlatformPathCaseSensitivityForDedupe()
+ {
+ string projectPath = Path.Combine(Path.GetTempPath(), "projectdata-writer-tests", "App.csproj");
+ string projectDir = Path.GetDirectoryName(projectPath)!;
+ ITaskItem first = MakeItem(Path.Combine(projectDir, "analyzers", "CaseSensitive.dll"));
+ ITaskItem second = MakeItem(Path.Combine(projectDir, "analyzers", "casesensitive.dll"));
+ List diagnostics = [];
+
+ string content = Build(projectPath: projectPath, analyzerReferences: [first, second], duplicateItemReporter: diagnostics.Add);
+ string normalized = content.Replace("\r\n", "\n");
+
+ int matches = CountOccurrencesInSection(normalized, "[analyzerReferences]", "casesensitive.dll");
+ if (OperatingSystem.IsLinux())
+ {
+ Assert.Equal(2, matches);
+ Assert.Empty(diagnostics);
+ }
+ else
+ {
+ Assert.Equal(1, matches);
+ Assert.Single(diagnostics);
+ }
+ }
+
+ [Fact]
+ public void WriteAnalyzerReferenceSection_UsesPlatformPathCaseSensitivityForCompressedDirectories()
+ {
+ string projectPath = Path.Combine(Path.GetTempPath(), "projectdata-writer-tests", "App.csproj");
+ string projectDir = Path.GetDirectoryName(projectPath)!;
+ ITaskItem first = MakeItem(Path.Combine(projectDir, "Analyzers", "CaseSensitive.dll"));
+ ITaskItem second = MakeItem(Path.Combine(projectDir, "analyzers", "casesensitive.dll"));
+ List diagnostics = [];
+
+ string content = Build(projectPath: projectPath, analyzerReferences: [first, second], duplicateItemReporter: diagnostics.Add);
+ string normalized = content.Replace("\r\n", "\n");
+
+ Assert.Contains("Analyzers/CaseSensitive.dll\n", normalized);
+ if (OperatingSystem.IsLinux())
+ {
+ Assert.Contains("analyzers/casesensitive.dll\n", normalized);
+ Assert.Empty(diagnostics);
+ }
+ else
+ {
+ Assert.DoesNotContain("analyzers/casesensitive.dll\n", normalized);
+ Assert.Single(diagnostics);
+ }
+ }
+
+ // Counts case-insensitive occurrences of ``needle`` between ``sectionHeader`` and
+ // the next section header (or end of content).
+ private static int CountOccurrencesInSection(string content, string sectionHeader, string needle)
+ {
+ int sectionStart = content.IndexOf(sectionHeader + "\n", StringComparison.Ordinal);
+ Assert.NotEqual(-1, sectionStart);
+ string sectionTail = content[(sectionStart + sectionHeader.Length + 1)..];
+ int sectionEnd = sectionTail.IndexOf("\n[", StringComparison.Ordinal);
+ string section = sectionEnd >= 0 ? sectionTail[..sectionEnd] : sectionTail;
+
+ int count = 0;
+ int idx = 0;
+ while ((idx = section.IndexOf(needle, idx, StringComparison.OrdinalIgnoreCase)) >= 0)
+ {
+ count++;
+ idx += needle.Length;
+ }
+ return count;
+ }
+
+ [Fact]
+ public void WriteMetadataRefSection_EmitsAliases()
+ {
+ var item = new Mock();
+ item.Setup(i => i.ItemSpec).Returns(@"C:\project\ref\Interop.dll");
+ item.Setup(i => i.GetMetadata("Aliases")).Returns("MyAlias");
+ item.Setup(i => i.GetMetadata("EmbedInteropTypes")).Returns("false");
+ item.Setup(i => i.GetMetadata("Value")).Returns(string.Empty);
+
+ string content = Build(metadataReferences: [item.Object]);
+ string normalized = content.Replace("\r\n", "\n");
+
+ Assert.Contains("[metadataReferences]", normalized);
+ Assert.Contains("@aliases=MyAlias", normalized);
+ }
+
+ [Fact]
+ public void WriteMetadataRefSection_EmitsEmbedInteropTypes()
+ {
+ var item = new Mock();
+ item.Setup(i => i.ItemSpec).Returns(@"C:\project\ref\Interop.dll");
+ item.Setup(i => i.GetMetadata("Aliases")).Returns("global");
+ item.Setup(i => i.GetMetadata("EmbedInteropTypes")).Returns("true");
+ item.Setup(i => i.GetMetadata("Value")).Returns(string.Empty);
+
+ string content = Build(metadataReferences: [item.Object]);
+ string normalized = content.Replace("\r\n", "\n");
+
+ Assert.Contains("@embedInteropTypes", normalized);
+ Assert.DoesNotContain("@aliases=", normalized); // "global" is not emitted
+ }
+
+ #region Framework packs
+
+ [Fact]
+ public void TryExtractRefPackName_RecognizesValidPath()
+ {
+ Assert.Equal(
+ "Microsoft.NETCore.App.Ref",
+ ProjectDataWriter.TryExtractRefPackName("/packs/Microsoft.NETCore.App.Ref/10.0.7/ref/net10.0/System.Runtime.dll"));
+ }
+
+ [Fact]
+ public void TryExtractRefPackName_ReturnsNullForNonPackPaths()
+ {
+ Assert.Null(ProjectDataWriter.TryExtractRefPackName("/foo/1.0/lib/net10.0/Foo.dll"));
+ Assert.Null(ProjectDataWriter.TryExtractRefPackName("/microsoft.netcore.app.ref/8.0.26/ref/net8.0/System.Runtime.dll"));
+ Assert.Null(ProjectDataWriter.TryExtractRefPackName("/sdk/9.0.100/Sdks/Microsoft.NET.Sdk/analyzers/x.dll"));
+ Assert.Null(ProjectDataWriter.TryExtractRefPackName("/packs/Microsoft.Android.Ref.36/36.0.0/ref/net10.0/Mono.Android.dll"));
+ Assert.Null(ProjectDataWriter.TryExtractRefPackName("/packs/Microsoft.iOS.Ref.net10.0_26.5/26.5.0/ref/net10.0/Microsoft.iOS.dll"));
+ Assert.Null(ProjectDataWriter.TryExtractRefPackName("/packs/Microsoft.MacCatalyst.Ref.net10.0_26.5/26.5.0/ref/net10.0/Microsoft.MacCatalyst.dll"));
+ Assert.Null(ProjectDataWriter.TryExtractRefPackName("/packs/Foo")); // no version segment
+ Assert.Null(ProjectDataWriter.TryExtractRefPackName("/packs/Foo/1.0")); // no path under version
+ Assert.Null(ProjectDataWriter.TryExtractRefPackName(""));
+ Assert.Null(ProjectDataWriter.TryExtractRefPackName(null));
+ }
+
+ [Fact]
+ public void WriteFrameworkPacksSection_EmitsSortedDistinctNames()
+ {
+ var sb = new StringBuilder();
+ var packs = new SortedSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "Microsoft.NETCore.App.Ref",
+ "Microsoft.AspNetCore.App.Ref",
+ };
+ ProjectDataWriter.WriteFrameworkPacksSection(sb, packs);
+ string content = sb.ToString().Replace("\r\n", "\n");
+
+ Assert.Contains("[frameworkPacks]\n", content);
+ int aspIdx = content.IndexOf("Microsoft.AspNetCore.App.Ref");
+ int netIdx = content.IndexOf("Microsoft.NETCore.App.Ref");
+ Assert.True(aspIdx > 0 && netIdx > aspIdx, "Pack names must be sorted OrdinalIgnoreCase");
+ }
+
+ [Fact]
+ public void WriteFrameworkPacksSection_EmptyPacks_OmitsSection()
+ {
+ var sb = new StringBuilder();
+ ProjectDataWriter.WriteFrameworkPacksSection(sb, new SortedSet());
+ Assert.Equal(string.Empty, sb.ToString());
+ }
+
+ // Builds a resolver wired to a synthetic dotnet root so paths under that root
+ // get rewritten to /... portable form for testing the prepare helpers.
+ private static (CachePathResolver Resolver, string DotNetRoot) MakeSyntheticResolver(string projectDir)
+ {
+ // Use the project dir's drive root as the synthetic dotnet root: any path under it
+ // that starts with "\\packs\\..." will be classified as /packs/...
+ string dotnetRoot = Path.Combine(projectDir, "fakedotnet") + Path.DirectorySeparatorChar;
+ var resolver = new CachePathResolver(
+ projectDir: projectDir,
+ nugetFolders: [Path.Combine(projectDir, "fakenuget") + Path.DirectorySeparatorChar],
+ dotnetRoots: [dotnetRoot],
+ netFxRefRoot: null);
+ return (resolver, dotnetRoot);
+ }
+
+ private static (CachePathResolver Resolver, string DotNetRoot, string NuGetRoot) MakeSyntheticResolverWithRoots(string projectDir)
+ {
+ string dotnetRoot = Path.Combine(projectDir, "fakedotnet") + Path.DirectorySeparatorChar;
+ string nugetRoot = Path.Combine(projectDir, "fakenuget") + Path.DirectorySeparatorChar;
+ var resolver = new CachePathResolver(
+ projectDir: projectDir,
+ nugetFolders: [nugetRoot],
+ dotnetRoots: [dotnetRoot],
+ netFxRefRoot: null);
+ return (resolver, dotnetRoot, nugetRoot);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(null)]
+ public void ToPortable_PreservesNullOrEmptyInput(string? inputPath)
+ {
+ var resolver = new CachePathResolver(Path.Combine(Path.GetTempPath(), "proj"), [], [], null);
+
+ Assert.Equal(inputPath, resolver.ToPortable(inputPath!));
+ }
+
+ [Fact]
+ public void PrepareMetadataRefs_DivertsPackEntriesAndKeepsOthers()
+ {
+ string projectDir = Path.Combine(Path.GetTempPath(), "proj");
+ (CachePathResolver resolver, string dotnetRoot) = MakeSyntheticResolver(projectDir);
+ ITaskItem[] items =
+ [
+ MakeItem(Path.Combine(dotnetRoot, "packs", "Microsoft.NETCore.App.Ref", "10.0.7", "ref", "net10.0", "System.Runtime.dll")),
+ MakeItem(Path.Combine(dotnetRoot, "packs", "Microsoft.AspNetCore.App.Ref", "10.0.7", "ref", "net10.0", "Microsoft.AspNetCore.dll")),
+ MakeItem(Path.Combine(projectDir, "bin", "App.dll")),
+ ];
+ var packs = new SortedSet(StringComparer.OrdinalIgnoreCase);
+ List> prepared = ProjectDataWriter.PrepareMetadataRefs(items, resolver, packs);
+
+ Assert.Equal(2, packs.Count);
+ Assert.Contains("Microsoft.NETCore.App.Ref", packs);
+ Assert.Contains("Microsoft.AspNetCore.App.Ref", packs);
+ Assert.Single(prepared); // only the non-pack ref survives
+ Assert.DoesNotContain("packs/", prepared[0].Key, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void PrepareMetadataRefs_KeepsWorkloadPackEntriesExplicit()
+ {
+ string projectDir = Path.Combine(Path.GetTempPath(), "proj");
+ (CachePathResolver resolver, string dotnetRoot) = MakeSyntheticResolver(projectDir);
+ ITaskItem[] items =
+ [
+ MakeItem(Path.Combine(dotnetRoot, "packs", "Microsoft.Android.Ref.36", "36.0.0", "ref", "net10.0", "Mono.Android.dll")),
+ MakeItem(Path.Combine(dotnetRoot, "packs", "Microsoft.iOS.Ref.net10.0_26.5", "26.5.0", "ref", "net10.0", "Microsoft.iOS.dll")),
+ MakeItem(Path.Combine(dotnetRoot, "packs", "Microsoft.MacCatalyst.Ref.net10.0_26.5", "26.5.0", "ref", "net10.0", "Microsoft.MacCatalyst.dll")),
+ ];
+ var packs = new SortedSet(StringComparer.OrdinalIgnoreCase);
+
+ List> prepared = ProjectDataWriter.PrepareMetadataRefs(items, resolver, packs);
+
+ Assert.Empty(packs);
+ Assert.Equal(3, prepared.Count);
+ Assert.Contains(prepared, item => item.Key.EndsWith("Mono.Android.dll", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(prepared, item => item.Key.EndsWith("Microsoft.iOS.dll", StringComparison.OrdinalIgnoreCase));
+ Assert.Contains(prepared, item => item.Key.EndsWith("Microsoft.MacCatalyst.dll", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void PrepareAnalyzerRefs_DivertsPackEntriesAndKeepsOthers()
+ {
+ string projectDir = Path.Combine(Path.GetTempPath(), "proj");
+ (CachePathResolver resolver, string dotnetRoot) = MakeSyntheticResolver(projectDir);
+ ITaskItem[] items =
+ [
+ MakeItem(Path.Combine(dotnetRoot, "packs", "Microsoft.NETCore.App.Ref", "10.0.7", "analyzers", "dotnet", "cs", "x.dll")),
+ MakeItem(Path.Combine(dotnetRoot, "sdk", "9.0.100", "Sdks", "Microsoft.NET.Sdk", "analyzers", "Foo.dll")),
+ ];
+ var packs = new SortedSet(StringComparer.OrdinalIgnoreCase);
+ List prepared = ProjectDataWriter.PrepareAnalyzerRefs(items, resolver, packs);
+
+ Assert.Single(packs);
+ Assert.Contains("Microsoft.NETCore.App.Ref", packs);
+ Assert.Single(prepared); // only the SDK-folder analyzer survives
+ // The SDK analyzer path is rewritten via the sentinel
+ // (the version segment is dropped — see CachePathResolver.RewriteSdkPath).
+ Assert.StartsWith("/", prepared[0]);
+ Assert.DoesNotContain("9.0.100", prepared[0]);
+ }
+
+ [Fact]
+ public void PrepareRefs_UnifiesSdkAndNuGetResolvedFrameworkPacks()
+ {
+ // Regression test: the same canonical framework pack must end up in
+ // [frameworkPacks] regardless of whether MSBuild resolved it from
+ // /packs/ (SDK install) or / (NuGet download). Otherwise
+ // the cache contents would depend on which dotnet SDKs are installed
+ // on the writer's machine, producing environment-dependent churn.
+ string projectDir = Path.Combine(Path.GetTempPath(), "proj-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(projectDir);
+ try
+ {
+ (CachePathResolver resolver, string dotnetRoot, string nugetRoot) = MakeSyntheticResolverWithRoots(projectDir);
+
+ // One pack resolved from SDK install, another (same canonical name) resolved from NuGet.
+ string sdkRef = Path.Combine(dotnetRoot, "packs", "Microsoft.AspNetCore.App.Ref", "8.0.20", "ref", "net8.0", "Microsoft.AspNetCore.dll");
+ string nugetRef = Path.Combine(nugetRoot, "microsoft.netcore.app.ref", "8.0.26", "ref", "net8.0", "System.Runtime.dll");
+
+ var frameworkPacks = new SortedSet(StringComparer.OrdinalIgnoreCase);
+ List> prepared = ProjectDataWriter.PrepareMetadataRefs(
+ [MakeItem(sdkRef), MakeItem(nugetRef, nuGetPackageId: "Microsoft.NETCore.App.Ref", nuGetPackageVersion: "8.0.26", frameworkReferenceName: "Microsoft.NETCore.App")],
+ resolver,
+ frameworkPacks,
+ new ProjectDataWriter.TargetFramework("net8.0", null, "v8.0"));
+
+ Assert.Equal(2, frameworkPacks.Count);
+ Assert.Contains("Microsoft.AspNetCore.App.Ref", frameworkPacks);
+ Assert.Contains("Microsoft.NETCore.App.Ref", frameworkPacks);
+ Assert.Empty(prepared);
+ }
+ finally
+ {
+ try { Directory.Delete(projectDir, recursive: true); } catch { }
+ }
+ }
+
+ [Fact]
+ public void PrepareRefs_ClassifiesNuGetResolvedFrameworkPacksAsFrameworkPacks()
+ {
+ string projectDir = Path.Combine(Path.GetTempPath(), "proj-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(projectDir);
+ try
+ {
+ (CachePathResolver resolver, _, string nugetRoot) = MakeSyntheticResolverWithRoots(projectDir);
+ string packageRoot = Path.Combine(nugetRoot, "microsoft.netcore.app.ref", "8.0.26");
+ string metadataRef = Path.Combine(packageRoot, "ref", "net8.0", "System.Runtime.dll");
+ string analyzerRef = Path.Combine(packageRoot, "analyzers", "dotnet", "cs", "FrameworkAnalyzer.dll");
+ string arbitraryNuGetRef = Path.Combine(nugetRoot, "some.package", "1.0.0", "lib", "net8.0", "Some.Package.dll");
+ ITaskItem packMetadataRef = MakeItem(metadataRef, nuGetPackageId: "Microsoft.NETCore.App.Ref", nuGetPackageVersion: "8.0.26", frameworkReferenceName: "Microsoft.NETCore.App");
+ ITaskItem packAnalyzerRef = MakeItem(analyzerRef, nuGetPackageId: "Microsoft.NETCore.App.Ref", nuGetPackageVersion: "8.0.26", frameworkReferenceName: "Microsoft.NETCore.App");
+
+ var frameworkPacks = new SortedSet(StringComparer.OrdinalIgnoreCase);
+ var targetFramework = new ProjectDataWriter.TargetFramework("net8.0", null, "v8.0");
+ List> preparedMetadata = ProjectDataWriter.PrepareMetadataRefs(
+ [packMetadataRef, MakeItem(arbitraryNuGetRef)],
+ resolver,
+ frameworkPacks,
+ targetFramework);
+ List preparedAnalyzers = ProjectDataWriter.PrepareAnalyzerRefs(
+ [packAnalyzerRef],
+ resolver,
+ frameworkPacks,
+ new SortedSet(StringComparer.OrdinalIgnoreCase),
+ null,
+ targetFramework);
+
+ Assert.Single(frameworkPacks);
+ Assert.Contains("Microsoft.NETCore.App.Ref", frameworkPacks);
+ Assert.Single(preparedMetadata);
+ Assert.Contains("some.package", preparedMetadata[0].Key, StringComparison.OrdinalIgnoreCase);
+ Assert.Empty(preparedAnalyzers);
+ }
+ finally
+ {
+ try { Directory.Delete(projectDir, recursive: true); } catch { }
+ }
+ }
+
+ [Fact]
+ public void PrepareRefs_ClassifiesNuGetResolvedFrameworkPacksAsFrameworkPacks_WhenFrameworkReferenceMetadataIsMissing()
+ {
+ string projectDir = Path.Combine(Path.GetTempPath(), "proj-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(projectDir);
+ try
+ {
+ (CachePathResolver resolver, _, string nugetRoot) = MakeSyntheticResolverWithRoots(projectDir);
+ string packageRoot = Path.Combine(nugetRoot, "microsoft.netcore.app.ref", "8.0.26");
+ string metadataRef = Path.Combine(packageRoot, "ref", "net8.0", "System.Runtime.dll");
+ string arbitraryNuGetRef = Path.Combine(nugetRoot, "some.package", "1.0.0", "lib", "net8.0", "Some.Package.dll");
+
+ var frameworkPacks = new SortedSet(StringComparer.OrdinalIgnoreCase);
+ List> preparedMetadata = ProjectDataWriter.PrepareMetadataRefs(
+ [MakeItem(metadataRef), MakeItem(arbitraryNuGetRef)],
+ resolver,
+ frameworkPacks,
+ new ProjectDataWriter.TargetFramework("net8.0", null, "v8.0"));
+
+ Assert.Single(frameworkPacks);
+ Assert.Contains("Microsoft.NETCore.App.Ref", frameworkPacks);
+ Assert.Single(preparedMetadata);
+ Assert.Contains("some.package", preparedMetadata[0].Key, StringComparison.OrdinalIgnoreCase);
+ }
+ finally
+ {
+ try { Directory.Delete(projectDir, recursive: true); } catch { }
+ }
+ }
+
+ [Fact]
+ public void PrepareRefs_NormalizesNuGetPackageIdCasingToCanonicalName()
+ {
+ // Regression: NuGet preserves the casing from the package's .nuspec, which has
+ // historically varied across SDK versions and feeds (e.g. lowercase
+ // `microsoft.netcore.app.ref` on some restore paths vs PascalCase
+ // `Microsoft.NETCore.App.Ref` on others). Echoing that casing through to the
+ // cache reintroduces the very environment dependence this PR is eliminating.
+ // `TryExtractNuGetRefPackName` must always emit the canonical pack id regardless
+ // of what `NuGetPackageId` metadata supplies.
+ string projectDir = Path.Combine(Path.GetTempPath(), "proj-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(projectDir);
+ try
+ {
+ (CachePathResolver resolver, _, string nugetRoot) = MakeSyntheticResolverWithRoots(projectDir);
+ string packageRoot = Path.Combine(nugetRoot, "microsoft.netcore.app.ref", "8.0.26");
+ string metadataRef = Path.Combine(packageRoot, "ref", "net8.0", "System.Runtime.dll");
+
+ var frameworkPacks = new SortedSet(StringComparer.Ordinal);
+ List> preparedMetadata = ProjectDataWriter.PrepareMetadataRefs(
+ [
+ MakeItem(
+ metadataRef,
+ nuGetPackageId: "microsoft.netcore.app.ref",
+ nuGetPackageVersion: "8.0.26",
+ frameworkReferenceName: "Microsoft.NETCore.App"),
+ ],
+ resolver,
+ frameworkPacks,
+ new ProjectDataWriter.TargetFramework("net8.0", null, "v8.0"));
+
+ // The frameworkPacks set is `StringComparer.Ordinal`, so the assertion below
+ // would fail if we emitted lowercase `microsoft.netcore.app.ref` from the
+ // metadata casing.
+ Assert.Single(frameworkPacks);
+ Assert.Contains("Microsoft.NETCore.App.Ref", frameworkPacks);
+ Assert.DoesNotContain("microsoft.netcore.app.ref", frameworkPacks);
+ }
+ finally
+ {
+ try { Directory.Delete(projectDir, recursive: true); } catch { }
+ }
+ }
+
+ [Fact]
+ public void PrepareRefs_ClassifiesNuGetResolvedFrameworkPacksAsFrameworkPacks_WhenTargetFrameworkUsesCompactTfm()
+ {
+ string projectDir = Path.Combine(Path.GetTempPath(), "proj-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(projectDir);
+ try
+ {
+ (CachePathResolver resolver, _, string nugetRoot) = MakeSyntheticResolverWithRoots(projectDir);
+ string packageRoot = Path.Combine(nugetRoot, "microsoft.netcore.app.ref", "8.0.26");
+ string metadataRef = Path.Combine(packageRoot, "ref", "net8.0", "System.Runtime.dll");
+
+ var frameworkPacks = new SortedSet(StringComparer.OrdinalIgnoreCase);
+ List> preparedMetadata = ProjectDataWriter.PrepareMetadataRefs(
+ [MakeItem(metadataRef)],
+ resolver,
+ frameworkPacks,
+ new ProjectDataWriter.TargetFramework("net8", null, "v8.0"));
+
+ Assert.Single(frameworkPacks);
+ Assert.Contains("Microsoft.NETCore.App.Ref", frameworkPacks);
+ Assert.Empty(preparedMetadata);
+ }
+ finally
+ {
+ try { Directory.Delete(projectDir, recursive: true); } catch { }
+ }
+ }
+
+ [Fact]
+ public void BuildContent_NuGetResolvedFrameworkPacksAppearInFrameworkPacksSectionBeforeMetadataReferences_AndPackEntriesAreFiltered()
+ {
+ string projectDir = Path.Combine(Path.GetTempPath(), "lscache-nuget-fpacks-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(projectDir);
+ string previousNuGet = Environment.GetEnvironmentVariable("NUGET_PACKAGES") ?? string.Empty;
+ try
+ {
+ string nugetRoot = Path.Combine(projectDir, "nuget");
+ Environment.SetEnvironmentVariable("NUGET_PACKAGES", nugetRoot);
+ string projectFile = Path.Combine(projectDir, "App.csproj");
+ string packDll = Path.Combine(nugetRoot, "microsoft.netcore.app.ref", "8.0.26", "ref", "net8.0", "System.Runtime.dll");
+ string nugetDll = Path.Combine(nugetRoot, "foo", "1.0.0", "lib", "net8.0", "Foo.dll");
+
+ string content = ProjectDataWriter.BuildContent(
+ projectFilePath: projectFile,
+ writeHeader: true,
+ isPrimary: false,
+ lastDtbSucceeded: false,
+ sliceDimensions: [MakeItem("TargetFramework", "net8.0")],
+ properties: null,
+ commandLineArguments: null,
+ sourceFiles: null,
+ metadataReferences: [MakeItem(packDll, nuGetPackageId: "Microsoft.NETCore.App.Ref", nuGetPackageVersion: "8.0.26", frameworkReferenceName: "Microsoft.NETCore.App"), MakeItem(nugetDll)],
+ analyzerReferences: null,
+ analyzerConfigFiles: null,
+ additionalFiles: null,
+ projectReferences: null,
+ capabilities: null);
+
+ int packsIdx = content.IndexOf("[frameworkPacks]");
+ int metaIdx = content.IndexOf("[metadataReferences]");
+ Assert.True(packsIdx > 0, "[frameworkPacks] section expected");
+ Assert.True(metaIdx > packsIdx, "[frameworkPacks] must precede [metadataReferences]");
+ Assert.Contains("Microsoft.NETCore.App.Ref", content);
+ Assert.DoesNotContain("[nugetFrameworkPacks]", content);
+ Assert.DoesNotContain("System.Runtime.dll", content);
+ Assert.DoesNotContain("8.0.26/ref/net8.0", content);
+ Assert.Contains("Foo.dll", content);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("NUGET_PACKAGES", previousNuGet);
+ try { Directory.Delete(projectDir, recursive: true); } catch { }
+ }
+ }
+
+ [Fact]
+ public void PrepareMetadataRefs_CanonicalizesNetFrameworkReferenceAssembliesToNetFxRefMetadata()
+ {
+ string projectDir = Path.Combine(Path.GetTempPath(), "proj-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(projectDir);
+ try
+ {
+ string nugetRoot = Path.Combine(projectDir, "fakenuget") + Path.DirectorySeparatorChar;
+ string netFxRoot = Path.Combine(projectDir, "Reference Assemblies", "Microsoft", "Framework", ".NETFramework") + Path.DirectorySeparatorChar;
+ var resolver = new CachePathResolver(projectDir, [nugetRoot], [], netFxRoot);
+ ITaskItem developerPackRef = MakeItem(Path.Combine(netFxRoot, "v4.7.2", "mscorlib.dll"));
+ ITaskItem nugetRef = MakeItem(Path.Combine(nugetRoot, "microsoft.netframework.referenceassemblies.net472", "1.0.3", "build", ".NETFramework", "v4.7.2", "System.dll"));
+ ITaskItem packageRef = MakeItem(Path.Combine(nugetRoot, "some.package", "1.0.0", "lib", "net472", "Some.Package.dll"));
+
+ var frameworkPacks = new SortedSet(StringComparer.OrdinalIgnoreCase);
+ List> prepared = ProjectDataWriter.PrepareMetadataRefs(
+ [developerPackRef, nugetRef, packageRef],
+ resolver,
+ frameworkPacks,
+ new ProjectDataWriter.TargetFramework("net472", ".NETFramework", "v4.7.2"));
+
+ Assert.Empty(frameworkPacks);
+ Assert.Equal(["/v4.7.2/mscorlib.dll", "/v4.7.2/System.dll", "/some.package/1.0.0/lib/net472/Some.Package.dll"], prepared.Select(reference => reference.Key));
+ }
+ finally
+ {
+ try { Directory.Delete(projectDir, recursive: true); } catch { }
+ }
+ }
+
+ [Fact]
+ public void BuildContent_NetFrameworkReferenceAssembliesAreCanonicalMetadataReferences()
+ {
+ string projectDir = Path.Combine(Path.GetTempPath(), "lscache-netfx-refs-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(projectDir);
+ try
+ {
+ string netFxRoot = Path.Combine(projectDir, "refs", ".NETFramework") + Path.DirectorySeparatorChar;
+ string projectFile = Path.Combine(projectDir, "App.csproj");
+ string mscorlib = Path.Combine(netFxRoot, "v4.7.2", "mscorlib.dll");
+ string packageRef = Path.Combine(projectDir, "packages", "Some.Package.dll");
+
+ string content = ProjectDataWriter.BuildContent(
+ projectFilePath: projectFile,
+ writeHeader: true,
+ isPrimary: false,
+ lastDtbSucceeded: false,
+ sliceDimensions: [MakeItem("TargetFramework", "net472")],
+ properties: [MakeItem("TargetFrameworkIdentifier", ".NETFramework"), MakeItem("TargetFrameworkVersion", "v4.7.2")],
+ commandLineArguments: null,
+ sourceFiles: null,
+ metadataReferences: [MakeItem(mscorlib), MakeItem(packageRef)],
+ analyzerReferences: null,
+ analyzerConfigFiles: null,
+ additionalFiles: null,
+ projectReferences: null,
+ capabilities: null);
+
+ int metaIdx = content.IndexOf("[metadataReferences]");
+ Assert.True(metaIdx > 0, "[metadataReferences] section expected");
+ Assert.DoesNotContain("[netFrameworkReferenceAssemblies]", content);
+ Assert.Contains("/v4.7.2/mscorlib.dll", content);
+ Assert.DoesNotContain("refs/.NETFramework/v4.7.2/mscorlib.dll", content);
+ Assert.Contains("Some.Package.dll", content);
+ }
+ finally
+ {
+ try { Directory.Delete(projectDir, recursive: true); } catch { }
+ }
+ }
+
+ [Fact]
+ public void TryValidateNetFrameworkReferences_RejectsMissingBareFrameworkReferences()
+ {
+ string projectDir = Path.Combine(Path.GetTempPath(), "lscache-netfx-validation-" + Guid.NewGuid().ToString("N"));
+ string projectFile = Path.Combine(projectDir, "App.csproj");
+ Directory.CreateDirectory(projectDir);
+ try
+ {
+ bool valid = ProjectDataWriter.TryValidateNetFrameworkReferences(
+ projectFile,
+ sliceDimensions: null,
+ properties: [MakeItem("TargetFrameworkIdentifier", ".NETFramework"), MakeItem("TargetFramework", "net472")],
+ metadataReferences: [MakeItem("mscorlib.dll")],
+ out string unsupportedReason);
+
+ Assert.False(valid);
+ Assert.Equal("MissingNetFrameworkReferenceAssemblies", unsupportedReason);
+ }
+ finally
+ {
+ try { Directory.Delete(projectDir, recursive: true); } catch { }
+ }
+ }
+
+ [Fact]
+ public void TryValidateNetFrameworkReferences_AcceptsExistingCanonicalReferences()
+ {
+ string projectDir = Path.Combine(Path.GetTempPath(), "lscache-netfx-validation-" + Guid.NewGuid().ToString("N"));
+ string projectFile = Path.Combine(projectDir, "App.csproj");
+ string reference = Path.Combine(projectDir, "refs", ".NETFramework", "v4.7.2", "mscorlib.dll");
+ Directory.CreateDirectory(Path.GetDirectoryName(reference)!);
+ File.WriteAllText(reference, string.Empty);
+ try
+ {
+ bool valid = ProjectDataWriter.TryValidateNetFrameworkReferences(
+ projectFile,
+ sliceDimensions: null,
+ properties: [MakeItem("TargetFrameworkIdentifier", ".NETFramework"), MakeItem("TargetFramework", "net472"), MakeItem("TargetFrameworkVersion", "v4.7.2")],
+ metadataReferences: [MakeItem(reference)],
+ out string unsupportedReason);
+
+ Assert.True(valid);
+ Assert.Equal(string.Empty, unsupportedReason);
+ }
+ finally
+ {
+ try { Directory.Delete(projectDir, recursive: true); } catch { }
+ }
+ }
+
+ [Fact]
+ public void PrepareAnalyzerRefs_DivertsSdkKnownAnalyzerPacks_WithoutRequiringSdkKnownVersionMatch()
+ {
+ string projectDir = Path.Combine(Path.GetTempPath(), "proj-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(projectDir);
+ try
+ {
+ (CachePathResolver resolver, _, string nugetRoot) = MakeSyntheticResolverWithRoots(projectDir);
+ string illinkAnalyzer = Path.Combine(nugetRoot, "microsoft.net.illink.tasks", "10.0.8", "analyzers", "dotnet", "cs", "ILLink.RoslynAnalyzer.dll");
+ string otherAnalyzer = Path.Combine(nugetRoot, "some.analyzer", "1.0.0", "analyzers", "dotnet", "cs", "Some.Analyzer.dll");
+ ITaskItem illinkItem = MakeItem(illinkAnalyzer, nuGetPackageId: "Microsoft.NET.ILLink.Tasks", nuGetPackageVersion: "10.0.8");
+ ITaskItem otherItem = MakeItem(otherAnalyzer, nuGetPackageId: "Some.Analyzer", nuGetPackageVersion: "1.0.0");
+
+ var frameworkPacks = new SortedSet(StringComparer.OrdinalIgnoreCase);
+ var sdkAnalyzerPacks = new SortedSet(StringComparer.OrdinalIgnoreCase);
+ List prepared = ProjectDataWriter.PrepareAnalyzerRefs(
+ [illinkItem, otherItem],
+ resolver,
+ frameworkPacks,
+ sdkAnalyzerPacks,
+ [MakeSdkKnownAnalyzerPack("Microsoft.NET.ILLink.Tasks", "net10.0", "10.0.7")],
+ new ProjectDataWriter.TargetFramework("net10.0", null, "v10.0"));
+
+ Assert.Empty(frameworkPacks);
+ Assert.Single(sdkAnalyzerPacks);
+ Assert.Contains("Microsoft.NET.ILLink.Tasks", sdkAnalyzerPacks);
+ Assert.Single(prepared);
+ Assert.Contains("some.analyzer", prepared[0], StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("microsoft.net.illink.tasks", prepared[0], StringComparison.OrdinalIgnoreCase);
+ }
+ finally
+ {
+ try { Directory.Delete(projectDir, recursive: true); } catch { }
+ }
+ }
+
+ [Fact]
+ public void PrepareAnalyzerRefs_KeepsNuGetAnalyzerPackage_WhenNotSdkKnown()
+ {
+ string projectDir = Path.Combine(Path.GetTempPath(), "proj-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(projectDir);
+ try
+ {
+ (CachePathResolver resolver, _, string nugetRoot) = MakeSyntheticResolverWithRoots(projectDir);
+ string analyzer = Path.Combine(nugetRoot, "some.analyzer", "1.0.0", "analyzers", "dotnet", "cs", "Some.Analyzer.dll");
+ ITaskItem analyzerItem = MakeItem(analyzer, nuGetPackageId: "Some.Analyzer", nuGetPackageVersion: "1.0.0");
+
+ var frameworkPacks = new SortedSet(StringComparer.OrdinalIgnoreCase);
+ var sdkAnalyzerPacks = new SortedSet(StringComparer.OrdinalIgnoreCase);
+ List prepared = ProjectDataWriter.PrepareAnalyzerRefs(
+ [analyzerItem],
+ resolver,
+ frameworkPacks,
+ sdkAnalyzerPacks,
+ [MakeSdkKnownAnalyzerPack("Microsoft.NET.ILLink.Tasks", "net10.0", "10.0.7")],
+ new ProjectDataWriter.TargetFramework("net10.0", null, "v10.0"));
+
+ Assert.Empty(sdkAnalyzerPacks);
+ Assert.Single(prepared);
+ Assert.Contains("some.analyzer", prepared[0], StringComparison.OrdinalIgnoreCase);
+ }
+ finally
+ {
+ try { Directory.Delete(projectDir, recursive: true); } catch { }
+ }
+ }
+
+ [Fact]
+ public void BuildContent_SdkAnalyzerPacksAppearsBeforeAnalyzerReferences_AndPackEntriesAreFiltered()
+ {
+ string projectDir = Path.Combine(Path.GetTempPath(), "lscache-sdk-apacks-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(projectDir);
+ string previousNuGet = Environment.GetEnvironmentVariable("NUGET_PACKAGES") ?? string.Empty;
+ try
+ {
+ string nugetRoot = Path.Combine(projectDir, "nuget");
+ Environment.SetEnvironmentVariable("NUGET_PACKAGES", nugetRoot);
+ string projectFile = Path.Combine(projectDir, "App.csproj");
+ string illinkAnalyzer = Path.Combine(nugetRoot, "microsoft.net.illink.tasks", "10.0.8", "analyzers", "dotnet", "cs", "ILLink.RoslynAnalyzer.dll");
+ string otherAnalyzer = Path.Combine(nugetRoot, "some.analyzer", "1.0.0", "analyzers", "dotnet", "cs", "Some.Analyzer.dll");
+
+ string content = ProjectDataWriter.BuildContent(
+ projectFilePath: projectFile,
+ writeHeader: true,
+ isPrimary: false,
+ lastDtbSucceeded: false,
+ sliceDimensions: [MakeItem("TargetFramework", "net10.0")],
+ properties: [MakeItem("TargetFrameworkIdentifier", ".NETCoreApp"), MakeItem("TargetFrameworkVersion", "v10.0")],
+ commandLineArguments: null,
+ sourceFiles: null,
+ metadataReferences: null,
+ analyzerReferences:
+ [
+ MakeItem(illinkAnalyzer, nuGetPackageId: "Microsoft.NET.ILLink.Tasks", nuGetPackageVersion: "10.0.8"),
+ MakeItem(otherAnalyzer, nuGetPackageId: "Some.Analyzer", nuGetPackageVersion: "1.0.0"),
+ ],
+ analyzerConfigFiles: null,
+ additionalFiles: null,
+ projectReferences: null,
+ capabilities: null,
+ sdkKnownAnalyzerPacks: [MakeSdkKnownAnalyzerPack("Microsoft.NET.ILLink.Tasks", "net10.0", "10.0.7")]);
+
+ int packsIdx = content.IndexOf("[sdkAnalyzerPacks]");
+ int analyzerIdx = content.IndexOf("[analyzerReferences]");
+ Assert.True(packsIdx > 0, "[sdkAnalyzerPacks] section expected");
+ Assert.True(analyzerIdx > packsIdx, "[sdkAnalyzerPacks] must precede [analyzerReferences]");
+ Assert.Contains("Microsoft.NET.ILLink.Tasks", content);
+ Assert.DoesNotContain("ILLink.RoslynAnalyzer.dll", content);
+ Assert.DoesNotContain("10.0.8/analyzers", content);
+ Assert.Contains("Some.Analyzer.dll", content);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("NUGET_PACKAGES", previousNuGet);
+ try { Directory.Delete(projectDir, recursive: true); } catch { }
+ }
+ }
+
+ [Fact]
+ public void BuildContent_SdkAnalyzerConfigPolicyAppearsBeforeAnalyzerConfigFiles()
+ {
+ string projectDir = Path.Combine(Path.GetTempPath(), "lscache-sdk-configs-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(projectDir);
+ string fakeDotnet = Path.Combine(projectDir, "dotnet");
+ Directory.CreateDirectory(fakeDotnet);
+ string previous = Environment.GetEnvironmentVariable("DOTNET_ROOT") ?? string.Empty;
+ try
+ {
+ Environment.SetEnvironmentVariable("DOTNET_ROOT", fakeDotnet);
+ string projectFile = Path.Combine(projectDir, "App.csproj");
+ string sdkConfig = Path.Combine(fakeDotnet, "sdk", "10.0.202", "Sdks", "Microsoft.NET.Sdk", "analyzers", "build", "config", "analysislevel_10_default.globalconfig");
+ string styleConfig = Path.Combine(
+ fakeDotnet,
+ "sdk",
+ "10.0.202",
+ "Sdks",
+ "Microsoft.NET.Sdk",
+ "codestyle",
+ "cs",
+ "build",
+ "config",
+ "analysislevelstyle_default.globalconfig");
+ string projectConfig = Path.Combine(projectDir, "Directory.Build.globalconfig");
+
+ string content = ProjectDataWriter.BuildContent(
+ projectFilePath: projectFile,
+ writeHeader: true,
+ isPrimary: false,
+ lastDtbSucceeded: false,
+ sliceDimensions: [MakeItem("TargetFramework", "net10.0")],
+ properties: [MakeItem("TargetFrameworkIdentifier", ".NETCoreApp"), MakeItem("TargetFrameworkVersion", "v10.0")],
+ commandLineArguments: null,
+ sourceFiles: null,
+ metadataReferences: null,
+ analyzerReferences: null,
+ analyzerConfigFiles: [sdkConfig, styleConfig, projectConfig],
+ additionalFiles: null,
+ projectReferences: null,
+ sdkAnalyzerConfigPolicy: [MakeDefaultSdkAnalyzerConfigPolicy()]);
+
+ int sdkConfigIdx = content.IndexOf("[sdkAnalyzerConfigPolicy]");
+ int configIdx = content.IndexOf("[analyzerConfigFiles]");
+ Assert.True(sdkConfigIdx > 0, "[sdkAnalyzerConfigPolicy] section expected");
+ Assert.True(configIdx > sdkConfigIdx, "[sdkAnalyzerConfigPolicy] must precede [analyzerConfigFiles]");
+ string normalized = content.Replace("\r\n", "\n");
+ Assert.Contains("Microsoft.NET.Sdk/analyzers\n", normalized);
+ Assert.Contains("Microsoft.NET.Sdk/codestyle/cs\n", normalized);
+ Assert.DoesNotContain("AnalysisLevel=latest", normalized);
+ Assert.DoesNotContain("analysislevel_10_default.globalconfig", content);
+ Assert.DoesNotContain("analysislevelstyle_default.globalconfig", content);
+ Assert.Contains("Directory.Build.globalconfig", content);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("DOTNET_ROOT", previous);
+ try { Directory.Delete(projectDir, recursive: true); } catch { }
+ }
+ }
+
+ [Theory]
+ [InlineData("net10.0", "10.0", "10", ".NETCoreApp", "v10.0")]
+ [InlineData("net10", "10.0", "10", ".NETCoreApp", "v10.0")]
+ [InlineData("net8", "8.0", "8", ".NETCoreApp", "v8.0")]
+ public void BuildContent_SdkAnalyzerConfigPolicyCanonicalizesNumericDefaults(string targetFramework, string numericAnalysisLevel, string shortAnalysisLevel, string tfmIdentifier, string tfmVersion)
+ {
+ ITaskItem[] targetFrameworkDimension = [MakeItem("TargetFramework", targetFramework)];
+ ITaskItem[] tfmProperties = [MakeItem("TargetFrameworkIdentifier", tfmIdentifier), MakeItem("TargetFrameworkVersion", tfmVersion)];
+
+ string numeric = Build(
+ sliceDimensions: targetFrameworkDimension,
+ properties: tfmProperties,
+ sdkAnalyzerConfigPolicy: [MakeDefaultSdkAnalyzerConfigPolicy(numericAnalysisLevel, numericAnalysisLevel)]);
+ string numericShort = Build(
+ sliceDimensions: targetFrameworkDimension,
+ properties: tfmProperties,
+ sdkAnalyzerConfigPolicy: [MakeDefaultSdkAnalyzerConfigPolicy(shortAnalysisLevel, numericAnalysisLevel)]);
+
+ Assert.Equal(numeric, numericShort);
+ string normalized = numeric.Replace("\r\n", "\n");
+ Assert.Contains("[sdkAnalyzerConfigPolicy]\nMicrosoft.NET.Sdk/analyzers\nMicrosoft.NET.Sdk/codestyle/cs\n", normalized);
+ Assert.DoesNotContain($"AnalysisLevel={numericAnalysisLevel}", normalized);
+ }
+
+ [Fact]
+ public void BuildContent_SdkAnalyzerConfigPolicyCanonicalizesLatestWhenEffectiveLevelMatchesTargetFramework()
+ {
+ string content = Build(
+ sliceDimensions: [MakeItem("TargetFramework", "net10.0")],
+ properties: [MakeItem("TargetFrameworkIdentifier", ".NETCoreApp"), MakeItem("TargetFrameworkVersion", "v10.0")],
+ sdkAnalyzerConfigPolicy: [MakeDefaultSdkAnalyzerConfigPolicy("latest", "10.0")]);
+
+ string normalized = content.Replace("\r\n", "\n");
+ Assert.Contains("Microsoft.NET.Sdk/analyzers\n", normalized);
+ Assert.Contains("Microsoft.NET.Sdk/codestyle/cs\n", normalized);
+ Assert.DoesNotContain("AnalysisLevel=latest", normalized);
+ }
+
+ [Fact]
+ public void BuildContent_SdkAnalyzerConfigPolicyCanonicalizesCapitalLatestWhenEffectiveLevelMatchesTargetFramework()
+ {
+ // Windows SDK evaluation yields AnalysisLevel="Latest" (capital L) + EffectiveAnalysisLevel="10.0"
+ // for a net10.0 project with Latest in Directory.Build.props.
+ // Verify this is treated identically to lowercase "latest".
+ string content = Build(
+ sliceDimensions: [MakeItem("TargetFramework", "net10.0")],
+ properties: [MakeItem("TargetFrameworkIdentifier", ".NETCoreApp"), MakeItem("TargetFrameworkVersion", "v10.0")],
+ sdkAnalyzerConfigPolicy: [MakeDefaultSdkAnalyzerConfigPolicy("Latest", "10.0")]);
+
+ string normalized = content.Replace("\r\n", "\n");
+ Assert.Contains("Microsoft.NET.Sdk/analyzers\n", normalized);
+ Assert.Contains("Microsoft.NET.Sdk/codestyle/cs\n", normalized);
+ Assert.DoesNotContain("AnalysisLevel=Latest", normalized);
+ }
+
+ [Fact]
+ public void BuildContent_SdkAnalyzerConfigPolicyCanonicalizesLatestWhenEffectiveLevelIsNewerThanTargetFramework()
+ {
+ // The extension-driven design-time build can evaluate Latest
+ // on a net10.0 project as EffectiveAnalysisLevel="11.0" when running on a newer SDK.
+ string content = Build(
+ sliceDimensions: [MakeItem("TargetFramework", "net10.0")],
+ properties: [MakeItem("TargetFrameworkIdentifier", ".NETCoreApp"), MakeItem("TargetFrameworkVersion", "v10.0")],
+ sdkAnalyzerConfigPolicy: [MakeDefaultSdkAnalyzerConfigPolicy("Latest", "11.0")]);
+
+ string normalized = content.Replace("\r\n", "\n");
+ Assert.Contains("Microsoft.NET.Sdk/analyzers\n", normalized);
+ Assert.Contains("Microsoft.NET.Sdk/codestyle/cs\n", normalized);
+ Assert.DoesNotContain("AnalysisLevel=Latest", normalized);
+ }
+
+ [Fact]
+ public void BuildContent_SdkAnalyzerConfigPolicyCanonicalizesLatestWithoutEffectiveLevel()
+ {
+ ITaskItem policy = MakeSdkAnalyzerConfigPolicy(
+ ("Language", "C#"),
+ ("EnableNETAnalyzers", "true"),
+ ("EnforceCodeStyleInBuild", "true"),
+ ("AnalysisLevel", "Latest"),
+ ("AnalysisLevelStyle", "Latest"),
+ ("AnalysisMode", "Default"),
+ ("AnalysisModeStyle", "Default"));
+
+ string content = Build(
+ sliceDimensions: [MakeItem("TargetFramework", "net10.0")],
+ properties: [MakeItem("TargetFrameworkIdentifier", ".NETCoreApp"), MakeItem("TargetFrameworkVersion", "v10.0")],
+ sdkAnalyzerConfigPolicy: [policy]);
+
+ string normalized = content.Replace("\r\n", "\n");
+ Assert.Contains("Microsoft.NET.Sdk/analyzers|AnalysisMode=Default\n", normalized);
+ Assert.Contains("Microsoft.NET.Sdk/codestyle/cs|AnalysisMode=Default\n", normalized);
+ Assert.DoesNotContain("AnalysisLevel=Latest", normalized);
+ }
+
+ [Fact]
+ public void BuildContent_SdkAnalyzerConfigPolicyCanonicalizesLatestWithEffectiveLatest()
+ {
+ string content = Build(
+ properties: [MakeItem("TargetFramework", "net10.0"), MakeItem("TargetFrameworkIdentifier", ".NETCoreApp"), MakeItem("TargetFrameworkVersion", "v10.0")],
+ sdkAnalyzerConfigPolicy: [MakeDefaultSdkAnalyzerConfigPolicy("Latest", "Latest")]);
+
+ string normalized = content.Replace("\r\n", "\n");
+ Assert.Contains("Microsoft.NET.Sdk/analyzers\n", normalized);
+ Assert.Contains("Microsoft.NET.Sdk/codestyle/cs\n", normalized);
+ Assert.DoesNotContain("AnalysisLevel=Latest", normalized);
+ }
+
+ [Fact]
+ public void BuildContent_SdkAnalyzerConfigPolicyCanonicalizesNetFrameworkLatestWithoutEffectiveLevel()
+ {
+ ITaskItem policy = MakeSdkAnalyzerConfigPolicy(
+ ("Language", "C#"),
+ ("EnableNETAnalyzers", "true"),
+ ("EnforceCodeStyleInBuild", "true"),
+ ("AnalysisLevel", "Latest"),
+ ("AnalysisLevelStyle", "Latest"),
+ ("AnalysisMode", "Default"),
+ ("AnalysisModeStyle", "Default"));
+
+ string content = Build(
+ sliceDimensions: [MakeItem("TargetFramework", "net472")],
+ properties: [MakeItem("TargetFrameworkIdentifier", ".NETFramework"), MakeItem("TargetFrameworkVersion", "v4.7.2")],
+ sdkAnalyzerConfigPolicy: [policy]);
+
+ string normalized = content.Replace("\r\n", "\n");
+ Assert.Contains("Microsoft.NET.Sdk/analyzers|AnalysisMode=Default\n", normalized);
+ Assert.Contains("Microsoft.NET.Sdk/codestyle/cs|AnalysisMode=Default\n", normalized);
+ Assert.DoesNotContain("AnalysisLevel=Latest", normalized);
+ }
+
+ [Theory]
+ [InlineData("preview", "11.0")]
+ [InlineData("none", "4.0")]
+ [InlineData("9.0", "9.0")]
+ public void BuildContent_SdkAnalyzerConfigPolicyPreservesNonDefaultAnalysisLevel(string analysisLevel, string effectiveAnalysisLevel)
+ {
+ string content = Build(
+ sliceDimensions: [MakeItem("TargetFramework", "net10.0")],
+ sdkAnalyzerConfigPolicy: [MakeDefaultSdkAnalyzerConfigPolicy(analysisLevel, effectiveAnalysisLevel)]);
+
+ Assert.Contains($"AnalysisLevel={analysisLevel}", content);
+ }
+
+ [Theory]
+ [InlineData("net472")]
+ [InlineData("net48")]
+ public void BuildContent_SdkAnalyzerConfigPolicyPreservesLatestForNetFrameworkTfms(string targetFramework)
+ {
+ string content = Build(
+ sliceDimensions: [MakeItem("TargetFramework", targetFramework)],
+ sdkAnalyzerConfigPolicy: [MakeDefaultSdkAnalyzerConfigPolicy("latest", "4.8")]);
+
+ Assert.Contains("Microsoft.NET.Sdk/analyzers|AnalysisLevel=latest", content);
+ Assert.Contains("Microsoft.NET.Sdk/codestyle/cs|AnalysisLevel=latest", content);
+ }
+
+ [Fact]
+ public void BuildContent_SdkAnalyzerConfigPolicyPreservesLatestSuffix()
+ {
+ ITaskItem policy = MakeSdkAnalyzerConfigPolicy(
+ ("Language", "C#"),
+ ("EnableNETAnalyzers", "true"),
+ ("EnforceCodeStyleInBuild", "true"),
+ ("AnalysisLevel", "latest-all"),
+ ("AnalysisLevelStyle", "latest-all"),
+ ("AnalysisLevelSuffix", "all"),
+ ("AnalysisLevelSuffixStyle", "all"),
+ ("EffectiveAnalysisLevel", "10.0"),
+ ("EffectiveAnalysisLevelStyle", "10.0"),
+ ("MicrosoftCodeAnalysisNetAnalyzersRulesVersion", "10"));
+
+ string content = Build(
+ sliceDimensions: [MakeItem("TargetFramework", "net10.0")],
+ properties: [MakeItem("TargetFrameworkIdentifier", ".NETCoreApp"), MakeItem("TargetFrameworkVersion", "v10.0")],
+ sdkAnalyzerConfigPolicy: [policy]);
+
+ string normalized = content.Replace("\r\n", "\n");
+ Assert.Contains("Microsoft.NET.Sdk/analyzers|AnalysisLevelSuffix=all", normalized);
+ Assert.Contains("Microsoft.NET.Sdk/codestyle/cs|AnalysisLevelSuffix=all", normalized);
+ Assert.DoesNotContain("AnalysisLevel=latest-all", normalized);
+ }
+
+ [Fact]
+ public void BuildContent_SdkAnalyzerConfigPolicyPreservesStyleLevelWhenItDiffersFromCoreSpelling()
+ {
+ ITaskItem policy = MakeSdkAnalyzerConfigPolicy(
+ ("Language", "C#"),
+ ("EnableNETAnalyzers", "true"),
+ ("EnforceCodeStyleInBuild", "true"),
+ ("AnalysisLevel", "latest"),
+ ("AnalysisLevelStyle", "10.0"),
+ ("EffectiveAnalysisLevel", "10.0"),
+ ("EffectiveAnalysisLevelStyle", "10.0"),
+ ("MicrosoftCodeAnalysisNetAnalyzersRulesVersion", "10"));
+
+ string content = Build(
+ sliceDimensions: [MakeItem("TargetFramework", "net10.0")],
+ properties: [MakeItem("TargetFrameworkIdentifier", ".NETCoreApp"), MakeItem("TargetFrameworkVersion", "v10.0")],
+ sdkAnalyzerConfigPolicy: [policy]);
+
+ Assert.Contains("Microsoft.NET.Sdk/codestyle/cs|AnalysisLevelStyle=10.0", content);
+ Assert.DoesNotContain("AnalysisLevel=latest", content);
+ }
+
+ [Theory]
+ [InlineData("Microsoft.NET.Sdk/analyzers")]
+ [InlineData("Microsoft.NET.Sdk/codestyle/cs")]
+ [InlineData("Microsoft.NET.Sdk/analyzers|AnalysisMode=Default")]
+ [InlineData("Microsoft.NET.Sdk/codestyle/cs|AnalysisLevelStyle=10.0")]
+ [InlineData("Microsoft.NET.Sdk/analyzers|AnalysisLevelSuffix=all")]
+ [InlineData("not-an-sdk-policy-line")]
+ public void CanonicalizeSdkAnalyzerConfigPolicyLine_IsNoOpWhenTargetFrameworkIsNull(string alreadyCanonicalLine)
+ {
+ // ``ProjectDataMerger.ParseSlice`` invokes ``CanonicalizeSdkAnalyzerConfigPolicyLine`` while
+ // parsing the shared block of a merged ``.lscache`` — at that point ``SliceDimensions``
+ // is empty and ``GetTargetFramework()`` returns ``null``. Since lines in the shared
+ // block already agreed across every per-TFM slice (otherwise they would not have been
+ // hoisted), the canonicalizer must leave already-canonical input unchanged when the
+ // TFM context is unavailable. Pin that contract so a future refactor of
+ // ``CanonicalizeAnalysisLevel``'s null-TFM branch cannot silently mutate shared-block
+ // data on round-trip.
+ string result = ProjectDataWriter.CanonicalizeSdkAnalyzerConfigPolicyLine(alreadyCanonicalLine, targetFrameworkIdentifier: null, targetFrameworkVersion: null);
+ Assert.Equal(alreadyCanonicalLine, result);
+ }
+
+ [Fact]
+ public void BuildContent_SdkAnalyzerConfigPolicyIsStableWhenSdkAddsCodeStyleConfig()
+ {
+ string projectDir = Path.Combine(Path.GetTempPath(), "lscache-sdk-config-policy-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(projectDir);
+ string fakeDotnet = Path.Combine(projectDir, "dotnet");
+ Directory.CreateDirectory(fakeDotnet);
+ string previous = Environment.GetEnvironmentVariable("DOTNET_ROOT") ?? string.Empty;
+ try
+ {
+ Environment.SetEnvironmentVariable("DOTNET_ROOT", fakeDotnet);
+ string projectFile = Path.Combine(projectDir, "App.csproj");
+ string sdkConfig = Path.Combine(fakeDotnet, "sdk", "10.0.202", "Sdks", "Microsoft.NET.Sdk", "analyzers", "build", "config", "analysislevel_10_default.globalconfig");
+ string styleConfig = Path.Combine(
+ fakeDotnet,
+ "sdk",
+ "10.0.202",
+ "Sdks",
+ "Microsoft.NET.Sdk",
+ "codestyle",
+ "cs",
+ "build",
+ "config",
+ "analysislevelstyle_default.globalconfig");
+ string projectConfig = Path.Combine(projectDir, "Directory.Build.globalconfig");
+ ITaskItem policy = MakeDefaultSdkAnalyzerConfigPolicy();
+
+ string withoutCodeStyleConfig = ProjectDataWriter.BuildContent(
+ projectFilePath: projectFile,
+ writeHeader: true,
+ isPrimary: false,
+ lastDtbSucceeded: false,
+ sliceDimensions: [MakeItem("TargetFramework", "net10.0")],
+ properties: null,
+ commandLineArguments: null,
+ sourceFiles: null,
+ metadataReferences: null,
+ analyzerReferences: null,
+ analyzerConfigFiles: [sdkConfig, projectConfig],
+ additionalFiles: null,
+ projectReferences: null,
+ sdkAnalyzerConfigPolicy: [policy]);
+
+ string withCodeStyleConfig = ProjectDataWriter.BuildContent(
+ projectFilePath: projectFile,
+ writeHeader: true,
+ isPrimary: false,
+ lastDtbSucceeded: false,
+ sliceDimensions: [MakeItem("TargetFramework", "net10.0")],
+ properties: null,
+ commandLineArguments: null,
+ sourceFiles: null,
+ metadataReferences: null,
+ analyzerReferences: null,
+ analyzerConfigFiles: [sdkConfig, styleConfig, projectConfig],
+ additionalFiles: null,
+ projectReferences: null,
+ sdkAnalyzerConfigPolicy: [policy]);
+
+ Assert.Equal(withoutCodeStyleConfig, withCodeStyleConfig);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("DOTNET_ROOT", previous);
+ try { Directory.Delete(projectDir, recursive: true); } catch { }
+ }
+ }
+
+ [Fact]
+ public void PrepareRefs_SharedPackBetweenMetadataAndAnalyzersIsDeduplicated()
+ {
+ string projectDir = Path.Combine(Path.GetTempPath(), "proj");
+ (CachePathResolver resolver, string dotnetRoot) = MakeSyntheticResolver(projectDir);
+ ITaskItem[] meta = [MakeItem(Path.Combine(dotnetRoot, "packs", "Microsoft.NETCore.App.Ref", "10.0.7", "ref", "net10.0", "System.Runtime.dll"))];
+ ITaskItem[] analyzers = [MakeItem(Path.Combine(dotnetRoot, "packs", "Microsoft.NETCore.App.Ref", "10.0.7", "analyzers", "dotnet", "cs", "x.dll"))];
+
+ var packs = new SortedSet(StringComparer.OrdinalIgnoreCase);
+ ProjectDataWriter.PrepareMetadataRefs(meta, resolver, packs);
+ ProjectDataWriter.PrepareAnalyzerRefs(analyzers, resolver, packs);
+
+ Assert.Single(packs);
+ }
+
+ [Fact]
+ public void BuildContent_FrameworkPacksAppearsBeforeMetadataReferences_AndPackEntriesAreFiltered()
+ {
+ // Use a custom DOTNET_ROOT so the live writer's resolver picks up our fake pack paths.
+ string projectDir = Path.Combine(Path.GetTempPath(), "lscache-fpacks-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(projectDir);
+ string fakeDotnet = Path.Combine(projectDir, "dotnet");
+ Directory.CreateDirectory(fakeDotnet);
+ string previous = Environment.GetEnvironmentVariable("DOTNET_ROOT") ?? string.Empty;
+ try
+ {
+ Environment.SetEnvironmentVariable("DOTNET_ROOT", fakeDotnet);
+ string projectFile = Path.Combine(projectDir, "App.csproj");
+ string packDll = Path.Combine(fakeDotnet, "packs", "Microsoft.NETCore.App.Ref", "10.0.7", "ref", "net10.0", "System.Runtime.dll");
+ string nugetDll = @"C:\nuget\foo\1.0\lib\net10.0\Foo.dll";
+
+ string content = ProjectDataWriter.BuildContent(
+ projectFilePath: projectFile,
+ writeHeader: true,
+ isPrimary: false,
+ lastDtbSucceeded: false,
+ sliceDimensions: null,
+ properties: null,
+ commandLineArguments: null,
+ sourceFiles: null,
+ metadataReferences: [MakeItem(packDll), MakeItem(nugetDll)],
+ analyzerReferences: null,
+ analyzerConfigFiles: null,
+ additionalFiles: null,
+ projectReferences: null);
+
+ int packsIdx = content.IndexOf("[frameworkPacks]");
+ int metaIdx = content.IndexOf("[metadataReferences]");
+ Assert.True(packsIdx > 0, "[frameworkPacks] section expected");
+ Assert.True(metaIdx > packsIdx, "[frameworkPacks] must precede [metadataReferences]");
+ Assert.Contains("Microsoft.NETCore.App.Ref", content);
+ Assert.DoesNotContain("System.Runtime.dll", content);
+ // The non-pack ref still appears.
+ Assert.Contains("Foo.dll", content);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("DOTNET_ROOT", previous);
+ try { Directory.Delete(projectDir, recursive: true); } catch { }
+ }
+ }
+
+ [Fact]
+ public void WriteCapabilitiesSection_ExcludesUniversalCapabilities()
+ {
+ string content = Build(capabilities: ["Aspire", "CSharp", "TestingPlatformServer", "OutputGroups", "SupportsHotReload"]);
+ string normalized = content.Replace("\r\n", "\n");
+
+ Assert.Contains("[capabilities]\nAspire\nSupportsHotReload\nTestingPlatformServer\n", normalized);
+ Assert.DoesNotContain("\nCSharp\n", normalized);
+ Assert.DoesNotContain("\nOutputGroups\n", normalized);
+ }
+
+ [Fact]
+ public void WriteCapabilitiesSection_ExcludesNewDenylistEntries()
+ {
+ string content = Build(capabilities:
+ [
+ "Aspire", "AppServicePublish", "AspNetCoreInProcessHosting",
+ "BuildWindowsDesktopTarget", "DeclaredSourceItems", "DotNetCoreRazorConfiguration",
+ "DynamicDependentFile", "DynamicFileNesting", "GenerateDocumentationFile",
+ "NetSdkOCIImageBuild", "SupportHierarchyContextSvc", "SupportsComputeRunCommand",
+ "SupportsTypeScriptNuGet", "UserSourceItems", "WebNestingDefaults",
+ ]);
+ string normalized = content.Replace("\r\n", "\n");
+
+ Assert.Contains("[capabilities]\nAspire\n", normalized);
+ Assert.DoesNotContain("\nAppServicePublish\n", normalized);
+ Assert.DoesNotContain("\nAspNetCoreInProcessHosting\n", normalized);
+ Assert.DoesNotContain("\nBuildWindowsDesktopTarget\n", normalized);
+ Assert.DoesNotContain("\nDeclaredSourceItems\n", normalized);
+ Assert.DoesNotContain("\nDotNetCoreRazorConfiguration\n", normalized);
+ Assert.DoesNotContain("\nDynamicDependentFile\n", normalized);
+ Assert.DoesNotContain("\nDynamicFileNesting\n", normalized);
+ Assert.DoesNotContain("\nGenerateDocumentationFile\n", normalized);
+ Assert.DoesNotContain("\nNetSdkOCIImageBuild\n", normalized);
+ Assert.DoesNotContain("\nSupportHierarchyContextSvc\n", normalized);
+ Assert.DoesNotContain("\nSupportsComputeRunCommand\n", normalized);
+ Assert.DoesNotContain("\nSupportsTypeScriptNuGet\n", normalized);
+ Assert.DoesNotContain("\nUserSourceItems\n", normalized);
+ Assert.DoesNotContain("\nWebNestingDefaults\n", normalized);
+ }
+
+ [Fact]
+ public void WriteCapabilitiesSection_NullCapabilities_OmitsSection()
+ {
+ string content = Build(capabilities: null);
+ Assert.DoesNotContain("[capabilities]", content);
+ }
+
+ [Fact]
+ public void WriteCapabilitiesSection_EmptyCapabilities_OmitsSection()
+ {
+ string content = Build(capabilities: []);
+ Assert.DoesNotContain("[capabilities]", content);
+ }
+
+ [Fact]
+ public void WriteCapabilitiesSection_SortsAlphabetically()
+ {
+ string content = Build(capabilities: ["Zebra", "Alpha", "Middle"]);
+ string normalized = content.Replace("\r\n", "\n");
+
+ Assert.Contains("[capabilities]\nAlpha\nMiddle\nZebra\n", normalized);
+ }
+
+ [Fact]
+ public void WriteCapabilitiesSection_Deduplicates()
+ {
+ string content = Build(capabilities: ["Aspire", "Aspire", "TestContainer"]);
+ string normalized = content.Replace("\r\n", "\n");
+
+ Assert.Contains("[capabilities]\nAspire\nTestContainer\n", normalized);
+ int count = normalized.Split("\nAspire\n").Length - 1;
+ Assert.Equal(1, count);
+ }
+
+ [Fact]
+ public void WriteCapabilitiesSection_CaseInsensitiveExclusion()
+ {
+ string content = Build(capabilities: ["csharp", "OUTPUTGROUPS", "Aspire"]);
+ string normalized = content.Replace("\r\n", "\n");
+
+ Assert.Contains("[capabilities]\nAspire\n", normalized);
+ Assert.DoesNotContain("csharp", normalized, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("OUTPUTGROUPS", normalized, StringComparison.OrdinalIgnoreCase);
+ }
+
+ #endregion
+
+ #region TryRewriteAsNuGetPp
+
+ [Theory]
+ [InlineData("obj/Debug/net8.0/NuGet/7E7D116BF0B1C551/Nullable/1.3.0/Nullable/NullableAttributes.cs",
+ "/Nullable/1.3.0/Nullable/NullableAttributes.cs")]
+ [InlineData("obj/Release/net6.0/NuGet/ABCDEF0123456789/SomePackage/2.0.0/File.cs",
+ "/SomePackage/2.0.0/File.cs")]
+ [InlineData("obj/Debug/net8.0/NuGet/abcdef0123456789/Pkg/1.0.0/Dir/File.cs",
+ "/Pkg/1.0.0/Dir/File.cs")]
+ public void TryRewriteAsNuGetPp_RewritesToSentinel(string input, string expected)
+ {
+ string? result = CachePathResolver.TryRewriteAsNuGetPp(input);
+ Assert.Equal(expected, result);
+ }
+
+ [Theory]
+ [InlineData("src/Program.cs")]
+ [InlineData("obj/Debug/net8.0/SomeFile.cs")]
+ [InlineData("obj/Debug/net8.0/NuGet/SHORT/Pkg/1.0/File.cs")] // Hash too short
+ [InlineData("obj/Debug/net8.0/NuGet/ZZZZZZZZZZZZZZZZ/Pkg/1.0/File.cs")] // Non-hex chars
+ [InlineData("obj/Debug/net8.0/NuGet/7E7D116BF0B1C551")] // No trailing path after hash
+ public void TryRewriteAsNuGetPp_ReturnsNullForNonMatchingPaths(string input)
+ {
+ string? result = CachePathResolver.TryRewriteAsNuGetPp(input);
+ Assert.Null(result);
+ }
+
+ #endregion
+}
diff --git a/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/TargetsFileSmokeTests.cs b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/TargetsFileSmokeTests.cs
new file mode 100644
index 0000000000000..e37f9850733b7
--- /dev/null
+++ b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/TargetsFileSmokeTests.cs
@@ -0,0 +1,3513 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Diagnostics;
+using System.Text;
+using Microsoft.NET.ProjectData;
+using Xunit;
+
+namespace Microsoft.NET.ProjectData.Tasks.Tests;
+
+///
+/// Smoke tests for the Microsoft.NET.ProjectData.targets file. Spawns
+/// dotnet msbuild against a fixture project with the targets file wired
+/// in via CustomAfterMicrosoftCommonTargets, mirroring how the extension
+/// activates the writer at runtime.
+///
+public sealed class TargetsFileSmokeTests : IDisposable
+{
+ private static readonly string TargetsFile = Path.Combine(
+ AppContext.BaseDirectory,
+ "Microsoft.NET.ProjectData.targets");
+
+ ///
+ /// Safety-net timeout for each spawned dotnet msbuild invocation. If a child MSBuild/restore
+ /// process (or a lingering MSBuild worker node / build server that inherited the redirected stdout/stderr
+ /// pipe) fails to exit, the read below would otherwise never reach EOF and the test would hang forever.
+ /// That escalated a transient child hang into a 180-minute CI job timeout on macOS (the vstest
+ /// --blame-hang dump collection also hangs on macOS, so it never recovered). Bounding the wait here
+ /// and killing the whole process tree fails the individual test in minutes with captured output instead.
+ /// Overridable via PROJECTDATA_SMOKE_TEST_PROCESS_TIMEOUT_SECONDS for slower agents.
+ ///
+ private static readonly TimeSpan ProcessTimeout = GetProcessTimeout();
+
+ private readonly string workDir;
+
+ public TargetsFileSmokeTests()
+ {
+ this.workDir = Path.Combine(Path.GetTempPath(), "projectdata-targets-smoke-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(this.workDir);
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(this.workDir, recursive: true); }
+ catch { /* best-effort cleanup */ }
+ }
+
+ [Fact]
+ public void TargetsFileShipsAlongsideTaskAssembly()
+ {
+ Assert.True(
+ File.Exists(TargetsFile),
+ $"Expected the targets file to be next to the test binary so MSBuild can resolve it. Looked for: {TargetsFile}");
+ }
+
+ [Fact]
+ public async Task SingleTfmProject_EvaluatesProjectDataPathNextToCsproj()
+ {
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: false, writeAssetsFile: false);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs: ["/getProperty:_ProjectDataPath", "/p:EnableProjectDataInProjectFolder=true"]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+
+ // /getProperty prints the evaluated value on its own line.
+ string expected = projectFile + ".lscache";
+ Assert.Contains(expected, result.Output, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task ExistingProjectFolderCache_ForcesProjectDataPathNextToCsproj()
+ {
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: false, writeAssetsFile: false);
+ string expected = projectFile + ".lscache";
+ await File.WriteAllTextAsync(expected, "existing cache", TestContext.Current.CancellationToken);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs: ["/getProperty:_ProjectDataPath"]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+
+ // Even though the unset default is user-folder mode, committed/in-project
+ // caches keep using the project-folder path so they stay up to date.
+ Assert.Contains(expected, result.Output, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task DefaultMode_DisablesProjectFolderStorage()
+ {
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: false, writeAssetsFile: false);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs: ["/getProperty:EnableProjectDataInProjectFolder"]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.Contains(
+ result.Output.Replace("\r\n", "\n").Split('\n'),
+ line => string.Equals(line.Trim(), "false", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public async Task SingleTfmProject_DTBProducesProjectDataFile()
+ {
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: false);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ string expected = projectFile + ".lscache";
+ Assert.True(File.Exists(expected), $"Expected the writer to produce {expected}.\n{result.Output}");
+ AssertNoUnsupportedMarker(projectFile);
+
+ string content = File.ReadAllText(expected);
+ Assert.Contains("OutputType=Exe", content);
+ Assert.Contains("[commandLineArguments]", content);
+ }
+
+ [Fact]
+ public async Task ProjectDataBuild_PersistsIsTestProjectOptOut()
+ {
+ string projectFile = this.WriteProject(
+ "App.csproj",
+ multiTargeting: false,
+ extraProperties: "false");
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ string content = File.ReadAllText(projectFile + ".lscache").Replace("\r\n", "\n");
+ Assert.Contains("\nIsTestProject=false\n", content);
+ }
+
+ [Fact]
+ public async Task ProjectDataBuild_AfterSdkImport_DoesNotSuppressGeneratedAssemblyInfoAndFiltersItFromCache()
+ {
+ string projectFile = this.WriteProject(
+ "App.csproj",
+ multiTargeting: false,
+ extraProperties: "ProjectDataAuditCompany");
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ $"/p:AfterMicrosoftNETSdkTargets={TargetsFile}",
+ ],
+ wireProjectDataTargets: false);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ string assemblyInfoFile = Path.Combine(this.workDir, "obj", "Debug", "net8.0", "App.AssemblyInfo.cs");
+ Assert.True(File.Exists(assemblyInfoFile), $"Expected the SDK to generate {assemblyInfoFile}.\n{result.Output}");
+ Assert.Contains("ProjectDataAuditCompany", File.ReadAllText(assemblyInfoFile));
+
+ string cacheContent = File.ReadAllText(projectFile + ".lscache");
+ Assert.DoesNotContain("App.AssemblyInfo.cs", cacheContent);
+ }
+
+ [Fact]
+ public async Task ProjectDataBuild_DoesNotFilterUserOwnedCompileItemAtGeneratedAssemblyInfoPath()
+ {
+ string projectFile = this.WriteProject(
+ "App.csproj",
+ multiTargeting: false,
+ extraProperties:
+ """
+ false
+ $(IntermediateOutputPath)Manual.AssemblyInfo.cs
+ """,
+ extraXml:
+ """
+
+
+
+
+
+
+
+ """);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ string cacheContent = File.ReadAllText(projectFile + ".lscache");
+ Assert.Contains("Manual.AssemblyInfo.cs", cacheContent);
+ }
+
+ [Fact]
+ public async Task SingleTfmProject_DTBRunsCompileDependsOnTargetsForKeyFile()
+ {
+ string keyFile = Path.Combine(this.workDir, "TestKey.snk");
+ await File.WriteAllTextAsync(keyFile, "test-key", TestContext.Current.CancellationToken);
+ string projectFile = this.WriteProject(
+ "App.csproj",
+ multiTargeting: false,
+ extraProperties:
+ $"""
+ true
+ true
+ {keyFile}
+ """);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ string content = File.ReadAllText(projectFile + ".lscache").Replace("\r\n", "\n");
+ Assert.Contains("/keyfile:", content);
+ Assert.Contains("TestKey.snk", content);
+ }
+
+ [Fact]
+ public async Task SingleTfmProject_ProjectDataBuildCommandLineArgumentsMatchDesignTimeCompile()
+ {
+ string baselineArgsFile = Path.Combine(this.workDir, "compile.args");
+ string projectFile = this.WriteProject(
+ "App.csproj",
+ multiTargeting: false,
+ extraXml:
+ $$"""
+
+
+ $(DefineConstants);PROJECTDATA_BEFORE_COMPILE
+
+
+
+
+
+
+ """);
+
+ ProcessResult compileResult = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:Compile",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:ProvideCommandLineArgs=true",
+ ]);
+
+ Assert.True(compileResult.ExitCode == 0, compileResult.Output);
+ Assert.True(File.Exists(baselineArgsFile), $"Expected direct Compile to capture CscCommandLineArgs at {baselineArgsFile}.\n{compileResult.Output}");
+ string[] compileArgs = File.ReadAllLines(baselineArgsFile);
+
+ ProcessResult projectDataResult = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:ProvideCommandLineArgs=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(projectDataResult.ExitCode == 0, projectDataResult.Output);
+ string[] cacheArgs = ExtractCommandLineArguments(File.ReadAllText(projectFile + ".lscache"));
+
+ string[] expected = NormalizeForStableArgumentParity(compileArgs);
+ string[] actual = NormalizeForStableArgumentParity(cacheArgs);
+ Assert.Equal(expected, actual);
+ Assert.Contains(actual, arg => arg.Contains("PROJECTDATA_BEFORE_COMPILE", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task SingleTfmProject_DTBProducesProjectDataFile_WhenCoreCompileIsUpToDate()
+ {
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: false);
+ string expected = projectFile + ".lscache";
+
+ ProcessResult firstResult = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(firstResult.ExitCode == 0, firstResult.Output);
+ Assert.True(File.Exists(expected), $"Expected the first DTB to produce {expected}.\n{firstResult.Output}");
+
+ File.Delete(expected);
+
+ ProcessResult secondResult = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(secondResult.ExitCode == 0, secondResult.Output);
+ Assert.True(File.Exists(expected), $"Expected the second DTB to produce {expected} even when CoreCompile is up-to-date.\n{secondResult.Output}");
+ Assert.Contains("[commandLineArguments]", File.ReadAllText(expected));
+ }
+
+ [Fact]
+ public async Task DirectWriteTarget_DoesNotProduceCacheWithoutCommandLineArguments()
+ {
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: false);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:_WriteProjectData",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataOnBuild=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.False(File.Exists(projectFile + ".lscache"), $"Direct _WriteProjectData should not silently create a cache without CscCommandLineArgs.\n{result.Output}");
+
+ // The opportunistic EnableProjectDataOnBuild hook did NOT force CoreCompile, so an empty
+ // CscCommandLineArgs here means CoreCompile was skipped as up-to-date (its AfterTargets hook
+ // still fires) — a perfectly good project. The writer must NOT poison the shared cache with a
+ // spurious unsupported marker; doing so makes projects silently vanish on the next non-forced
+ // workspace refresh (the aspire-starter regression).
+ AssertNoUnsupportedMarker(projectFile);
+ }
+
+ [Fact]
+ public async Task DirectWriteTarget_WritesUnsupportedMarker_WhenCoreCompileForcedAndArgumentsEmpty()
+ {
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: false);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:_WriteProjectData",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataOnBuild=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+
+ // The authoritative ProjectDataBuild graph forces CoreCompile to run, so an empty
+ // CscCommandLineArgs genuinely means the project produces no C# compilation and should
+ // be marked unsupported.
+ "/p:_ProjectDataBuildActive=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.False(File.Exists(projectFile + ".lscache"), $"Direct _WriteProjectData should not silently create a cache without CscCommandLineArgs.\n{result.Output}");
+ AssertUnsupportedMarker(projectFile, "CompilerCommandLineArgumentsEmpty");
+ }
+
+ [Fact]
+ public async Task DirectWriteTarget_PreservesExistingCache_WhenNotForcedAndArgumentsEmpty()
+ {
+ // Regression test for the aspire-starter "projects vanish" bug. An authoritative ProjectDataBuild
+ // writes a good `.lscache`; then an ordinary incremental build's opportunistic
+ // EnableProjectDataOnBuild hook fires AfterTargets="CoreCompile" with empty CscCommandLineArgs
+ // (CoreCompile was skipped as up-to-date). The writer must leave the good cache in place and NOT
+ // poison the shared cache with an unsupported marker.
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: false);
+ string cache = projectFile + ".lscache";
+
+ ProcessResult dtbResult = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(dtbResult.ExitCode == 0, dtbResult.Output);
+ Assert.True(File.Exists(cache), $"Expected ProjectDataBuild to produce {cache}.\n{dtbResult.Output}");
+ string originalContent = File.ReadAllText(cache);
+
+ // Simulate the ordinary incremental build hook: EnableProjectDataOnBuild without the
+ // ProjectDataBuild graph (so CoreCompile is not forced) and empty CscCommandLineArgs.
+ ProcessResult hookResult = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:_WriteProjectData",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataOnBuild=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(hookResult.ExitCode == 0, hookResult.Output);
+ Assert.True(File.Exists(cache), $"The non-forced empty-args hook must NOT delete the existing good cache {cache}.\n{hookResult.Output}");
+ Assert.Equal(originalContent, File.ReadAllText(cache));
+ AssertNoUnsupportedMarker(projectFile);
+ }
+
+ [Fact]
+ public async Task ProjectDataBuild_NonSdkProject_IsNoOp()
+ {
+ string projectFile = this.WriteLegacyProject("LegacyApp.csproj");
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.False(File.Exists(projectFile + ".lscache"), $"Non-SDK projects should not produce project data.\n{result.Output}");
+ AssertUnsupportedMarker(projectFile, "UsingMicrosoftNETSdkFalse");
+ }
+
+ [Fact]
+ public async Task ProjectDataBuild_SdkFSharpProject_IsNoOp()
+ {
+ string projectFile = this.WriteProject("FSharpApp.fsproj", multiTargeting: false, extraProperties: "F#");
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.False(File.Exists(projectFile + ".lscache"), $"Non-C# projects should not produce project data.\n{result.Output}");
+ AssertUnsupportedMarker(projectFile, "LanguageNotCSharp");
+ }
+
+ [Fact]
+ public async Task ProjectDataBuild_NoTargetsSdkIdentity_IsNoOp()
+ {
+ // Microsoft.Build.NoTargets sets this property in its Sdk.props. Set the
+ // identity bit directly so the smoke test does not have to resolve an
+ // external MSBuild SDK package.
+ string projectFile = this.WriteProject(
+ "NoTargets.csproj",
+ multiTargeting: false,
+ extraProperties:
+ """
+ true
+ """);
+ await File.WriteAllTextAsync(projectFile + ".lscache", "stale", TestContext.Current.CancellationToken);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.False(File.Exists(projectFile + ".lscache"), $"Microsoft.Build.NoTargets projects should not produce or retain project data.\n{result.Output}");
+ AssertUnsupportedMarker(projectFile, "MicrosoftBuildNoTargetsSdk");
+ }
+
+ [Fact]
+ public async Task ProjectDataBuild_ExcludedProject_PreservesProjectFolderCache()
+ {
+ string projectFile = this.WriteProject(
+ "Excluded.csproj",
+ multiTargeting: false,
+ extraProperties:
+ """
+ true
+ """);
+ await File.WriteAllTextAsync(projectFile + ".lscache", "committed cache", TestContext.Current.CancellationToken);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ "/p:OS=Unix",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.Equal("committed cache", File.ReadAllText(projectFile + ".lscache"));
+ AssertUnsupportedMarker(projectFile, "ExcludeFromBuildTrue");
+ }
+
+ [Fact]
+ public async Task MultiTfmProject_DTBProducesMergedProjectDataFile()
+ {
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: true);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ string expected = projectFile + ".lscache";
+ Assert.True(File.Exists(expected), $"Expected the merge target to produce {expected}.\n{result.Output}");
+
+ string content = File.ReadAllText(expected);
+ Assert.Contains("[commandLineArguments]", content);
+ }
+
+ [Fact]
+ public async Task MultiTfmProject_ProjectDataBuildWritesSdkLayoutSlices()
+ {
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: true, targetFrameworks: "net8.0;net9.0");
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.True(
+ File.Exists(Path.Combine(this.workDir, "obj", "Debug", "net8.0", "App.csproj.slice")),
+ $"Expected ProjectData to write the net8.0 slice to the deterministic TFM path.\n{result.Output}");
+ Assert.True(
+ File.Exists(Path.Combine(this.workDir, "obj", "Debug", "net9.0", "App.csproj.slice")),
+ $"Expected ProjectData to write the net9.0 slice to the deterministic TFM path.\n{result.Output}");
+
+ string expected = projectFile + ".lscache";
+ Assert.True(File.Exists(expected), $"Expected the merge target to produce {expected}.\n{result.Output}");
+ AssertNoUnsupportedMarker(projectFile);
+
+ string content = File.ReadAllText(expected).Replace("\r\n", "\n");
+ Assert.Equal(2, CountOccurrences(content, "\n[sliceDimensions]\n"));
+ Assert.Contains("TargetFramework=net8.0", content);
+ Assert.Contains("TargetFramework=net9.0", content);
+ }
+
+ [Fact]
+ public async Task MultiTfmUserFolderProject_RegeneratesCacheWhenStampExists()
+ {
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: true);
+ string cacheRoot = GetTestCacheRoot(projectFile);
+ string stampPath = Path.Combine(this.workDir, "obj", "Debug", "App.csproj.lscache.stamp");
+ string[] args =
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=false",
+ ];
+
+ ProcessResult firstResult = await RunDotnetMsbuildAsync(projectFile, extraArgs: args);
+
+ Assert.True(firstResult.ExitCode == 0, firstResult.Output);
+ Assert.True(File.Exists(stampPath), $"Expected user-folder multi-TFM merge stamp at {stampPath}.\n{firstResult.Output}");
+ string[] cacheFiles = Directory.GetFiles(cacheRoot, "*", SearchOption.AllDirectories);
+ string cacheFile = Assert.Single(cacheFiles);
+ Assert.Contains("version=2", File.ReadAllText(cacheFile));
+ File.Delete(cacheFile);
+
+ ProcessResult secondResult = await RunDotnetMsbuildAsync(projectFile, extraArgs: args);
+
+ Assert.True(secondResult.ExitCode == 0, secondResult.Output);
+ string regeneratedCacheFile = Assert.Single(Directory.GetFiles(cacheRoot, "*", SearchOption.AllDirectories));
+ Assert.Contains("version=2", File.ReadAllText(regeneratedCacheFile));
+ AssertNoUnsupportedMarker(projectFile);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task MultiTfmUserFolderProject_WhenMergeFails_DoesNotRefreshStamp(bool force)
+ {
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: true);
+ string stampPath = Path.Combine(this.workDir, "obj", "Debug", "App.csproj.lscache.stamp");
+ string cacheRootFile = Path.Combine(this.workDir, "user-cache-file");
+ await File.WriteAllTextAsync(cacheRootFile, "not a directory", TestContext.Current.CancellationToken);
+ List args =
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=false",
+ ];
+ if (force)
+ {
+ args.Add("/p:_ProjectDataBuildForce=true");
+ }
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs: [.. args],
+ extraEnv: new() { ["DOTNET_PROJECTDATA_CACHE_DIR"] = cacheRootFile });
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.Contains("ProjectData: failed to merge slices", result.Output);
+ Assert.False(File.Exists(stampPath), $"A failed user-folder merge must not mark ProjectData as fresh.\n{result.Output}");
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task MultiTfmRidProject_ProjectDataBuildProducesMergedProjectDataFile(bool force)
+ {
+ const string runtimeIdentifier = "win-x64";
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: true, runtimeIdentifier: runtimeIdentifier);
+ List args =
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ];
+ if (force)
+ {
+ args.Add("/p:_ProjectDataBuildForce=true");
+ }
+
+ ProcessResult result = await RunDotnetMsbuildAsync(projectFile, extraArgs: [.. args]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.True(
+ File.Exists(Path.Combine(this.workDir, "obj", "Debug", "net8.0", runtimeIdentifier, "App.csproj.slice")),
+ $"Expected the net8.0 RID-specific inner slice.\n{result.Output}");
+ Assert.True(
+ File.Exists(Path.Combine(this.workDir, "obj", "Debug", "net9.0", runtimeIdentifier, "App.csproj.slice")),
+ $"Expected the net9.0 RID-specific inner slice.\n{result.Output}");
+ Assert.False(
+ File.Exists(Path.Combine(this.workDir, "obj", "Debug", "net8.0", "App.csproj.slice")),
+ $"The SDK should place RID-specific slices under the runtime identifier directory.\n{result.Output}");
+
+ string expected = projectFile + ".lscache";
+ Assert.True(File.Exists(expected), $"Expected the merge target to produce {expected}.\n{result.Output}");
+ AssertNoUnsupportedMarker(projectFile);
+
+ string content = File.ReadAllText(expected).Replace("\r\n", "\n");
+ Assert.Equal(2, CountOccurrences(content, "\n[sliceDimensions]\n"));
+ Assert.Contains("TargetFramework=net8.0", content);
+ Assert.Contains("TargetFramework=net9.0", content);
+ }
+
+ [Fact]
+ public async Task MultiTfmRidProject_WhenRuntimeIdentifierOutputPathAppendDisabled_ProducesMergedProjectDataFile()
+ {
+ const string runtimeIdentifier = "win-x64";
+ string projectFile = this.WriteProject(
+ "App.csproj",
+ multiTargeting: true,
+ runtimeIdentifier: runtimeIdentifier,
+ extraProperties: "false");
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.True(
+ File.Exists(Path.Combine(this.workDir, "obj", "Debug", "net8.0", "App.csproj.slice")),
+ $"Expected the net8.0 non-RID inner slice when AppendRuntimeIdentifierToOutputPath=false.\n{result.Output}");
+ Assert.True(
+ File.Exists(Path.Combine(this.workDir, "obj", "Debug", "net9.0", "App.csproj.slice")),
+ $"Expected the net9.0 non-RID inner slice when AppendRuntimeIdentifierToOutputPath=false.\n{result.Output}");
+ Assert.False(
+ File.Exists(Path.Combine(this.workDir, "obj", "Debug", "net8.0", runtimeIdentifier, "App.csproj.slice")),
+ $"The SDK should not place slices under the runtime identifier directory when AppendRuntimeIdentifierToOutputPath=false.\n{result.Output}");
+
+ string expected = projectFile + ".lscache";
+ Assert.True(File.Exists(expected), $"Expected the merge target to produce {expected}.\n{result.Output}");
+ AssertNoUnsupportedMarker(projectFile);
+
+ string content = File.ReadAllText(expected).Replace("\r\n", "\n");
+ Assert.Equal(2, CountOccurrences(content, "\n[sliceDimensions]\n"));
+ Assert.Contains("TargetFramework=net8.0", content);
+ Assert.Contains("TargetFramework=net9.0", content);
+ }
+
+ [Fact]
+ public async Task MultiTfmProject_ProjectDataBuildWithUnchangedSlicesPreservesMergedCache()
+ {
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: true);
+ string cacheFile = projectFile + ".lscache";
+
+ ProcessResult firstResult = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(firstResult.ExitCode == 0, firstResult.Output);
+ Assert.True(File.Exists(cacheFile), $"Expected the first DTB to produce {cacheFile}.\n{firstResult.Output}");
+ string expectedContent = File.ReadAllText(cacheFile);
+ File.SetLastWriteTimeUtc(cacheFile, DateTime.UtcNow.AddMinutes(5));
+
+ ProcessResult secondResult = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(secondResult.ExitCode == 0, secondResult.Output);
+ Assert.Equal(expectedContent, File.ReadAllText(cacheFile));
+ AssertNoUnsupportedMarker(projectFile);
+ }
+
+ [Fact]
+ public async Task MultiTfmProject_ForceProjectDataBuildRefreshesMergedOutputWithoutDeletingCache()
+ {
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: true);
+ string cacheFile = projectFile + ".lscache";
+
+ ProcessResult firstResult = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(firstResult.ExitCode == 0, firstResult.Output);
+ Assert.True(File.Exists(cacheFile), $"Expected the first DTB to produce {cacheFile}.\n{firstResult.Output}");
+
+ await File.WriteAllTextAsync(cacheFile, "stale-cache-content", TestContext.Current.CancellationToken);
+ File.SetLastWriteTimeUtc(cacheFile, DateTime.UtcNow.AddMinutes(5));
+
+ ProcessResult secondResult = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ "/p:_ProjectDataBuildForce=true",
+ ]);
+
+ Assert.True(secondResult.ExitCode == 0, secondResult.Output);
+ string content = File.ReadAllText(cacheFile).Replace("\r\n", "\n");
+ Assert.DoesNotContain("stale-cache-content", content);
+ Assert.Contains("TargetFramework=net8.0", content);
+ Assert.Contains("TargetFramework=net9.0", content);
+ }
+
+ [Fact]
+ public async Task MultiTfmProject_ProjectDataBuildRefreshesCommandLineArgumentChanges()
+ {
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: true, targetFrameworks: "net8.0;net10.0");
+
+ ProcessResult firstResult = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ "/p:NoWarn=1111",
+ ]);
+
+ Assert.True(firstResult.ExitCode == 0, firstResult.Output);
+ string content = File.ReadAllText(projectFile + ".lscache");
+ Assert.Contains("1111", content);
+
+ ProcessResult secondResult = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ "/p:NoWarn=2222",
+ ]);
+
+ Assert.True(secondResult.ExitCode == 0, secondResult.Output);
+ content = File.ReadAllText(projectFile + ".lscache");
+ Assert.DoesNotContain("1111", content);
+ Assert.Contains("2222", content);
+ }
+
+ [Fact]
+ public async Task MultiTfmProject_RemovedTargetFrameworkStaleSliceIsNotMerged()
+ {
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: true, targetFrameworks: "net8.0;net9.0");
+
+ ProcessResult firstResult = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(firstResult.ExitCode == 0, firstResult.Output);
+ string staleSlice = Path.Combine(this.workDir, "obj", "Debug", "net9.0", "App.csproj.slice");
+ Assert.True(File.Exists(staleSlice), $"Expected first build to preserve the net9.0 slice.\n{firstResult.Output}");
+
+ projectFile = this.WriteProject("App.csproj", multiTargeting: true, targetFrameworks: "net8.0");
+
+ ProcessResult secondResult = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ "/p:OS=Windows_NT",
+ ]);
+
+ Assert.True(secondResult.ExitCode == 0, secondResult.Output);
+ string content = File.ReadAllText(projectFile + ".lscache").Replace("\r\n", "\n");
+ Assert.Contains("TargetFramework=net8.0", content);
+ Assert.DoesNotContain("TargetFramework=net9.0", content);
+ }
+
+ [Fact]
+ public async Task MultiTfmProject_NonWindowsPreservesExistingUnevaluatedSlice()
+ {
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: true, targetFrameworks: "net8.0;net9.0");
+
+ ProcessResult firstResult = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(firstResult.ExitCode == 0, firstResult.Output);
+
+ projectFile = this.WriteProject("App.csproj", multiTargeting: true, targetFrameworks: "net8.0");
+
+ ProcessResult secondResult = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ "/p:OS=Unix",
+ ]);
+
+ Assert.True(secondResult.ExitCode == 0, secondResult.Output);
+ string content = File.ReadAllText(projectFile + ".lscache").Replace("\r\n", "\n");
+ Assert.Contains("TargetFramework=net8.0", content);
+ Assert.Contains("TargetFramework=net9.0", content);
+ }
+
+ [Theory]
+ [InlineData("net8.0;net9.0", "net8.0", "net9.0")]
+ [InlineData("net9.0;net8.0", "net9.0", "net8.0")]
+ public async Task MultiTfmProject_DTBMarksFirstTargetFrameworkAsPrimary(
+ string targetFrameworks,
+ string expectedPrimary,
+ string expectedNonPrimary)
+ {
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: true, targetFrameworks: targetFrameworks);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ string cacheFile = projectFile + ".lscache";
+ Assert.True(File.Exists(cacheFile), $"Expected the merge target to produce {cacheFile}.\n{result.Output}");
+
+ string content = File.ReadAllText(cacheFile).Replace("\r\n", "\n");
+ Assert.Equal(1, CountOccurrences(content, "\nprimary\n"));
+ Assert.Contains("\nprimary\n", GetSliceBlock(content, expectedPrimary));
+ Assert.DoesNotContain("\nprimary\n", GetSliceBlock(content, expectedNonPrimary));
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Compile_WritesProjectDataOnlyWhenBuildHookIsEnabled(bool enableProjectDataOnBuild)
+ {
+ await File.WriteAllTextAsync(
+ Path.Combine(this.workDir, "Program.cs"),
+ "using System; Console.WriteLine(\"hello\");",
+ TestContext.Current.CancellationToken);
+ string projectFile = this.WriteProject(
+ "App.csproj",
+ multiTargeting: false,
+ extraXml:
+ """
+
+
+
+ """);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:Compile",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:UseAppHost=false",
+ "/p:EnableProjectDataInProjectFolder=true",
+ $"/p:EnableProjectDataOnBuild={enableProjectDataOnBuild.ToString().ToLowerInvariant()}",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ string cacheFile = projectFile + ".lscache";
+ Assert.Equal(enableProjectDataOnBuild, File.Exists(cacheFile));
+ }
+
+ [Fact]
+ public async Task DefaultMode_WritesToUserFolderCacheLocation()
+ {
+ // Default user-folder mode writes under DOTNET_PROJECTDATA_CACHE_DIR
+ // (test-only override) using the same SHA-1 layout the reader uses.
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: false);
+ string cacheRoot = Path.Combine(this.workDir, "user-cache");
+ Directory.CreateDirectory(cacheRoot);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/v:n",
+ ],
+ extraEnv: new() { ["DOTNET_PROJECTDATA_CACHE_DIR"] = cacheRoot });
+
+ Assert.True(result.ExitCode == 0, result.Output);
+
+ // Confirm at least one cache file appeared somewhere under the override
+ // root, and nothing was written next to the .csproj.
+ string[] cacheFiles = Directory.GetFiles(cacheRoot, "*", SearchOption.AllDirectories);
+ Assert.True(cacheFiles.Length > 0, $"Expected user-folder cache file under {cacheRoot}.\n--- MSBUILD OUTPUT ---\n{result.Output}");
+ Assert.False(
+ File.Exists(projectFile + ".lscache"),
+ $"Expected the cache to live under the user-folder root, not next to the .csproj.\n{result.Output}");
+ }
+
+ [Fact]
+ public async Task ExplicitUserFolderMode_RefreshesStampOnProjectDataBuild()
+ {
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: false);
+ string cacheRoot = Path.Combine(this.workDir, "user-cache");
+ Directory.CreateDirectory(cacheRoot);
+
+ string[] args =
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=false",
+ ];
+
+ ProcessResult firstResult = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs: args,
+ extraEnv: new() { ["DOTNET_PROJECTDATA_CACHE_DIR"] = cacheRoot });
+
+ Assert.True(firstResult.ExitCode == 0, firstResult.Output);
+ string stampPath = Path.Combine(this.workDir, "obj", "Debug", "net8.0", "App.csproj.lscache.stamp");
+ Assert.True(File.Exists(stampPath), $"Expected user-folder stamp at {stampPath}.\n{firstResult.Output}");
+ DateTime firstStampTime = File.GetLastWriteTimeUtc(stampPath);
+
+ await Task.Delay(TimeSpan.FromSeconds(1.1), TestContext.Current.CancellationToken);
+
+ ProcessResult secondResult = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs: args,
+ extraEnv: new() { ["DOTNET_PROJECTDATA_CACHE_DIR"] = cacheRoot });
+
+ Assert.True(secondResult.ExitCode == 0, secondResult.Output);
+ Assert.True(File.GetLastWriteTimeUtc(stampPath) > firstStampTime);
+ }
+
+ [Fact]
+ public async Task NoAssetsFile_DoesNotIncludeRestoreInDependsOn()
+ {
+ // ProjectDataBuild requires a restored evaluation. It must not run Restore
+ // inside the same MSBuild invocation because generated NuGet imports would
+ // be written after this project was already evaluated.
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: false, writeAssetsFile: false);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/getProperty:_ProjectDataBuildDependsOn",
+ "/p:DesignTimeBuild=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.DoesNotContain("Restore;", result.Output);
+ Assert.Contains("Compile", result.Output);
+ }
+
+ [Fact]
+ public async Task FreshAssetsFile_OmitsRestoreFromDependsOn()
+ {
+ // Steady-state path: when obj/project.assets.json exists AND is newer than
+ // the project file, Restore is dropped from the dependsOn list. The host
+ // owns restore lifecycle and re-running it on every DTB is wasted work
+ // (~150-300ms warm).
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: false);
+
+ // Place a fresh assets file (mtime later than the .csproj).
+ string objDir = Path.Combine(Path.GetDirectoryName(projectFile)!, "obj");
+ Directory.CreateDirectory(objDir);
+ string assetsFile = Path.Combine(objDir, "project.assets.json");
+ await File.WriteAllTextAsync(assetsFile, "{}", TestContext.Current.CancellationToken);
+ File.SetLastWriteTime(assetsFile, DateTime.Now.AddMinutes(1));
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/getProperty:_ProjectDataBuildDependsOn",
+ "/p:DesignTimeBuild=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.DoesNotContain("Restore;", result.Output);
+ // Inner-build target list must still be there.
+ Assert.Contains("Compile", result.Output);
+ }
+
+ [Fact]
+ public async Task UnsupportedSdkProject_UsesMarkerOnlyDependsOn()
+ {
+ string projectFile = this.WriteProject("FSharpApp.fsproj", multiTargeting: false, extraProperties: "F#");
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/getProperty:_ProjectDataBuildDependsOn",
+ "/p:DesignTimeBuild=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.Contains("_DeleteUnsupportedProjectData;_WriteUnsupportedProjectDataMarker", result.Output);
+ Assert.DoesNotContain("Compile", result.Output);
+ Assert.DoesNotContain("DispatchToInnerBuilds", result.Output);
+ }
+
+ [Fact]
+ public async Task MultiTfmOuterBuild_DispatchesInnerBuildsInDependsOn()
+ {
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: true);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/getProperty:_ProjectDataBuildDependsOn",
+ "/p:DesignTimeBuild=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.Contains("DispatchToInnerBuilds", result.Output);
+ Assert.DoesNotContain("_DeleteUnsupportedProjectData;_WriteUnsupportedProjectDataMarker", result.Output);
+ }
+
+ [Fact]
+ public async Task MultiTfmUnsupportedInnerBuild_UsesMarkerOnlyDependsOn()
+ {
+ string projectFile = this.WriteProject("FSharpApp.fsproj", multiTargeting: true, extraProperties: "F#");
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/getProperty:_ProjectDataBuildDependsOn",
+ "/p:DesignTimeBuild=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ "/p:TargetFramework=net8.0",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.Contains("_DeleteUnsupportedProjectData;_WriteUnsupportedProjectDataMarker", result.Output);
+ Assert.DoesNotContain("Compile", result.Output);
+ Assert.DoesNotContain("DispatchToInnerBuilds", result.Output);
+ }
+
+ [Fact]
+ public async Task StaleAssetsFile_DoesNotIncludeRestoreInDependsOn()
+ {
+ // The ProjectDataBuild target must never add Restore to its target graph:
+ // generated NuGet imports would be written after this project was already
+ // evaluated. Restore orchestration belongs to the caller.
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: false);
+
+ // Place an assets file with mtime BEFORE the .csproj (simulate user edit
+ // after a previous successful restore).
+ string objDir = Path.Combine(Path.GetDirectoryName(projectFile)!, "obj");
+ Directory.CreateDirectory(objDir);
+ string assetsFile = Path.Combine(objDir, "project.assets.json");
+ await File.WriteAllTextAsync(assetsFile, "{}", TestContext.Current.CancellationToken);
+ File.SetLastWriteTime(assetsFile, DateTime.Now.AddMinutes(-5));
+ File.SetLastWriteTime(projectFile, DateTime.Now);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/getProperty:_ProjectDataBuildDependsOn",
+ "/p:DesignTimeBuild=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.DoesNotContain("Restore;", result.Output);
+ Assert.Contains("Compile", result.Output);
+ }
+
+ [Fact]
+ public async Task ForceIncludeRestore_DoesNotAddRestoreToDependsOn()
+ {
+ // Historical compatibility: the old private switch no longer makes
+ // ProjectDataBuild run Restore inside this evaluation.
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: false);
+ string objDir = Path.Combine(Path.GetDirectoryName(projectFile)!, "obj");
+ Directory.CreateDirectory(objDir);
+ string assetsFile = Path.Combine(objDir, "project.assets.json");
+ await File.WriteAllTextAsync(assetsFile, "{}", TestContext.Current.CancellationToken);
+ File.SetLastWriteTime(assetsFile, DateTime.Now.AddMinutes(1));
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/getProperty:_ProjectDataBuildDependsOn",
+ "/p:DesignTimeBuild=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ "/p:_ProjectDataBuildIncludeRestore=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.DoesNotContain("Restore;", result.Output);
+ }
+
+ [Theory]
+ [InlineData(".sln")]
+ [InlineData(".slnx")]
+ public async Task SolutionLevel_DispatchesProjectDataBuildToAllProjects(string solutionExtension)
+ {
+ // Verifies that running /t:ProjectDataBuild against a solution file dispatches
+ // the custom target to each project, including multi-TFM outer builds.
+ string projectA = this.WriteProject("App.csproj", multiTargeting: true);
+ string projectBDir = Path.Combine(this.workDir, "Lib");
+ Directory.CreateDirectory(projectBDir);
+ string projectB = Path.Combine(projectBDir, "Lib.csproj");
+ string stubRef = Path.Combine(this.workDir, "stub-reference.dll");
+ File.WriteAllText(projectB,
+ $"""
+
+
+ net8.0
+ false
+
+
+
+
+ Microsoft.NETCore.App
+
+
+
+
+ """);
+ this.WriteProjectAssetsFile(projectB, ["net8.0"]);
+
+ string solutionPath = Path.Combine(this.workDir, "Solution" + solutionExtension);
+ string receiptDirectory = Path.Combine(this.workDir, "receipts-" + solutionExtension.TrimStart('.'));
+ string attemptId = Guid.NewGuid().ToString("N");
+ string slnRelA = Path.GetRelativePath(this.workDir, projectA).Replace('/', '\\');
+ string slnRelB = Path.GetRelativePath(this.workDir, projectB).Replace('/', '\\');
+ if (solutionExtension.Equals(".sln", StringComparison.OrdinalIgnoreCase))
+ {
+ File.WriteAllText(solutionPath,
+ $$"""
+ Microsoft Visual Studio Solution File, Format Version 12.00
+ # Visual Studio Version 17
+ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App", "{{slnRelA}}", "{11111111-1111-1111-1111-111111111111}"
+ EndProject
+ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lib", "{{slnRelB}}", "{22222222-2222-2222-2222-222222222222}"
+ EndProject
+ Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {11111111-1111-1111-1111-111111111111}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {11111111-1111-1111-1111-111111111111}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {22222222-2222-2222-2222-222222222222}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {22222222-2222-2222-2222-222222222222}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ EndGlobalSection
+ EndGlobal
+ """);
+ }
+ else
+ {
+ File.WriteAllText(solutionPath,
+ $$"""
+
+
+
+
+ """);
+ }
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ solutionPath,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ $"/p:ProjectDataBuildReceiptDirectory={receiptDirectory}",
+ $"/p:ProjectDataBuildReceiptAttemptId={attemptId}",
+ GetCompletionLoggerArgument(receiptDirectory, attemptId),
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.True(File.Exists(projectA + ".lscache"), $"Expected lscache for App.csproj.\n{result.Output}");
+ Assert.True(File.Exists(projectB + ".lscache"), $"Expected lscache for Lib.csproj.\n{result.Output}");
+ Assert.True(ProjectDataBuildReceipt.TryRead(receiptDirectory, attemptId, projectA, out _));
+ Assert.True(ProjectDataBuildReceipt.TryRead(receiptDirectory, attemptId, projectB, out _));
+ Assert.True(ProjectDataBuildReceipt.TryReadAggregateCompletion(receiptDirectory, attemptId));
+ Assert.True(ProjectDataBuildAttemptManifest.TryRead(receiptDirectory, attemptId, out ProjectDataBuildAttemptManifest manifest));
+ Assert.True(manifest.BuildFinished);
+ Assert.True(manifest.BuildSucceeded);
+ Assert.NotEmpty(manifest.Contexts);
+ Assert.Contains(manifest.Submissions, submission => submission.Phase == "ProjectDataBuild");
+
+ string appCacheContent = File.ReadAllText(projectA + ".lscache").Replace("\r\n", "\n");
+ Assert.Contains("TargetFramework=net8.0", appCacheContent);
+ Assert.Contains("TargetFramework=net9.0", appCacheContent);
+ }
+
+ [Fact]
+ public async Task ProjectDataBuild_ReceiptProtocol_NormalAggregateFailureHasManifestWithoutProjectCompletion()
+ {
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: false, writeAssetsFile: false);
+ string receiptDirectory = Path.Combine(this.workDir, "failure-receipts");
+ string attemptId = Guid.NewGuid().ToString("N");
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ $"/p:ProjectDataBuildReceiptDirectory={receiptDirectory}",
+ $"/p:ProjectDataBuildReceiptAttemptId={attemptId}",
+ GetCompletionLoggerArgument(receiptDirectory, attemptId),
+ ]);
+
+ Assert.NotEqual(0, result.ExitCode);
+ Assert.False(ProjectDataBuildReceipt.TryRead(receiptDirectory, attemptId, projectFile, out _));
+ Assert.True(ProjectDataBuildReceipt.TryReadAggregateCompletion(receiptDirectory, attemptId));
+ Assert.True(ProjectDataBuildAttemptManifest.TryRead(receiptDirectory, attemptId, out ProjectDataBuildAttemptManifest manifest));
+ Assert.True(manifest.BuildFinished);
+ Assert.False(manifest.BuildSucceeded);
+ ProjectDataBuildDiagnosticRecord diagnostic = Assert.Single(manifest.Diagnostics, diagnostic => diagnostic.Severity == "Error");
+ Assert.True(string.Equals(
+ projectFile,
+ diagnostic.ProjectFilePath,
+ OperatingSystem.IsLinux() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase));
+ Assert.Contains("project.assets.json", diagnostic.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public async Task ProjectDataBuild_StaticGraphRestorePreservesDirectFailureAttributionAndTrimmedProjectsProduceCaches()
+ {
+ string missingFeed = Path.Combine(this.workDir, "missing-private-feed");
+ File.WriteAllText(
+ Path.Combine(this.workDir, "NuGet.Config"),
+ $"""
+
+
+
+
+
+
+
+ """);
+
+ string broken = WriteRestoreProject(
+ "Broken",
+ """ """);
+ string dependent = WriteRestoreProject(
+ "Dependent",
+ """ """);
+ string healthyA = WriteRestoreProject("HealthyA");
+ string healthyB = WriteRestoreProject("HealthyB");
+ string fullSolution = WriteSolution("Full.sln", [broken, dependent, healthyA, healthyB]);
+ string fullReceiptDirectory = Path.Combine(this.workDir, "full-restore-receipts");
+ string fullAttemptId = Guid.NewGuid().ToString("N");
+
+ ProcessResult fullResult = await RunDotnetMsbuildAsync(
+ fullSolution,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/m:1",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ $"/p:ProjectDataBuildReceiptDirectory={fullReceiptDirectory}",
+ $"/p:ProjectDataBuildReceiptAttemptId={fullAttemptId}",
+ GetCompletionLoggerArgument(fullReceiptDirectory, fullAttemptId),
+ ],
+ useBuildCommand: true);
+
+ Assert.NotEqual(0, fullResult.ExitCode);
+ Assert.True(
+ ProjectDataBuildAttemptManifest.TryRead(fullReceiptDirectory, fullAttemptId, out ProjectDataBuildAttemptManifest fullManifest),
+ fullResult.Output);
+ ProjectDataBuildDiagnosticRecord directFailure = Assert.Single(
+ fullManifest.Diagnostics,
+ diagnostic =>
+ string.Equals(diagnostic.Severity, "Error", StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(diagnostic.Code, "NU1301", StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(diagnostic.ProjectFilePath, broken, StringComparison.OrdinalIgnoreCase));
+ Assert.Equal(ProjectDataBuildDiagnosticRecord.FileProjectPathSource, directFailure.ProjectFilePathSource);
+ Assert.False(File.Exists(healthyA + ".lscache"));
+ Assert.False(File.Exists(healthyB + ".lscache"));
+
+ string trimmedSolution = WriteSolution("Trimmed.sln", [healthyA, healthyB]);
+ string trimmedReceiptDirectory = Path.Combine(this.workDir, "trimmed-restore-receipts");
+ string trimmedAttemptId = Guid.NewGuid().ToString("N");
+ ProcessResult trimmedResult = await RunDotnetMsbuildAsync(
+ trimmedSolution,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/m:1",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ $"/p:ProjectDataBuildReceiptDirectory={trimmedReceiptDirectory}",
+ $"/p:ProjectDataBuildReceiptAttemptId={trimmedAttemptId}",
+ GetCompletionLoggerArgument(trimmedReceiptDirectory, trimmedAttemptId),
+ ],
+ useBuildCommand: true);
+
+ Assert.True(trimmedResult.ExitCode == 0, trimmedResult.Output);
+ Assert.True(ProjectDataBuildReceipt.TryRead(trimmedReceiptDirectory, trimmedAttemptId, healthyA, out _));
+ Assert.True(ProjectDataBuildReceipt.TryRead(trimmedReceiptDirectory, trimmedAttemptId, healthyB, out _));
+ Assert.True(File.Exists(healthyA + ".lscache"));
+ Assert.True(File.Exists(healthyB + ".lscache"));
+ Assert.StartsWith("version=2", File.ReadAllText(healthyA + ".lscache"));
+ Assert.StartsWith("version=2", File.ReadAllText(healthyB + ".lscache"));
+
+ string WriteRestoreProject(string name, string item = "")
+ {
+ string directory = Path.Combine(this.workDir, name);
+ Directory.CreateDirectory(directory);
+ string projectPath = Path.Combine(directory, $"{name}.csproj");
+ File.WriteAllText(
+ projectPath,
+ $$"""
+
+
+ net10.0
+ false
+
+
+ {{item}}
+
+
+ """);
+ return projectPath;
+ }
+
+ string WriteSolution(string fileName, IReadOnlyCollection projects)
+ {
+ string solutionPath = Path.Combine(this.workDir, fileName);
+ string[] projectEntries = projects.Select((project, index) =>
+ {
+ string relativePath = Path.GetRelativePath(this.workDir, project).Replace('/', '\\');
+ string projectName = Path.GetFileNameWithoutExtension(project);
+ string projectGuid = $"{{00000000-0000-0000-0000-{index + 1:D12}}}";
+ return $"Project(\"{{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}}\") = \"{projectName}\", \"{relativePath}\", \"{projectGuid}\"{Environment.NewLine}EndProject";
+ }).ToArray();
+ string[] projectConfigurations = projects.SelectMany((_, index) =>
+ {
+ string projectGuid = $"{{00000000-0000-0000-0000-{index + 1:D12}}}";
+ return new[]
+ {
+ $" {projectGuid}.Debug|Any CPU.ActiveCfg = Debug|Any CPU",
+ $" {projectGuid}.Debug|Any CPU.Build.0 = Debug|Any CPU",
+ };
+ }).ToArray();
+ File.WriteAllText(
+ solutionPath,
+ $"""
+ Microsoft Visual Studio Solution File, Format Version 12.00
+ # Visual Studio Version 17
+ {string.Join(Environment.NewLine, projectEntries)}
+ Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {string.Join(Environment.NewLine, projectConfigurations)}
+ EndGlobalSection
+ EndGlobal
+ """);
+ return solutionPath;
+ }
+ }
+
+ [Theory]
+ [InlineData(1)]
+ [InlineData(4)]
+ public async Task ProjectDataBuild_ReceiptProtocol_EvaluationFailuresDoNotDropOtherReferencedProjects(int maxCpuCount)
+ {
+ string core = this.WriteGraphProject("Core");
+ string broken = this.WriteGraphProject("Broken", ["Core"], failDuringProjectDataEvaluation: true);
+ string dependent = this.WriteGraphProject("Dependent", ["Broken"]);
+ string independent = this.WriteGraphProject("Independent", ["Core"]);
+ string tail = this.WriteGraphProject("Tail", ["Independent"]);
+ string[] projects = [core, broken, dependent, independent, tail];
+ string solutionPath = Path.Combine(this.workDir, $"Graph-{maxCpuCount}.slnx");
+ File.WriteAllText(
+ solutionPath,
+ $"""
+
+ {string.Join(Environment.NewLine, projects.Select(project => $" "))}
+
+ """);
+ string receiptDirectory = Path.Combine(this.workDir, $"graph-receipts-{maxCpuCount}");
+ string attemptId = Guid.NewGuid().ToString("N");
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ solutionPath,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ $"/m:{maxCpuCount}",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ $"/p:ProjectDataBuildReceiptDirectory={receiptDirectory}",
+ $"/p:ProjectDataBuildReceiptAttemptId={attemptId}",
+ GetCompletionLoggerArgument(receiptDirectory, attemptId),
+ ]);
+
+ Assert.NotEqual(0, result.ExitCode);
+ Assert.True(ProjectDataBuildReceipt.TryReadAggregateCompletion(receiptDirectory, attemptId));
+ Assert.False(ProjectDataBuildReceipt.TryRead(receiptDirectory, attemptId, broken, out _));
+ foreach (string successfulProject in projects.Except([broken], StringComparer.OrdinalIgnoreCase))
+ {
+ Assert.True(
+ ProjectDataBuildReceipt.TryRead(receiptDirectory, attemptId, successfulProject, out _),
+ $"Expected completion receipt for {successfulProject}.{Environment.NewLine}{result.Output}");
+ Assert.True(File.Exists(successfulProject + ".lscache"), result.Output);
+ }
+
+ Assert.True(ProjectDataBuildAttemptManifest.TryRead(receiptDirectory, attemptId, out ProjectDataBuildAttemptManifest manifest));
+ Assert.Contains(manifest.Diagnostics, diagnostic =>
+ diagnostic.Severity == "Error" &&
+ string.Equals(diagnostic.ProjectFilePath, broken, OperatingSystem.IsLinux() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public async Task ProjectDataBuild_ReceiptProtocol_LoadsCompletionLoggerFromQuotedDelimiterPath()
+ {
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: false);
+ string loggerDirectory = Path.Combine(this.workDir, "logger,with;delimiters");
+ Directory.CreateDirectory(loggerDirectory);
+ string loggerAssemblyPath = CopyAssembly(typeof(ProjectDataBuildCompletionLogger).Assembly.Location, loggerDirectory);
+ string receiptDirectory = Path.Combine(this.workDir, "delimiter-receipts");
+ string attemptId = Guid.NewGuid().ToString("N");
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ $"/p:ProjectDataBuildReceiptDirectory={receiptDirectory}",
+ $"/p:ProjectDataBuildReceiptAttemptId={attemptId}",
+ GetCompletionLoggerArgument(receiptDirectory, attemptId, loggerAssemblyPath),
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.True(ProjectDataBuildReceipt.TryReadAggregateCompletion(receiptDirectory, attemptId));
+ }
+
+ [Fact]
+ public async Task ProjectDataBuild_ReceiptProtocol_UnsupportedProjectWritesMarkerAndCompletion()
+ {
+ string projectFile = this.WriteProject("FSharpApp.fsproj", multiTargeting: false, extraProperties: "F#");
+ string receiptDirectory = Path.Combine(this.workDir, "unsupported-receipts");
+ string attemptId = Guid.NewGuid().ToString("N");
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ $"/p:ProjectDataBuildReceiptDirectory={receiptDirectory}",
+ $"/p:ProjectDataBuildReceiptAttemptId={attemptId}",
+ GetCompletionLoggerArgument(receiptDirectory, attemptId),
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.True(ProjectDataBuildReceipt.TryRead(receiptDirectory, attemptId, projectFile, out _));
+ AssertUnsupportedMarker(projectFile, "LanguageNotCSharp");
+ }
+
+ [Fact]
+ public async Task ProjectDataBuild_ReceiptProtocol_CompletionCanProduceNoOutputOrMarker()
+ {
+ string projectFile = this.WriteProject(
+ "App.csproj",
+ multiTargeting: false,
+ extraXml:
+ """
+
+
+
+ """);
+ string receiptDirectory = Path.Combine(this.workDir, "no-output-receipts");
+ string attemptId = Guid.NewGuid().ToString("N");
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ $"/p:ProjectDataBuildReceiptDirectory={receiptDirectory}",
+ $"/p:ProjectDataBuildReceiptAttemptId={attemptId}",
+ GetCompletionLoggerArgument(receiptDirectory, attemptId),
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.True(ProjectDataBuildReceipt.TryRead(receiptDirectory, attemptId, projectFile, out _));
+ Assert.False(File.Exists(projectFile + ".lscache"));
+ Assert.False(File.Exists(GetMarkerPath(projectFile)));
+ }
+
+ [Fact]
+ public async Task SolutionLevel_ForcedProjectDataBuildPreservesMultiTfmSlices()
+ {
+ string projectA = this.WriteProject("App.csproj", multiTargeting: true);
+ string projectBDir = Path.Combine(this.workDir, "Lib");
+ Directory.CreateDirectory(projectBDir);
+ string projectB = Path.Combine(projectBDir, "Lib.csproj");
+ File.WriteAllText(projectB,
+ """
+
+
+ net8.0
+ false
+
+
+ """);
+ this.WriteProjectAssetsFile(projectB, ["net8.0"]);
+
+ string solutionPath = Path.Combine(this.workDir, "Solution.slnx");
+ string slnRelA = Path.GetRelativePath(this.workDir, projectA).Replace('/', '\\');
+ string slnRelB = Path.GetRelativePath(this.workDir, projectB).Replace('/', '\\');
+ File.WriteAllText(solutionPath,
+ $$"""
+
+
+
+
+ """);
+
+ string cacheFile = projectA + ".lscache";
+ await File.WriteAllTextAsync(cacheFile, "stale-cache-content", TestContext.Current.CancellationToken);
+ File.SetLastWriteTimeUtc(cacheFile, DateTime.UtcNow.AddMinutes(5));
+
+ // CI agents are firewalled away from api.nuget.org but have credentialed
+ // access to the org-internal feed. Point restore at that feed so it can
+ // satisfy the implicit framework-reference downloads
+ // (Microsoft.NETCore.App.Ref/.AspNetCore.App.Ref/etc) that the multi-TFM
+ // project requires. An empty source folder does NOT work here -- the
+ // SDK 10 install only ships the net10 pack, so net8/net9 must come from
+ // the feed.
+ string restoreSource = "https://pkgs.dev.azure.com/devdiv/DevDiv/_packaging/vs-green/nuget/v3/index.json";
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ solutionPath,
+ extraArgs:
+ [
+ "/restore",
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ "/p:_ProjectDataBuildForce=true",
+ $"/p:RestoreSources={restoreSource}",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.True(File.Exists(cacheFile), $"Expected lscache for App.csproj.\n{result.Output}");
+
+ string appCacheContent = File.ReadAllText(cacheFile).Replace("\r\n", "\n");
+ Assert.DoesNotContain("stale-cache-content", appCacheContent);
+ Assert.Equal(2, CountOccurrences(appCacheContent, "\n[sliceDimensions]\n"));
+ Assert.Contains("TargetFramework=net8.0", appCacheContent);
+ Assert.Contains("TargetFramework=net9.0", appCacheContent);
+ }
+
+ [Fact]
+ public async Task UnsupportedProject_ProjectDataBuildIsNoOp()
+ {
+ string projectFile = Path.Combine(this.workDir, "Unsupported.proj");
+ File.WriteAllText(projectFile,
+ """
+
+
+
+ """);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.False(File.Exists(projectFile + ".lscache"), $"Unsupported project should not produce lscache.\n{result.Output}");
+ AssertUnsupportedMarker(projectFile, "UsingMicrosoftNETSdkFalse");
+ }
+
+ [Fact]
+ public async Task ExcludedProject_ProjectDataBuildIsNoOp()
+ {
+ string projectFile = this.WriteProject(
+ "Excluded.csproj",
+ multiTargeting: false,
+ extraProperties:
+ """
+ true
+ """);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.False(File.Exists(projectFile + ".lscache"), $"Excluded project should not produce lscache.\n{result.Output}");
+ AssertUnsupportedMarker(projectFile, "ExcludeFromBuildTrue");
+ }
+
+ [Fact]
+ public async Task ProjectDataBuild_NetFrameworkProject_WithoutReferenceAssemblies_PreservesProjectFolderCacheAndWritesMissingReferenceMarker()
+ {
+ string projectFile = this.WriteProject(
+ "NetFramework.csproj",
+ multiTargeting: false,
+ targetFramework: "net472",
+ extraProperties: this.MissingNetFrameworkReferenceAssembliesProperties());
+ await File.WriteAllTextAsync(projectFile + ".lscache", "stale", TestContext.Current.CancellationToken);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.DoesNotContain("CollectFrameworkReferences", result.Output);
+ Assert.Equal("stale", File.ReadAllText(projectFile + ".lscache"));
+ AssertUnsupportedMarker(projectFile, "MissingNetFrameworkReferenceAssemblies");
+ }
+
+ [Fact]
+ public async Task DirectWriteTarget_NetFrameworkProjectWithoutCommandLineArguments_PreservesProjectFolderCache()
+ {
+ string projectFile = this.WriteProject(
+ "NetFramework.csproj",
+ multiTargeting: false,
+ targetFramework: "net472",
+ extraProperties: $"{Path.Combine(this.workDir, "missing-netfx-refs")}");
+ await File.WriteAllTextAsync(projectFile + ".lscache", "stale", TestContext.Current.CancellationToken);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:_WriteProjectData",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataOnBuild=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.Equal("stale", File.ReadAllText(projectFile + ".lscache"));
+ AssertUnsupportedMarker(projectFile, "MissingNetFrameworkReferenceAssemblies");
+ }
+
+ [Fact]
+ public async Task ProjectDataBuild_NetFrameworkProject_WithReferenceAssemblies_WritesProjectData()
+ {
+ string referenceAssemblyDirectory = this.WriteNet472ReferenceAssemblies();
+ string projectFile = this.WriteProject(
+ "NetFramework.csproj",
+ multiTargeting: false,
+ targetFramework: "net472",
+ extraProperties: $"{referenceAssemblyDirectory}");
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.DoesNotContain("CollectFrameworkReferences", result.Output);
+ string cacheFile = projectFile + ".lscache";
+ Assert.True(File.Exists(cacheFile), $"Expected .NET Framework project data when reference assemblies are available.\n{result.Output}");
+ string content = File.ReadAllText(cacheFile).Replace("\r\n", "\n");
+ Assert.DoesNotContain("[netFrameworkReferenceAssemblies]\n", content);
+ Assert.Contains("[metadataReferences]\n", content);
+ Assert.Contains("/v4.7.2/\n", content);
+ Assert.Contains(" mscorlib.dll", content);
+ Assert.Contains(" System.dll", content);
+ Assert.Contains(" System.Core.dll", content);
+ Assert.DoesNotContain(referenceAssemblyDirectory.Replace('\\', '/'), content);
+ AssertNoUnsupportedMarker(projectFile);
+ }
+
+ [Fact]
+ public async Task MultiTfmProject_SkipsUnsupportedTargetFrameworkSlices()
+ {
+ string projectFile = this.WriteProject(
+ "Mixed.csproj",
+ multiTargeting: true,
+ targetFrameworks: "net8.0;net472",
+ extraProperties: this.MissingNetFrameworkReferenceAssembliesProperties());
+ string staleNet472Slice = Path.Combine(this.workDir, "obj", "Debug", "net472", "Mixed.csproj.slice");
+ Directory.CreateDirectory(Path.GetDirectoryName(staleNet472Slice)!);
+ await File.WriteAllTextAsync(
+ staleNet472Slice,
+ """
+ version=2
+
+ [project]
+ project=Mixed.csproj
+ language=C#
+
+ [sliceDimensions]
+ TargetFramework=net472
+
+ [metadataReferences]
+ /v4.7.2/
+ """,
+ TestContext.Current.CancellationToken);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ string cacheFile = projectFile + ".lscache";
+ Assert.True(File.Exists(cacheFile), $"Expected project data for supported target frameworks.\n{result.Output}");
+ string content = File.ReadAllText(cacheFile).Replace("\r\n", "\n");
+ Assert.Contains("TargetFramework=net8.0", content);
+ Assert.DoesNotContain("TargetFramework=net472", content);
+ Assert.DoesNotContain("", content);
+ Assert.False(File.Exists(staleNet472Slice), $"Unsupported slices should be deleted before merge.\n{result.Output}");
+ AssertNoUnsupportedMarker(projectFile);
+ }
+
+ [Fact]
+ public async Task MultiTfmProject_UnsupportedInnerBuild_PreservesMergedCache()
+ {
+ string projectFile = this.WriteProject(
+ "Mixed.csproj",
+ multiTargeting: true,
+ targetFrameworks: "net8.0;net472",
+ extraProperties: this.MissingNetFrameworkReferenceAssembliesProperties());
+ string staleCache = projectFile + ".lscache";
+ await File.WriteAllTextAsync(staleCache, "existing merged cache", TestContext.Current.CancellationToken);
+ string staleNet472Slice = Path.Combine(this.workDir, "obj", "Debug", "net472", "Mixed.csproj.slice");
+ Directory.CreateDirectory(Path.GetDirectoryName(staleNet472Slice)!);
+ await File.WriteAllTextAsync(staleNet472Slice, "stale slice", TestContext.Current.CancellationToken);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ "/p:TargetFramework=net472",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.True(File.Exists(staleCache), $"Unsupported inner target framework builds should not delete the merged project cache.\n{result.Output}");
+ Assert.Equal("existing merged cache", File.ReadAllText(staleCache));
+ Assert.False(File.Exists(staleNet472Slice), $"Unsupported inner target framework builds should delete their stale slice.\n{result.Output}");
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task MultiTfmProject_AllUnsupportedSlices_PreservesStaleCacheAndWritesMarker(bool force)
+ {
+ string projectFile = this.WriteProject(
+ "NetFrameworkOnly.csproj",
+ multiTargeting: true,
+ targetFrameworks: "net472",
+ extraProperties: this.MissingNetFrameworkReferenceAssembliesProperties());
+ string staleCache = projectFile + ".lscache";
+ await File.WriteAllTextAsync(staleCache, "stale", TestContext.Current.CancellationToken);
+ string staleNet472Slice = Path.Combine(this.workDir, "obj", "Debug", "net472", "NetFrameworkOnly.csproj.slice");
+ Directory.CreateDirectory(Path.GetDirectoryName(staleNet472Slice)!);
+ await File.WriteAllTextAsync(staleNet472Slice, "stale", TestContext.Current.CancellationToken);
+
+ List args =
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ];
+ if (force)
+ {
+ args.Add("/p:_ProjectDataBuildForce=true");
+ }
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs: [.. args]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.False(File.Exists(staleNet472Slice), $"Unsupported slices should be deleted before merge.\n{result.Output}");
+ Assert.Equal("stale", File.ReadAllText(staleCache));
+ AssertUnsupportedMarker(projectFile, "AllTargetFrameworksUnsupported");
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task MultiTfmUserFolderProject_AllUnsupportedSlices_WritesMarker(bool force)
+ {
+ string projectFile = this.WriteProject(
+ "NetFrameworkOnly.csproj",
+ multiTargeting: true,
+ targetFrameworks: "net472",
+ extraProperties: this.MissingNetFrameworkReferenceAssembliesProperties());
+ string stampPath = Path.Combine(this.workDir, "obj", "Debug", "NetFrameworkOnly.csproj.lscache.stamp");
+
+ List args =
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=false",
+ ];
+ if (force)
+ {
+ args.Add("/p:_ProjectDataBuildForce=true");
+ }
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs: [.. args]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.False(File.Exists(projectFile + ".lscache"), $"User-folder mode should not write a project-folder cache.\n{result.Output}");
+ Assert.True(File.Exists(stampPath), $"Expected user-folder multi-TFM merge stamp at {stampPath}.\n{result.Output}");
+ AssertUnsupportedMarker(projectFile, "AllTargetFrameworksUnsupported");
+ }
+
+ [Fact]
+ public async Task ProjectDataBuild_RestoreSkippedAndAssetsMissing_FailsWithoutWritingCache()
+ {
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: false, writeAssetsFile: false);
+ string cacheFile = projectFile + ".lscache";
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.NotEqual(0, result.ExitCode);
+ AssertMissingAssetsError(projectFile, result.Output);
+ Assert.False(File.Exists(cacheFile), $"ProjectDataBuild should not create {cacheFile} without project.assets.json.\n{result.Output}");
+ }
+
+ [Fact]
+ public async Task ProjectDataBuild_RestoreSkippedAndAssetsMissing_PreservesExistingCache()
+ {
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: false, writeAssetsFile: false);
+ string cacheFile = projectFile + ".lscache";
+ await File.WriteAllTextAsync(cacheFile, "existing-cache", TestContext.Current.CancellationToken);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.NotEqual(0, result.ExitCode);
+ AssertMissingAssetsError(projectFile, result.Output);
+ Assert.Equal("existing-cache", File.ReadAllText(cacheFile));
+ }
+
+ [Fact]
+ public async Task ProjectDataBuild_StaleRestoreTimestampsWithResolvedInputs_WritesCache()
+ {
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: false);
+ string cacheFile = projectFile + ".lscache";
+ await File.WriteAllTextAsync(cacheFile, "existing-cache", TestContext.Current.CancellationToken);
+
+ string objDir = Path.Combine(Path.GetDirectoryName(projectFile)!, "obj");
+ string assetsFile = Path.Combine(objDir, "project.assets.json");
+ string nugetCacheFile = Path.Combine(objDir, "project.nuget.cache");
+ string escapedProjectFile = projectFile.Replace("\\", "\\\\");
+ await File.WriteAllTextAsync(
+ nugetCacheFile,
+ $$"""{"version":2,"success":true,"projectFilePath":"{{escapedProjectFile}}"}""",
+ TestContext.Current.CancellationToken);
+
+ DateTime projectTimeUtc = DateTime.UtcNow.AddMinutes(-5);
+ File.SetLastWriteTimeUtc(assetsFile, projectTimeUtc.AddMinutes(-5));
+ File.SetLastWriteTimeUtc(projectFile, projectTimeUtc);
+ File.SetLastWriteTimeUtc(nugetCacheFile, projectTimeUtc.AddMinutes(-5));
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.True(File.Exists(cacheFile), $"ProjectDataBuild should write {cacheFile} when assets/imports/resolved references are available.\n{result.Output}");
+ Assert.NotEqual("existing-cache", File.ReadAllText(cacheFile));
+ }
+
+ [Fact]
+ public async Task ProjectDataBuild_RestoreAndProjectDataBuildSameEvaluation_FailsWithoutWritingCache()
+ {
+ string projectFile = this.WriteProject("App.csproj", multiTargeting: false, targetFramework: "net11.0", writeAssetsFile: false);
+ string cacheFile = projectFile + ".lscache";
+ string restoreSource = Path.Combine(this.workDir, "empty-restore-source");
+ Directory.CreateDirectory(restoreSource);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:Restore;ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ $"/p:RestoreSources={restoreSource}",
+ ]);
+
+ Assert.NotEqual(0, result.ExitCode);
+ Assert.Contains("were not imported into the current project evaluation", result.Output);
+ Assert.Contains("Do not run Restore and ProjectDataBuild in the same MSBuild evaluation", result.Output);
+ Assert.False(File.Exists(cacheFile), $"ProjectDataBuild should not create {cacheFile} when Restore ran in the same evaluation.\n{result.Output}");
+ }
+
+ [Fact]
+ public async Task ProjectDataBuild_NoMetadataReferencesResolved_WithoutNoStdLib_FailsWithoutWritingCache()
+ {
+ // Force `@(ReferencePathWithRefAssemblies)` to be empty by construction so the
+ // `_ValidateProjectDataMetadataReferences` target fires deterministically, regardless
+ // of which SDKs/targeting packs happen to be installed on the host machine. Using an
+ // "uninstalled TFM" (e.g. net7.0) would be brittle: it would silently regress the day
+ // someone installs that SDK, and it would exercise the SDK's unknown-framework error
+ // path rather than our validator.
+ string projectFile = this.WriteProject(
+ "NoRefs.csproj",
+ multiTargeting: false,
+ targetFramework: "net10.0",
+ extraXml:
+ """
+
+
+ false
+
+
+
+
+
+ """);
+ string cacheFile = projectFile + ".lscache";
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.NotEqual(0, result.ExitCode);
+ Assert.Contains("no metadata references were resolved", result.Output);
+ Assert.Contains(projectFile, result.Output);
+ Assert.False(File.Exists(cacheFile), $"ProjectDataBuild should not create {cacheFile} when no metadata references resolved.\n{result.Output}");
+ }
+
+ [Fact]
+ public async Task ProjectDataBuild_NoMetadataReferencesResolved_WithoutNoStdLib_PreservesExistingCache()
+ {
+ string projectFile = this.WriteProject(
+ "NoRefs.csproj",
+ multiTargeting: false,
+ targetFramework: "net10.0",
+ extraXml:
+ """
+
+
+ false
+
+
+
+
+
+ """);
+ string cacheFile = projectFile + ".lscache";
+ await File.WriteAllTextAsync(cacheFile, "existing-cache", TestContext.Current.CancellationToken);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.NotEqual(0, result.ExitCode);
+ Assert.Contains("no metadata references were resolved", result.Output);
+ Assert.Equal("existing-cache", File.ReadAllText(cacheFile));
+ }
+
+ [Fact]
+ public async Task ProjectDataBuild_NoStdLibWithoutMetadataReferences_WritesCache()
+ {
+ string projectFile = this.WriteProject(
+ "NoStdLibCoreLib.csproj",
+ multiTargeting: false,
+ targetFramework: "net10.0",
+ extraProperties:
+ """
+ true
+ true
+ """);
+ string cacheFile = projectFile + ".lscache";
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.True(File.Exists(cacheFile), $"NoStdLib projects with compiler command-line data should still produce project data.\n{result.Output}");
+ string content = File.ReadAllText(cacheFile).Replace("\r\n", "\n");
+ Assert.Contains("[commandLineArguments]\n", content);
+ Assert.Contains("[metadataReferences]\n", content);
+ AssertNoUnsupportedMarker(projectFile);
+ }
+
+ [Fact]
+ public async Task ProjectDataBuild_ProjectReferenceOnlyMetadataReferences_WithoutNoStdLib_PreservesExistingCache()
+ {
+ string libraryFile = this.WriteProject("Core.csproj", multiTargeting: false);
+ string projectFile = this.WriteProject(
+ "App.csproj",
+ multiTargeting: false,
+ extraXml:
+ $$"""
+
+
+
+
+
+ false
+
+
+
+
+ ProjectReference
+
+
+
+ """);
+ string cacheFile = projectFile + ".lscache";
+ await File.WriteAllTextAsync(cacheFile, "existing-cache", TestContext.Current.CancellationToken);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.NotEqual(0, result.ExitCode);
+ Assert.Contains("no framework reference assemblies were resolved", result.Output);
+ Assert.Equal("existing-cache", File.ReadAllText(cacheFile));
+ }
+
+ [Fact]
+ public async Task ProjectDataBuild_NoStdLibProjectReferenceMetadataReferences_WritesCache()
+ {
+ string libraryFile = this.WriteProject("Core.csproj", multiTargeting: false);
+ string projectReferenceAssembly = Path.Combine(this.workDir, "artifacts", "bin", "System.Runtime", "ref", "Debug", "net10.0", "System.Runtime.dll");
+ Directory.CreateDirectory(Path.GetDirectoryName(projectReferenceAssembly)!);
+ await File.WriteAllTextAsync(projectReferenceAssembly, string.Empty, TestContext.Current.CancellationToken);
+ string projectFile = this.WriteProject(
+ "App.csproj",
+ multiTargeting: false,
+ targetFramework: "net10.0",
+ extraProperties:
+ """
+ true
+ true
+ """,
+ extraXml:
+ $$"""
+
+
+
+
+
+
+ ProjectReference
+
+
+
+ """);
+ string cacheFile = projectFile + ".lscache";
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.True(File.Exists(cacheFile), $"NoStdLib projects should accept project-reference metadata references without FrameworkReferenceName metadata.\n{result.Output}");
+ string content = File.ReadAllText(cacheFile).Replace("\r\n", "\n");
+ Assert.Contains("[metadataReferences]\n", content);
+ Assert.Contains("System.Runtime.dll", content);
+ AssertNoUnsupportedMarker(projectFile);
+ }
+
+ [Fact]
+ public async Task ValidateProjectDataMetadataReferences_NetStandardReferenceWithoutFrameworkReferenceName_Succeeds()
+ {
+ string netstandardReference = Path.Combine(this.workDir, "netstandard.dll");
+ string projectFile = this.WriteProject(
+ "NetStandardApp.csproj",
+ multiTargeting: false,
+ targetFramework: "netstandard2.0",
+ extraProperties:
+ """
+ true
+ true
+ """,
+ extraXml:
+ $$"""
+
+
+
+
+
+ """);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:_ValidateProjectDataMetadataReferences",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ }
+
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(true, false)]
+ [InlineData(false, true)]
+ [InlineData(true, true)]
+ public async Task ProjectDataBuild_AnalyzerPolicy_NeverHasOrphanLines(bool enableNetAnalyzers, bool enforceCodeStyleInBuild)
+ {
+ // Writer regression guard for the
+ // "Gate [sdkAnalyzerConfigPolicy] lines on the SDK's analyzer-pack
+ // property gates" fix. Exercises the full toolchain (targets → writer →
+ // cache file) with every combination of the two gating properties and
+ // asserts the output never contains a policy line for an analyzer pack
+ // that has no DLLs in [analyzerReferences]. This is robust to *how* a
+ // future regression might happen — at the targets layer, the writer
+ // layer, a new policy type, or a new caller — because it asserts an
+ // invariant on the *output* rather than the writer's internal behavior.
+ string projectFile = this.WriteProject(
+ "AnalyzerPolicy.csproj",
+ multiTargeting: false,
+ targetFramework: "net10.0",
+ extraProperties:
+ $"""
+ {(enableNetAnalyzers ? "true" : "false")}
+ {(enforceCodeStyleInBuild ? "true" : "false")}
+ """);
+ string cacheFile = projectFile + ".lscache";
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.True(File.Exists(cacheFile), $"Expected {cacheFile} to exist.\n{result.Output}");
+
+ string content = File.ReadAllText(cacheFile);
+ LscacheInvariants.AssertNoOrphanAnalyzerPolicyLines(content);
+ }
+
+ [Fact]
+ public async Task ProjectDataBuild_RestoreSkippedAndPackageFilesMissing_FailsWithoutWritingCache()
+ {
+ const string packageId = "PackageApp.Dependency";
+ const string packageVersion = "1.0.0";
+ string projectFile = this.WriteProject(
+ "PackageApp.csproj",
+ multiTargeting: false,
+ extraXml:
+ $$"""
+
+
+
+ """,
+ writeAssetsFile: false);
+ string packagesPath = Path.Combine(this.workDir, "packages");
+ this.WriteProjectAssetsFileWithPackage(projectFile, packageId, packageVersion, "net8.0", packagesPath);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ "/p:ContinueOnError=true",
+ $"/p:RestorePackagesPath={packagesPath}",
+ ]);
+
+ Assert.NotEqual(0, result.ExitCode);
+ Assert.Contains("restore graph does not contain declared PackageReference items", result.Output);
+ Assert.Contains(packageId, result.Output);
+ Assert.False(File.Exists(projectFile + ".lscache"), $"ProjectDataBuild should not create a cache when restored package files are missing.\n{result.Output}");
+ }
+
+ [Fact]
+ public async Task ProjectDataBuild_FloatingPackageVersionUsesConcreteRestoredPackageFolder()
+ {
+ const string packageId = "Floating.Package";
+ const string requestedVersion = "11.0.0-preview.6.*";
+ const string resolvedVersion = "11.0.0-preview.6.26359.118";
+ const string targetFramework = "net8.0";
+ string projectFile = this.WriteProject(
+ "FloatingPackage.csproj",
+ multiTargeting: false,
+ extraXml:
+ $$"""
+
+
+
+ """,
+ writeAssetsFile: false);
+ string packagesPath = Path.Combine(this.workDir, "packages");
+ string packagePath = Path.Combine(packagesPath, packageId.ToLowerInvariant(), resolvedVersion);
+ string packageAssetPath = Path.Combine(packagePath, "lib", targetFramework, packageId + ".dll");
+ Directory.CreateDirectory(Path.GetDirectoryName(packageAssetPath)!);
+ await File.WriteAllBytesAsync(packageAssetPath, [], TestContext.Current.CancellationToken);
+ await File.WriteAllTextAsync(
+ Path.Combine(packagePath, packageId.ToLowerInvariant() + ".nuspec"),
+ "Floating.Package11.0.0-preview.6.26359.118TestTest",
+ TestContext.Current.CancellationToken);
+ await File.WriteAllTextAsync(
+ Path.Combine(packagePath, $"{packageId.ToLowerInvariant()}.{resolvedVersion}.nupkg.sha512"),
+ string.Empty,
+ TestContext.Current.CancellationToken);
+ this.WriteProjectAssetsFileWithPackage(
+ projectFile,
+ packageId,
+ resolvedVersion,
+ targetFramework,
+ packagesPath,
+ requestedVersion);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:ProjectDataBuild",
+ "/p:DesignTimeBuild=true",
+ "/p:BuildingProject=false",
+ "/p:SkipCompilerExecution=true",
+ "/p:EnableProjectDataInProjectFolder=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ Assert.True(File.Exists(projectFile + ".lscache"), $"ProjectDataBuild should use the concrete package folder selected by restore.\n{result.Output}");
+ }
+
+ [Fact]
+ public async Task ValidateProjectDataResolvedPackages_MatchesCentrallyManagedReferenceWithoutVersionMetadata()
+ {
+ const string packageId = "Central.Package";
+ const string resolvedVersion = "2.0.0";
+ string packagePath = Path.Combine(this.workDir, "packages", packageId.ToLowerInvariant(), resolvedVersion);
+ Directory.CreateDirectory(packagePath);
+ string projectFile = this.WriteProject(
+ "CentralPackage.csproj",
+ multiTargeting: false,
+ extraXml:
+ $$"""
+
+
+
+
+
+
+
+ <_PackageDependenciesDesignTime Include="{{packageId}}/{{resolvedVersion}}">
+ {{packageId}}
+ {{resolvedVersion}}
+ {{packagePath}}
+
+
+
+ """,
+ extraProperties: "true",
+ writeAssetsFile: false);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:_ValidateProjectDataResolvedPackages",
+ "/p:_ProjectDataCanWriteOutput=true",
+ ]);
+
+ Assert.True(result.ExitCode == 0, result.Output);
+ }
+
+ [Fact]
+ public async Task ValidateProjectDataResolvedPackages_ReportsVersionChangeMissingFromRestoreGraph()
+ {
+ const string packageId = "Stale.Package";
+ const string requestedVersion = "12.0.3";
+ const string resolvedVersion = "13.0.1";
+ const string targetFramework = "net8.0";
+ string projectFile = this.WriteProject(
+ "StalePackageVersion.csproj",
+ multiTargeting: false,
+ extraXml:
+ $$"""
+
+
+
+ """,
+ writeAssetsFile: false);
+ string packagesPath = Path.Combine(this.workDir, "packages");
+ string packagePath = Path.Combine(packagesPath, packageId.ToLowerInvariant(), resolvedVersion);
+ string packageAssetPath = Path.Combine(packagePath, "lib", targetFramework, packageId + ".dll");
+ Directory.CreateDirectory(Path.GetDirectoryName(packageAssetPath)!);
+ await File.WriteAllBytesAsync(packageAssetPath, [], TestContext.Current.CancellationToken);
+ await File.WriteAllTextAsync(
+ Path.Combine(packagePath, packageId.ToLowerInvariant() + ".nuspec"),
+ "Stale.Package13.0.1TestTest",
+ TestContext.Current.CancellationToken);
+ await File.WriteAllTextAsync(
+ Path.Combine(packagePath, $"{packageId.ToLowerInvariant()}.{resolvedVersion}.nupkg.sha512"),
+ string.Empty,
+ TestContext.Current.CancellationToken);
+ this.WriteProjectAssetsFileWithPackage(projectFile, packageId, resolvedVersion, targetFramework, packagesPath);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:_ValidateProjectDataAssetsFile;ResolvePackageAssets;_ValidateProjectDataResolvedPackages",
+ "/p:_ProjectDataCanWriteOutput=true",
+ ]);
+
+ Assert.NotEqual(0, result.ExitCode);
+ Assert.Contains("declared PackageReference requests differ from the restore graph", result.Output);
+ Assert.Contains(packageId, result.Output);
+ Assert.Contains($"current request '{requestedVersion}'", result.Output);
+ Assert.Contains("restored request", result.Output);
+ }
+
+ [Theory]
+ [InlineData("runtime", "compile")]
+ [InlineData("build", "runtime")]
+ public async Task ValidateProjectDataResolvedPackages_ReportsAssetSelectionChangeMissingFromRestoreGraph(
+ string restoredExcludeAssets,
+ string currentExcludeAssets)
+ {
+ const string packageId = "Microsoft.Build.Utilities.Core";
+ const string packageVersion = "17.14.0-preview-25119-36";
+
+ string WriteFilteredProject(string excludeAssets)
+ => this.WriteProject(
+ "StalePackageAssets.csproj",
+ multiTargeting: false,
+ targetFramework: "net9.0",
+ extraXml:
+ $$"""
+
+
+
+ """,
+ writeAssetsFile: false);
+
+ string projectFile = WriteFilteredProject(restoredExcludeAssets);
+ ProcessResult restoreResult = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:Restore",
+ $"/p:RestoreConfigFile={Path.Combine(FindRepoRoot(), "NuGet.config")}",
+ ]);
+ Assert.True(restoreResult.ExitCode == 0, restoreResult.Output);
+
+ ProcessResult matchingResult = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:_ValidateProjectDataAssetsFile;ResolvePackageAssets;_ValidateProjectDataResolvedPackages",
+ "/p:_ProjectDataCanWriteOutput=true",
+ ]);
+ Assert.True(matchingResult.ExitCode == 0, matchingResult.Output);
+
+ WriteFilteredProject(currentExcludeAssets);
+ ProcessResult staleResult = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:_ValidateProjectDataAssetsFile;ResolvePackageAssets;_ValidateProjectDataResolvedPackages",
+ "/p:_ProjectDataCanWriteOutput=true",
+ ]);
+
+ Assert.NotEqual(0, staleResult.ExitCode);
+ Assert.Contains("declared PackageReference requests differ from the restore graph", staleResult.Output);
+ Assert.Contains("current assets", staleResult.Output);
+ Assert.Contains(packageId, staleResult.Output);
+ }
+
+ [Fact]
+ public async Task ValidateProjectDataResolvedPackages_ReportsActiveTransitiveCentralPinChangeMissingFromRestoreGraph()
+ {
+ const string directPackageId = "Microsoft.Build.Utilities.Core";
+ const string directPackageVersion = "17.14.0-preview-25119-36";
+ const string transitivePackageId = "Microsoft.NET.StringTools";
+ const string restoredPinVersion = "18.9.11";
+ const string currentPinVersion = "18.4.0";
+ const string targetFramework = "net9.0";
+
+ string WritePinnedProject(string pinVersion)
+ {
+ File.WriteAllText(
+ Path.Combine(this.workDir, "Directory.Packages.props"),
+ $$"""
+
+
+ true
+ true
+
+
+
+
+
+
+ """);
+ return this.WriteProject(
+ "TransitivePin.csproj",
+ multiTargeting: false,
+ targetFramework: targetFramework,
+ extraXml:
+ $$"""
+
+
+
+ """,
+ writeAssetsFile: false);
+ }
+
+ string projectFile = WritePinnedProject(restoredPinVersion);
+ ProcessResult restoreResult = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:Restore",
+ $"/p:RestoreConfigFile={Path.Combine(FindRepoRoot(), "NuGet.config")}",
+ ]);
+ Assert.True(restoreResult.ExitCode == 0, restoreResult.Output);
+
+ ProcessResult currentResult = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:_ValidateProjectDataAssetsFile;ResolvePackageAssets;_ValidateProjectDataResolvedPackages",
+ "/p:_ProjectDataCanWriteOutput=true",
+ ]);
+ Assert.True(currentResult.ExitCode == 0, currentResult.Output);
+
+ WritePinnedProject(currentPinVersion);
+ ProcessResult staleResult = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:_ValidateProjectDataAssetsFile;ResolvePackageAssets;_ValidateProjectDataResolvedPackages",
+ "/p:_ProjectDataCanWriteOutput=true",
+ ]);
+
+ Assert.NotEqual(0, staleResult.ExitCode);
+ Assert.Contains("central transitive package version requests differ from the restore graph", staleResult.Output);
+ Assert.Contains(transitivePackageId, staleResult.Output);
+ Assert.Contains($"current request '{currentPinVersion}'", staleResult.Output);
+ Assert.Contains($"restored request '[{restoredPinVersion}, )'", staleResult.Output);
+ }
+
+ [Theory]
+ [InlineData(false, false, true)]
+ [InlineData(true, true, true)]
+ [InlineData(false, true, false)]
+ [InlineData(true, false, false)]
+ public async Task ValidateProjectDataResolvedPackages_ValidatesCentralTransitivePinningModeAgainstRestoreGraph(
+ bool restoredPinningEnabled,
+ bool currentPinningEnabled,
+ bool expectedSuccess)
+ {
+ const string directPackageId = "Microsoft.Build.Utilities.Core";
+ const string directPackageVersion = "17.14.0-preview-25119-36";
+ const string transitivePackageId = "Microsoft.NET.StringTools";
+ const string pinVersion = "18.9.11";
+ const string targetFramework = "net9.0";
+
+ string WritePinnedProject(bool pinningEnabled)
+ {
+ File.WriteAllText(
+ Path.Combine(this.workDir, "Directory.Packages.props"),
+ $$"""
+
+
+ true
+ {{pinningEnabled.ToString().ToLowerInvariant()}}
+
+
+
+
+
+
+ """);
+ return this.WriteProject(
+ "TransitivePinMode.csproj",
+ multiTargeting: false,
+ targetFramework: targetFramework,
+ extraXml:
+ $$"""
+
+
+
+ """,
+ writeAssetsFile: false);
+ }
+
+ string projectFile = WritePinnedProject(restoredPinningEnabled);
+ ProcessResult restoreResult = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:Restore",
+ $"/p:RestoreConfigFile={Path.Combine(FindRepoRoot(), "NuGet.config")}",
+ ]);
+ Assert.True(restoreResult.ExitCode == 0, restoreResult.Output);
+
+ WritePinnedProject(currentPinningEnabled);
+ ProcessResult staleResult = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:_ValidateProjectDataAssetsFile;ResolvePackageAssets;_ValidateProjectDataResolvedPackages",
+ "/p:_ProjectDataCanWriteOutput=true",
+ ]);
+
+ if (expectedSuccess)
+ {
+ Assert.True(staleResult.ExitCode == 0, staleResult.Output);
+ Assert.DoesNotContain("central transitive package pinning mode differs from the restore graph", staleResult.Output);
+ }
+ else
+ {
+ Assert.NotEqual(0, staleResult.ExitCode);
+ Assert.Contains("central transitive package pinning mode differs from the restore graph", staleResult.Output);
+ Assert.Contains($"current '{currentPinningEnabled}'", staleResult.Output, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains($"restored '{restoredPinningEnabled}'", staleResult.Output, StringComparison.OrdinalIgnoreCase);
+ }
+ }
+
+ [Fact]
+ public async Task ValidateProjectDataResolvedPackages_StillValidatesImplicitPackageDependencyPaths()
+ {
+ string missingPackageAsset = Path.Combine(this.workDir, "missing-packages", "implicit.dependency", "1.0.0", "lib", "net8.0", "Implicit.Dependency.dll");
+ string projectFile = this.WriteProject(
+ "ImplicitResolvedPackage.csproj",
+ multiTargeting: false,
+ extraXml:
+ $$"""
+
+
+
+
+
+
+ <_PackageDependenciesDesignTime Include="Implicit.Dependency/1.0.0">
+ {{missingPackageAsset}}
+
+
+
+ """,
+ writeAssetsFile: false);
+
+ ProcessResult result = await RunDotnetMsbuildAsync(
+ projectFile,
+ extraArgs:
+ [
+ "/t:_ValidateProjectDataResolvedPackages",
+ "/p:_ProjectDataCanWriteOutput=true",
+ ]);
+
+ Assert.NotEqual(0, result.ExitCode);
+ Assert.Contains("package files are missing", result.Output);
+ Assert.Contains("Implicit.Dependency/1.0.0", result.Output);
+ }
+
+ [Fact]
+ public async Task RunProcessWithTimeoutAsync_TerminatesHungProcessInsteadOfHanging()
+ {
+ // Regression test for the macOS CI job that hung for 180 minutes: a spawned `dotnet msbuild`
+ // child never exited, and the old harness awaited it unbounded. The harness must now bound the
+ // wait, kill the whole process tree, and surface a TimeoutException with the captured output.
+ ProcessStartInfo psi = CreateLongRunningProcess();
+ var timeout = TimeSpan.FromSeconds(1);
+
+ var stopwatch = System.Diagnostics.Stopwatch.StartNew();
+ TimeoutException ex = await Assert.ThrowsAsync(
+ () => RunProcessWithTimeoutAsync(psi, timeout, "hung-test-process"));
+ stopwatch.Stop();
+
+ // It should fail promptly (well under the process' own ~5 minute lifetime), not hang.
+ Assert.True(
+ stopwatch.Elapsed < TimeSpan.FromMinutes(1),
+ $"Expected the hung process to be terminated promptly but it took {stopwatch.Elapsed}.");
+ Assert.Contains("did not complete within", ex.Message);
+ }
+
+ private static ProcessStartInfo CreateLongRunningProcess()
+ {
+ // A portable process that blocks for far longer than the test's timeout on every CI OS.
+ ProcessStartInfo psi = OperatingSystem.IsWindows()
+ ? new ProcessStartInfo("cmd.exe", "/c \"ping 127.0.0.1 -n 300 > NUL\"")
+ : new ProcessStartInfo("/bin/sh", "-c \"sleep 300\"");
+ psi.RedirectStandardOutput = true;
+ psi.RedirectStandardError = true;
+ psi.UseShellExecute = false;
+ psi.CreateNoWindow = true;
+ return psi;
+ }
+
+ private string WriteProject(
+ string fileName,
+ bool multiTargeting,
+ string? targetFramework = null,
+ string? targetFrameworks = null,
+ string? runtimeIdentifier = null,
+ string? extraProperties = null,
+ string? extraXml = null,
+ bool writeAssetsFile = true)
+ {
+ string projectFile = Path.Combine(this.workDir, fileName);
+ string resolvedTargetFrameworks = targetFrameworks ?? "net8.0;net9.0";
+ string[] projectTargetFrameworks = multiTargeting ? resolvedTargetFrameworks.Split(';') : [targetFramework ?? "net8.0"];
+ string tfm = multiTargeting
+ ? $"{resolvedTargetFrameworks}"
+ : $"{targetFramework ?? "net8.0"}";
+ string runtimeIdentifierProperty = runtimeIdentifier is null ? string.Empty : $"{runtimeIdentifier}";
+
+ // Inject a stub framework-reference-shaped `@(ReferencePathWithRefAssemblies)`
+ // item so the production `_ValidateProjectDataMetadataReferences` target
+ // passes during synthetic unit tests.
+ //
+ // These smoke tests use a hand-crafted `project.assets.json` with empty
+ // `packageFolders` and never run a real Restore, so RAR has nothing to
+ // resolve from. That's an artefact of the hermetic test fixture, not a
+ // real broken build — none of these tests assert on `[metadataReferences]`
+ // content; they care about target dispatch, merge behaviour, slice
+ // management, etc. The stub is the cheapest way to feed the validator
+ // without standing up a network-dependent restore.
+ //
+ // The stub does NOT defeat the validator's purpose in production: the
+ // injection lives in the synthetic .csproj produced here, not in the
+ // targets file. Real projects continue to be validated normally.
+ //
+ // Two important escape hatches:
+ //
+ // * `Condition="'$(DisableImplicitFrameworkReferences)' != 'true'"`
+ // lets the dedicated regression tests
+ // (`ProjectDataBuild_NoMetadataReferencesResolved_*`) force-empty
+ // `@(ReferencePathWithRefAssemblies)` by setting that property — the
+ // stub naturally opts out and the validator fires as those tests
+ // require.
+ //
+ // * `BeforeTargets="_ValidateProjectDataMetadataReferences"` is enough:
+ // the stub item is in scope for the writer task too and gets written into `[metadataReferences]`,
+ // but for `.NETFramework` slices the writer's own
+ // `TryValidateNetFrameworkReferences` requires a *canonical* ref
+ // assembly (mscorlib/NETFXREF-shaped), which the stub is not, so the
+ // graceful `MissingNetFrameworkReferenceAssemblies` skip path still
+ // fires for net472 inner builds where the targeting pack is absent.
+ string stubReferenceAssembly = Path.Combine(this.workDir, "stub-reference.dll");
+ if (!File.Exists(stubReferenceAssembly))
+ {
+ File.WriteAllBytes(stubReferenceAssembly, []);
+ }
+
+ File.WriteAllText(projectFile,
+$@"
+
+ {tfm}
+ {runtimeIdentifierProperty}
+ Exe
+ false
+ {extraProperties}
+
+ {extraXml}
+
+
+
+ Microsoft.NETCore.App
+
+
+
+");
+
+ if (writeAssetsFile)
+ {
+ this.WriteProjectAssetsFile(projectFile, projectTargetFrameworks, runtimeIdentifier);
+ }
+
+ return projectFile;
+ }
+
+ private string WriteGraphProject(string name, string[]? references = null, bool failDuringProjectDataEvaluation = false)
+ {
+ string directory = Path.Combine(this.workDir, name);
+ Directory.CreateDirectory(directory);
+ string projectPath = Path.Combine(directory, $"{name}.csproj");
+ string projectReferences = string.Join(
+ Environment.NewLine,
+ (references ?? []).Select(reference => $""" """));
+ string failureProperty = failDuringProjectDataEvaluation
+ ? """ $([System.String]::MissingProjectDataMethod())"""
+ : string.Empty;
+ File.WriteAllText(
+ projectPath,
+ $$"""
+
+
+ net11.0
+ false
+ {{failureProperty}}
+
+
+ {{projectReferences}}
+
+
+ """);
+ this.WriteProjectAssetsFile(projectPath, ["net11.0"]);
+ return projectPath;
+ }
+
+ private void WriteProjectAssetsFile(string projectFile, IReadOnlyList targetFrameworks, string? runtimeIdentifier = null)
+ {
+ string objDir = Path.Combine(Path.GetDirectoryName(projectFile)!, "obj");
+ Directory.CreateDirectory(objDir);
+ string assetsFile = Path.Combine(objDir, "project.assets.json");
+ string projectName = Path.GetFileNameWithoutExtension(projectFile);
+ string escapedProjectFile = projectFile.Replace("\\", "\\\\");
+ string escapedObjDir = objDir.Replace("\\", "\\\\") + "\\\\";
+ IEnumerable targetGraphs = runtimeIdentifier is null
+ ? targetFrameworks
+ : targetFrameworks.Concat(targetFrameworks.Select(tfm => $"{tfm}/{runtimeIdentifier}"));
+ string targets = string.Join(",\n ", targetGraphs.Select(tfm => $"\"{tfm}\": {{}}"));
+ string dependencyGroups = string.Join(",\n ", targetFrameworks.Select(tfm => $"\"{tfm}\": []"));
+ string restoreFrameworks = string.Join(",\n ", targetFrameworks.Select(tfm => $"\"{tfm}\": {{ \"targetAlias\": \"{tfm}\", \"projectReferences\": {{}} }}"));
+ string projectFrameworks = string.Join(",\n ", targetFrameworks.Select(tfm => $"\"{tfm}\": {{ \"targetAlias\": \"{tfm}\" }}"));
+ string projectRuntimes = runtimeIdentifier is null
+ ? string.Empty
+ : $$"""
+ "runtimes": {
+ "{{runtimeIdentifier}}": {
+ "#import": []
+ }
+ },
+ """;
+
+ File.WriteAllText(
+ assetsFile,
+ $$"""
+ {
+ "version": 3,
+ "targets": {
+ {{targets}}
+ },
+ "libraries": {},
+ "projectFileDependencyGroups": {
+ {{dependencyGroups}}
+ },
+ "packageFolders": {},
+ "project": {
+ "version": "1.0.0",
+ "restore": {
+ "projectUniqueName": "{{escapedProjectFile}}",
+ "projectName": "{{projectName}}",
+ "projectPath": "{{escapedProjectFile}}",
+ "packagesPath": "",
+ "outputPath": "{{escapedObjDir}}",
+ "projectStyle": "PackageReference",
+ "configFilePaths": [],
+ "originalTargetFrameworks": [{{string.Join(", ", targetFrameworks.Select(tfm => $"\"{tfm}\""))}}],
+ "sources": {},
+ "frameworks": {
+ {{restoreFrameworks}}
+ },
+ {{projectRuntimes}}
+ "warningProperties": {
+ "warnAsError": []
+ }
+ },
+ "frameworks": {
+ {{projectFrameworks}}
+ }
+ }
+ }
+ """);
+
+ this.WriteNuGetGeneratedImports(projectFile);
+ }
+
+ private void WriteProjectAssetsFileWithPackage(
+ string projectFile,
+ string packageId,
+ string packageVersion,
+ string targetFramework,
+ string packagesPath,
+ string? requestedVersion = null)
+ {
+ string objDir = Path.Combine(Path.GetDirectoryName(projectFile)!, "obj");
+ Directory.CreateDirectory(objDir);
+ string assetsFile = Path.Combine(objDir, "project.assets.json");
+ string projectName = Path.GetFileNameWithoutExtension(projectFile);
+ string escapedProjectFile = projectFile.Replace("\\", "\\\\");
+ string escapedObjDir = objDir.Replace("\\", "\\\\") + "\\\\";
+ string escapedPackagesPath = (packagesPath + Path.DirectorySeparatorChar).Replace("\\", "\\\\");
+ string packageAsset = $"lib/{targetFramework}/{packageId}.dll";
+ string packageLibrary = $"{packageId}/{packageVersion}";
+ string packagePath = $"{packageId.ToLowerInvariant()}/{packageVersion}";
+ string dependencyVersion = requestedVersion ?? $"[{packageVersion}, )";
+
+ File.WriteAllText(
+ assetsFile,
+ $$"""
+ {
+ "version": 3,
+ "targets": {
+ "{{targetFramework}}": {
+ "{{packageLibrary}}": {
+ "type": "package",
+ "compile": {
+ "{{packageAsset}}": {}
+ },
+ "runtime": {
+ "{{packageAsset}}": {}
+ }
+ }
+ }
+ },
+ "libraries": {
+ "{{packageLibrary}}": {
+ "type": "package",
+ "path": "{{packagePath}}",
+ "files": [
+ "{{packageAsset}}",
+ "{{packageId.ToLowerInvariant()}}.nuspec"
+ ]
+ }
+ },
+ "projectFileDependencyGroups": {
+ "{{targetFramework}}": [
+ "{{packageId}} >= {{packageVersion}}"
+ ]
+ },
+ "packageFolders": {
+ "{{escapedPackagesPath}}": {}
+ },
+ "project": {
+ "version": "1.0.0",
+ "restore": {
+ "projectUniqueName": "{{escapedProjectFile}}",
+ "projectName": "{{projectName}}",
+ "projectPath": "{{escapedProjectFile}}",
+ "packagesPath": "{{escapedPackagesPath}}",
+ "outputPath": "{{escapedObjDir}}",
+ "projectStyle": "PackageReference",
+ "configFilePaths": [],
+ "originalTargetFrameworks": ["{{targetFramework}}"],
+ "sources": {},
+ "frameworks": {
+ "{{targetFramework}}": {
+ "targetAlias": "{{targetFramework}}",
+ "projectReferences": {}
+ }
+ },
+ "warningProperties": {
+ "warnAsError": []
+ }
+ },
+ "frameworks": {
+ "{{targetFramework}}": {
+ "targetAlias": "{{targetFramework}}",
+ "dependencies": {
+ "{{packageId}}": {
+ "target": "Package",
+ "version": "{{dependencyVersion}}"
+ }
+ }
+ }
+ }
+ }
+ }
+ """);
+
+ this.WriteNuGetGeneratedImports(projectFile);
+ }
+
+ private void WriteNuGetGeneratedImports(string projectFile)
+ {
+ string objDir = Path.Combine(Path.GetDirectoryName(projectFile)!, "obj");
+ Directory.CreateDirectory(objDir);
+ string projectName = Path.GetFileName(projectFile);
+ File.WriteAllText(
+ Path.Combine(objDir, projectName + ".nuget.g.props"),
+ """
+
+
+ True
+ $(MSBuildThisFileDirectory)project.assets.json
+
+
+ """);
+ File.WriteAllText(
+ Path.Combine(objDir, projectName + ".nuget.g.targets"),
+ """
+
+
+ """);
+ }
+
+ private string WriteNet472ReferenceAssemblies()
+ {
+ string referenceAssemblyDirectory = Path.Combine(this.workDir, "refs", ".NETFramework", "v4.7.2");
+ Directory.CreateDirectory(referenceAssemblyDirectory);
+ Directory.CreateDirectory(Path.Combine(referenceAssemblyDirectory, "RedistList"));
+
+ File.WriteAllText(Path.Combine(referenceAssemblyDirectory, "mscorlib.dll"), string.Empty);
+ File.WriteAllText(Path.Combine(referenceAssemblyDirectory, "System.dll"), string.Empty);
+ File.WriteAllText(Path.Combine(referenceAssemblyDirectory, "System.Core.dll"), string.Empty);
+ File.WriteAllText(
+ Path.Combine(referenceAssemblyDirectory, "RedistList", "FrameworkList.xml"),
+ """
+
+
+
+
+
+ """);
+
+ return referenceAssemblyDirectory;
+ }
+
+ private string MissingNetFrameworkReferenceAssembliesProperties()
+ => $"{Path.Combine(this.workDir, "missing-reference-assemblies")}";
+
+ private static string FindRepoRoot()
+ {
+ DirectoryInfo? directory = new(AppContext.BaseDirectory);
+ while (directory is not null)
+ {
+ if (File.Exists(Path.Combine(directory.FullName, "Roslyn.slnx")))
+ {
+ return directory.FullName;
+ }
+
+ directory = directory.Parent;
+ }
+
+ throw new DirectoryNotFoundException($"Could not find the repository root from {AppContext.BaseDirectory}.");
+ }
+
+ private string WriteLegacyProject(string fileName)
+ {
+ string projectFile = Path.Combine(this.workDir, fileName);
+ File.WriteAllText(projectFile,
+"""
+
+
+ Exe
+ v4.7.2
+
+
+
+""");
+
+ return projectFile;
+ }
+
+ private static string[] ExtractCommandLineArguments(string cacheContent)
+ {
+ string normalized = cacheContent.Replace("\r\n", "\n");
+ const string header = "\n[commandLineArguments]\n";
+ int start = normalized.IndexOf(header, StringComparison.Ordinal);
+ Assert.True(start >= 0, $"Could not find [commandLineArguments] section.\n{cacheContent}");
+ start += header.Length;
+ int end = normalized.IndexOf("\n[", start, StringComparison.Ordinal);
+ if (end < 0)
+ end = normalized.IndexOf("\n---\n", start, StringComparison.Ordinal);
+ if (end < 0)
+ end = normalized.Length;
+
+ return normalized.Substring(start, end - start)
+ .Split('\n', StringSplitOptions.RemoveEmptyEntries);
+ }
+
+ private static string[] NormalizeForStableArgumentParity(IEnumerable args)
+ {
+ return args
+ .Where(arg => !IsFileArgument(arg))
+ .Where(arg => !IsPathBearingArgument(arg))
+ .Select(NormalizeNetCoreAppNoWarn)
+ .Select(arg => arg.Replace('\\', '/'))
+ .ToArray();
+ }
+
+ private static string NormalizeNetCoreAppNoWarn(string arg)
+ {
+ const string noWarn = "/nowarn:";
+ if (!arg.StartsWith(noWarn, StringComparison.OrdinalIgnoreCase))
+ return arg;
+ if (arg.Split(',', ';').Any(part => string.Equals(part.Trim(), "8002", StringComparison.OrdinalIgnoreCase)))
+ return arg;
+ return arg + ",8002";
+ }
+
+ private static bool IsFileArgument(string arg)
+ {
+ if (string.IsNullOrWhiteSpace(arg)) return false;
+ if (!arg.StartsWith("/", StringComparison.Ordinal) && !arg.StartsWith("-", StringComparison.Ordinal))
+ return true;
+ return arg.StartsWith("/reference:", StringComparison.OrdinalIgnoreCase)
+ || arg.StartsWith("/r:", StringComparison.OrdinalIgnoreCase)
+ || arg.StartsWith("/analyzer:", StringComparison.OrdinalIgnoreCase)
+ || arg.StartsWith("/additionalfile:", StringComparison.OrdinalIgnoreCase)
+ || arg.StartsWith("/analyzerconfig:", StringComparison.OrdinalIgnoreCase)
+ || arg.StartsWith("/resource:", StringComparison.OrdinalIgnoreCase)
+ || arg.StartsWith("/linkresource:", StringComparison.OrdinalIgnoreCase)
+ || arg.StartsWith("/embed:", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool IsPathBearingArgument(string arg)
+ {
+ return arg.Contains(":\\", StringComparison.Ordinal)
+ || arg.Contains(":/", StringComparison.Ordinal)
+ || arg.Contains("", StringComparison.Ordinal)
+ || arg.Contains("", StringComparison.Ordinal)
+ || arg.Contains("", StringComparison.Ordinal)
+ || arg.Contains("", StringComparison.Ordinal);
+ }
+
+ private static int CountOccurrences(string content, string value)
+ {
+ int count = 0;
+ int index = 0;
+ while ((index = content.IndexOf(value, index, StringComparison.Ordinal)) >= 0)
+ {
+ count++;
+ index += value.Length;
+ }
+
+ return count;
+ }
+
+ private static string GetSliceBlock(string content, string targetFramework)
+ {
+ foreach (string block in content.Split("\n---\n", StringSplitOptions.None).Skip(1))
+ {
+ if (block.Contains($"TargetFramework={targetFramework}\n", StringComparison.Ordinal))
+ return block;
+ }
+
+ throw new InvalidOperationException($"Could not find slice block for {targetFramework}.\n{content}");
+ }
+
+ private static async Task RunDotnetMsbuildAsync(
+ string projectFile,
+ string[] extraArgs,
+ System.Collections.Generic.Dictionary? extraEnv = null,
+ bool wireProjectDataTargets = true,
+ bool useBuildCommand = false)
+ {
+ var psi = new ProcessStartInfo("dotnet")
+ {
+ WorkingDirectory = Path.GetDirectoryName(projectFile)!,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ };
+ string command = useBuildCommand ? "build" : "msbuild";
+ foreach (string argument in new[] { command, projectFile, "/nologo", "/v:minimal" }.Concat(extraArgs))
+ {
+ psi.ArgumentList.Add(argument);
+ }
+
+ if (wireProjectDataTargets)
+ {
+ // Wire the targets file in the same way the extension does at runtime.
+ psi.Environment["CustomAfterMicrosoftCommonTargets"] = TargetsFile;
+ psi.Environment["CustomAfterMicrosoftCommonCrossTargetingTargets"] = TargetsFile;
+ }
+ psi.Environment["DOTNET_PROJECTDATA_CACHE_DIR"] = GetTestCacheRoot(projectFile);
+ // Avoid surprising restore behavior in the smoke test.
+ psi.Environment["MSBUILDDISABLENODEREUSE"] = "1";
+ psi.Environment["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1";
+ // Don't let the persistent MSBuild build server linger and keep the redirected
+ // stdout/stderr pipe open after `dotnet msbuild` returns; that would deadlock the
+ // stream reads below (observed as an intermittent hang on macOS CI).
+ psi.Environment["DOTNET_CLI_DO_NOT_USE_MSBUILD_SERVER"] = "1";
+
+ if (extraEnv is not null)
+ {
+ foreach (KeyValuePair kvp in extraEnv)
+ {
+ psi.Environment[kvp.Key] = kvp.Value;
+ }
+ }
+
+ return await RunProcessWithTimeoutAsync(psi, ProcessTimeout, $"dotnet {string.Join(' ', psi.ArgumentList)}");
+ }
+
+ ///
+ /// Starts , reads stdout/stderr concurrently, and waits for the process to exit,
+ /// but never for longer than . On timeout the entire process tree is killed
+ /// (so lingering MSBuild worker nodes release the redirected pipe handles) and a
+ /// is thrown with whatever output was captured, instead of hanging the test host indefinitely.
+ ///
+ private static async Task RunProcessWithTimeoutAsync(
+ ProcessStartInfo psi,
+ TimeSpan timeout,
+ string commandDescription)
+ {
+ using Process proc = Process.Start(psi)!;
+
+ // Read both streams concurrently so a full stderr buffer can never block stdout (and vice versa).
+ Task stdoutTask = proc.StandardOutput.ReadToEndAsync();
+ Task stderrTask = proc.StandardError.ReadToEndAsync();
+ Task completion = Task.WhenAll(stdoutTask, stderrTask, proc.WaitForExitAsync());
+
+ Task finished = await Task.WhenAny(completion, Task.Delay(timeout));
+ if (finished != completion)
+ {
+ // Timed out. Kill the whole tree (dotnet + any lingering MSBuild worker nodes) so the redirected
+ // pipes close, the reads can drain, and we don't leak processes onto the agent.
+ try { proc.Kill(entireProcessTree: true); }
+ catch { /* the process may have exited between the timeout check and the kill */ }
+
+ // After the kill the pipe write handles close, so give the reads a bounded chance to drain.
+ try { await completion.WaitAsync(TimeSpan.FromSeconds(30)); }
+ catch { /* best-effort drain */ }
+
+ string partialStdout = stdoutTask.IsCompletedSuccessfully ? stdoutTask.Result : string.Empty;
+ string partialStderr = stderrTask.IsCompletedSuccessfully ? stderrTask.Result : string.Empty;
+
+ throw new TimeoutException(
+ $"'{commandDescription}' did not complete within {timeout.TotalMinutes:N1} minute(s) and was terminated. " +
+ "This usually indicates a hung MSBuild/restore child process." + Environment.NewLine +
+ "--- stdout ---" + Environment.NewLine + partialStdout + Environment.NewLine +
+ "--- stderr ---" + Environment.NewLine + partialStderr);
+ }
+
+ await completion;
+ return new ProcessResult(proc.ExitCode, stdoutTask.Result + Environment.NewLine + stderrTask.Result);
+ }
+
+ private static TimeSpan GetProcessTimeout()
+ {
+ string? raw = Environment.GetEnvironmentVariable("PROJECTDATA_SMOKE_TEST_PROCESS_TIMEOUT_SECONDS");
+ if (!string.IsNullOrWhiteSpace(raw)
+ && int.TryParse(raw, out int seconds)
+ && seconds > 0)
+ {
+ return TimeSpan.FromSeconds(seconds);
+ }
+
+ return TimeSpan.FromMinutes(5);
+ }
+
+ private static string GetCompletionLoggerArgument(string receiptDirectory, string attemptId, string? loggerAssemblyPath = null)
+ {
+ string encodedReceiptDirectory = Convert.ToBase64String(Encoding.UTF8.GetBytes(receiptDirectory));
+ loggerAssemblyPath ??= typeof(ProjectDataBuildCompletionLogger).Assembly.Location;
+ return $"/logger:{typeof(ProjectDataBuildCompletionLogger).FullName},\"{loggerAssemblyPath}\";{encodedReceiptDirectory};{attemptId}";
+ }
+
+ private static string CopyAssembly(string sourcePath, string destinationDirectory)
+ {
+ string destinationPath = Path.Combine(destinationDirectory, Path.GetFileName(sourcePath));
+ File.Copy(sourcePath, destinationPath, overwrite: true);
+ return destinationPath;
+ }
+
+ private sealed record ProcessResult(int ExitCode, string Output);
+
+ private static void AssertMissingAssetsError(string projectFile, string output)
+ {
+ Assert.Contains("ProjectData: cannot write project data", output);
+ Assert.Contains(projectFile, output);
+ Assert.Contains("project.assets.json", output);
+ }
+
+ private static void AssertUnsupportedMarker(string projectFile, string expectedReason)
+ {
+ string markerPath = GetMarkerPath(projectFile);
+ Assert.True(File.Exists(markerPath), $"Expected unsupported marker at {markerPath}.");
+ string content = File.ReadAllText(markerPath);
+ Assert.Contains($"reason={expectedReason}", content);
+ }
+
+ private static void AssertNoUnsupportedMarker(string projectFile)
+ {
+ string markerPath = GetMarkerPath(projectFile);
+ Assert.False(File.Exists(markerPath), $"Expected no unsupported marker at {markerPath}.");
+ }
+
+ private static string GetMarkerPath(string projectFile)
+ {
+ string? previous = Environment.GetEnvironmentVariable("DOTNET_PROJECTDATA_CACHE_DIR");
+ try
+ {
+ Environment.SetEnvironmentVariable("DOTNET_PROJECTDATA_CACHE_DIR", GetTestCacheRoot(projectFile));
+ return UnsupportedProjectDataMarker.GetMarkerFilePath(projectFile);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable("DOTNET_PROJECTDATA_CACHE_DIR", previous);
+ }
+ }
+
+ private static string GetTestCacheRoot(string projectFile)
+ => Path.Combine(Path.GetDirectoryName(projectFile)!, ".projectdata-cache");
+}
diff --git a/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/TaskExecutionTests.cs b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/TaskExecutionTests.cs
new file mode 100644
index 0000000000000..aeeb66f0bbbcb
--- /dev/null
+++ b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/TaskExecutionTests.cs
@@ -0,0 +1,2040 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Reflection;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Build.Execution;
+using Microsoft.Build.Framework;
+using Moq;
+using Xunit;
+
+namespace Microsoft.NET.ProjectData.Tasks.Tests;
+
+///
+/// Tests for the MSBuild task entry points (``MergeProjectDataSlicesTask``,
+/// ``WriteProjectDataSliceTask``). Validates how the tasks surface
+/// misconfiguration and runtime failures back to MSBuild — specifically that
+/// errors are not silently swallowed at ``MessageImportance.Low``, which the
+/// default ``dotnet build`` verbosity hides.
+///
+public class TaskExecutionTests
+{
+ [Fact]
+ public void MergeTask_LogsError_AndReturnsFalse_WhenNeitherInputProvided()
+ {
+ var engine = new BuildEngineStub();
+ var task = new MergeProjectDataSlicesTask
+ {
+ BuildEngine = engine,
+ // Both OutputPath and ProjectFilePath left empty — neither route to a target path.
+ };
+
+ bool result = task.Execute();
+
+ Assert.False(result);
+ Assert.Single(engine.Errors);
+ Assert.Contains("requires either OutputPath or ProjectFilePath", engine.Errors[0].Message);
+ }
+
+ [Fact]
+ public void MergeTask_LogsWarning_NotMessage_OnRuntimeIOFailure()
+ {
+ // Force a runtime failure inside ``Execute`` by pointing ``OutputPath`` at a
+ // location that cannot be written (a path under a non-existent drive root on
+ // Windows, or under an invalid character path on Unix). The catch-all should
+ // surface the failure as a Warning so it appears under ``-v:minimal``.
+ var engine = new BuildEngineStub();
+ string unwritablePath = OperatingSystem.IsWindows()
+ ? @"Z:\nonexistent-drive\out.lscache"
+ : "/nonexistent-root-XYZZY/out.lscache";
+
+ var task = new MergeProjectDataSlicesTask
+ {
+ BuildEngine = engine,
+ OutputPath = unwritablePath,
+ SliceGlob = Path.Combine(Path.GetTempPath(), "lscache-tests-no-such-glob", "**", "*.slice"),
+ };
+
+ // No slices match the glob, so ``Merge`` returns 0 and ``DeleteOutputPathIfNotProjectFolder``
+ // is invoked on the unwritable path. That throws, which exercises the catch-all.
+ // We don't assert ``Execute`` returns true/false here — the contract is just
+ // that any runtime failure produces a Warning, not a hidden Low-importance Message.
+ try
+ {
+ task.Execute();
+ }
+ catch
+ {
+ // Any uncaught exception would mean the catch-all filter is too narrow —
+ // also a failure mode worth surfacing, but not under this test.
+ }
+
+ // Either the warning was logged (caught path) or no work happened (lucky path).
+ // Assert NOTHING is logged at MessageImportance.Low pretending to be an error —
+ // that's the bug we're fixing.
+ foreach (BuildMessageEventArgs message in engine.Messages)
+ {
+ Assert.DoesNotContain("failed to merge", message.Message, StringComparison.OrdinalIgnoreCase);
+ }
+ }
+
+ [Fact]
+ public void WriteTask_LogsWarning_NotMessage_OnRuntimeIOFailure()
+ {
+ // Same shape as the merge-task test. ``WriteProjectDataSliceTask`` calls
+ // ``ProjectDataWriter.AtomicWriteStreamed`` which throws on an unwritable
+ // output path. The catch-all must surface that as a Warning.
+ var engine = new BuildEngineStub();
+ string unwritablePath = OperatingSystem.IsWindows()
+ ? @"Z:\nonexistent-drive\out.lscache"
+ : "/nonexistent-root-XYZZY/out.lscache";
+
+ string fakeProject = Path.Combine(Path.GetTempPath(), "lscache-tests", Guid.NewGuid().ToString("N"), "App.csproj");
+ var task = new WriteProjectDataSliceTask
+ {
+ BuildEngine = engine,
+ ProjectFilePath = fakeProject,
+ OutputPath = unwritablePath,
+ CommandLineArguments = ["/noconfig"],
+ };
+
+ try
+ {
+ task.Execute();
+ }
+ catch
+ {
+ }
+
+ foreach (BuildMessageEventArgs message in engine.Messages)
+ {
+ Assert.DoesNotContain("failed to write", message.Message, StringComparison.OrdinalIgnoreCase);
+ }
+ }
+
+ [Fact]
+ public void UnsupportedMarkerTask_TreatsCacheRootResolutionFailureAsRecoverable()
+ {
+ MethodInfo? method = typeof(WriteUnsupportedProjectDataMarkerTask).GetMethod(
+ "IsRecoverableMarkerWriteException",
+ BindingFlags.NonPublic | BindingFlags.Static);
+
+ Assert.NotNull(method);
+ Assert.True((bool)method.Invoke(null, [new InvalidOperationException("Unable to determine cache root.")])!);
+ }
+
+ [Fact]
+ public void UnsupportedProjectDataMarker_DeleteTreatsPathResolutionFailuresAsRecoverable()
+ {
+ MethodInfo? method = typeof(UnsupportedProjectDataMarker).GetMethod(
+ "IsRecoverableMarkerException",
+ BindingFlags.NonPublic | BindingFlags.Static);
+
+ Assert.NotNull(method);
+ Assert.True((bool)method.Invoke(null, [new InvalidOperationException("Unable to determine cache root.")])!);
+ UnsupportedProjectDataMarker.Delete(string.Empty);
+ }
+
+ [Fact]
+ public void ProjectDataBuildReceipt_RoundTripsOnlyMatchingAttemptAndProject()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string receiptDirectory = Path.Combine(tempRoot, "receipts");
+ string projectPath = Path.Combine(tempRoot, "App", "App.csproj");
+ string otherProjectPath = Path.Combine(tempRoot, "Other", "Other.csproj");
+ string attemptId = "attempt-1";
+
+ ProjectDataBuildReceipt.Write(receiptDirectory, attemptId, projectPath);
+ ProjectDataBuildReceipt.WriteAggregateCompletion(receiptDirectory, attemptId);
+
+ Assert.True(ProjectDataBuildReceipt.TryRead(receiptDirectory, attemptId, projectPath, out ProjectDataBuildReceiptData receipt));
+ Assert.Equal(Path.GetFullPath(projectPath), receipt.ProjectFilePath);
+ Assert.True(ProjectDataBuildReceipt.TryReadAggregateCompletion(receiptDirectory, attemptId));
+ Assert.False(ProjectDataBuildReceipt.TryRead(receiptDirectory, "attempt-2", projectPath, out _));
+ Assert.False(ProjectDataBuildReceipt.TryRead(receiptDirectory, attemptId, otherProjectPath, out _));
+ Assert.False(ProjectDataBuildReceipt.TryReadAggregateCompletion(receiptDirectory, "attempt-2"));
+
+ string differentlyCasedProject = projectPath.ToUpperInvariant();
+ Assert.Equal(
+ OperatingSystem.IsLinux() ? false : true,
+ string.Equals(
+ ProjectDataBuildReceipt.GetReceiptFilePath(receiptDirectory, projectPath),
+ ProjectDataBuildReceipt.GetReceiptFilePath(receiptDirectory, differentlyCasedProject),
+ StringComparison.Ordinal));
+
+ File.WriteAllText(receipt.ReceiptFilePath, "version=2\nattempt=attempt-1\nproject=wrong\nextra=value\n");
+ Assert.False(ProjectDataBuildReceipt.TryRead(receiptDirectory, attemptId, projectPath, out _));
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void WriteProjectDataBuildReceiptTask_WriteFailureIsObservable()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string receiptDirectory = Path.Combine(tempRoot, "receipt-file");
+ File.WriteAllText(receiptDirectory, "not a directory");
+ BuildEngineStub engine = new();
+ WriteProjectDataBuildReceiptTask task = new()
+ {
+ BuildEngine = engine,
+ ReceiptDirectory = receiptDirectory,
+ AttemptId = "attempt-1",
+ ProjectFilePath = Path.Combine(tempRoot, "App.csproj"),
+ };
+
+ Assert.False(task.Execute());
+ BuildErrorEventArgs error = Assert.Single(engine.Errors);
+ Assert.Contains("failed to write completed receipt", error.Message, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("attempt-1", error.Message, StringComparison.Ordinal);
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void ProjectDataBuildCompletionLogger_CapsPerProjectAndGlobalDiagnostics()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string receiptDirectory = Path.Combine(tempRoot, "logger-receipts");
+ string attemptId = "attempt-1";
+ Mock eventSource = new(MockBehavior.Loose);
+ ProjectDataBuildCompletionLogger logger = new()
+ {
+ Parameters = $"{Convert.ToBase64String(Encoding.UTF8.GetBytes(receiptDirectory))};{attemptId}",
+ };
+ logger.Initialize(eventSource.Object);
+
+ for (int projectIndex = 0; projectIndex < 41; projectIndex++)
+ {
+ string projectPath = Path.Combine(tempRoot, $"Project{projectIndex}.csproj");
+ for (int diagnosticIndex = 0; diagnosticIndex < 6; diagnosticIndex++)
+ {
+ BuildWarningEventArgs warning = new(
+ subcategory: string.Empty,
+ code: $"W{diagnosticIndex}",
+ file: projectPath,
+ lineNumber: diagnosticIndex + 1,
+ columnNumber: 1,
+ endLineNumber: diagnosticIndex + 1,
+ endColumnNumber: 2,
+ message: "bounded warning",
+ helpKeyword: string.Empty,
+ senderName: "test")
+ {
+ ProjectFile = projectPath,
+ };
+ eventSource.Raise(source => source.AnyEventRaised += null, warning);
+ }
+ }
+
+ eventSource.Raise(
+ source => source.AnyEventRaised += null,
+ new BuildFinishedEventArgs("Build finished", string.Empty, succeeded: false));
+ logger.Shutdown();
+
+ Assert.True(ProjectDataBuildAttemptManifest.TryRead(receiptDirectory, attemptId, out ProjectDataBuildAttemptManifest manifest));
+ Assert.Equal(200, manifest.Diagnostics.Length);
+ Assert.Equal(46, manifest.TruncatedDiagnosticCount);
+ Assert.All(
+ manifest.Diagnostics.GroupBy(diagnostic => diagnostic.ProjectFilePath),
+ group => Assert.True(group.Count() <= 5, $"Expected at most 5 diagnostics for {group.Key}."));
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void ProjectDataBuildCompletionLogger_ErrorDisplacesWarningAtProjectCap()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string receiptDirectory = Path.Combine(tempRoot, "severity-aware-cap");
+ string attemptId = "attempt-1";
+ string projectPath = Path.Combine(tempRoot, "App.csproj");
+ Mock eventSource = new(MockBehavior.Loose);
+ ProjectDataBuildCompletionLogger logger = new()
+ {
+ Parameters = $"{Convert.ToBase64String(Encoding.UTF8.GetBytes(receiptDirectory))};{attemptId}",
+ };
+ logger.Initialize(eventSource.Object);
+ for (int index = 0; index < 5; index++)
+ {
+ eventSource.Raise(
+ source => source.AnyEventRaised += null,
+ new BuildWarningEventArgs(string.Empty, $"W{index}", projectPath, 0, 0, 0, 0, "warning", string.Empty, "test")
+ {
+ ProjectFile = projectPath,
+ });
+ }
+ eventSource.Raise(
+ source => source.AnyEventRaised += null,
+ new BuildErrorEventArgs(string.Empty, "E1", projectPath, 0, 0, 0, 0, "actionable error", string.Empty, "test")
+ {
+ ProjectFile = projectPath,
+ });
+ eventSource.Raise(
+ source => source.AnyEventRaised += null,
+ new BuildFinishedEventArgs("Build finished", string.Empty, succeeded: false));
+
+ Assert.True(ProjectDataBuildAttemptManifest.TryRead(receiptDirectory, attemptId, out ProjectDataBuildAttemptManifest manifest));
+ Assert.Equal(5, manifest.Diagnostics.Length);
+ Assert.Equal(4, manifest.Diagnostics.Count(diagnostic => diagnostic.Severity == "Warning"));
+ Assert.Contains(manifest.Diagnostics, diagnostic => diagnostic.Severity == "Error" && diagnostic.Code == "E1");
+ Assert.Equal(1, manifest.TruncatedDiagnosticCount);
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void ProjectDataBuildCompletionLogger_ProjectCapDoesNotDisplaceAnotherProjectsWarning()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string receiptDirectory = Path.Combine(tempRoot, "project-cap-isolation");
+ string attemptId = "attempt-1";
+ string cappedProject = Path.Combine(tempRoot, "Capped.csproj");
+ Mock eventSource = new(MockBehavior.Loose);
+ ProjectDataBuildCompletionLogger logger = new()
+ {
+ Parameters = $"{Convert.ToBase64String(Encoding.UTF8.GetBytes(receiptDirectory))};{attemptId}",
+ };
+ logger.Initialize(eventSource.Object);
+
+ for (int index = 0; index < 5; index++)
+ {
+ eventSource.Raise(
+ source => source.AnyEventRaised += null,
+ new BuildErrorEventArgs(string.Empty, $"E{index}", cappedProject, 0, 0, 0, 0, "error", string.Empty, "test")
+ {
+ ProjectFile = cappedProject,
+ });
+ }
+ for (int projectIndex = 0; projectIndex < 39; projectIndex++)
+ {
+ string projectPath = Path.Combine(tempRoot, $"Warning{projectIndex}.csproj");
+ for (int diagnosticIndex = 0; diagnosticIndex < 5; diagnosticIndex++)
+ {
+ eventSource.Raise(
+ source => source.AnyEventRaised += null,
+ new BuildWarningEventArgs(string.Empty, $"W{diagnosticIndex}", projectPath, 0, 0, 0, 0, "warning", string.Empty, "test")
+ {
+ ProjectFile = projectPath,
+ });
+ }
+ }
+ eventSource.Raise(
+ source => source.AnyEventRaised += null,
+ new BuildErrorEventArgs(string.Empty, "E5", cappedProject, 0, 0, 0, 0, "extra error", string.Empty, "test")
+ {
+ ProjectFile = cappedProject,
+ });
+ eventSource.Raise(
+ source => source.AnyEventRaised += null,
+ new BuildFinishedEventArgs("Build finished", string.Empty, succeeded: false));
+
+ Assert.True(ProjectDataBuildAttemptManifest.TryRead(receiptDirectory, attemptId, out ProjectDataBuildAttemptManifest manifest));
+ Assert.Equal(200, manifest.Diagnostics.Length);
+ Assert.Equal(5, manifest.Diagnostics.Count(diagnostic => diagnostic.ProjectFilePath == cappedProject));
+ Assert.Equal(1, manifest.TruncatedDiagnosticCount);
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void ProjectDataBuildCompletionLogger_MalformedParametersAndWriteFailureNeverThrow()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ Mock eventSource = new(MockBehavior.Loose);
+ ProjectDataBuildCompletionLogger malformed = new() { Parameters = "not-base64;attempt-1" };
+ Exception? malformedException = Record.Exception(() => malformed.Initialize(eventSource.Object));
+ Assert.Null(malformedException);
+
+ string receiptFile = Path.Combine(tempRoot, "not-a-directory");
+ File.WriteAllText(receiptFile, string.Empty);
+ ProjectDataBuildCompletionLogger unwritable = new()
+ {
+ Parameters = $"{Convert.ToBase64String(Encoding.UTF8.GetBytes(receiptFile))};attempt-2",
+ };
+ unwritable.Initialize(eventSource.Object);
+ Exception? eventException = Record.Exception(() => eventSource.Raise(
+ source => source.AnyEventRaised += null,
+ new BuildFinishedEventArgs("Build finished", string.Empty, succeeded: false)));
+ Assert.Null(eventException);
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void ProjectDataBuildCompletionLogger_DistinguishesCancellationFromProcessLoss()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string cancelledDirectory = Path.Combine(tempRoot, "cancelled");
+ Mock cancelledSource = new(MockBehavior.Loose);
+ ProjectDataBuildCompletionLogger cancelledLogger = new()
+ {
+ Parameters = $"{Convert.ToBase64String(Encoding.UTF8.GetBytes(cancelledDirectory))};cancelled-attempt",
+ };
+ cancelledLogger.Initialize(cancelledSource.Object);
+ cancelledSource.Raise(
+ source => source.AnyEventRaised += null,
+ new BuildCanceledEventArgs("Build cancelled"));
+ cancelledLogger.Shutdown();
+
+ Assert.True(ProjectDataBuildAttemptManifest.TryRead(cancelledDirectory, "cancelled-attempt", out ProjectDataBuildAttemptManifest cancelledManifest));
+ Assert.True(cancelledManifest.BuildCancelled);
+ Assert.False(cancelledManifest.BuildFinished);
+ Assert.True(ProjectDataBuildReceipt.TryReadAggregateCompletion(cancelledDirectory, "cancelled-attempt"));
+
+ string lostDirectory = Path.Combine(tempRoot, "lost");
+ Mock lostSource = new(MockBehavior.Loose);
+ ProjectDataBuildCompletionLogger lostLogger = new()
+ {
+ Parameters = $"{Convert.ToBase64String(Encoding.UTF8.GetBytes(lostDirectory))};lost-attempt",
+ };
+ lostLogger.Initialize(lostSource.Object);
+ lostLogger.Shutdown();
+
+ Assert.False(ProjectDataBuildAttemptManifest.TryRead(lostDirectory, "lost-attempt", out _));
+ Assert.False(ProjectDataBuildReceipt.TryReadAggregateCompletion(lostDirectory, "lost-attempt"));
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void ProjectDataBuildCompletionLogger_RecordsSubmissionPhases()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string receiptDirectory = Path.Combine(tempRoot, "phase-evidence");
+ string attemptId = "attempt-1";
+ Mock eventSource = new(MockBehavior.Loose);
+ ProjectDataBuildCompletionLogger logger = new()
+ {
+ Parameters = $"{Convert.ToBase64String(Encoding.UTF8.GetBytes(receiptDirectory))};{attemptId}",
+ };
+ logger.Initialize(eventSource.Object);
+ eventSource.Raise(
+ source => source.AnyEventRaised += null,
+ new BuildSubmissionStartedEventArgs(
+ new Dictionary { ["MSBuildIsRestoring"] = "true" },
+ [@"C:\repo\App.slnx"],
+ ["Restore"],
+ BuildRequestDataFlags.None,
+ submissionId: 1));
+ eventSource.Raise(
+ source => source.AnyEventRaised += null,
+ new BuildSubmissionStartedEventArgs(
+ new Dictionary(),
+ [@"C:\repo\App.slnx"],
+ ["ProjectDataBuild"],
+ BuildRequestDataFlags.None,
+ submissionId: 2));
+ eventSource.Raise(
+ source => source.AnyEventRaised += null,
+ new BuildFinishedEventArgs("Build finished", string.Empty, succeeded: true));
+
+ Assert.True(ProjectDataBuildAttemptManifest.TryRead(receiptDirectory, attemptId, out ProjectDataBuildAttemptManifest manifest));
+ Assert.True(manifest.ProjectDataBuildSubmissionObserved);
+ Assert.Contains(manifest.Submissions, submission => submission.SubmissionId == 1 && submission.Phase == "Restore" && submission.MSBuildIsRestoring);
+ Assert.Contains(manifest.Submissions, submission => submission.SubmissionId == 2 && submission.Phase == "ProjectDataBuild" && !submission.MSBuildIsRestoring);
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Theory]
+ [InlineData(".csproj")]
+ [InlineData(".vbproj")]
+ [InlineData(".fsproj")]
+ [InlineData(".vcxproj")]
+ [InlineData(".esproj")]
+ [InlineData(".proj")]
+ public void ProjectDataBuildCompletionLogger_UsesStructuredFileWhenNuGetReportsSolutionAsProject(string projectExtension)
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string receiptDirectory = Path.Combine(tempRoot, "nuget-diagnostic");
+ string attemptId = "attempt-1";
+ string solutionPath = Path.Combine(tempRoot, "App.slnx");
+ string brokenProjectPath = Path.Combine(tempRoot, "Broken", "Broken" + projectExtension);
+ Mock eventSource = new(MockBehavior.Loose);
+ ProjectDataBuildCompletionLogger logger = new()
+ {
+ Parameters = $"{Convert.ToBase64String(Encoding.UTF8.GetBytes(receiptDirectory))};{attemptId}",
+ };
+ logger.Initialize(eventSource.Object);
+ BuildErrorEventArgs error = new(
+ subcategory: string.Empty,
+ code: "NU1101",
+ file: brokenProjectPath,
+ lineNumber: 0,
+ columnNumber: 0,
+ endLineNumber: 0,
+ endColumnNumber: 0,
+ message: "Package was not found.",
+ helpKeyword: string.Empty,
+ senderName: "NuGet")
+ {
+ ProjectFile = solutionPath,
+ };
+ eventSource.Raise(source => source.AnyEventRaised += null, error);
+ eventSource.Raise(
+ source => source.AnyEventRaised += null,
+ new BuildFinishedEventArgs("Build finished", string.Empty, succeeded: false));
+
+ Assert.True(ProjectDataBuildAttemptManifest.TryRead(receiptDirectory, attemptId, out ProjectDataBuildAttemptManifest manifest));
+ ProjectDataBuildDiagnosticRecord diagnostic = Assert.Single(manifest.Diagnostics);
+ Assert.Equal(brokenProjectPath, diagnostic.ProjectFilePath);
+ Assert.Equal(ProjectDataBuildDiagnosticRecord.FileProjectPathSource, diagnostic.ProjectFilePathSource);
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void WriteTask_RecordsDonorIndex_AfterSuccessfulFinalCacheWrite()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string workspaceRoot = Path.Combine(tempRoot, "worktree");
+ string projectFile = Path.Combine(workspaceRoot, "src", "App", "App.csproj");
+ string outputPath = projectFile + ".lscache";
+ string indexPath = Path.Combine(tempRoot, "index", "lscache-donor-index.json");
+ var engine = new BuildEngineStub();
+ var task = new WriteProjectDataSliceTask
+ {
+ BuildEngine = engine,
+ ProjectFilePath = projectFile,
+ OutputPath = outputPath,
+ DonorCacheIndexPath = indexPath,
+ DonorCacheWorkspaceRoot = workspaceRoot,
+ WriteHeader = true,
+ IsPrimary = true,
+ LastDtbSucceeded = true,
+ CommandLineArguments = ["/noconfig"],
+ };
+
+ bool result = task.Execute();
+
+ Assert.True(result);
+ Assert.True(task.Succeeded);
+ Assert.True(File.Exists(indexPath), $"Expected donor index at {indexPath}.");
+ string content = File.ReadAllText(indexPath);
+ Assert.Contains("\"version\": 2", content);
+ Assert.Contains(JsonString(Path.GetFullPath(workspaceRoot)), content);
+ using JsonDocument index = JsonDocument.Parse(content);
+ Assert.Equal(["version", "entries"], index.RootElement.EnumerateObject().Select(static property => property.Name));
+ JsonElement entry = index.RootElement.GetProperty("entries")[0];
+ Assert.Equal(["path", "newestMtimeMs", "updatedUtc"], entry.EnumerateObject().Select(static property => property.Name));
+ Assert.True(entry.TryGetProperty("newestMtimeMs", out _));
+ Assert.True(entry.TryGetProperty("updatedUtc", out _));
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Theory]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ public void WriteTask_DoesNotRecordDonorIndex_ForNonFinalCacheWrite(bool writeHeader, bool isPrimary)
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string workspaceRoot = Path.Combine(tempRoot, "worktree");
+ string indexPath = Path.Combine(tempRoot, "index", "lscache-donor-index.json");
+ WriteProjectDataSliceTask task = CreateFinalWriteTask(workspaceRoot, indexPath);
+ task.WriteHeader = writeHeader;
+ task.IsPrimary = isPrimary;
+
+ Assert.True(task.Execute());
+ Assert.True(task.Succeeded);
+ Assert.False(File.Exists(indexPath));
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void WriteTask_RecordsSparseEntries_ForDistinctWorkspaces()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string mainWorkspaceRoot = Path.Combine(tempRoot, "main-worktree");
+ string capitalizedMainWorkspaceRoot = Path.Combine(tempRoot, "capitalized-main-worktree");
+ string indexPath = Path.Combine(tempRoot, "index", "lscache-donor-index.json");
+
+ Assert.True(CreateFinalWriteTask(mainWorkspaceRoot, indexPath).Execute());
+ Assert.True(CreateFinalWriteTask(capitalizedMainWorkspaceRoot, indexPath).Execute());
+
+ using JsonDocument index = JsonDocument.Parse(File.ReadAllText(indexPath));
+ string[] paths = index.RootElement
+ .GetProperty("entries")
+ .EnumerateArray()
+ .Select(entry => entry.GetProperty("path").GetString()!)
+ .ToArray();
+ Assert.Contains(Path.GetFullPath(mainWorkspaceRoot), paths);
+ Assert.Contains(Path.GetFullPath(capitalizedMainWorkspaceRoot), paths);
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void DonorIndex_OutsideGitRepository_DoesNotRecordWrite()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string projectFile = Path.Combine(tempRoot, "worktree", "src", "App", "App.csproj");
+ string cacheFile = Path.Combine(tempRoot, "output", "App.csproj.lscache");
+ Directory.CreateDirectory(Path.GetDirectoryName(projectFile)!);
+ Directory.CreateDirectory(Path.GetDirectoryName(cacheFile)!);
+ File.WriteAllText(projectFile, "");
+ File.WriteAllText(cacheFile, "version=2\n");
+
+ Assert.Null(ProjectDataDonorIndex.TryResolveDefaultIndexPath(projectFile));
+ Assert.False(ProjectDataDonorIndex.TryRecordWrite(projectFile, cacheFile, options: null, out string? message));
+ Assert.Null(message);
+ Assert.Empty(Directory.EnumerateFiles(tempRoot, "lscache-donor-index.json", SearchOption.AllDirectories));
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void DonorIndex_Disabled_DoesNotRecordWrite()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string workspaceRoot = Path.Combine(tempRoot, "worktree");
+ string projectFile = Path.Combine(workspaceRoot, "src", "App", "App.csproj");
+ string cacheFile = projectFile + ".lscache";
+ string indexPath = Path.Combine(tempRoot, "index", "lscache-donor-index.json");
+ Directory.CreateDirectory(Path.GetDirectoryName(cacheFile)!);
+ File.WriteAllText(cacheFile, "version=2\n");
+
+ Assert.False(ProjectDataDonorIndex.TryRecordWrite(
+ projectFile,
+ cacheFile,
+ new ProjectDataDonorWriteOptions
+ {
+ Enabled = false,
+ IndexPath = indexPath,
+ WorkspaceRoot = workspaceRoot,
+ },
+ out string? message));
+ Assert.Null(message);
+ Assert.False(File.Exists(indexPath));
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void WriteTask_InvalidDonorIndexOverride_DoesNotFailSuccessfulCacheWrite()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ BuildEngineStub engine = new();
+ WriteProjectDataSliceTask task = CreateFinalWriteTask(
+ Path.Combine(tempRoot, "worktree"),
+ "\0invalid-index-path",
+ engine);
+
+ Assert.True(task.Execute());
+ Assert.True(task.Succeeded);
+ Assert.True(File.Exists(task.OutputPath));
+ Assert.Empty(engine.Warnings);
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void DonorIndex_UnavailableGitMetadata_RecordsSparseEntry()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string workspaceRoot = Path.Combine(tempRoot, "worktree");
+ string projectFile = Path.Combine(workspaceRoot, "src", "App", "App.csproj");
+ string cacheFile = projectFile + ".lscache";
+ Directory.CreateDirectory(Path.Combine(workspaceRoot, ".git"));
+ Directory.CreateDirectory(Path.GetDirectoryName(projectFile)!);
+ File.WriteAllText(projectFile, "");
+ File.WriteAllText(cacheFile, "version=2\n");
+ string indexPath = Assert.IsType(ProjectDataDonorIndex.TryResolveDefaultIndexPath(projectFile));
+
+ Assert.True(ProjectDataDonorIndex.TryRecordWrite(projectFile, cacheFile, options: null, out string? message));
+ Assert.Null(message);
+
+ using JsonDocument index = JsonDocument.Parse(File.ReadAllText(indexPath));
+ JsonElement entry = Assert.Single(index.RootElement.GetProperty("entries").EnumerateArray());
+ Assert.Equal(Path.GetFullPath(workspaceRoot), entry.GetProperty("path").GetString());
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void WriteTask_MissingIndexes_DoNotShareEntries()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string firstWorkspaceRoot = Path.Combine(tempRoot, "first-worktree");
+ string secondWorkspaceRoot = Path.Combine(tempRoot, "second-worktree");
+ string firstIndexPath = Path.Combine(tempRoot, "first-index", "lscache-donor-index.json");
+ string secondIndexPath = Path.Combine(tempRoot, "second-index", "lscache-donor-index.json");
+
+ Assert.True(CreateFinalWriteTask(firstWorkspaceRoot, firstIndexPath).Execute());
+ Assert.True(CreateFinalWriteTask(secondWorkspaceRoot, secondIndexPath).Execute());
+
+ string secondIndexContent = File.ReadAllText(secondIndexPath);
+ Assert.Contains(JsonString(Path.GetFullPath(secondWorkspaceRoot)), secondIndexContent);
+ Assert.DoesNotContain(JsonString(Path.GetFullPath(firstWorkspaceRoot)), secondIndexContent);
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void DonorIndex_DoesNotOverwrite_UnsupportedFutureVersion()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string workspaceRoot = Path.Combine(tempRoot, "worktree");
+ string projectFile = Path.Combine(workspaceRoot, "src", "App", "App.csproj");
+ string cacheFile = projectFile + ".lscache";
+ string indexPath = Path.Combine(tempRoot, "index", "lscache-donor-index.json");
+ Directory.CreateDirectory(Path.GetDirectoryName(cacheFile)!);
+ Directory.CreateDirectory(Path.GetDirectoryName(indexPath)!);
+ File.WriteAllText(cacheFile, "version=2\n");
+ const string FutureIndex = """{"version":3,"entries":{"futureData":"preserve"}}""";
+ File.WriteAllText(indexPath, FutureIndex);
+
+ bool result = ProjectDataDonorIndex.TryRecordWrite(
+ projectFile,
+ cacheFile,
+ new ProjectDataDonorWriteOptions { IndexPath = indexPath, WorkspaceRoot = workspaceRoot },
+ out string? message);
+
+ Assert.False(result);
+ Assert.Contains("unsupported donor index version 3", message);
+ Assert.Equal(FutureIndex, File.ReadAllText(indexPath));
+ Assert.Empty(Directory.EnumerateFiles(Path.GetDirectoryName(indexPath)!, "lscache-donor-index.json.corrupt-*"));
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Theory]
+ [InlineData("""{"version":2,"entries":{"futureData":"preserve"}}""")]
+ [InlineData("""{"version":2,"entries":[{"path":"\u0000"}]}""")]
+ [InlineData("""{"entries":[]}""")]
+ [InlineData("[]")]
+ public void DonorIndex_QuarantinesAndRecreates_CorruptIndex(string corruptIndex)
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string workspaceRoot = Path.Combine(tempRoot, "worktree");
+ string projectFile = Path.Combine(workspaceRoot, "src", "App", "App.csproj");
+ string cacheFile = projectFile + ".lscache";
+ string indexPath = Path.Combine(tempRoot, "index", "lscache-donor-index.json");
+ Directory.CreateDirectory(Path.GetDirectoryName(cacheFile)!);
+ Directory.CreateDirectory(Path.GetDirectoryName(indexPath)!);
+ File.WriteAllText(cacheFile, "version=2\n");
+ File.WriteAllText(indexPath, corruptIndex);
+
+ bool result = ProjectDataDonorIndex.TryRecordWrite(
+ projectFile,
+ cacheFile,
+ new ProjectDataDonorWriteOptions { IndexPath = indexPath, WorkspaceRoot = workspaceRoot },
+ out string? message);
+
+ Assert.True(result);
+ Assert.Contains("Recovered corrupt donor index", message);
+ Assert.Contains("\"version\": 2", File.ReadAllText(indexPath));
+ string quarantinePath = Assert.Single(Directory.EnumerateFiles(Path.GetDirectoryName(indexPath)!, "lscache-donor-index.json.corrupt-*"));
+ Assert.Equal(corruptIndex, File.ReadAllText(quarantinePath));
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void DonorIndex_CleansTemporaryFile_WhenReplacementFails()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string workspaceRoot = Path.Combine(tempRoot, "worktree");
+ string projectFile = Path.Combine(workspaceRoot, "src", "App", "App.csproj");
+ string cacheFile = projectFile + ".lscache";
+ string indexPath = Path.Combine(tempRoot, "index");
+ Directory.CreateDirectory(Path.GetDirectoryName(cacheFile)!);
+ Directory.CreateDirectory(indexPath);
+ File.WriteAllText(cacheFile, "version=2\n");
+
+ Assert.False(ProjectDataDonorIndex.TryRecordWrite(
+ projectFile,
+ cacheFile,
+ new ProjectDataDonorWriteOptions { IndexPath = indexPath, WorkspaceRoot = workspaceRoot },
+ out string? message));
+ Assert.False(string.IsNullOrEmpty(message));
+ Assert.Empty(Directory.EnumerateFiles(tempRoot, "index.*.tmp", SearchOption.TopDirectoryOnly));
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void DonorIndex_ReplacesIndex_WhileDeleteSharedReaderIsOpen()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string workspaceRoot = Path.Combine(tempRoot, "worktree");
+ string indexPath = Path.Combine(tempRoot, "index", "lscache-donor-index.json");
+ Assert.True(CreateFinalWriteTask(workspaceRoot, indexPath).Execute());
+
+ using FileStream reader = new(indexPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
+
+ Assert.True(CreateFinalWriteTask(workspaceRoot, indexPath).Execute());
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public async Task WriteTask_WaitsForExclusiveIndexFileLock()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string workspaceRoot = Path.Combine(tempRoot, "worktree");
+ string indexPath = Path.Combine(tempRoot, "index", "lscache-donor-index.json");
+ Directory.CreateDirectory(Path.GetDirectoryName(indexPath)!);
+ using FileStream indexLock = new(indexPath + ".lock", FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
+
+ Task write = Task.Run(() => CreateFinalWriteTask(workspaceRoot, indexPath).Execute(), TestContext.Current.CancellationToken);
+ await Task.Delay(200, TestContext.Current.CancellationToken);
+ Assert.False(write.IsCompleted);
+
+ indexLock.Dispose();
+ Assert.True(await write);
+ Assert.True(File.Exists(indexPath));
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void TaskAssembly_DoesNotContainReaderSelectionOrGitTypes()
+ {
+ Assembly taskAssembly = typeof(WriteProjectDataSliceTask).Assembly;
+
+ Assert.Null(taskAssembly.GetType("Microsoft.NET.ProjectData.ProjectDataDonorCandidate"));
+ Assert.Null(taskAssembly.GetType("Microsoft.NET.ProjectData.ProjectDataDonorOptions"));
+ Assert.Null(taskAssembly.GetType("Microsoft.NET.ProjectData.ProjectDataDonorIndex+GitQueryContext"));
+ Assert.Null(typeof(ProjectDataDonorIndex).GetMethod("EnumerateDonorCandidates", BindingFlags.Public | BindingFlags.Static));
+ }
+
+ [Fact]
+ public void DonorIndex_PreservesNewestCacheMtime_ForWorkspace()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string workspaceRoot = Path.Combine(tempRoot, "worktree");
+ string projectFile = Path.Combine(workspaceRoot, "src", "App", "App.csproj");
+ string cacheFile = projectFile + ".lscache";
+ string indexPath = Path.Combine(tempRoot, "index", "lscache-donor-index.json");
+ Directory.CreateDirectory(Path.GetDirectoryName(cacheFile)!);
+ File.WriteAllText(cacheFile, "version=2\n");
+ File.SetLastWriteTimeUtc(cacheFile, DateTime.UtcNow.AddMinutes(-1));
+ ProjectDataDonorWriteOptions options = new()
+ {
+ IndexPath = indexPath,
+ WorkspaceRoot = workspaceRoot + Path.DirectorySeparatorChar,
+ };
+
+ Assert.True(ProjectDataDonorIndex.TryRecordWrite(projectFile, cacheFile, options, out _));
+ using JsonDocument firstIndex = JsonDocument.Parse(File.ReadAllText(indexPath));
+ long newestMtimeMs = firstIndex.RootElement
+ .GetProperty("entries")[0]
+ .GetProperty("newestMtimeMs")
+ .GetInt64();
+
+ File.SetLastWriteTimeUtc(cacheFile, DateTime.UtcNow.AddHours(-1));
+ options.WorkspaceRoot = workspaceRoot;
+ Assert.True(ProjectDataDonorIndex.TryRecordWrite(projectFile, cacheFile, options, out _));
+
+ using JsonDocument secondIndex = JsonDocument.Parse(File.ReadAllText(indexPath));
+ JsonElement entry = Assert.Single(secondIndex.RootElement.GetProperty("entries").EnumerateArray());
+ Assert.Equal(
+ newestMtimeMs,
+ entry.GetProperty("newestMtimeMs").GetInt64());
+ Assert.Equal(workspaceRoot, entry.GetProperty("path").GetString());
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void WriteTask_DoesNotRecordDonorIndex_ForInnerSliceWrites()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string workspaceRoot = Path.Combine(tempRoot, "worktree");
+ string projectFile = Path.Combine(workspaceRoot, "src", "App", "App.csproj");
+ string outputPath = Path.Combine(workspaceRoot, "obj", "App.csproj.slice");
+ string indexPath = Path.Combine(tempRoot, "index", "lscache-donor-index.json");
+ var engine = new BuildEngineStub();
+ var task = new WriteProjectDataSliceTask
+ {
+ BuildEngine = engine,
+ ProjectFilePath = projectFile,
+ OutputPath = outputPath,
+ DonorCacheIndexPath = indexPath,
+ DonorCacheWorkspaceRoot = workspaceRoot,
+ WriteHeader = false,
+ IsPrimary = false,
+ LastDtbSucceeded = true,
+ CommandLineArguments = ["/noconfig"],
+ };
+
+ bool result = task.Execute();
+
+ Assert.True(result);
+ Assert.True(task.Succeeded);
+ Assert.False(File.Exists(indexPath), $"Inner slice writes must not update donor index {indexPath}.");
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void WriteTask_DoesNotRecordDonorIndex_ForNonPrimaryHeaderWrites()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string workspaceRoot = Path.Combine(tempRoot, "worktree");
+ string projectFile = Path.Combine(workspaceRoot, "src", "App", "App.csproj");
+ string outputPath = Path.Combine(workspaceRoot, "obj", "App.csproj.slice");
+ string indexPath = Path.Combine(tempRoot, "index", "lscache-donor-index.json");
+ var engine = new BuildEngineStub();
+ var task = new WriteProjectDataSliceTask
+ {
+ BuildEngine = engine,
+ ProjectFilePath = projectFile,
+ OutputPath = outputPath,
+ DonorCacheIndexPath = indexPath,
+ DonorCacheWorkspaceRoot = workspaceRoot,
+ WriteHeader = true,
+ IsPrimary = false,
+ LastDtbSucceeded = true,
+ CommandLineArguments = ["/noconfig"],
+ };
+
+ bool result = task.Execute();
+
+ Assert.True(result);
+ Assert.True(task.Succeeded);
+ Assert.False(File.Exists(indexPath), $"Non-primary writes must not update donor index {indexPath}.");
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void MergeTask_RecordsDonorIndex_AfterSuccessfulMerge()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string workspaceRoot = Path.Combine(tempRoot, "worktree");
+ string projectFile = Path.Combine(workspaceRoot, "src", "App", "App.csproj");
+ string outputPath = projectFile + ".lscache";
+ string slicePath = Path.Combine(workspaceRoot, "obj", "Debug", "App.csproj.slice");
+ string indexPath = Path.Combine(tempRoot, "index", "lscache-donor-index.json");
+ WriteSlice(slicePath, "MergedAssembly", "net10.0");
+ var engine = new BuildEngineStub();
+ var task = new MergeProjectDataSlicesTask
+ {
+ BuildEngine = engine,
+ ProjectFilePath = projectFile,
+ OutputPath = outputPath,
+ SliceFiles = [new Microsoft.Build.Utilities.TaskItem(slicePath)],
+ DonorCacheIndexPath = indexPath,
+ DonorCacheWorkspaceRoot = workspaceRoot,
+ TargetFrameworks = "net10.0",
+ };
+
+ bool result = task.Execute();
+
+ Assert.True(result);
+ Assert.True(task.Succeeded);
+ Assert.True(File.Exists(outputPath), $"Expected merged cache at {outputPath}.");
+ Assert.True(File.Exists(indexPath), $"Expected donor index at {indexPath}.");
+ using JsonDocument index = JsonDocument.Parse(File.ReadAllText(indexPath));
+ JsonElement entry = Assert.Single(index.RootElement.GetProperty("entries").EnumerateArray());
+ Assert.Equal(Path.GetFullPath(workspaceRoot), entry.GetProperty("path").GetString());
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void MergeTask_DoesNotRecordDonorIndex_WhenNoSlicesAreMerged()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string workspaceRoot = Path.Combine(tempRoot, "worktree");
+ string projectFile = Path.Combine(workspaceRoot, "src", "App", "App.csproj");
+ string outputPath = projectFile + ".lscache";
+ string missingSlicePath = Path.Combine(workspaceRoot, "obj", "Debug", "App.csproj.slice");
+ string indexPath = Path.Combine(tempRoot, "index", "lscache-donor-index.json");
+ var engine = new BuildEngineStub();
+ var task = new MergeProjectDataSlicesTask
+ {
+ BuildEngine = engine,
+ ProjectFilePath = projectFile,
+ OutputPath = outputPath,
+ SliceFiles = [new Microsoft.Build.Utilities.TaskItem(missingSlicePath)],
+ DonorCacheIndexPath = indexPath,
+ DonorCacheWorkspaceRoot = workspaceRoot,
+ TargetFrameworks = "net10.0",
+ };
+
+ bool result = task.Execute();
+
+ Assert.True(result);
+ Assert.False(task.Succeeded);
+ Assert.False(File.Exists(indexPath), $"No-slice merge must not update donor index {indexPath}.");
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Theory]
+ // A bare NuGet version is a minimum, so the resolved version may legitimately be higher.
+ // Stale restore validation separately compares the current request with project.assets.json.
+ [InlineData("12.0.3", "13.0.1", true)]
+ [InlineData("13.0.4", "13.0.1", false)]
+ [InlineData("11.0.0-preview.6.*", "11.0.0-preview.6.26359.118", true)]
+ [InlineData("11.0.0-preview.6.*", "11.0.0-preview.7.1", false)]
+ [InlineData("11.0.0-preview.6.*", "11.1.0", false)]
+ [InlineData("11.0.0-preview.6.*", "12.0.0", false)]
+ [InlineData("[1.0.0,2.0.0)", "1.5.0", true)]
+ [InlineData("[1.0.0,2.0.0)", "2.0.0", false)]
+ public void ValidatePackagesTask_UsesNuGetVersionRangeSemantics(string requestedVersion, string resolvedVersion, bool expectedResult)
+ {
+ string packagePath = CreatePackageDirectory();
+ try
+ {
+ var engine = new BuildEngineStub();
+ var task = new ValidateProjectDataPackagesTask
+ {
+ BuildEngine = engine,
+ ProjectFilePath = "App.csproj",
+ PackageReferences = [CreateItem("Test.Package", ("Version", requestedVersion))],
+ ResolvedPackages = [CreateItem($"test.package/{resolvedVersion}", ("Name", "test.package"), ("Version", resolvedVersion), ("Path", packagePath))],
+ };
+
+ Assert.Equal(expectedResult, task.Execute());
+ Assert.Equal(expectedResult ? 0 : 1, engine.Errors.Count);
+ }
+ finally
+ {
+ Directory.Delete(packagePath, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void ValidatePackagesTask_UsesVersionOverrideBeforeDirectAndCentralVersions()
+ {
+ string packagePath = CreatePackageDirectory();
+ try
+ {
+ var engine = new BuildEngineStub();
+ var task = new ValidateProjectDataPackagesTask
+ {
+ BuildEngine = engine,
+ ProjectFilePath = "App.csproj",
+ ManagePackageVersionsCentrally = true,
+ PackageReferences = [CreateItem("Test.Package", ("Version", "1.0.0"), ("VersionOverride", "3.0.0"))],
+ PackageVersions = [CreateItem("Test.Package", ("Version", "2.0.0"))],
+ ResolvedPackages = [CreateItem("Test.Package/2.0.0", ("Name", "Test.Package"), ("Version", "2.0.0"), ("Path", packagePath))],
+ };
+
+ Assert.False(task.Execute());
+ Assert.Contains("requested '3.0.0', resolved '2.0.0'", Assert.Single(engine.Errors).Message);
+ }
+ finally
+ {
+ Directory.Delete(packagePath, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void ValidatePackagesTask_UsesCentralVersionWhenReferenceHasNoVersion()
+ {
+ string packagePath = CreatePackageDirectory();
+ try
+ {
+ var engine = new BuildEngineStub();
+ var task = new ValidateProjectDataPackagesTask
+ {
+ BuildEngine = engine,
+ ProjectFilePath = "App.csproj",
+ ManagePackageVersionsCentrally = true,
+ PackageReferences = [CreateItem("Test.Package")],
+ PackageVersions = [CreateItem("Test.Package", ("Version", "2.0.0"))],
+ ResolvedPackages = [CreateItem("Test.Package/1.0.0", ("Name", "Test.Package"), ("Version", "1.0.0"), ("Path", packagePath))],
+ };
+
+ Assert.False(task.Execute());
+ Assert.Contains("requested '2.0.0', resolved '1.0.0'", Assert.Single(engine.Errors).Message);
+ }
+ finally
+ {
+ Directory.Delete(packagePath, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void ValidatePackagesTask_RejectsStaleRequestedVersion()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string packagePath = Path.Combine(tempRoot, "test.package", "13.0.1");
+ Directory.CreateDirectory(packagePath);
+ string assetsFile = Path.Combine(tempRoot, "project.assets.json");
+ File.WriteAllText(
+ assetsFile,
+ """
+ {
+ "project": {
+ "frameworks": {
+ "net8.0": {
+ "dependencies": {
+ "Test.Package": {
+ "target": "Package",
+ "version": "[13.0.1, )"
+ }
+ }
+ }
+ }
+ }
+ }
+ """);
+ var engine = new BuildEngineStub();
+ var task = new ValidateProjectDataPackagesTask
+ {
+ BuildEngine = engine,
+ ProjectFilePath = "App.csproj",
+ AssetsFile = assetsFile,
+ TargetFramework = "net8.0",
+ PackageReferences = [CreateItem("Test.Package", ("Version", "12.0.3"))],
+ ResolvedPackages = [CreateItem("Test.Package/13.0.1", ("Name", "Test.Package"), ("Version", "13.0.1"), ("Path", packagePath))],
+ };
+
+ Assert.False(task.Execute());
+ Assert.Contains("current request '12.0.3', restored request '[13.0.1, )'", Assert.Single(engine.Errors).Message);
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Theory]
+ [InlineData("", "", "", null, null, true)]
+ [InlineData(";", "", "", null, null, true)]
+ [InlineData("", ";", " ; ", null, null, true)]
+ [InlineData("compile;build", "", "all", "Compile, Build", "All", true)]
+ [InlineData("compile,build", "", "", "None", null, true)]
+ [InlineData("all", "build", "", "Runtime, Compile, ContentFiles, Native, Analyzers, BuildTransitive", null, true)]
+ [InlineData("", "runtime", "", null, null, false)]
+ [InlineData("", "", "all", null, null, false)]
+ public void ValidatePackagesTask_ValidatesPackageAssetSelection(
+ string includeAssets,
+ string excludeAssets,
+ string privateAssets,
+ string? restoredInclude,
+ string? restoredSuppressParent,
+ bool expectedResult)
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string packagePath = Path.Combine(tempRoot, "test.package", "1.0.0");
+ Directory.CreateDirectory(packagePath);
+ string includeProperty = restoredInclude is null ? string.Empty : $", \"include\": \"{restoredInclude}\"";
+ string suppressParentProperty = restoredSuppressParent is null ? string.Empty : $", \"suppressParent\": \"{restoredSuppressParent}\"";
+ string assetsFile = Path.Combine(tempRoot, "project.assets.json");
+ File.WriteAllText(
+ assetsFile,
+ $$"""
+ {
+ "project": {
+ "frameworks": {
+ "net8.0": {
+ "dependencies": {
+ "Test.Package": {
+ "target": "Package",
+ "version": "[1.0.0, )"{{includeProperty}}{{suppressParentProperty}}
+ }
+ }
+ }
+ }
+ }
+ }
+ """);
+ var engine = new BuildEngineStub();
+ var task = new ValidateProjectDataPackagesTask
+ {
+ BuildEngine = engine,
+ ProjectFilePath = "App.csproj",
+ AssetsFile = assetsFile,
+ TargetFramework = "net8.0",
+ PackageReferences =
+ [
+ CreateItem(
+ "Test.Package",
+ ("Version", "1.0.0"),
+ ("IncludeAssets", includeAssets),
+ ("ExcludeAssets", excludeAssets),
+ ("PrivateAssets", privateAssets)),
+ ],
+ ResolvedPackages = [CreateItem("Test.Package/1.0.0", ("Name", "Test.Package"), ("Version", "1.0.0"), ("Path", packagePath))],
+ };
+
+ Assert.Equal(expectedResult, task.Execute());
+ if (!expectedResult)
+ {
+ Assert.Contains("current assets", Assert.Single(engine.Errors).Message);
+ }
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void ValidatePackagesTask_RejectsMissingDeclaredPackageAndResolvedFolder()
+ {
+ var engine = new BuildEngineStub();
+ string missingPath = Path.Combine(Path.GetTempPath(), "projectdata-missing-package-" + Guid.NewGuid().ToString("N"));
+ var task = new ValidateProjectDataPackagesTask
+ {
+ BuildEngine = engine,
+ ProjectFilePath = "App.csproj",
+ PackageReferences = [CreateItem("Declared.Package", ("Version", "1.0.0"))],
+ ResolvedPackages = [CreateItem("Other.Package/2.0.0", ("Name", "Other.Package"), ("Version", "2.0.0"), ("Path", missingPath))],
+ };
+
+ Assert.False(task.Execute());
+ Assert.Contains(engine.Errors, error => error.Message?.Contains("restore graph does not contain declared PackageReference items: Declared.Package", StringComparison.Ordinal) == true);
+ Assert.Contains(engine.Errors, error => error.Message?.Contains($"package files are missing: Other.Package/2.0.0 at {missingPath}", StringComparison.Ordinal) == true);
+ }
+
+ [Fact]
+ public void ValidatePackagesTask_RejectsRestoredRequestRemovedFromCurrentProject()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string assetsFile = Path.Combine(tempRoot, "project.assets.json");
+ File.WriteAllText(
+ assetsFile,
+ """
+ {
+ "project": {
+ "frameworks": {
+ "net8.0": {
+ "dependencies": {
+ "Removed.Package": {
+ "target": "Package",
+ "version": "[1.0.0, )"
+ }
+ }
+ }
+ }
+ }
+ }
+ """);
+ var engine = new BuildEngineStub();
+ var task = new ValidateProjectDataPackagesTask
+ {
+ BuildEngine = engine,
+ ProjectFilePath = "App.csproj",
+ AssetsFile = assetsFile,
+ TargetFramework = "net8.0",
+ };
+
+ Assert.False(task.Execute());
+ Assert.Contains("Removed.Package (restored request '[1.0.0, )', no current PackageReference)", Assert.Single(engine.Errors).Message);
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void ValidatePackagesTask_RejectsExplicitReferenceWithoutEvaluatedVersion()
+ {
+ string packagePath = CreatePackageDirectory();
+ try
+ {
+ var engine = new BuildEngineStub();
+ var task = new ValidateProjectDataPackagesTask
+ {
+ BuildEngine = engine,
+ ProjectFilePath = "App.csproj",
+ ManagePackageVersionsCentrally = true,
+ PackageReferences = [CreateItem("Central.Package")],
+ ResolvedPackages = [CreateItem("Central.Package/2.0.0", ("Name", "Central.Package"), ("Version", "2.0.0"), ("Path", packagePath))],
+ };
+
+ Assert.False(task.Execute());
+ Assert.Contains("declared PackageReference items have no evaluated version request: Central.Package", Assert.Single(engine.Errors).Message);
+ }
+ finally
+ {
+ Directory.Delete(packagePath, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void ValidatePackagesTask_ReportsMalformedRestoreGraphWithContext()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string packagePath = Path.Combine(tempRoot, "test.package", "1.0.0");
+ Directory.CreateDirectory(packagePath);
+ string assetsFile = Path.Combine(tempRoot, "project.assets.json");
+ File.WriteAllText(
+ assetsFile,
+ """
+ {
+ "project": {
+ "frameworks": {
+ "net8.0": []
+ }
+ }
+ }
+ """);
+ var engine = new BuildEngineStub();
+ var task = new ValidateProjectDataPackagesTask
+ {
+ BuildEngine = engine,
+ ProjectFilePath = "App.csproj",
+ AssetsFile = assetsFile,
+ TargetFramework = "net8.0",
+ PackageReferences = [CreateItem("Test.Package", ("Version", "1.0.0"))],
+ ResolvedPackages = [CreateItem("Test.Package/1.0.0", ("Name", "Test.Package"), ("Version", "1.0.0"), ("Path", packagePath))],
+ };
+
+ Assert.False(task.Execute());
+ BuildErrorEventArgs error = Assert.Single(engine.Errors);
+ Assert.Contains($"restore graph '{assetsFile}' could not be read", error.Message);
+ Assert.Contains("target framework 'net8.0' must be a JSON object", error.Message);
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void ValidatePackagesTask_ReportsMalformedDependencyTargetWithContext()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string assetsFile = Path.Combine(tempRoot, "project.assets.json");
+ File.WriteAllText(
+ assetsFile,
+ """
+ {
+ "project": {
+ "frameworks": {
+ "net8.0": {
+ "dependencies": {
+ "Broken.Package": {
+ "target": [],
+ "version": "[1.0.0, )"
+ }
+ }
+ }
+ }
+ }
+ }
+ """);
+ var engine = new BuildEngineStub();
+ var task = new ValidateProjectDataPackagesTask
+ {
+ BuildEngine = engine,
+ ProjectFilePath = "App.csproj",
+ AssetsFile = assetsFile,
+ TargetFramework = "net8.0",
+ };
+
+ Assert.False(task.Execute());
+ BuildErrorEventArgs error = Assert.Single(engine.Errors);
+ Assert.Contains($"restore graph '{assetsFile}' could not be read", error.Message);
+ Assert.Contains("dependency request 'Broken.Package' for target framework 'net8.0' has no string target", error.Message);
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void ValidatePackagesTask_IgnoresRestoredSdkAutoReferencedPackagesMissingFromCurrentEvaluation()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string assetsFile = Path.Combine(tempRoot, "project.assets.json");
+ File.WriteAllText(
+ assetsFile,
+ """
+ {
+ "project": {
+ "frameworks": {
+ "net10.0": {
+ "dependencies": {
+ "Aspire.Hosting.AppHost": {
+ "autoReferenced": true,
+ "target": "Package",
+ "version": "[13.3.5, )"
+ }
+ }
+ }
+ }
+ }
+ }
+ """);
+ var engine = new BuildEngineStub();
+ var task = new ValidateProjectDataPackagesTask
+ {
+ BuildEngine = engine,
+ ProjectFilePath = "AppHost.csproj",
+ AssetsFile = assetsFile,
+ TargetFramework = "net10.0",
+ };
+
+ Assert.True(task.Execute());
+ Assert.Empty(engine.Errors);
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void ValidatePackagesTask_MatchesCurrentReferenceToRestoredAutoReferencedRequest()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string packagePath = Path.Combine(tempRoot, "aspire.hosting.apphost", "13.3.5");
+ Directory.CreateDirectory(packagePath);
+ string assetsFile = WriteAutoReferencedAssetsFile(tempRoot, "true", "\"[13.3.5, )\"");
+ var engine = new BuildEngineStub();
+ var task = new ValidateProjectDataPackagesTask
+ {
+ BuildEngine = engine,
+ ProjectFilePath = "AppHost.csproj",
+ AssetsFile = assetsFile,
+ TargetFramework = "net10.0",
+ PackageReferences = [CreateItem("Aspire.Hosting.AppHost", ("Version", "13.3.5"))],
+ ResolvedPackages =
+ [
+ CreateItem(
+ "Aspire.Hosting.AppHost/13.3.5",
+ ("Name", "Aspire.Hosting.AppHost"),
+ ("Version", "13.3.5"),
+ ("Path", packagePath)),
+ ],
+ };
+
+ Assert.True(task.Execute());
+ Assert.Empty(engine.Errors);
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Theory]
+ [InlineData("false")]
+ [InlineData("\"true\"")]
+ public void ValidatePackagesTask_DoesNotIgnoreInvalidAutoReferencedMarkers(string autoReferenced)
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string assetsFile = WriteAutoReferencedAssetsFile(tempRoot, autoReferenced, "\"[13.3.5, )\"");
+ var engine = new BuildEngineStub();
+ var task = new ValidateProjectDataPackagesTask
+ {
+ BuildEngine = engine,
+ ProjectFilePath = "AppHost.csproj",
+ AssetsFile = assetsFile,
+ TargetFramework = "net10.0",
+ };
+
+ Assert.False(task.Execute());
+ Assert.Contains("no current PackageReference", Assert.Single(engine.Errors).Message);
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void ValidatePackagesTask_ReportsMalformedAutoReferencedVersionWithContext()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string assetsFile = WriteAutoReferencedAssetsFile(tempRoot, "true", "[]");
+ var engine = new BuildEngineStub();
+ var task = new ValidateProjectDataPackagesTask
+ {
+ BuildEngine = engine,
+ ProjectFilePath = "AppHost.csproj",
+ AssetsFile = assetsFile,
+ TargetFramework = "net10.0",
+ };
+
+ Assert.False(task.Execute());
+ BuildErrorEventArgs error = Assert.Single(engine.Errors);
+ Assert.Contains($"restore graph '{assetsFile}' could not be read", error.Message);
+ Assert.Contains("package dependency request 'Aspire.Hosting.AppHost'", error.Message);
+ Assert.Contains("has no string version", error.Message);
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ private static string WriteAutoReferencedAssetsFile(string tempRoot, string autoReferenced, string version)
+ {
+ string assetsFile = Path.Combine(tempRoot, "project.assets.json");
+ File.WriteAllText(
+ assetsFile,
+ $$"""
+ {
+ "project": {
+ "frameworks": {
+ "net10.0": {
+ "dependencies": {
+ "Aspire.Hosting.AppHost": {
+ "autoReferenced": {{autoReferenced}},
+ "target": "Package",
+ "version": {{version}}
+ }
+ }
+ }
+ }
+ }
+ }
+ """);
+ return assetsFile;
+ }
+
+ [Theory]
+ [InlineData("2.0.0", "2.0.0", true)]
+ [InlineData("2.0.0", "1.0.0", false)]
+ [InlineData(null, "2.0.0", false)]
+ [InlineData("2.0.0", null, false)]
+ public void ValidatePackagesTask_ValidatesActiveCentralTransitivePins(
+ string? restoredPinVersion,
+ string? currentPinVersion,
+ bool expectedResult)
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string restoredPin = restoredPinVersion is null
+ ? string.Empty
+ : $$"""
+ "Pinned.Package": {
+ "include": "Runtime, Compile",
+ "version": "[{{restoredPinVersion}}, )"
+ }
+ """;
+ string assetsFile = WriteCentralTransitiveAssetsFile(tempRoot, restoredPin);
+ var engine = new BuildEngineStub();
+ var task = new ValidateProjectDataPackagesTask
+ {
+ BuildEngine = engine,
+ ProjectFilePath = "App.csproj",
+ AssetsFile = assetsFile,
+ TargetFramework = "netstandard2.0",
+ TargetFrameworkMoniker = ".NETStandard,Version=v2.0",
+ ManagePackageVersionsCentrally = true,
+ CentralPackageTransitivePinningEnabled = true,
+ PackageVersions = currentPinVersion is null
+ ? []
+ : [CreateItem("Pinned.Package", ("Version", currentPinVersion))],
+ };
+
+ Assert.Equal(expectedResult, task.Execute());
+ if (expectedResult)
+ {
+ Assert.Empty(engine.Errors);
+ }
+ else
+ {
+ Assert.Contains(
+ engine.Errors,
+ error => error.Message?.Contains("central transitive package version requests differ from the restore graph", StringComparison.Ordinal) == true);
+ }
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void ValidatePackagesTask_IgnoresInactiveCentralVersionAndUnpinnedTransitivePackage()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string assetsFile = WriteCentralTransitiveAssetsFile(tempRoot, centralTransitiveRequests: string.Empty);
+ var engine = new BuildEngineStub();
+ var task = new ValidateProjectDataPackagesTask
+ {
+ BuildEngine = engine,
+ ProjectFilePath = "App.csproj",
+ AssetsFile = assetsFile,
+ TargetFramework = "netstandard2.0",
+ TargetFrameworkMoniker = ".NETStandard,Version=v2.0",
+ ManagePackageVersionsCentrally = true,
+ CentralPackageTransitivePinningEnabled = true,
+ PackageVersions =
+ [
+ CreateItem("Inactive.Package", ("Version", "5.0.0")),
+ CreateItem("Inactive.Resolved", ("Version", "5.0.0")),
+ ],
+ };
+
+ Assert.True(task.Execute());
+ Assert.Empty(engine.Errors);
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void ValidatePackagesTask_ReportsMalformedCentralTransitivePinWithContext()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string assetsFile = WriteCentralTransitiveAssetsFile(
+ tempRoot,
+ """
+ "Pinned.Package": {
+ "version": []
+ }
+ """);
+ var engine = new BuildEngineStub();
+ var task = new ValidateProjectDataPackagesTask
+ {
+ BuildEngine = engine,
+ ProjectFilePath = "App.csproj",
+ AssetsFile = assetsFile,
+ TargetFramework = "netstandard2.0",
+ TargetFrameworkMoniker = ".NETStandard,Version=v2.0",
+ CentralPackageTransitivePinningEnabled = true,
+ };
+
+ Assert.False(task.Execute());
+ BuildErrorEventArgs error = Assert.Single(engine.Errors);
+ Assert.Contains($"restore graph '{assetsFile}' could not be read", error.Message);
+ Assert.Contains("central transitive dependency request 'Pinned.Package'", error.Message);
+ Assert.Contains("has no string version", error.Message);
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void ValidatePackagesTask_ReportsMalformedResolvedTargetGraphWithContext()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string assetsFile = Path.Combine(tempRoot, "project.assets.json");
+ File.WriteAllText(
+ assetsFile,
+ """
+ {
+ "targets": {
+ "netstandard2.0": []
+ },
+ "centralTransitiveDependencyGroups": {},
+ "project": {
+ "restore": {
+ "CentralPackageTransitivePinningEnabled": true
+ },
+ "frameworks": {
+ "netstandard2.0": {
+ "dependencies": {}
+ }
+ }
+ }
+ }
+ """);
+ var engine = new BuildEngineStub();
+ var task = new ValidateProjectDataPackagesTask
+ {
+ BuildEngine = engine,
+ ProjectFilePath = "App.csproj",
+ AssetsFile = assetsFile,
+ TargetFramework = "netstandard2.0",
+ TargetFrameworkMoniker = ".NETStandard,Version=v2.0",
+ CentralPackageTransitivePinningEnabled = true,
+ };
+
+ Assert.False(task.Execute());
+ BuildErrorEventArgs error = Assert.Single(engine.Errors);
+ Assert.Contains($"restore graph '{assetsFile}' could not be read", error.Message);
+ Assert.Contains("resolved target graph for target framework 'netstandard2.0' must be a JSON object", error.Message);
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Theory]
+ [InlineData("false", false, true)]
+ [InlineData("true", true, true)]
+ [InlineData("false", true, false)]
+ [InlineData("true", false, false)]
+ public void ValidatePackagesTask_ValidatesCentralTransitivePinningMode(
+ string restoredPinningMode,
+ bool currentPinningEnabled,
+ bool expectedResult)
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string assetsFile = WriteCentralTransitiveAssetsFile(
+ tempRoot,
+ centralTransitiveRequests: string.Empty,
+ restoredPinningMode: restoredPinningMode);
+ var engine = new BuildEngineStub();
+ var task = new ValidateProjectDataPackagesTask
+ {
+ BuildEngine = engine,
+ ProjectFilePath = "App.csproj",
+ AssetsFile = assetsFile,
+ TargetFramework = "netstandard2.0",
+ TargetFrameworkMoniker = ".NETStandard,Version=v2.0",
+ ManagePackageVersionsCentrally = true,
+ CentralPackageTransitivePinningEnabled = currentPinningEnabled,
+ };
+
+ Assert.Equal(expectedResult, task.Execute());
+ Assert.Equal(
+ expectedResult ? 0 : 1,
+ engine.Errors.Count(error => error.Message?.Contains("central transitive package pinning mode differs from the restore graph", StringComparison.Ordinal) == true));
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ [Fact]
+ public void ValidatePackagesTask_ReportsMalformedCentralTransitivePinningModeWithContext()
+ {
+ string tempRoot = CreateTempRoot();
+ try
+ {
+ string assetsFile = WriteCentralTransitiveAssetsFile(
+ tempRoot,
+ centralTransitiveRequests: string.Empty,
+ restoredPinningMode: "\"true\"");
+ var engine = new BuildEngineStub();
+ var task = new ValidateProjectDataPackagesTask
+ {
+ BuildEngine = engine,
+ ProjectFilePath = "App.csproj",
+ AssetsFile = assetsFile,
+ TargetFramework = "netstandard2.0",
+ TargetFrameworkMoniker = ".NETStandard,Version=v2.0",
+ ManagePackageVersionsCentrally = true,
+ CentralPackageTransitivePinningEnabled = true,
+ };
+
+ Assert.False(task.Execute());
+ BuildErrorEventArgs error = Assert.Single(engine.Errors);
+ Assert.Contains($"restore graph '{assetsFile}' could not be read", error.Message);
+ Assert.Contains("central transitive package pinning mode in restore settings must be a JSON boolean", error.Message);
+ }
+ finally
+ {
+ DeleteTempRoot(tempRoot);
+ }
+ }
+
+ private static string WriteCentralTransitiveAssetsFile(
+ string tempRoot,
+ string centralTransitiveRequests,
+ string restoredPinningMode = "true")
+ {
+ string assetsFile = Path.Combine(tempRoot, "project.assets.json");
+ File.WriteAllText(
+ assetsFile,
+ $$"""
+ {
+ "targets": {
+ "netstandard2.0": {
+ "Pinned.Package/2.0.0": {
+ "type": "package"
+ },
+ "Normal.Transitive/1.0.0": {
+ "type": "package"
+ },
+ "Inactive.Resolved/5.0.0": {
+ "type": "package"
+ }
+ }
+ },
+ "centralTransitiveDependencyGroups": {
+ ".NETStandard,Version=v2.0": {
+ {{centralTransitiveRequests}}
+ }
+ },
+ "project": {
+ "restore": {
+ "CentralPackageTransitivePinningEnabled": {{restoredPinningMode}}
+ },
+ "frameworks": {
+ "netstandard2.0": {
+ "dependencies": {},
+ "centralPackageVersions": {
+ "Inactive.Resolved": "5.0.0"
+ }
+ }
+ }
+ }
+ }
+ """);
+ return assetsFile;
+ }
+
+ private static Microsoft.Build.Utilities.TaskItem CreateItem(string itemSpec, params (string Name, string Value)[] metadata)
+ {
+ var item = new Microsoft.Build.Utilities.TaskItem(itemSpec);
+ foreach ((string name, string value) in metadata)
+ {
+ item.SetMetadata(name, value);
+ }
+
+ return item;
+ }
+
+ private static string CreatePackageDirectory()
+ {
+ string path = Path.Combine(Path.GetTempPath(), "projectdata-package-validation-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(path);
+ return path;
+ }
+
+ private sealed class BuildEngineStub : IBuildEngine
+ {
+ public List Errors { get; } = [];
+ public List Warnings { get; } = [];
+ public List Messages { get; } = [];
+ public List CustomEvents { get; } = [];
+
+ public bool ContinueOnError => false;
+ public int LineNumberOfTaskNode => 0;
+ public int ColumnNumberOfTaskNode => 0;
+ public string ProjectFileOfTaskNode => string.Empty;
+
+ public bool BuildProjectFile(string projectFileName, string[] targetNames, System.Collections.IDictionary globalProperties, System.Collections.IDictionary targetOutputs)
+ => throw new NotSupportedException();
+
+ public void LogCustomEvent(CustomBuildEventArgs e) => this.CustomEvents.Add(e);
+ public void LogErrorEvent(BuildErrorEventArgs e) => this.Errors.Add(e);
+ public void LogMessageEvent(BuildMessageEventArgs e) => this.Messages.Add(e);
+ public void LogWarningEvent(BuildWarningEventArgs e) => this.Warnings.Add(e);
+ }
+
+ private static string CreateTempRoot()
+ {
+ string path = Path.Combine(Path.GetTempPath(), "projectdata-task-donor-tests-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(path);
+ return path;
+ }
+
+ private static void DeleteTempRoot(string tempRoot)
+ {
+ try
+ {
+ Directory.Delete(tempRoot, recursive: true);
+ }
+ catch
+ {
+ }
+ }
+
+ private static string JsonString(string value)
+ => "\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + "\"";
+
+ private static WriteProjectDataSliceTask CreateFinalWriteTask(
+ string workspaceRoot,
+ string indexPath,
+ BuildEngineStub? buildEngine = null)
+ {
+ string projectFile = Path.Combine(workspaceRoot, "src", "App", "App.csproj");
+ return new WriteProjectDataSliceTask
+ {
+ BuildEngine = buildEngine ?? new BuildEngineStub(),
+ ProjectFilePath = projectFile,
+ OutputPath = projectFile + ".lscache",
+ DonorCacheIndexPath = indexPath,
+ DonorCacheWorkspaceRoot = workspaceRoot,
+ WriteHeader = true,
+ IsPrimary = true,
+ LastDtbSucceeded = true,
+ CommandLineArguments = ["/noconfig"],
+ };
+ }
+
+ private static void WriteSlice(string slicePath, string assemblyName, string targetFramework)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(slicePath)!);
+ File.WriteAllText(
+ slicePath,
+ $$"""
+ [project]
+ project=App.csproj
+ language=C#
+ primary
+ lastDtbSucceeded
+
+ [sliceDimensions]
+ TargetFramework={{targetFramework}}
+
+ [properties]
+ AssemblyName={{assemblyName}}
+
+ [commandLineArguments]
+ /noconfig
+ """);
+ }
+}
diff --git a/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/XunitV2TestContext.cs b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/XunitV2TestContext.cs
new file mode 100644
index 0000000000000..f19a26bc51a4e
--- /dev/null
+++ b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks.Tests/XunitV2TestContext.cs
@@ -0,0 +1,15 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+namespace Microsoft.NET.ProjectData.Tasks.Tests;
+
+internal static class TestContext
+{
+ public static TestContextState Current { get; } = new();
+
+ internal sealed class TestContextState
+ {
+ public CancellationToken CancellationToken => System.Threading.CancellationToken.None;
+ }
+}
diff --git a/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/AnalyzerConfigFileFilter.cs b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/AnalyzerConfigFileFilter.cs
new file mode 100644
index 0000000000000..7796789bc9cf0
--- /dev/null
+++ b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/AnalyzerConfigFileFilter.cs
@@ -0,0 +1,228 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.Build.Framework;
+using Microsoft.NET.ProjectData;
+
+namespace Microsoft.NET.ProjectData.Tasks;
+
+internal static class AnalyzerConfigFileFilter
+{
+ public static List Prepare(
+ string[]? items,
+ CachePathResolver resolver,
+ ITaskItem[]? sourceFiles,
+ bool filterSdkAnalyzerConfigFiles)
+ {
+ var result = new List();
+ if (items == null || items.Length == 0) return new List();
+ foreach (string item in items)
+ {
+ if (string.IsNullOrEmpty(item)) continue;
+ string absolute = resolver.ToAbsolutePath(item);
+ string portable = resolver.ToPortable(item);
+ if (filterSdkAnalyzerConfigFiles && IsSdkAnalyzerConfigFilePath(portable))
+ {
+ continue;
+ }
+
+ result.Add(new AnalyzerConfigFilePath(absolute, portable));
+ }
+
+ List sourceDirectories = GetSourceDirectories(sourceFiles, resolver);
+ List rootEditorConfigPaths = FindRootEditorConfigPaths(result, sourceDirectories);
+ return result
+ .Where(item => !IsEditorConfigFilePath(item.AbsolutePath)
+ || IsEditorConfigApplicableToAnySourceFile(item.AbsolutePath, sourceDirectories, rootEditorConfigPaths))
+ .Select(item => item.PortablePath)
+ .OrderBy(static item => item, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ internal static bool IsSdkAnalyzerConfigFilePath(string? portablePath)
+ {
+ if (portablePath == null) return false;
+
+ const string netSdkPrefix = PathSentinels.NetSdk + "/Sdks/Microsoft.NET.Sdk/";
+ if (!portablePath.StartsWith(netSdkPrefix, StringComparison.OrdinalIgnoreCase)) return false;
+
+ string sdkRelative = portablePath.Substring(netSdkPrefix.Length);
+ const string analyzerConfigPrefix = "analyzers/build/config/";
+ if (sdkRelative.StartsWith(analyzerConfigPrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ string fileName = sdkRelative.Substring(analyzerConfigPrefix.Length);
+ if (fileName.IndexOf('/') >= 0) return false;
+
+ return fileName.StartsWith("analysislevel_", StringComparison.OrdinalIgnoreCase)
+ && fileName.EndsWith(".globalconfig", StringComparison.OrdinalIgnoreCase);
+ }
+
+ const string codeStylePrefix = "codestyle/";
+ if (sdkRelative.StartsWith(codeStylePrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ string rest = sdkRelative.Substring(codeStylePrefix.Length);
+ const string configMarker = "/build/config/";
+ int markerIndex = rest.IndexOf(configMarker, StringComparison.OrdinalIgnoreCase);
+ if (markerIndex <= 0) return false;
+
+ string language = rest.Substring(0, markerIndex);
+ string fileName = rest.Substring(markerIndex + configMarker.Length);
+ if (!IsSafeLogicalSegment(language) || fileName.IndexOf('/') >= 0) return false;
+
+ const string stylePrefix = "analysislevelstyle_";
+ const string globalConfigSuffix = ".globalconfig";
+ return fileName.StartsWith(stylePrefix, StringComparison.OrdinalIgnoreCase)
+ && fileName.EndsWith(globalConfigSuffix, StringComparison.OrdinalIgnoreCase);
+ }
+
+ return false;
+ }
+
+ private static List GetSourceDirectories(ITaskItem[]? sourceFiles, CachePathResolver resolver)
+ {
+ var result = new HashSet(StringComparers.Paths);
+ if (sourceFiles != null)
+ {
+ foreach (ITaskItem sourceFile in sourceFiles)
+ {
+ if (sourceFile == null) continue;
+
+ string itemSpec = sourceFile.ItemSpec;
+ if (string.IsNullOrEmpty(itemSpec)) continue;
+
+ string? directory = Path.GetDirectoryName(resolver.ToAbsolutePath(itemSpec));
+ if (!string.IsNullOrEmpty(directory))
+ {
+ result.Add(NormalizePathForComparison(directory));
+ }
+ }
+ }
+
+ if (result.Count == 0)
+ {
+ result.Add(NormalizePathForComparison(resolver.ProjectDirectory));
+ }
+
+ return result.ToList();
+ }
+
+ private static List FindRootEditorConfigPaths(
+ IEnumerable items,
+ IReadOnlyList sourceDirectories)
+ {
+ return items
+ .Select(static item => item.AbsolutePath)
+ .Where(IsEditorConfigFilePath)
+ .Where(path => sourceDirectories.Any(sourceDirectory =>
+ IsSameOrAncestorDirectory(Path.GetDirectoryName(path) ?? string.Empty, sourceDirectory)))
+ .Where(EditorConfigHasRootTrue)
+ .Select(NormalizePathForComparison)
+ .ToList();
+ }
+
+ private static bool IsEditorConfigApplicableToAnySourceFile(
+ string editorConfigPath,
+ IReadOnlyList sourceDirectories,
+ IReadOnlyList rootEditorConfigPaths)
+ {
+ string editorConfigDirectory = Path.GetDirectoryName(editorConfigPath) ?? string.Empty;
+ return sourceDirectories.Any(sourceDirectory => IsSameOrAncestorDirectory(editorConfigDirectory, sourceDirectory)
+ && !HasRootEditorConfigBetween(editorConfigPath, editorConfigDirectory, sourceDirectory, rootEditorConfigPaths));
+ }
+
+ private static bool HasRootEditorConfigBetween(
+ string editorConfigPath,
+ string editorConfigDirectory,
+ string sourceDirectory,
+ IReadOnlyList rootEditorConfigPaths)
+ {
+ foreach (string rootEditorConfigPath in rootEditorConfigPaths)
+ {
+ if (PathsEqual(editorConfigPath, rootEditorConfigPath)) continue;
+
+ string rootDirectory = Path.GetDirectoryName(rootEditorConfigPath) ?? string.Empty;
+ if (IsSameOrAncestorDirectory(rootDirectory, sourceDirectory) && IsAncestorDirectory(editorConfigDirectory, rootDirectory))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool EditorConfigHasRootTrue(string path)
+ {
+ if (!File.Exists(path)) return false;
+
+ foreach (string line in File.ReadLines(path))
+ {
+ string trimmed = line.Trim();
+ if (trimmed.Length == 0 || trimmed[0] == '#' || trimmed[0] == ';') continue;
+ if (trimmed[0] == '[') return false;
+
+ int equals = trimmed.IndexOf('=');
+ if (equals < 0) continue;
+
+ string key = trimmed.Substring(0, equals).Trim();
+ string value = trimmed.Substring(equals + 1).Trim();
+ if (string.Equals(key, "root", StringComparison.OrdinalIgnoreCase)
+ && string.Equals(value, "true", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool IsEditorConfigFilePath(string path)
+ => string.Equals(Path.GetFileName(path), ".editorconfig", StringComparison.OrdinalIgnoreCase);
+
+ private static bool IsSameOrAncestorDirectory(string candidateDirectory, string descendantDirectory)
+ => PathsEqual(candidateDirectory, descendantDirectory) || IsAncestorDirectory(candidateDirectory, descendantDirectory);
+
+ private static bool IsAncestorDirectory(string candidateDirectory, string descendantDirectory)
+ {
+ if (string.IsNullOrEmpty(candidateDirectory) || string.IsNullOrEmpty(descendantDirectory)) return false;
+
+ string normalizedCandidate = NormalizeDirectoryForComparison(candidateDirectory);
+ string normalizedDescendant = NormalizeDirectoryForComparison(descendantDirectory);
+ return normalizedDescendant.Length > normalizedCandidate.Length
+ && normalizedDescendant.StartsWith(normalizedCandidate, StringComparisons.Paths);
+ }
+
+ private static bool PathsEqual(string left, string right)
+ {
+ string normalizedLeft = Path.GetFullPath(left).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ string normalizedRight = Path.GetFullPath(right).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ return string.Equals(normalizedLeft, normalizedRight, StringComparisons.Paths);
+ }
+
+ private static string NormalizeDirectoryForComparison(string path)
+ {
+ string normalized = NormalizePathForComparison(path);
+ return normalized + Path.DirectorySeparatorChar;
+ }
+
+ private static string NormalizePathForComparison(string path)
+ => Path.GetFullPath(path).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+
+ private static bool IsSafeLogicalSegment(string value)
+ => !string.IsNullOrWhiteSpace(value)
+ && value.IndexOf("..", StringComparison.Ordinal) < 0
+ && value.IndexOf('/') < 0
+ && value.IndexOf('\\') < 0;
+
+ private readonly struct AnalyzerConfigFilePath
+ {
+ public AnalyzerConfigFilePath(string absolutePath, string portablePath)
+ {
+ this.AbsolutePath = absolutePath;
+ this.PortablePath = portablePath;
+ }
+
+ public string AbsolutePath { get; }
+ public string PortablePath { get; }
+ }
+}
diff --git a/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/CachePathResolver.cs b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/CachePathResolver.cs
new file mode 100644
index 0000000000000..1764c593d1b12
--- /dev/null
+++ b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/CachePathResolver.cs
@@ -0,0 +1,365 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using Microsoft.NET.ProjectData;
+
+namespace Microsoft.NET.ProjectData.Tasks;
+
+///
+/// Substitutes well-known absolute paths with sentinel tokens
+/// (<NUGET>, <DOTNET>, <NETFXREF>) so that
+/// emitted project data is portable across machines. Used both for full path
+/// sections () and for absolute paths embedded inside
+/// property values or command-line arguments ().
+///
+internal sealed class CachePathResolver
+{
+ private readonly string[] nugetFolders;
+ private readonly string[] dotnetRoots;
+ private readonly string? netFxRefRoot;
+ private readonly string projectDir;
+ private readonly StringComparison pathsComparison;
+
+ public CachePathResolver(string projectFilePath)
+ {
+ this.projectDir = Path.GetDirectoryName(projectFilePath) ?? string.Empty;
+ bool caseInsensitive = Path.DirectorySeparatorChar == '\\';
+ this.pathsComparison = caseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
+ this.nugetFolders = ResolveNuGetFolders();
+ this.dotnetRoots = ResolveDotNetRoots();
+ this.netFxRefRoot = ResolveNetFxRefRoot();
+ }
+
+ // For testing — allows injecting roots directly.
+ internal CachePathResolver(string projectDir, string[] nugetFolders, string[] dotnetRoots, string? netFxRefRoot)
+ {
+ this.projectDir = projectDir;
+ bool caseInsensitive = Path.DirectorySeparatorChar == '\\';
+ this.pathsComparison = caseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
+ this.nugetFolders = nugetFolders;
+ this.dotnetRoots = dotnetRoots;
+ this.netFxRefRoot = netFxRefRoot;
+ }
+
+ internal string ProjectDirectory => this.projectDir;
+
+ // Converts a full or project-relative file path to a portable /...
+ // form when it falls under one of the well-known roots, or to a project-relative
+ // path otherwise.
+ public string ToPortable(string inputPath)
+ {
+ if (string.IsNullOrEmpty(inputPath)) return inputPath;
+
+ string absolutePath = this.ToAbsolutePath(inputPath);
+
+ for (int i = 0; i < this.nugetFolders.Length; i++)
+ {
+ if (absolutePath.StartsWith(this.nugetFolders[i], this.pathsComparison))
+ return PathSentinels.Nuget + "/" + absolutePath.Substring(this.nugetFolders[i].Length).Replace('\\', '/');
+ }
+ for (int i = 0; i < this.dotnetRoots.Length; i++)
+ {
+ if (absolutePath.StartsWith(this.dotnetRoots[i], this.pathsComparison))
+ {
+ string portable = PathSentinels.Dotnet + "/" + absolutePath.Substring(this.dotnetRoots[i].Length).Replace('\\', '/');
+ return RewriteSdkPath(portable);
+ }
+ }
+ if (this.netFxRefRoot != null && absolutePath.StartsWith(this.netFxRefRoot, this.pathsComparison))
+ return PathSentinels.NetFxRef + "/" + absolutePath.Substring(this.netFxRefRoot.Length).Replace('\\', '/');
+
+ string relative = MakeRelative(this.projectDir, absolutePath).Replace('\\', '/');
+ return TryRewriteAsNuGetPp(relative) ?? relative;
+ }
+
+ internal string ToAbsolutePath(string inputPath)
+ {
+ if (string.IsNullOrEmpty(inputPath)) return inputPath;
+ return Path.IsPathRooted(inputPath)
+ ? Path.GetFullPath(inputPath)
+ : Path.GetFullPath(Path.Combine(this.projectDir, inputPath));
+ }
+
+ ///
+ /// Detects project-relative paths produced by the SDK's NuGet content-asset preprocessor
+ /// (format: obj/{Config}/{TFM}/NuGet/{XxHash3-16hex}/{PackageId}/{Version}/...)
+ /// and rewrites them to the <NUGETPP>/{PackageId}/{Version}/... sentinel form.
+ /// This makes the cache fully portable: the obj-relative prefix and the environment-dependent
+ /// hash are both removed. At read time, the reader resolves <NUGETPP> by scanning
+ /// for the actual hash directory under the project's intermediate output path.
+ ///
+ internal static string? TryRewriteAsNuGetPp(string relativePath)
+ {
+ // Pattern: .../NuGet/<16-hex-chars>///...
+ // We look for "/NuGet/" followed by exactly 16 hex characters and then "/"
+ const string NuGetSegment = "/NuGet/";
+ int nugetIdx = relativePath.IndexOf(NuGetSegment, StringComparison.OrdinalIgnoreCase);
+ if (nugetIdx < 0) return null;
+
+ int hashStart = nugetIdx + NuGetSegment.Length;
+ // Must have at least 16 chars + trailing '/'
+ if (hashStart + 17 > relativePath.Length) return null;
+ if (relativePath[hashStart + 16] != '/') return null;
+
+ // Validate all 16 characters are hex digits
+ for (int i = 0; i < 16; i++)
+ {
+ char c = relativePath[hashStart + i];
+ if (!((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f')))
+ return null;
+ }
+
+ // Everything after the hash+separator is "//..."
+ string packageRelativePath = relativePath.Substring(hashStart + 17);
+ return PathSentinels.NugetPp + "/" + packageRelativePath;
+ }
+
+ // /sdk//Sdks/Microsoft.NET.Sdk/analyzers/Foo.dll
+ // → /Sdks/Microsoft.NET.Sdk/analyzers/Foo.dll
+ //
+ // The version segment is dropped because:
+ // - the contents of /sdk// are otherwise identical across SDK
+ // patch releases (analyzer DLLs, global configs);
+ // - keeping the version in the cache would invalidate every project's lscache
+ // on every SDK patch with no behavioral benefit.
+ //
+ // The reader's CachePathResolver requires a caller-supplied SDK binding to
+ // resolve ; the cache file itself is intentionally environment-agnostic.
+ internal static string RewriteSdkPath(string portable)
+ {
+ const string SdkPrefix = PathSentinels.Dotnet + "/sdk/";
+ if (!portable.StartsWith(SdkPrefix, StringComparison.Ordinal)) return portable;
+
+ int versionEnd = portable.IndexOf('/', SdkPrefix.Length);
+ if (versionEnd < 0) return portable;
+
+ return PathSentinels.NetSdk + portable.Substring(versionEnd);
+ }
+
+ // Finds an absolute path embedded anywhere inside a property value or
+ // command-line argument and replaces the first match with its sentinel form,
+ // preserving any surrounding text.
+ public string MakePortable(string text)
+ {
+ if (string.IsNullOrEmpty(text)) return text;
+
+ for (int i = 0; i < this.nugetFolders.Length; i++)
+ {
+ int idx = text.IndexOf(this.nugetFolders[i], this.pathsComparison);
+ if (idx >= 0)
+ return FormatEmbedded(text, idx, PathSentinels.Nuget, idx + this.nugetFolders[i].Length);
+ }
+ for (int i = 0; i < this.dotnetRoots.Length; i++)
+ {
+ int idx = text.IndexOf(this.dotnetRoots[i], this.pathsComparison);
+ if (idx >= 0)
+ {
+ string portable = FormatEmbedded(text, idx, PathSentinels.Dotnet, idx + this.dotnetRoots[i].Length);
+ return RewriteEmbeddedSdkPath(portable, idx);
+ }
+ }
+ if (this.netFxRefRoot != null)
+ {
+ int idx = text.IndexOf(this.netFxRefRoot, this.pathsComparison);
+ if (idx >= 0)
+ return FormatEmbedded(text, idx, PathSentinels.NetFxRef, idx + this.netFxRefRoot.Length);
+ }
+
+ int pathStart = FindAbsolutePathStart(text);
+ if (pathStart >= 0)
+ {
+ string prefix = text.Substring(0, pathStart);
+ string absolutePath = text.Substring(pathStart);
+ string relativePath = MakeRelative(this.projectDir, absolutePath).Replace('\\', '/');
+ return prefix + PathSentinels.Path + relativePath;
+ }
+
+ return text.Replace('\\', '/');
+ }
+
+ private static string FormatEmbedded(string source, int prefixLength, string sentinel, int suffixStart)
+ {
+ string prefix = source.Substring(0, prefixLength);
+ string suffix = source.Substring(suffixStart).Replace('\\', '/');
+ return prefix + sentinel + "/" + suffix;
+ }
+
+ // Same rewrite rule as RewriteSdkPath but for embedded matches: scans the
+ // result for "/sdk//" and rewrites to "/".
+ // sentinelStart marks where the original prefix ended; we look just after
+ // that for the embedded "" marker so we don't accidentally rewrite
+ // unrelated text earlier in the string.
+ internal static string RewriteEmbeddedSdkPath(string portable, int sentinelStart)
+ {
+ const string SdkSegment = PathSentinels.Dotnet + "/sdk/";
+ int idx = portable.IndexOf(SdkSegment, sentinelStart, StringComparison.Ordinal);
+ if (idx < 0) return portable;
+
+ int versionEnd = portable.IndexOf('/', idx + SdkSegment.Length);
+ if (versionEnd < 0) return portable;
+
+ return portable.Substring(0, idx) + PathSentinels.NetSdk + portable.Substring(versionEnd);
+ }
+
+ // Finds the start index of an absolute path embedded in text.
+ // Windows: looks for `[A-Za-z]:\` or `[A-Za-z]:/` not preceded by a letter-or-digit.
+ // Unix: looks for `/...` at start or after `:`, `"`, or ` `.
+ internal static int FindAbsolutePathStart(string text)
+ {
+ for (int i = 0; i <= text.Length - 3; i++)
+ {
+ if (char.IsLetter(text[i]) && text[i + 1] == ':' && (text[i + 2] == '\\' || text[i + 2] == '/'))
+ {
+ if (i == 0 || !char.IsLetterOrDigit(text[i - 1]))
+ return i;
+ }
+ }
+
+ if (Path.DirectorySeparatorChar != '\\')
+ {
+ for (int i = 0; i < text.Length - 1; i++)
+ {
+ if (text[i] == '/' && char.IsLetter(text[i + 1]))
+ {
+ if (i == 0 || text[i - 1] == ':' || text[i - 1] == '"' || text[i - 1] == ' ')
+ {
+ int nextSlash = text.IndexOf('/', i + 1);
+ if (nextSlash > i + 1)
+ {
+ int colon = text.IndexOf(':', i + 1);
+ if (colon >= 0 && colon < nextSlash) continue;
+ return i;
+ }
+ }
+ }
+ }
+ }
+ return -1;
+ }
+
+ // Returns the longest common directory prefix (ending with '/') of two forward-slash paths,
+ // or null if there is no common directory prefix.
+ internal static string? FindSharedDirPrefix(string a, string b)
+ {
+ int minLen = Math.Min(a.Length, b.Length);
+ int lastSlash = -1;
+ for (int i = 0; i < minLen; i++)
+ {
+ if (char.ToUpperInvariant(a[i]) != char.ToUpperInvariant(b[i]))
+ break;
+ if (a[i] == '/')
+ lastSlash = i;
+ }
+ return lastSlash < 0 ? null : a.Substring(0, lastSlash + 1);
+ }
+
+ // netstandard2.0-compatible MakeRelative. Both paths must be absolute.
+ // Output is '/'-normalized by callers.
+ internal static string MakeRelative(string basePath, string fullPath)
+ {
+ if (string.IsNullOrEmpty(basePath)) return fullPath;
+ string normalizedBase = basePath.Replace('\\', '/');
+ if (!normalizedBase.EndsWith("/")) normalizedBase += "/";
+ string normalizedFull = fullPath.Replace('\\', '/');
+ bool ci = Path.DirectorySeparatorChar == '\\';
+ StringComparison cmp = ci ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
+
+ string[] baseSegs = normalizedBase.TrimEnd('/').Split('/');
+ string[] fullSegs = normalizedFull.Split('/');
+ int common = 0;
+ int limit = Math.Min(baseSegs.Length, fullSegs.Length);
+ while (common < limit && string.Equals(baseSegs[common], fullSegs[common], cmp))
+ common++;
+
+ var rel = new System.Text.StringBuilder();
+ for (int i = common; i < baseSegs.Length; i++)
+ {
+ if (rel.Length > 0) rel.Append('/');
+ rel.Append("..");
+ }
+ for (int i = common; i < fullSegs.Length; i++)
+ {
+ if (rel.Length > 0) rel.Append('/');
+ rel.Append(fullSegs[i]);
+ }
+ return rel.Length == 0 ? "." : rel.ToString();
+ }
+
+ private static string[] ResolveNuGetFolders()
+ {
+ string? envVal = Environment.GetEnvironmentVariable("NUGET_PACKAGES");
+ if (!string.IsNullOrWhiteSpace(envVal))
+ return [NormalizeFolderPath(envVal)];
+
+ string userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ if (!string.IsNullOrEmpty(userProfile))
+ return [NormalizeFolderPath(Path.Combine(userProfile, ".nuget", "packages"))];
+
+ return [];
+ }
+
+ private static string[] ResolveDotNetRoots()
+ {
+ var list = new List();
+ void Add(string? candidate)
+ {
+ if (string.IsNullOrEmpty(candidate) || !Directory.Exists(candidate)) return;
+ string norm = NormalizeFolderPath(candidate!);
+ foreach (var existing in list)
+ if (string.Equals(existing, norm, StringComparison.OrdinalIgnoreCase)) return;
+ list.Add(norm);
+ }
+
+ Add(Environment.GetEnvironmentVariable("DOTNET_ROOT"));
+ Add(TryGetDotNetRootFromHostPath(Environment.GetEnvironmentVariable("DOTNET_HOST_PATH")));
+ Add(TryGetDotNetRootFromSdkPath(AppContext.BaseDirectory));
+ if (Path.DirectorySeparatorChar == '\\')
+ {
+ string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
+ if (!string.IsNullOrEmpty(programFiles)) Add(Path.Combine(programFiles, "dotnet"));
+ }
+ else
+ {
+ Add("/usr/share/dotnet");
+ Add("/usr/local/share/dotnet");
+ }
+ string userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ if (string.IsNullOrEmpty(userHome)) userHome = Environment.GetEnvironmentVariable("HOME") ?? string.Empty;
+ if (!string.IsNullOrEmpty(userHome)) Add(Path.Combine(userHome, ".dotnet"));
+ return list.ToArray();
+ }
+
+ internal static string? TryGetDotNetRootFromHostPath(string? hostPath)
+ {
+ if (string.IsNullOrWhiteSpace(hostPath) || !Path.IsPathRooted(hostPath)) return null;
+ return Path.GetDirectoryName(Path.GetFullPath(hostPath));
+ }
+
+ internal static string? TryGetDotNetRootFromSdkPath(string? sdkPath)
+ {
+ if (string.IsNullOrWhiteSpace(sdkPath)) return null;
+
+ DirectoryInfo sdkVersionDirectory = new DirectoryInfo(Path.GetFullPath(sdkPath));
+ DirectoryInfo? sdkDirectory = sdkVersionDirectory.Parent;
+ if (sdkDirectory is null || !string.Equals(sdkDirectory.Name, "sdk", StringComparison.OrdinalIgnoreCase)) return null;
+ return sdkDirectory.Parent?.FullName;
+ }
+
+ private static string? ResolveNetFxRefRoot()
+ {
+ if (Path.DirectorySeparatorChar != '\\') return null;
+ string pfx86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
+ if (string.IsNullOrEmpty(pfx86)) return null;
+ string candidate = Path.Combine(pfx86, "Reference Assemblies", "Microsoft", "Framework", ".NETFramework");
+ return Directory.Exists(candidate) ? NormalizeFolderPath(candidate) : null;
+ }
+
+ internal static string NormalizeFolderPath(string path)
+ {
+ string full = Path.GetFullPath(path);
+ if (full.Length == 0 || full[full.Length - 1] != Path.DirectorySeparatorChar)
+ full += Path.DirectorySeparatorChar;
+ return full;
+ }
+}
diff --git a/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/ForwardCompat.cs b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/ForwardCompat.cs
new file mode 100644
index 0000000000000..22245c1d4a793
--- /dev/null
+++ b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/ForwardCompat.cs
@@ -0,0 +1,938 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Collections.Generic;
+using Microsoft.NET.ProjectData;
+
+namespace Microsoft.NET.ProjectData.Tasks;
+
+///
+/// Forward-compatibility support for the .lscache format.
+///
+/// The cache version is version=<major>[.<minor>]. A newer minor
+/// only adds data an older build does not understand — new sections, new [properties] keys,
+/// or new item @metadata. So a teammate on an older C# Dev Kit must, when it regenerates a
+/// file authored by a newer minor, carry that unknown data through losslessly (so
+/// the file does not churn back and forth between versions) while otherwise writing exactly the
+/// content it knows about.
+///
+/// The candidate content an older build produces never contains future data (it generates
+/// from MSBuild inputs only). Preservation therefore means: extract the fragments of the
+/// existing file this build does not recognize and splice them, verbatim, into the freshly
+/// generated candidate. Known content is never reserialized, so it is byte-stable.
+///
+/// "Unknown" is defined against what the current writer can emit — the schema's
+/// emittable subset: a section not in , a [properties]
+/// key not in (the schema's cached properties), or an item
+/// @metadata key not emitted for that section's item type (see
+/// ). Metadata is judged per section, not as a flattened
+/// union, so a newer minor that reuses an existing metadata name on a different item type is still
+/// preserved rather than mistaken for regenerable data.
+///
+internal static class ForwardCompat
+{
+ // Sections the current writer emits. Schema-generated; matched case-sensitively because
+ // section names are emitted and matched verbatim.
+ private static readonly HashSet KnownSections = new HashSet(CacheFormat.Sections.All, StringComparer.Ordinal);
+
+ // Item @metadata keys the writer emits, resolved PER cache section rather than as a flattened
+ // union across all item types. Schema-generated from each section's itemType link
+ // (CacheFormat.Sections.MetadataBySection). Case-insensitive because the wire form diverges from
+ // the schema casing (the writer emits "@link" for the "Link" metadata). A section absent from
+ // this map emits no metadata, so every @metadata found under it is unknown (and therefore
+ // preserved). Keying per section is what lets a newer minor reuse a known metadata NAME on a
+ // different item type: the older writer that does not emit that name there still treats it as
+ // unknown and carries it forward, instead of mistaking it for data it can regenerate and
+ // dropping it.
+ internal static readonly Dictionary> KnownMetadataBySection = BuildKnownMetadataBySection();
+
+ private static Dictionary> BuildKnownMetadataBySection()
+ {
+ var map = new Dictionary>(StringComparer.Ordinal);
+ foreach (KeyValuePair entry in CacheFormat.Sections.MetadataBySection)
+ map[entry.Key] = new HashSet(entry.Value, StringComparer.OrdinalIgnoreCase);
+
+ return map;
+ }
+
+ ///
+ /// The exact set of [properties] keys the writer can emit: every schema property
+ /// (), which the generated _ProjectDataProperties
+ /// MSBuild allow-list mirrors. Using the emittable set is what makes "a brand-new key appeared"
+ /// detectable as forward-compat data rather than as a known-but-currently-absent property.
+ ///
+ internal static readonly HashSet KnownPropertyKeys = new HashSet(ProjectProperties.All, StringComparer.OrdinalIgnoreCase);
+
+ private const string VersionLinePrefix = "version=";
+
+ // Identity assigned to the shared/primary segment (the block before the first "---", or the
+ // sole segment of a single-target file). It has no [sliceDimensions], so it cannot collide
+ // with a per-TFM slice identity.
+ private const string SharedSegmentIdentity = "\u0000shared";
+
+ ///
+ /// Returns augmented with any forward-compatible data found in
+ /// that the current writer does not produce, or the same
+ /// reference when there is nothing to preserve.
+ ///
+ /// The current on-disk cache content (with any legacy hash=
+ /// line already stripped).
+ /// The freshly generated cache content.
+ /// The major version this build understands. Preservation only runs
+ /// when the existing file's major matches; a different major is an incompatible format and the
+ /// caller overwrites it wholesale.
+ internal static string PreserveUnknownData(string existingText, string candidateText, int currentMajor)
+ {
+ // Forward-compat preservation is pay-for-play. The expensive parse + scan below only ever
+ // needs to run when the existing file was authored by a NEWER minor than this writer emits.
+ // By the "bump the minor whenever the emittable section / [properties] key / item @metadata
+ // set grows" invariant (guarded by ForwardCompatTests.AddingEmittableField_RequiresMinorBump),
+ // a same-or-older minor cannot contain anything this writer does not already produce: there
+ // is nothing to preserve and no higher stamp to carry forward. Probing just the two version
+ // headers is a zero-allocation span scan — splitting and parsing the whole file (hundreds of
+ // KB of transient allocation on a large cache) is reserved for the rare newer-minor case.
+ // Today, with no minor field shipped, every file takes this fast path.
+ if (!TryReadVersionHeader(existingText, out int existingMajor, out int existingMinor)
+ || existingMajor != currentMajor)
+ {
+ // A different major (or unrecognized header) is an incompatible format the caller
+ // overwrites wholesale; an empty/headerless existing file has nothing to preserve.
+ return candidateText;
+ }
+
+ int candidateMinor = 0;
+ if (TryReadVersionHeader(candidateText, out int candidateMajor, out int parsedCandidateMinor)
+ && candidateMajor == currentMajor)
+ {
+ candidateMinor = parsedCandidateMinor;
+ }
+
+ if (existingMinor <= candidateMinor)
+ return candidateText;
+
+ // ---- Newer-minor path: the existing file may carry data this writer cannot regenerate. ----
+ List existingSegments = ParseSegments(existingText);
+ if (existingSegments.Count == 0)
+ return candidateText;
+
+ Segment existingPrimary = existingSegments[0];
+
+ // Index existing segments by slice identity so we can match them to candidate segments.
+ var existingByIdentity = new Dictionary(StringComparer.Ordinal);
+ foreach (Segment seg in existingSegments)
+ existingByIdentity[seg.Identity] = seg;
+
+ // Split the candidate once into raw lines, shared between the splice target (candidateLines,
+ // which must preserve exact bytes for round-trip) and the structural parse below. Splitting
+ // on '\n' and re-joining on '\n' round-trips the exact bytes, so known content is never
+ // reformatted; the parser only reads the array, so one split serves both consumers.
+ string[] candidateRawLines = candidateText.Split('\n');
+ var candidateLines = new List(candidateRawLines);
+ List candidateSegments = ParseSegments(candidateRawLines);
+
+ var insertions = new Dictionary>();
+ // Whole-section appends are tracked separately from item-local insertions (metadata,
+ // properties). At a shared anchor — e.g. when the last item in the file both carries unknown
+ // @metadata and is the line we append trailing unknown sections after — the item-local
+ // insertions must come FIRST so the @metadata stays attached to its item; the appended
+ // section then follows. Reassemble emits `insertions` before `appends` at each line.
+ var appends = new Dictionary>();
+ bool changed = false;
+
+ foreach (Segment candidateSeg in candidateSegments)
+ {
+ if (!existingByIdentity.TryGetValue(candidateSeg.Identity, out Segment? existingSeg))
+ continue;
+
+ changed |= PreserveSegment(existingSeg, candidateSeg, candidateLines, insertions, appends);
+ }
+
+ // Carry the newer minor-version stamp forward so the file does not flip-flop between minors
+ // as different versions open it. We only reach here when existingMinor > candidateMinor, so
+ // existingMinor is always >= 1; the parsed primary stamp is authoritative and the reconstructed
+ // fallback (used only if the primary segment somehow lacks a version line) always carries it.
+ if (candidatePrimary(candidateSegments) is { VersionLineIndex: >= 0 } cp)
+ {
+ candidateLines[cp.VersionLineIndex] = existingPrimary.VersionLine
+ ?? $"{VersionLinePrefix}{existingMajor}.{existingMinor}";
+ changed = true;
+ }
+
+ if (!changed)
+ return candidateText;
+
+ return Reassemble(candidateLines, insertions, appends);
+
+ static Segment? candidatePrimary(List segs) => segs.Count > 0 ? segs[0] : null;
+ }
+
+ ///