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; + } + + /// + /// Parses the first version= header in directly off the underlying + /// span without allocating, so the preservation fast path never splits the whole file. Blank lines, + /// leading comments, and a leading legacy hash= header are skipped (mirroring the reader); + /// the first version= line wins. Returns when the first non-blank, + /// non-comment, non-hash line is not a version line. + /// + internal static bool TryReadVersionHeader(string text, out int major, out int minor) + { + major = -1; + minor = 0; + int pos = 0; + int len = text.Length; + while (pos < len) + { + int nl = text.IndexOf('\n', pos); + int lineEnd = nl < 0 ? len : nl; + int trimmedEnd = lineEnd > pos && text[lineEnd - 1] == '\r' ? lineEnd - 1 : lineEnd; + + if (trimmedEnd > pos) + { + ReadOnlySpan line = text.AsSpan(pos, trimmedEnd - pos); + if (line.StartsWith(VersionLinePrefix.AsSpan(), StringComparison.Ordinal)) + return TryParseVersion(line, out major, out minor); + if (line.StartsWith("hash=".AsSpan(), StringComparison.Ordinal)) + { + // Legacy header: skip it and keep looking for the version line (mirrors the reader). + if (nl < 0) + break; + pos = nl + 1; + continue; + } + if (line[0] != CacheFormat.CommentChar) + return false; // first real content is not a version line + } + + if (nl < 0) + break; + pos = nl + 1; + } + + return false; + } + + /// + /// Byte-level precondition for : returns + /// only when was authored by a strictly-NEWER minor of the + /// SAME major as this writer — the one case in which would do + /// any work (see its version gate at lines ~98-114). It mirrors that gate exactly, but reads the + /// two version= headers straight off the UTF-8 bytes, so the writer can decide whether to + /// preserve without decoding the whole existing/candidate file to . + /// On the overwhelmingly common same-version change this skips two ~file-sized string + /// allocations (and the LOH traffic they cause on large caches). + /// + /// + /// Conservative by construction: any header it cannot parse, or a different major, yields + /// — matching 's own "unrecognized + /// header / different major → nothing to preserve" branch. The cache header is ASCII and both the + /// byte and char parsers apply identical blank-line / comment-skip rules, so the two parses always + /// agree for the same content. re-checks the gate itself, so even + /// an over-permissive result here only wastes the rare-path allocation; it can never drop data. + /// + internal static bool ExistingHasNewerMinor(ReadOnlySpan existingContent, ReadOnlySpan candidateContent, int currentMajor) + { + if (!TryReadVersionHeader(existingContent, out int existingMajor, out int existingMinor) + || existingMajor != currentMajor) + { + return false; + } + + int candidateMinor = 0; + if (TryReadVersionHeader(candidateContent, out int candidateMajor, out int parsedCandidateMinor) + && candidateMajor == currentMajor) + { + candidateMinor = parsedCandidateMinor; + } + + return existingMinor > candidateMinor; + } + + /// + /// Returns when the existing content was authored by an older minor of + /// the current major and is otherwise byte-for-byte identical to the candidate. + /// + /// + /// A minor version is a compatibility marker for the file's payload, not a mandatory stamp of the + /// writer binary that last evaluated the project. An existing older stamp remains valid when no + /// new data was emitted, avoiding a rewrite of every cache after an additive schema change. + /// + internal static bool MatchesExceptForOlderMinorVersion( + ReadOnlySpan existingContent, + ReadOnlySpan candidateContent, + int currentMajor) + { + if (!TryReadVersionHeader( + existingContent, + out int existingMajor, + out int existingMinor, + out int existingVersionStart, + out int existingVersionLength) + || existingMajor != currentMajor + || !TryReadVersionHeader( + candidateContent, + out int candidateMajor, + out int candidateMinor, + out int candidateVersionStart, + out int candidateVersionLength) + || candidateMajor != currentMajor + || existingMinor >= candidateMinor) + { + return false; + } + + return existingContent.Slice(0, existingVersionStart) + .SequenceEqual(candidateContent.Slice(0, candidateVersionStart)) + && existingContent.Slice(existingVersionStart + existingVersionLength) + .SequenceEqual(candidateContent.Slice(candidateVersionStart + candidateVersionLength)); + } + + /// + /// UTF-8 byte counterpart of : finds + /// the first version= header, skipping blank lines, comment lines, and a leading legacy + /// hash= header, without allocating. Returns when the first real + /// content line is not a version line. + /// + private static bool TryReadVersionHeader(ReadOnlySpan text, out int major, out int minor) + => TryReadVersionHeader(text, out major, out minor, out _, out _); + + private static bool TryReadVersionHeader( + ReadOnlySpan text, + out int major, + out int minor, + out int versionStart, + out int versionLength) + { + major = -1; + minor = 0; + versionStart = -1; + versionLength = 0; + int pos = 0; + int len = text.Length; + while (pos < len) + { + int rel = text.Slice(pos).IndexOf((byte)'\n'); + int nl = rel < 0 ? -1 : pos + rel; + int lineEnd = nl < 0 ? len : nl; + int trimmedEnd = lineEnd > pos && text[lineEnd - 1] == (byte)'\r' ? lineEnd - 1 : lineEnd; + + if (trimmedEnd > pos) + { + ReadOnlySpan line = text.Slice(pos, trimmedEnd - pos); + if (line.StartsWith(VersionLinePrefixUtf8)) + { + if (!TryParseVersion(line, out major, out minor)) + return false; + + versionStart = pos; + versionLength = trimmedEnd - pos; + return true; + } + if (line.StartsWith("hash="u8)) + { + // Legacy header: skip it and keep looking for the version line (mirrors the reader). + if (nl < 0) + break; + pos = nl + 1; + continue; + } + if (line[0] != (byte)CacheFormat.CommentChar) + return false; // first real content is not a version line + } + + if (nl < 0) + break; + pos = nl + 1; + } + + return false; + } + + private static bool TryParseVersion(ReadOnlySpan versionLine, out int major, out int minor) + { + major = -1; + minor = 0; + if (!versionLine.StartsWith(VersionLinePrefixUtf8)) + return false; + + ReadOnlySpan value = versionLine.Slice(VersionLinePrefixUtf8.Length); + int dot = value.IndexOf((byte)'.'); + ReadOnlySpan majorPart = dot >= 0 ? value.Slice(0, dot) : value; + ReadOnlySpan minorPart = dot >= 0 ? value.Slice(dot + 1) : "0"u8; + + if (!TryParseNonNegativeInt(majorPart, out major) || major <= 0) + { + major = -1; + return false; + } + + // A present-but-malformed or multi-part minor (e.g. "0.5", "3-preview") cannot be ordered as a + // plain integer. Treat it as the newest-possible minor so the preservation gate stays + // CONSERVATIVE: it runs the full preserve rather than skipping, and so never drops unknown + // data it cannot prove is older. The writer only ever emits an integer minor; this guards + // against a future/rogue writer that doesn't. The major still governs read compatibility. + if (!TryParseNonNegativeInt(minorPart, out minor)) + minor = int.MaxValue; + + return true; + } + + // netstandard2.0 lacks int.TryParse(ReadOnlySpan, ...); this zero-allocation parser keeps the + // preservation pre-check off the heap. Accepts only non-negative decimal integers. + private static bool TryParseNonNegativeInt(ReadOnlySpan span, out int value) + { + value = 0; + if (span.Length == 0) + return false; + + long acc = 0; + foreach (byte b in span) + { + if (b < (byte)'0' || b > (byte)'9') + return false; + acc = (acc * 10) + (b - '0'); + if (acc > int.MaxValue) + return false; + } + + value = (int)acc; + return true; + } + + private static ReadOnlySpan VersionLinePrefixUtf8 => "version="u8; + + private static bool PreserveSegment( + Segment existing, + Segment candidate, + List candidateLines, + Dictionary> insertions, + Dictionary> appends) + { + bool changed = false; + changed |= PreserveUnknownSections(existing, candidate, appends); + changed |= PreserveUnknownProperties(existing, candidate, candidateLines, insertions); + changed |= PreserveUnknownMetadata(existing, candidate, insertions); + return changed; + } + + // --- Unknown whole sections ------------------------------------------------------------------ + + private static bool PreserveUnknownSections(Segment existing, Segment candidate, Dictionary> appends) + { + // Preserve unknown sections near their existing known-section neighbors rather than appending + // everything to the segment end or re-sorting. Re-sorting would rewrite the newer writer's + // canonical layout and produce mixed-version flip-flop churn: a newer minor emits an additive + // section in its own order, an older minor here would move it, the newer minor moves it back, + // and so on. Round-tripping the bytes we found keeps the file stable. + var candidateSectionsByName = new Dictionary(StringComparer.Ordinal); + foreach (Section section in candidate.Sections) + { + if (section.Name is not null && !candidateSectionsByName.ContainsKey(section.Name)) + candidateSectionsByName.Add(section.Name, section); + } + + List? pendingUnknownSections = null; + bool changed = false; + foreach (Section section in existing.Sections) + { + if (section.Name is null) + continue; + + if (!KnownSections.Contains(section.Name)) + { + // Defensive: never duplicate a section the candidate somehow already has. + if (candidateSectionsByName.ContainsKey(section.Name)) + continue; + + pendingUnknownSections ??= new List(); + pendingUnknownSections.Add(string.Empty); + pendingUnknownSections.Add(CacheFormat.SectionHeader(section.Name)); + pendingUnknownSections.AddRange(section.Lines); + changed = true; + continue; + } + + if (pendingUnknownSections is not null + && candidateSectionsByName.TryGetValue(section.Name, out Section? candidateSection)) + { + AddInsertion(appends, LineBeforeSectionWithOptionalLeadingBlank(candidate, candidateSection), pendingUnknownSections); + pendingUnknownSections = null; + } + } + + if (pendingUnknownSections is not null) + { + AddInsertion(appends, candidate.LastContentLineIndex, pendingUnknownSections); + } + + return changed; + } + + private static int LineBeforeSectionWithOptionalLeadingBlank(Segment candidate, Section section) + { + int anchor = section.HeaderLineIndex == candidate.Start && candidate.Start > 0 + ? candidate.Start - 1 + : candidate.Start; + + if (candidate.VersionLineIndex >= candidate.Start && candidate.VersionLineIndex < section.HeaderLineIndex) + anchor = candidate.VersionLineIndex; + + foreach (Section candidateSection in candidate.Sections) + { + if (candidateSection.HeaderLineIndex >= section.HeaderLineIndex) + break; + + if (candidateSection.HeaderLineIndex > anchor) + anchor = candidateSection.HeaderLineIndex; + if (candidateSection.LastLineIndex > anchor && candidateSection.LastLineIndex < section.HeaderLineIndex) + anchor = candidateSection.LastLineIndex; + } + + return anchor; + } + + // --- Unknown [properties] keys --------------------------------------------------------------- + + private static bool PreserveUnknownProperties( + Segment existing, + Segment candidate, + List candidateLines, + Dictionary> insertions) + { + Section? existingProps = existing.Sections.FirstOrDefault(s => s.Name == CacheFormat.Sections.Properties); + if (existingProps is null) + return false; + + var unknown = new List(); + foreach (string line in existingProps.Lines) + { + string key = PropertyKey(line); + if (key.Length > 0 && !KnownPropertyKeys.Contains(key)) + unknown.Add(line); + } + + if (unknown.Count == 0) + return false; + + Section? candidateProps = candidate.Sections.FirstOrDefault(s => s.Name == CacheFormat.Sections.Properties); + if (candidateProps is null) + { + // Candidate has no [properties] section (rare — required properties almost always force + // one). Create one right after the [project]/[sliceDimensions] header block. + Section? anchorSection = candidate.Sections.FirstOrDefault(s => s.Name == CacheFormat.Sections.SliceDimensions) + ?? candidate.Sections.FirstOrDefault(s => s.Name == CacheFormat.Sections.Project) + ?? (candidate.Sections.Count > 0 ? candidate.Sections[candidate.Sections.Count - 1] : null); + + var block = new List { string.Empty, CacheFormat.SectionHeader(CacheFormat.Sections.Properties) }; + block.AddRange(unknown.OrderBy(PropertyKey, StringComparer.OrdinalIgnoreCase)); + + // Insert after the anchor's last content line. For a header-only anchor LastLineIndex is + // -1; fall back to its header line. With no sections at all, anchor at the segment's last + // content line. Reassemble only applies insertions keyed to a real candidate line (>= 0), + // so an insertion keyed at -1 would be silently dropped — never key there. + int anchorIndex = anchorSection is null + ? candidate.LastContentLineIndex + : anchorSection.LastLineIndex >= 0 ? anchorSection.LastLineIndex : anchorSection.HeaderLineIndex; + + AddInsertion(insertions, anchorIndex, block); + return true; + } + + // Merge each unknown key into the candidate's already-sorted [properties] block, skipping + // any the candidate already contains. + var present = new HashSet(candidateProps.Lines.Select(PropertyKey), StringComparer.OrdinalIgnoreCase); + bool changed = false; + foreach (string line in unknown) + { + string key = PropertyKey(line); + if (present.Contains(key)) + continue; + + int anchor = candidateProps.HeaderLineIndex; + for (int i = 0; i < candidateProps.Lines.Count; i++) + { + if (string.Compare(PropertyKey(candidateProps.Lines[i]), key, StringComparison.OrdinalIgnoreCase) < 0) + anchor = candidateProps.ContentLineIndices[i]; + else + break; + } + + AddInsertion(insertions, anchor, new List { line }); + present.Add(key); + changed = true; + } + + return changed; + } + + // --- Unknown item @metadata ------------------------------------------------------------------ + + private static bool PreserveUnknownMetadata(Segment existing, Segment candidate, Dictionary> insertions) + { + bool changed = false; + foreach (Section existingSection in existing.Sections) + { + if (existingSection.Name is null || !KnownSections.Contains(existingSection.Name)) + continue; + if (existingSection.Name is CacheFormat.Sections.Project + or CacheFormat.Sections.SliceDimensions + or CacheFormat.Sections.Properties) + { + continue; + } + + // Resolve "known" @metadata for THIS section. A section with no entry emits no metadata, + // so its known set is empty and every @metadata under it is unknown (and preserved). + HashSet? knownForSection = KnownMetadataBySection.TryGetValue(existingSection.Name, out HashSet? ks) + ? ks + : null; + + List existingLeaves = ExpandLeaves(existingSection); + bool anyUnknown = existingLeaves.Any(l => l.Metadata.Any(m => !IsKnownMetadata(knownForSection, m.Content))); + if (!anyUnknown) + continue; + + Section? candidateSection = candidate.Sections.FirstOrDefault(s => s.Name == existingSection.Name); + if (candidateSection is null) + continue; + + List candidateLeaves = ExpandLeaves(candidateSection); + var candidateByPath = new Dictionary(StringComparer.Ordinal); + foreach (Leaf leaf in candidateLeaves) + candidateByPath[leaf.Path] = leaf; + + foreach (Leaf existingLeaf in existingLeaves) + { + List unknownMeta = existingLeaf.Metadata + .Where(m => !IsKnownMetadata(knownForSection, m.Content)) + .ToList(); + if (unknownMeta.Count == 0) + continue; + if (!candidateByPath.TryGetValue(existingLeaf.Path, out Leaf? candidateLeaf)) + continue; // item removed from the candidate — drop its forward-compat metadata. + + var present = new HashSet( + candidateLeaf.Metadata.Select(m => MetadataKey(m.Content)), + StringComparer.OrdinalIgnoreCase); + + string indent = new string(' ', candidateLeaf.Indent + 1); + var toInsert = new List(); + // Preserve unknown @metadata in the existing file's encounter order — a newer writer + // emits an item's metadata in its own schema order, and re-sorting here would rewrite + // that layout and churn the cache in mixed-version teams. Dedup by key is order-free. + foreach (MetaLine meta in unknownMeta) + { + if (present.Add(MetadataKey(meta.Content))) + toInsert.Add(indent + meta.Content); + } + + if (toInsert.Count == 0) + continue; + + int anchor = candidateLeaf.Metadata.Count > 0 + ? candidateLeaf.Metadata[candidateLeaf.Metadata.Count - 1].LineIndex + : candidateLeaf.LineIndex; + AddInsertion(insertions, anchor, toInsert); + changed = true; + } + } + + return changed; + } + + // --- Parsing --------------------------------------------------------------------------------- + + private static List ParseSegments(string text) => ParseSegments(text.Split('\n')); + + // Overload that reuses an already-split line array, so a caller that also needs the raw lines + // (e.g. the splice target) does not pay for a second Split of the same text. The parser trims + // lines internally for its own decisions but never mutates the array, so sharing it is safe. + private static List ParseSegments(string[] lines) + { + var segments = new List(); + int segStart = 0; + for (int i = 0; i <= lines.Length; i++) + { + bool atSeparator = i < lines.Length && Trim(lines[i]) == CacheFormat.SliceSeparator; + bool atEnd = i == lines.Length; + if (!atSeparator && !atEnd) + continue; + + segments.Add(ParseSegment(lines, segStart, i)); + segStart = i + 1; + } + + return segments; + } + + private static Segment ParseSegment(string[] lines, int start, int end) + { + var segment = new Segment { Start = start, End = end }; + Section? current = null; + var sliceDimensionLines = new List(); + + for (int i = start; i < end; i++) + { + string raw = Trim(lines[i]); + if (raw.Length == 0) + continue; + + if (raw.StartsWith(VersionLinePrefix, StringComparison.Ordinal)) + { + segment.VersionLine = raw; + segment.VersionLineIndex = i; + continue; + } + + if (raw[0] == CacheFormat.CommentChar) + continue; + + if (raw.Length >= 2 && raw[0] == '[' && raw[raw.Length - 1] == ']') + { + string name = raw.Substring(1, raw.Length - 2); + current = new Section { Name = name, HeaderLineIndex = i }; + segment.Sections.Add(current); + continue; + } + + if (segment.LastContentLineIndex < i) + segment.LastContentLineIndex = i; + + if (current is null) + continue; + + current.Lines.Add(raw); + current.ContentLineIndices.Add(i); + current.LastLineIndex = i; + + if (current.Name == CacheFormat.Sections.SliceDimensions) + sliceDimensionLines.Add(raw); + } + + segment.Identity = sliceDimensionLines.Count == 0 + ? SharedSegmentIdentity + : string.Join("\n", sliceDimensionLines.OrderBy(l => l, StringComparer.OrdinalIgnoreCase)); + + if (segment.LastContentLineIndex < start) + segment.LastContentLineIndex = Math.Max(start, end - 1); + + return segment; + } + + /// + /// Expands a path section's indentation-compressed lines into leaves, recording each leaf's full + /// path, its line index, indentation, and the @metadata lines attached to it. Mirrors the + /// reader's expansion so existing/candidate leaves match by full path regardless of how sibling + /// changes alter compression. + /// + private static List ExpandLeaves(Section section) + { + var leaves = new List(); + var prefixStack = new Stack<(int Indent, string Prefix)>(); + Leaf? lastLeaf = null; + + for (int i = 0; i < section.Lines.Count; i++) + { + string line = section.Lines[i]; + int globalIndex = section.ContentLineIndices[i]; + int indent = CountIndent(line); + string content = line.Substring(indent); + + if (content.Length > 0 && content[0] == '@') + { + lastLeaf?.Metadata.Add(new MetaLine(content, globalIndex)); + continue; + } + + while (prefixStack.Count > 0 && prefixStack.Peek().Indent >= indent) + prefixStack.Pop(); + + string prefix = prefixStack.Count > 0 ? prefixStack.Peek().Prefix : string.Empty; + + if (content.Length > 0 && content[content.Length - 1] == '/') + { + prefixStack.Push((indent, prefix + content)); + lastLeaf = null; + } + else + { + lastLeaf = new Leaf(prefix + content, globalIndex, indent); + leaves.Add(lastLeaf); + } + } + + return leaves; + } + + // --- Reassembly ------------------------------------------------------------------------------ + + private static string Reassemble( + List candidateLines, + Dictionary> insertions, + Dictionary> appends) + { + int extraCount = insertions.Sum(kvp => kvp.Value.Count) + appends.Sum(kvp => kvp.Value.Count); + var output = new List(candidateLines.Count + extraCount); + for (int i = 0; i < candidateLines.Count; i++) + { + output.Add(candidateLines[i]); + // Item-local insertions (metadata, properties) first, then whole-section appends, so a + // section appended at the same anchor as the last item's @metadata never gets between the + // item and its metadata. + if (insertions.TryGetValue(i, out List? extra)) + output.AddRange(extra); + if (appends.TryGetValue(i, out List? appended)) + output.AddRange(appended); + } + + return string.Join("\n", output); + } + + private static void AddInsertion(Dictionary> insertions, int afterLineIndex, List lines) + { + if (!insertions.TryGetValue(afterLineIndex, out List? existing)) + { + existing = new List(); + insertions[afterLineIndex] = existing; + } + + existing.AddRange(lines); + } + + // --- Small helpers --------------------------------------------------------------------------- + + internal static bool TryParseVersion(string versionLine, out int major, out int minor) + { + major = -1; + minor = 0; + return versionLine is not null && TryParseVersion(versionLine.AsSpan(), out major, out minor); + } + + private static bool TryParseVersion(ReadOnlySpan versionLine, out int major, out int minor) + { + major = -1; + minor = 0; + if (!versionLine.StartsWith(VersionLinePrefix.AsSpan(), StringComparison.Ordinal)) + return false; + + ReadOnlySpan value = versionLine.Slice(VersionLinePrefix.Length); + int dot = value.IndexOf('.'); + ReadOnlySpan majorPart = dot >= 0 ? value.Slice(0, dot) : value; + ReadOnlySpan minorPart = dot >= 0 ? value.Slice(dot + 1) : "0".AsSpan(); + + if (!TryParseNonNegativeInt(majorPart, out major) || major <= 0) + { + major = -1; + return false; + } + + // A present-but-malformed or multi-part minor (e.g. "0.5", "3-preview") cannot be ordered as a + // plain integer. Treat it as the newest-possible minor so the preservation gate stays + // CONSERVATIVE: it runs the full preserve rather than skipping, and so never drops unknown + // data it cannot prove is older. The writer only ever emits an integer minor; this guards + // against a future/rogue writer that doesn't. The major still governs read compatibility. + if (!TryParseNonNegativeInt(minorPart, out minor)) + minor = int.MaxValue; + + return true; + } + + // netstandard2.0 lacks int.TryParse(ReadOnlySpan<char>, out int); this zero-allocation parser + // keeps the preservation fast path off the heap. Accepts only non-negative decimal integers. + private static bool TryParseNonNegativeInt(ReadOnlySpan span, out int value) + { + value = 0; + if (span.Length == 0) + return false; + + long acc = 0; + foreach (char c in span) + { + if (c < '0' || c > '9') + return false; + acc = (acc * 10) + (c - '0'); + if (acc > int.MaxValue) + return false; + } + + value = (int)acc; + return true; + } + + private static string Trim(string line) => line.Length > 0 && line[line.Length - 1] == '\r' ? line.Substring(0, line.Length - 1) : line; + + private static string PropertyKey(string line) + { + int eq = line.IndexOf('='); + return eq < 0 ? line : line.Substring(0, eq); + } + + private static string MetadataKey(string content) + { + // content starts with '@'. + string body = content.Substring(1); + int eq = body.IndexOf('='); + return eq < 0 ? body : body.Substring(0, eq); + } + + // A @metadata line is "known" only when the section that contains it actually emits that key. + // A null set means the section emits no metadata at all, so nothing under it is known. + private static bool IsKnownMetadata(HashSet? knownForSection, string content) + => knownForSection is not null && knownForSection.Contains(MetadataKey(content)); + + private static int CountIndent(string line) + { + int i = 0; + while (i < line.Length && line[i] == ' ') + i++; + return i; + } + + private sealed class Segment + { + public int Start { get; set; } + public int End { get; set; } + public string Identity { get; set; } = SharedSegmentIdentity; + public string? VersionLine { get; set; } + public int VersionLineIndex { get; set; } = -1; + public List
Sections { get; } = new List
(); + + // Index of the last non-blank line in the segment — the anchor for appending whole sections. + public int LastContentLineIndex { get; set; } = -1; + } + + private sealed class Section + { + public string? Name { get; set; } + public int HeaderLineIndex { get; set; } = -1; + public List Lines { get; } = new List(); + public List ContentLineIndices { get; } = new List(); + public int LastLineIndex { get; set; } = -1; + } + + private sealed class Leaf + { + public Leaf(string path, int lineIndex, int indent) + { + this.Path = path; + this.LineIndex = lineIndex; + this.Indent = indent; + } + + public string Path { get; } + public int LineIndex { get; } + public int Indent { get; } + public List Metadata { get; } = new List(); + } + + private readonly struct MetaLine + { + public MetaLine(string content, int lineIndex) + { + this.Content = content; + this.LineIndex = lineIndex; + } + + public string Content { get; } + public int LineIndex { get; } + } +} diff --git a/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/MergeProjectDataSlicesTask.cs b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/MergeProjectDataSlicesTask.cs new file mode 100644 index 0000000000000..98e229413545e --- /dev/null +++ b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/MergeProjectDataSlicesTask.cs @@ -0,0 +1,218 @@ +// 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; + +/// +/// Thin MSBuild wrapper around : runs once on the +/// outer multi-TFM build (after DispatchToInnerBuilds), collects all slice +/// files matching , merges them into +/// with structural deduplication, and swallows any exception so a merge fault +/// never fails the build. +/// +public sealed class MergeProjectDataSlicesTask : Microsoft.Build.Utilities.Task +{ + /// + /// Absolute path of the merged cache file. When empty, the user-folder cache + /// path is computed from via + /// . + /// + public string OutputPath { get; set; } = string.Empty; + + public string SliceGlob { get; set; } = string.Empty; + + public ITaskItem[]? SliceFiles { get; set; } + + public bool PreserveExistingSlices { get; set; } + + /// + /// Semicolon-separated TargetFrameworks list from the outer build, used + /// to select the primary slice for merged multi-TFM cache files. + /// + public string TargetFrameworks { get; set; } = string.Empty; + + /// + /// Required when is empty so the user-folder layout + /// can be derived for this project. + /// + public string ProjectFilePath { get; set; } = string.Empty; + + /// + /// Project intermediate output directory ($(IntermediateOutputPath)). Transient + /// .tmp side-files for the atomic write are placed here (when on the same volume as the + /// output) so they never appear next to committed source. Optional; falls back to the output + /// directory. + /// + public string IntermediateOutputPath { get; set; } = string.Empty; + + /// + /// Optional override for the repo-scoped donor index path. When empty, the task resolves + /// <git-common-dir>\dotnet-projectdata\lscache-donor-index.json from . + /// + public string DonorCacheIndexPath { get; set; } = string.Empty; + + /// + /// Optional override for the logical workspace root recorded in the donor index. + /// + public string DonorCacheWorkspaceRoot { get; set; } = string.Empty; + + [Output] + public bool Succeeded { get; set; } + + [Output] + public bool FoundSlices { get; set; } + + [Output] + public string ResolvedOutputPath { get; set; } = string.Empty; + + public override bool Execute() + { + if (string.IsNullOrEmpty(this.OutputPath) && string.IsNullOrEmpty(this.ProjectFilePath)) + { + // Misconfigured task invocation — neither input is supplied so we cannot + // determine where to write. Fail loud (Error) rather than swallow into the + // catch-all below, which is reserved for runtime IO/parse failures. + this.Log.LogError( + "MergeProjectDataSlicesTask requires either OutputPath or ProjectFilePath to be specified."); + return false; + } + + try + { + this.Succeeded = false; + this.FoundSlices = false; + this.ResolvedOutputPath = this.ResolveOutputPath(); + + int count; + if (this.SliceFiles is { Length: > 0 }) + { + string[] sliceFiles = this.SliceFiles + .Select(item => item.ItemSpec) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .ToArray(); + string[] existing = sliceFiles.Where(File.Exists).ToArray(); + string[] missing = sliceFiles.Except(existing, StringComparer.OrdinalIgnoreCase).ToArray(); + if (missing.Length > 0) + { + this.Log.LogMessage(MessageImportance.Low, + "ProjectData: skipping missing slices for {0}: {1}", + this.ProjectFilePath, + string.Join(";", missing)); + } + + if (existing.Length == 0) + { + this.DeleteOutputPathIfNotProjectFolder(); + return true; + } + + this.FoundSlices = true; + count = ProjectDataMerger.Merge(this.ResolvedOutputPath, existing, this.TargetFrameworks, this.PreserveExistingSlices, this.IntermediateOutputPath); + } + else + { + this.FoundSlices = true; + string[] sliceFiles = ProjectDataMerger.FindSlices(this.SliceGlob).ToArray(); + if (sliceFiles.Length == 0) + { + this.FoundSlices = false; + count = 0; + } + else + { + count = ProjectDataMerger.Merge(this.ResolvedOutputPath, sliceFiles, this.TargetFrameworks, this.PreserveExistingSlices, this.IntermediateOutputPath); + } + } + + if (count == 0) + { + this.DeleteOutputPathIfNotProjectFolder(); + this.Log.LogMessage(MessageImportance.Low, "ProjectData: no slice files found matching {0}; skipping merge.", this.SliceGlob); + } + else + { + this.Succeeded = true; + UnsupportedProjectDataMarker.Delete(this.ProjectFilePath); + this.RecordDonorIndexEntry(); + this.Log.LogMessage(MessageImportance.Low, "ProjectData: merged {0} slices into {1}.", count, this.ResolvedOutputPath); + } + } + catch (Exception ex) + { + // Cache-write failures should not break the user's build, but they must be + // visible at default verbosity so the user knows the cache might be stale. + // ``LogMessage(Low)`` was invisible under ``-v:minimal`` (the default for + // ``dotnet build``) and hid real diagnostics; ``LogWarning`` matches the + // .NET SDK convention for non-fatal task failures. Catch all exception + // types — narrowing previously caused legitimate DTB scenarios (e.g. a + // ``KeyNotFoundException`` from a missing slice property) to fail the + // build instead of degrading to a stale-cache warning. + this.Log.LogWarning( + "ProjectData: failed to merge slices for {0}: {1}", + string.IsNullOrEmpty(this.OutputPath) ? this.ProjectFilePath : this.OutputPath, + ex.Message); + } + return true; + } + + private string ResolveOutputPath() + { + if (!string.IsNullOrEmpty(this.OutputPath)) + { + return this.OutputPath; + } + if (string.IsNullOrEmpty(this.ProjectFilePath)) + { + throw new InvalidOperationException( + "MergeProjectDataSlicesTask: either OutputPath or ProjectFilePath must be supplied."); + } + return UserFolderCachePath.Compute(this.ProjectFilePath); + } + + private void RecordDonorIndexEntry() + { + ProjectDataDonorWriteOptions options = new() + { + IndexPath = string.IsNullOrEmpty(this.DonorCacheIndexPath) ? null : this.DonorCacheIndexPath, + WorkspaceRoot = string.IsNullOrEmpty(this.DonorCacheWorkspaceRoot) ? null : this.DonorCacheWorkspaceRoot, + }; + + bool recorded = ProjectDataDonorIndex.TryRecordWrite(this.ProjectFilePath, this.ResolvedOutputPath, options, out string? message); + if (recorded && !string.IsNullOrEmpty(message)) + { + this.Log.LogMessage(MessageImportance.Low, "ProjectData: {0}", message); + } + else if (!recorded && !string.IsNullOrEmpty(message)) + { + this.Log.LogMessage(MessageImportance.Low, "ProjectData: failed to update donor index for {0}: {1}", this.ProjectFilePath, message); + } + } + + private void DeleteOutputPathIfNotProjectFolder() + { + if (string.IsNullOrEmpty(this.ResolvedOutputPath) || + PathsEqual(this.ResolvedOutputPath, Path.GetFullPath(this.ProjectFilePath) + ".lscache")) + { + return; + } + + try + { + File.Delete(this.ResolvedOutputPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + this.Log.LogWarning("ProjectData: failed to delete stale output {0}: {1}", this.ResolvedOutputPath, ex.Message); + } + } + + private static bool PathsEqual(string left, string right) + => string.Equals( + Path.GetFullPath(left), + Path.GetFullPath(right), + StringComparisons.Paths); +} diff --git a/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/Microsoft.NET.ProjectData.Tasks.csproj b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/Microsoft.NET.ProjectData.Tasks.csproj new file mode 100644 index 0000000000000..6ddbf5514cd04 --- /dev/null +++ b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/Microsoft.NET.ProjectData.Tasks.csproj @@ -0,0 +1,59 @@ + + + + netstandard2.0 + + false + false + Microsoft.NET.ProjectData.Tasks + + false + false + + $(NoWarn);IDE0051;IDE0060 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/PooledCacheRender.cs b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/PooledCacheRender.cs new file mode 100644 index 0000000000000..7455cdc7dd194 --- /dev/null +++ b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/PooledCacheRender.cs @@ -0,0 +1,145 @@ +// 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.Buffers; +using System.Text; + +namespace Microsoft.NET.ProjectData.Tasks; + +/// +/// A that renders cache content into pooled and +/// buffers, normalizing line endings in place, so the writer never allocates +/// the candidate as a or a heap array on the common +/// path. The candidate string is produced lazily via only when a newer +/// minor version's data has to be spliced in. Always use with using so both pooled +/// buffers are returned. +/// +internal sealed class PooledCacheRender : TextWriter +{ + private static readonly char[] NewLineChars = { '\n' }; + + private char[] _chars; + private int _charLength; + private byte[]? _bytes; + private int _byteLength; + + private PooledCacheRender(int initialCapacity) + { + this._chars = ArrayPool.Shared.Rent(initialCapacity); + this.CoreNewLine = NewLineChars; + } + + public override Encoding Encoding => ProjectDataWriter.Utf8NoBom; + + public byte[] Bytes => this._bytes!; + + public int ByteLength => this._byteLength; + + public static PooledCacheRender Create(Action writeContent) + { + var render = new PooledCacheRender(initialCapacity: 4096); + try + { + writeContent(render); + render.Finish(); + return render; + } + catch + { + render.Dispose(); + throw; + } + } + + /// Materializes the rendered content as a string (rare splice path only). + public string GetText() => new string(this._chars, 0, this._charLength); + + public override void Write(char value) + { + this.EnsureCapacity(this._charLength + 1); + this._chars[this._charLength++] = value; + } + + public override void Write(string? value) + { + if (string.IsNullOrEmpty(value)) + return; + + this.EnsureCapacity(this._charLength + value!.Length); + value.CopyTo(0, this._chars, this._charLength, value.Length); + this._charLength += value.Length; + } + + public override void Write(char[] buffer, int index, int count) + { + if (count <= 0) + return; + + this.EnsureCapacity(this._charLength + count); + Array.Copy(buffer, index, this._chars, this._charLength, count); + this._charLength += count; + } + + /// + /// Normalizes line endings in in place (\r\n and lone + /// \r both collapse to \n), returning the new logical length. Mirrors + /// ProjectDataWriter.NormalizeLineEndings(string) exactly but without allocating. + /// + private static int NormalizeNewLinesInPlace(char[] buffer, int length) + { + int firstCr = Array.IndexOf(buffer, '\r', 0, length); + if (firstCr < 0) + return length; + + int write = firstCr; + for (int read = firstCr; read < length; read++) + { + char c = buffer[read]; + if (c == '\r') + { + buffer[write++] = '\n'; + if (read + 1 < length && buffer[read + 1] == '\n') + read++; // collapse the "\n" half of a "\r\n" pair + } + else + { + buffer[write++] = c; + } + } + + return write; + } + + private void Finish() + { + this._charLength = NormalizeNewLinesInPlace(this._chars, this._charLength); + int byteCount = ProjectDataWriter.Utf8NoBom.GetByteCount(this._chars, 0, this._charLength); + this._bytes = ArrayPool.Shared.Rent(byteCount); + this._byteLength = ProjectDataWriter.Utf8NoBom.GetBytes(this._chars, 0, this._charLength, this._bytes, 0); + } + + private void EnsureCapacity(int required) + { + if (required <= this._chars.Length) + return; + + int newSize = Math.Max(required, this._chars.Length * 2); + char[] larger = ArrayPool.Shared.Rent(newSize); + Array.Copy(this._chars, 0, larger, 0, this._charLength); + ArrayPool.Shared.Return(this._chars); + this._chars = larger; + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + ArrayPool.Shared.Return(this._chars); + if (this._bytes != null) + ArrayPool.Shared.Return(this._bytes); + } + + base.Dispose(disposing); + } +} diff --git a/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/ProjectDataBuildCompletionLogger.cs b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/ProjectDataBuildCompletionLogger.cs new file mode 100644 index 0000000000000..adba10be30d2c --- /dev/null +++ b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/ProjectDataBuildCompletionLogger.cs @@ -0,0 +1,535 @@ +// 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.Json; +using Microsoft.Build.Framework; +using Microsoft.NET.ProjectData; + +namespace Microsoft.NET.ProjectData.Tasks; + +/// +/// Central MSBuild logger that records structured aggregate ProjectDataBuild evidence. +/// +public sealed class ProjectDataBuildCompletionLogger : ILogger +{ + private const int MaxDiagnosticsPerProject = 5; + private const int MaxDiagnostics = 200; + private const int MaxSubmissions = 1024; + private const int MaxContexts = 20_000; + + private static readonly Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + private static readonly JsonSerializerOptions SerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = false, + }; + + private readonly object gate = new(); + private readonly List submissions = []; + private readonly List contexts = []; + private readonly List diagnostics = []; + private readonly Dictionary projectByContext = new(StringComparer.Ordinal); + private readonly Dictionary phaseBySubmission = []; + private readonly Dictionary diagnosticCountByProject = new(StringComparer.OrdinalIgnoreCase); + + private string receiptDirectory = string.Empty; + private string attemptId = string.Empty; + private string latestPhase = "Unknown"; + private bool initialized; + private bool buildFinished; + private bool buildSucceeded; + private bool buildCancelled; + private bool projectDataBuildSubmissionObserved; + private int truncatedDiagnosticCount; + private int truncatedSubmissionCount; + private int truncatedContextCount; + private string completedUtc = string.Empty; + private IEventSource? eventSource; + + public LoggerVerbosity Verbosity { get; set; } = LoggerVerbosity.Quiet; + + public string? Parameters { get; set; } + + public void Initialize(IEventSource eventSource) + { + try + { + if (eventSource is null) + { + throw new ArgumentNullException(nameof(eventSource)); + } + if (!TryParseParameters(this.Parameters, out this.receiptDirectory, out this.attemptId)) + { + TryWriteLoggerError("ProjectDataBuild completion logger parameters were invalid."); + return; + } + + this.initialized = true; + this.eventSource = eventSource; + eventSource.AnyEventRaised += this.OnAnyEventRaised; + } + catch (Exception ex) + { + TryWriteLoggerError($"ProjectDataBuild completion logger failed to initialize: {ex.Message}"); + } + } + + public void Shutdown() + { + try + { + if (this.initialized && this.buildCancelled && !this.buildFinished) + { + this.WriteEvidence(); + } + if (this.eventSource is not null) + { + this.eventSource.AnyEventRaised -= this.OnAnyEventRaised; + this.eventSource = null; + } + } + catch (Exception ex) + { + TryWriteLoggerError($"ProjectDataBuild completion logger failed during shutdown: {ex.Message}"); + } + } + + private void OnAnyEventRaised(object sender, BuildEventArgs e) + { + _ = sender; + try + { + lock (this.gate) + { + switch (e) + { + case BuildSubmissionStartedEventArgs submission: + this.RecordSubmission(submission); + break; + case ProjectStartedEventArgs projectStarted: + this.RecordProjectContext(projectStarted); + break; + case ProjectEvaluationStartedEventArgs evaluationStarted: + this.RecordEvaluationContext(evaluationStarted); + break; + case BuildErrorEventArgs error: + this.RecordDiagnostic( + severity: "Error", + error.ProjectFile, + error.File, + error.Code, + error.Message, + error.LineNumber, + error.ColumnNumber, + error.BuildEventContext, + emitFrame: true); + break; + case BuildWarningEventArgs warning: + this.RecordDiagnostic( + severity: "Warning", + warning.ProjectFile, + warning.File, + warning.Code, + warning.Message, + warning.LineNumber, + warning.ColumnNumber, + warning.BuildEventContext, + emitFrame: false); + break; + case BuildCanceledEventArgs: + this.buildCancelled = true; + this.completedUtc = DateTimeOffset.UtcNow.ToString("O"); + this.WriteEvidence(); + break; + case BuildFinishedEventArgs finished: + this.buildFinished = true; + this.buildSucceeded = finished.Succeeded; + this.completedUtc = finished.Timestamp.ToUniversalTime().ToString("O"); + this.WriteEvidence(); + break; + } + } + } + catch (Exception ex) + { + TryWriteLoggerError($"ProjectDataBuild completion logger ignored an event failure: {ex.Message}"); + } + } + + private void RecordSubmission(BuildSubmissionStartedEventArgs submission) + { + string[] targetNames = submission.TargetNames?.Where(static target => !string.IsNullOrWhiteSpace(target)).ToArray() ?? []; + bool isRestoring = TryGetBooleanGlobalProperty(submission.GlobalProperties, "MSBuildIsRestoring"); + string phase = ClassifyPhase(isRestoring, targetNames); + this.latestPhase = phase; + this.projectDataBuildSubmissionObserved |= string.Equals(phase, "ProjectDataBuild", StringComparison.Ordinal); + if (this.submissions.Count >= MaxSubmissions) + { + this.truncatedSubmissionCount++; + return; + } + + this.phaseBySubmission[submission.SubmissionId] = phase; + this.submissions.Add(new ProjectDataBuildSubmissionRecord + { + SubmissionId = submission.SubmissionId, + Phase = phase, + MSBuildIsRestoring = isRestoring, + EntryProjects = submission.EntryProjectsFullPath?.Where(static path => !string.IsNullOrWhiteSpace(path)).ToArray() ?? [], + TargetNames = targetNames, + Context = ConvertContext(submission.BuildEventContext), + }); + } + + private void RecordProjectContext(ProjectStartedEventArgs projectStarted) + { + if (this.contexts.Count >= MaxContexts) + { + this.truncatedContextCount++; + return; + } + + string projectFile = projectStarted.ProjectFile ?? string.Empty; + string contextKey = GetContextKey(projectStarted.BuildEventContext); + if (contextKey.Length > 0 && projectFile.Length > 0) + { + this.projectByContext[contextKey] = projectFile; + } + + this.contexts.Add(new ProjectDataBuildContextRecord + { + Kind = "Project", + ProjectFilePath = projectFile, + Context = ConvertContext(projectStarted.BuildEventContext), + ParentContext = ConvertContext(projectStarted.ParentProjectBuildEventContext), + }); + } + + private void RecordEvaluationContext(ProjectEvaluationStartedEventArgs evaluationStarted) + { + if (this.contexts.Count >= MaxContexts) + { + this.truncatedContextCount++; + return; + } + + string projectFile = evaluationStarted.ProjectFile ?? string.Empty; + string contextKey = GetContextKey(evaluationStarted.BuildEventContext); + if (contextKey.Length > 0 && projectFile.Length > 0) + { + this.projectByContext[contextKey] = projectFile; + } + + this.contexts.Add(new ProjectDataBuildContextRecord + { + Kind = "Evaluation", + ProjectFilePath = projectFile, + Context = ConvertContext(evaluationStarted.BuildEventContext), + }); + } + + private void RecordDiagnostic( + string severity, + string? projectFile, + string? file, + string? code, + string? message, + int line, + int column, + BuildEventContext? context, + bool emitFrame) + { + string resolvedProjectFile; + string projectFilePathSource; + if (IsProjectFilePath(file)) + { + resolvedProjectFile = file!; + projectFilePathSource = ProjectDataBuildDiagnosticRecord.FileProjectPathSource; + } + else if (IsProjectFilePath(projectFile)) + { + resolvedProjectFile = projectFile!; + projectFilePathSource = ProjectDataBuildDiagnosticRecord.ProjectFileProjectPathSource; + } + else if (this.projectByContext.TryGetValue(GetContextKey(context), out string? contextProjectFile)) + { + resolvedProjectFile = contextProjectFile; + projectFilePathSource = ProjectDataBuildDiagnosticRecord.ContextProjectPathSource; + } + else + { + resolvedProjectFile = projectFile ?? string.Empty; + projectFilePathSource = ProjectDataBuildDiagnosticRecord.UnknownProjectPathSource; + } + + string diagnosticKey = resolvedProjectFile.Length == 0 ? "" : resolvedProjectFile; + this.diagnosticCountByProject.TryGetValue(diagnosticKey, out int projectDiagnosticCount); + bool globalCapReached = this.diagnostics.Count >= MaxDiagnostics; + bool projectCapReached = projectDiagnosticCount >= MaxDiagnosticsPerProject; + int replacementIndex = -1; + if (globalCapReached || projectCapReached) + { + if (string.Equals(severity, "Error", StringComparison.OrdinalIgnoreCase)) + { + if (projectCapReached) + { + replacementIndex = this.diagnostics.FindLastIndex(existing => + string.Equals(existing.Severity, "Warning", StringComparison.OrdinalIgnoreCase) && + string.Equals( + string.IsNullOrEmpty(existing.ProjectFilePath) ? "" : existing.ProjectFilePath, + diagnosticKey, + StringComparison.OrdinalIgnoreCase)); + } + + if (replacementIndex < 0 && globalCapReached && !projectCapReached) + { + replacementIndex = this.diagnostics.FindLastIndex(static existing => + string.Equals(existing.Severity, "Warning", StringComparison.OrdinalIgnoreCase)); + } + } + + this.truncatedDiagnosticCount++; + if (replacementIndex < 0) + { + return; + } + } + + ProjectDataBuildDiagnosticRecord diagnostic = new() + { + Severity = severity, + Phase = this.GetPhase(context), + ProjectFilePath = resolvedProjectFile, + ProjectFilePathSource = projectFilePathSource, + FilePath = file ?? string.Empty, + Code = code ?? string.Empty, + Message = message ?? string.Empty, + Line = line, + Column = column, + Context = ConvertContext(context), + }; + + if (replacementIndex >= 0) + { + ProjectDataBuildDiagnosticRecord replaced = this.diagnostics[replacementIndex]; + string replacedKey = string.IsNullOrEmpty(replaced.ProjectFilePath) ? "" : replaced.ProjectFilePath; + this.DecrementDiagnosticCount(replacedKey); + this.diagnostics[replacementIndex] = diagnostic; + this.diagnosticCountByProject.TryGetValue(diagnosticKey, out int replacementProjectDiagnosticCount); + this.diagnosticCountByProject[diagnosticKey] = replacementProjectDiagnosticCount + 1; + } + else + { + this.diagnosticCountByProject[diagnosticKey] = projectDiagnosticCount + 1; + this.diagnostics.Add(diagnostic); + } + + if (emitFrame) + { + this.EmitProvisionalDiagnostic(diagnostic); + } + } + + private void EmitProvisionalDiagnostic(ProjectDataBuildDiagnosticRecord diagnostic) + { + try + { + Console.Error.WriteLine(ProjectDataBuildDiagnosticProtocol.Encode(this.attemptId, diagnostic)); + } + catch (Exception ex) + { + TryWriteLoggerError($"ProjectDataBuild completion logger failed to emit a provisional diagnostic: {ex.Message}"); + } + } + + private void DecrementDiagnosticCount(string diagnosticKey) + { + if (!this.diagnosticCountByProject.TryGetValue(diagnosticKey, out int count) || count <= 1) + { + this.diagnosticCountByProject.Remove(diagnosticKey); + return; + } + + this.diagnosticCountByProject[diagnosticKey] = count - 1; + } + + private string GetPhase(BuildEventContext? context) + { + if (context is not null && this.phaseBySubmission.TryGetValue(context.SubmissionId, out string? phase)) + { + return phase; + } + + return this.latestPhase; + } + + private void WriteEvidence() + { + if (!this.initialized) + { + return; + } + + try + { + Directory.CreateDirectory(this.receiptDirectory); + ProjectDataBuildAttemptManifest manifest = new() + { + AttemptId = this.attemptId, + BuildFinished = this.buildFinished, + BuildSucceeded = this.buildSucceeded, + BuildCancelled = this.buildCancelled, + ProjectDataBuildSubmissionObserved = this.projectDataBuildSubmissionObserved, + CompletedUtc = this.completedUtc, + TruncatedDiagnosticCount = this.truncatedDiagnosticCount, + TruncatedSubmissionCount = this.truncatedSubmissionCount, + TruncatedContextCount = this.truncatedContextCount, + Submissions = [.. this.submissions], + Contexts = [.. this.contexts], + Diagnostics = [.. this.diagnostics], + }; + string manifestPath = ProjectDataBuildAttemptManifest.GetManifestFilePath(this.receiptDirectory); + WriteJsonAtomically(manifestPath, JsonSerializer.Serialize(manifest, SerializerOptions)); + ProjectDataBuildReceipt.WriteAggregateCompletion(this.receiptDirectory, this.attemptId); + } + catch (Exception ex) + { + TryWriteLoggerError($"ProjectDataBuild completion logger failed to write evidence: {ex.Message}"); + } + } + + private static bool TryParseParameters(string? parameters, out string receiptDirectory, out string attemptId) + { + receiptDirectory = string.Empty; + attemptId = string.Empty; + if (string.IsNullOrWhiteSpace(parameters)) + { + return false; + } + + string[] parts = parameters!.Split(';'); + if (parts.Length != 2) + { + return false; + } + + try + { + receiptDirectory = Utf8NoBom.GetString(Convert.FromBase64String(parts[0])); + attemptId = parts[1]; + return receiptDirectory.Length > 0 && attemptId.Length > 0; + } + catch (FormatException) + { + return false; + } + } + + private static string ClassifyPhase(bool isRestoring, IReadOnlyList targetNames) + { + if (isRestoring || targetNames.Any(static target => string.Equals(target, "Restore", StringComparison.OrdinalIgnoreCase))) + { + return "Restore"; + } + + if (targetNames.Any(static target => string.Equals(target, "ProjectDataBuild", StringComparison.OrdinalIgnoreCase))) + { + return "ProjectDataBuild"; + } + + return "Unknown"; + } + + private static bool TryGetBooleanGlobalProperty(IReadOnlyDictionary? properties, string name) + { + if (properties is null) + { + return false; + } + + if (!properties.TryGetValue(name, out string? value)) + { + value = properties + .FirstOrDefault(pair => string.Equals(pair.Key, name, StringComparison.OrdinalIgnoreCase)) + .Value; + } + + return bool.TryParse(value, out bool result) && result; + } + + private static ProjectDataBuildEventContextRecord? ConvertContext(BuildEventContext? context) + => context is null + ? null + : new ProjectDataBuildEventContextRecord + { + NodeId = context.NodeId, + ProjectContextId = context.ProjectContextId, + ProjectInstanceId = context.ProjectInstanceId, + TargetId = context.TargetId, + TaskId = context.TaskId, + SubmissionId = context.SubmissionId, + EvaluationId = context.EvaluationId, + BuildRequestId = context.BuildRequestId, + }; + + private static string GetContextKey(BuildEventContext? context) + => context is null + ? string.Empty + : $"{context.NodeId}:{context.ProjectContextId}:{context.ProjectInstanceId}:{context.SubmissionId}:{context.EvaluationId}:{context.BuildRequestId}"; + + private static bool IsProjectFilePath(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + string extension = Path.GetExtension(path); + return extension.Equals(".csproj", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".vbproj", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".fsproj", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".vcxproj", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".esproj", StringComparison.OrdinalIgnoreCase) || + extension.Equals(".proj", StringComparison.OrdinalIgnoreCase); + } + + private static void WriteJsonAtomically(string path, string content) + { + string tempPath = path + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + File.WriteAllText(tempPath, content, Utf8NoBom); + if (File.Exists(path)) + { + File.Replace(tempPath, path, destinationBackupFileName: null); + } + else + { + File.Move(tempPath, path); + } + } + finally + { + try + { + File.Delete(tempPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + } + } + } + + private static void TryWriteLoggerError(string message) + { + try + { + Console.Error.WriteLine(message); + } + catch + { + } + } +} diff --git a/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/ProjectDataMerger.cs b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/ProjectDataMerger.cs new file mode 100644 index 0000000000000..a2050e66de285 --- /dev/null +++ b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/ProjectDataMerger.cs @@ -0,0 +1,644 @@ +// 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; + +namespace Microsoft.NET.ProjectData.Tasks; + +/// +/// Reads all per-TFM slice files for a multi-targeting project, parses them into +/// structured sections, deduplicates content shared across TFMs, and writes a +/// single merged file with shared sections at the top and per-TFM diffs after +/// --- separators. Invoked from the outer multi-TFM build after all +/// inner builds have completed. +/// +internal static class ProjectDataMerger +{ + // The major version this merger emits, parsed once from the canonical version header. Used to + // gate slice preservation: an existing merged cache of a different major is incompatible and its + // slices must not be re-emitted under this header. + private static readonly int CurrentMajorVersion = + ForwardCompat.TryReadVersionHeader(CacheFormat.VersionHeader, out int major, out _) ? major : 2; + + // Section names in output order. + private static readonly string[] ListSectionNames = + [ + "commandLineArguments", + "sourceFiles", + "frameworkPacks", + "metadataReferences", + "sdkAnalyzerPacks", + "analyzerReferences", + "sdkAnalyzerConfigPolicy", + "analyzerConfigFiles", + "additionalFiles", + "embeddedResources", + "projectReferences", + "capabilities", + ]; + + // Sections whose lines use indentation-based path compression: an unindented + // line establishes a directory/path prefix, and following lines whose indent + // is greater than the header's belong to that group. The merger must intersect + // such sections at the *group* level — never line-by-line — or a child line + // (e.g. " ConsoleApp2.AssemblyInfo.cs") could be hoisted into the shared block + // without its parent prefix line, producing a corrupt cache file. + private static readonly HashSet IndentedSections = new(StringComparer.Ordinal) + { + "sourceFiles", + "metadataReferences", + "analyzerReferences", + "analyzerConfigFiles", + "additionalFiles", + "embeddedResources", + "projectReferences", + }; + + /// + /// Reads all slice files matching , merges them into + /// with a banner and --- separators. + /// Returns the number of slices merged, or 0 if none were found. + /// + /// + /// The glob is expected to follow MSBuild conventions, e.g. obj/**/<project>.csproj.slice. + /// We split on "**" to get the base directory and the filename to search for recursively. + /// + public static int Merge(string outputPath, string sliceGlob, string? targetFrameworks = null) + { + return Merge(outputPath, FindSlices(sliceGlob), targetFrameworks); + } + + public static int Merge(string outputPath, IEnumerable sliceFiles, string? targetFrameworks = null, bool preserveExistingSlices = false, string? tempDirectory = null) + { + List sortedSliceFiles = sliceFiles + .Where(static file => !string.IsNullOrWhiteSpace(file)) + .OrderBy(static file => file, StringComparer.OrdinalIgnoreCase) + .ToList(); + if (sortedSliceFiles.Count == 0) return 0; + + var slices = new List(sortedSliceFiles.Count); + foreach (string file in sortedSliceFiles) + slices.Add(ParseSlice(File.ReadAllText(file))); + + if (preserveExistingSlices) + AddPreservedExistingSlices(outputPath, slices); + + ProjectDataWriter.AtomicWriteStreamed(outputPath, writer => WriteMergedContent(writer, slices, targetFrameworks), tempDirectory); + + return sortedSliceFiles.Count; + } + + private static void AddPreservedExistingSlices(string outputPath, List slices) + { + if (!File.Exists(outputPath)) + return; + + string existingContent = File.ReadAllText(outputPath); + + // Only preserve slices from a SAME-major cache. A different (or unrecognized) major is an + // incompatible format: parsing it with this version's grammar and re-emitting the slices + // under our version= header would corrupt data or smuggle future-major content into a + // current-major file that the reader would then accept. Preserve nothing — the merge writes + // a clean current-major file from the freshly generated slices instead. + if (!ForwardCompat.TryReadVersionHeader(existingContent, out int existingMajor, out _) + || existingMajor != CurrentMajorVersion) + { + return; + } + + List existingSlices = ParseMergedContent(existingContent); + if (existingSlices.Count == 0) + return; + + // Preserve every existing slice for a TFM that wasn't regenerated in THIS build. This path is + // non-Windows-only (see _ProjectDataPreserveExistingProjectFolderCache) and exists so a slice + // for a TFM that is OS-conditionally excluded on this machine — e.g. + // net8.0;net9.0 — is + // carried forward instead of churning out of the committed cache. We deliberately do NOT filter + // by the current $(TargetFrameworks): at merge time the task only sees this OS's evaluated TFM + // list, so an OS-excluded TFM is indistinguishable from a removed one. Permanent removals are + // cleaned up by the platform that builds the full TFM set (the Windows preserve=false path drops + // non-regenerated slices); over-filtering here would silently churn the legitimate OS-excluded + // case the feature exists to protect. + var currentSliceIdentities = new HashSet( + slices.Select(GetSliceIdentity).OfType(), + StringComparer.OrdinalIgnoreCase); + + foreach (SliceData existingSlice in existingSlices) + { + string? identity = GetSliceIdentity(existingSlice); + if (identity is null || currentSliceIdentities.Contains(identity)) + continue; + + slices.Add(existingSlice); + currentSliceIdentities.Add(identity); + } + } + + internal static List FindSlices(string sliceGlob) + { + string baseDir; + string pattern; + int starStar = sliceGlob.IndexOf("**", StringComparison.Ordinal); + if (starStar >= 0) + { + baseDir = sliceGlob.Substring(0, starStar).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + if (string.IsNullOrEmpty(baseDir)) baseDir = "."; + string rest = sliceGlob.Substring(starStar + 2).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + pattern = string.IsNullOrEmpty(rest) ? "*" : rest; + } + else + { + baseDir = Path.GetDirectoryName(sliceGlob) ?? "."; + if (string.IsNullOrEmpty(baseDir)) baseDir = "."; + pattern = Path.GetFileName(sliceGlob); + } + + if (!Directory.Exists(baseDir)) return []; + var files = new List(Directory.EnumerateFiles(baseDir, pattern, SearchOption.AllDirectories)); + files.Sort(StringComparer.OrdinalIgnoreCase); + return files; + } + + /// + /// Writes the merged .lscache content directly to . + /// + internal static void WriteMergedContent(TextWriter writer, List slices, string? targetFrameworks = null) + { + WriteBanner(writer); + StampPrimarySlice(slices, targetFrameworks); + + if (slices.Count == 1) + { + WriteSingleSliceContent(writer, slices[0]); + return; + } + + // Compute shared content across all slices. + List sharedProjectLines = IntersectAllOrdered(slices.Select(s => s.ProjectLines).ToList()); + List sharedProperties = IntersectAllOrdered(slices.Select(s => s.Properties).ToList()); + var sharedSections = new Dictionary>(); + foreach (string section in ListSectionNames) + { + var allLists = slices.Select(s => s.ListSections.TryGetValue(section, out List? v) ? v : []).ToList(); + sharedSections[section] = IndentedSections.Contains(section) + ? FlattenGroups(IntersectAllOrderedGroups(allLists.Select(GroupByIndentation).ToList())) + : IntersectAllOrdered(allLists); + } + + // Write shared [project] (no [sliceDimensions]). + writer.WriteLine(); + WriteProjectSection(writer, sharedProjectLines); + + // Write shared [properties]. + if (sharedProperties.Count > 0) + { + writer.WriteLine(); + writer.WriteLine(CacheFormat.SectionHeader(CacheFormat.Sections.Properties)); + foreach (string line in sharedProperties) + writer.WriteLine(line); + } + + // Write shared list sections. + foreach (string section in ListSectionNames) + { + if (sharedSections[section].Count > 0) + { + writer.WriteLine(); + writer.WriteLine(CacheFormat.SectionHeader(section)); + foreach (string line in sharedSections[section]) + writer.WriteLine(line); + } + } + + // Write per-TFM slices (diff only). + for (int i = 0; i < slices.Count; i++) + { + writer.WriteLine(); + writer.WriteLine(CacheFormat.SliceSeparator); + writer.WriteLine(); + + // Per-TFM [project] lines (excluding shared ones). + var sharedProjectSet = new HashSet(sharedProjectLines, StringComparer.Ordinal); + WriteProjectSection(writer, slices[i].ProjectLines.Where(line => !sharedProjectSet.Contains(line)), slices[i].IsPrimary); + + // [sliceDimensions] is always per-TFM. + writer.WriteLine(); + writer.WriteLine(CacheFormat.SectionHeader(CacheFormat.Sections.SliceDimensions)); + foreach (string line in slices[i].SliceDimensions) + writer.WriteLine(line); + + // Per-TFM [properties] (excluding shared ones). + var sharedPropsSet = new HashSet(sharedProperties, StringComparer.Ordinal); + var diffProps = slices[i].Properties.Where(p => !sharedPropsSet.Contains(p)).ToList(); + if (diffProps.Count > 0) + { + writer.WriteLine(); + writer.WriteLine(CacheFormat.SectionHeader(CacheFormat.Sections.Properties)); + foreach (string line in diffProps) + writer.WriteLine(line); + } + + // Per-TFM list sections (excluding shared lines). + foreach (string section in ListSectionNames) + { + List sliceLines = slices[i].ListSections.TryGetValue(section, out List? v) ? v : []; + List diffLines; + if (IndentedSections.Contains(section)) + { + // Group-aware diff: drop entire groups whose stringified content matches a shared group. + var sharedGroupKeys = new HashSet( + GroupByIndentation(sharedSections[section]).Select(StringifyGroup), + StringComparer.Ordinal); + diffLines = FlattenGroups(GroupByIndentation(sliceLines) + .Where(g => !sharedGroupKeys.Contains(StringifyGroup(g))) + .ToList()); + } + else + { + var sharedSet = new HashSet(sharedSections[section], StringComparer.Ordinal); + diffLines = sliceLines.Where(line => !sharedSet.Contains(line)).ToList(); + } + if (diffLines.Count > 0) + { + writer.WriteLine(); + writer.WriteLine(CacheFormat.SectionHeader(section)); + foreach (string line in diffLines) + writer.WriteLine(line); + } + } + } + } + + private static void WriteSingleSliceContent(TextWriter writer, SliceData slice) + { + writer.WriteLine(); + // Intentionally omit the ``primary`` marker for single-slice output: the reader's + // ``ToProjectDto`` falls back to ``slices[0]`` when no slice has ``IsPrimary``, so + // the marker is redundant when only one slice exists. Skipping it keeps cache + // files smaller and avoids spurious diffs when a project's TargetFrameworks set + // shrinks from multi- to single-targeting. The multi-slice path below still + // writes ``primary`` on the canonical (non-.NETFramework) slice, which is where + // the marker actually disambiguates between sibling slices. + WriteProjectSection(writer, slice.ProjectLines, isPrimary: false); + + if (slice.Properties.Count > 0) + { + writer.WriteLine(); + writer.WriteLine(CacheFormat.SectionHeader(CacheFormat.Sections.Properties)); + foreach (string line in slice.Properties) + writer.WriteLine(line); + } + + foreach (string section in ListSectionNames) + { + if (slice.ListSections.TryGetValue(section, out List? lines) && lines.Count > 0) + { + writer.WriteLine(); + writer.WriteLine(CacheFormat.SectionHeader(section)); + foreach (string line in lines) + writer.WriteLine(line); + } + } + } + + private static void WriteProjectSection(TextWriter writer, IEnumerable projectLines, bool isPrimary = false) + { + List lines = projectLines.ToList(); + writer.WriteLine(CacheFormat.SectionHeader(CacheFormat.Sections.Project)); + foreach (string line in lines.Where(static l => l.StartsWith(CacheFormat.ProjectHeaderPrefix, StringComparison.Ordinal))) + writer.WriteLine(line); + writer.Write(CacheFormat.LanguagePrefix); writer.WriteLine("C#"); + if (isPrimary) + writer.WriteLine(CacheFormat.PrimaryMarker); + foreach (string line in lines.Where(static l => !l.StartsWith(CacheFormat.ProjectHeaderPrefix, StringComparison.Ordinal))) + writer.WriteLine(line); + } + + private static void StampPrimarySlice(List slices, string? targetFrameworks) + { + if (slices.Count == 0) return; + + foreach (SliceData slice in slices) + slice.IsPrimary = false; + + SliceData? primarySlice = GetPrimarySlice(slices, targetFrameworks); + + // If the outer build does not provide a usable TargetFrameworks list, fall + // back to the deterministic slice order supplied by FindSlices/callers. + (primarySlice ?? slices[0]).IsPrimary = true; + } + + private static SliceData? GetPrimarySlice(List slices, string? targetFrameworks) + { + List orderedTargetFrameworks = GetTargetFrameworks(targetFrameworks); + SliceData? primarySlice = orderedTargetFrameworks + .Where(static targetFramework => !IsNetFrameworkTargetFramework(targetFramework)) + .Select(targetFramework => FindSlice(slices, targetFramework)) + .FirstOrDefault(static slice => slice is not null); + if (primarySlice is not null) + return primarySlice; + + primarySlice = orderedTargetFrameworks + .Select(targetFramework => FindSlice(slices, targetFramework)) + .FirstOrDefault(static slice => slice is not null); + if (primarySlice is not null) + return primarySlice; + + return slices.FirstOrDefault(slice => !IsNetFrameworkTargetFramework(slice.GetSliceDimension(ProjectProperties.TargetFramework))); + } + + private static SliceData? FindSlice(List slices, string targetFramework) + { + return slices.FirstOrDefault(slice => string.Equals( + slice.GetSliceDimension(ProjectProperties.TargetFramework), + targetFramework, + StringComparison.OrdinalIgnoreCase)); + } + + private static List GetTargetFrameworks(string? targetFrameworks) + { + if (string.IsNullOrWhiteSpace(targetFrameworks)) + return []; + + var result = new List(); + foreach (string targetFramework in targetFrameworks!.Split(';')) + { + string trimmed = targetFramework.Trim(); + if (trimmed.Length > 0) + result.Add(trimmed); + } + + return result; + } + + private static bool IsNetFrameworkTargetFramework(string? targetFramework) + { + if (string.IsNullOrWhiteSpace(targetFramework)) + return false; + + string normalized = targetFramework!.Trim(); + if (!normalized.StartsWith("net", StringComparison.OrdinalIgnoreCase)) + return false; + + string version = normalized.Substring(3); + return version.Length is 2 or 3 + && version.All(static c => c >= '0' && c <= '9') + && version[0] is >= '1' and <= '4'; + } + + /// Parses a slice file into structured sections. + internal static SliceData ParseSlice(string content) + { + var data = new SliceData(); + string? currentSection = null; + List? currentList = null; + + foreach (string rawLine in content.Split('\n')) + { + string line = rawLine.TrimEnd('\r'); + if (line.Length == 0) continue; + if (line.StartsWith("version=", StringComparison.Ordinal)) continue; + if (line[0] == CacheFormat.CommentChar) continue; + + if (line[0] == '[' && line[line.Length - 1] == ']') + { + currentSection = line.Substring(1, line.Length - 2); + currentList = null; + if (currentSection != CacheFormat.Sections.Project && currentSection != CacheFormat.Sections.SliceDimensions && currentSection != CacheFormat.Sections.Properties) + { + currentList = []; + data.ListSections[currentSection] = currentList; + } + continue; + } + + switch (currentSection) + { + case CacheFormat.Sections.Project: + if (string.Equals(line, CacheFormat.PrimaryMarker, StringComparison.Ordinal)) + data.IsPrimary = true; + else if (!line.StartsWith(CacheFormat.LanguagePrefix, StringComparison.Ordinal)) + data.ProjectLines.Add(line); + break; + case CacheFormat.Sections.SliceDimensions: + data.SliceDimensions.Add(line); + break; + case CacheFormat.Sections.Properties: + data.Properties.Add(line); + break; + default: + currentList?.Add(string.Equals(currentSection, CacheFormat.Sections.SdkAnalyzerConfigPolicy, StringComparison.Ordinal) + ? ProjectDataWriter.CanonicalizeSdkAnalyzerConfigPolicyLine(line, data.GetTargetFrameworkIdentifier(), data.GetTargetFrameworkVersion()) + : line); + break; + } + } + + return data; + } + + internal static List ParseMergedContent(string content) + { + List segments = SplitMergedContent(content); + if (segments.Count == 0) + return []; + + if (segments.Count == 1) + { + SliceData singleSlice = ParseSlice(segments[0]); + return singleSlice.SliceDimensions.Count == 0 ? [] : [singleSlice]; + } + + SliceData sharedData = ParseSlice(segments[0]); + var slices = new List(segments.Count - 1); + foreach (string segment in segments.Skip(1)) + { + SliceData sliceDiff = ParseSlice(segment); + if (sliceDiff.SliceDimensions.Count == 0) + continue; + + slices.Add(CombineSlices(sharedData, sliceDiff)); + } + + return slices; + } + + private static List SplitMergedContent(string content) + { + var segments = new List(); + var builder = new StringBuilder(); + using var reader = new StringReader(content); + string? line; + while ((line = reader.ReadLine()) is not null) + { + if (string.Equals(line.TrimEnd('\r'), CacheFormat.SliceSeparator, StringComparison.Ordinal)) + { + segments.Add(builder.ToString()); + builder.Clear(); + continue; + } + + builder.AppendLine(line); + } + + segments.Add(builder.ToString()); + return segments; + } + + private static SliceData CombineSlices(SliceData sharedData, SliceData sliceDiff) + { + var combined = new SliceData { IsPrimary = sliceDiff.IsPrimary }; + combined.ProjectLines.AddRange(sharedData.ProjectLines); + combined.ProjectLines.AddRange(sliceDiff.ProjectLines); + combined.SliceDimensions.AddRange(sliceDiff.SliceDimensions); + combined.Properties.AddRange(sharedData.Properties); + combined.Properties.AddRange(sliceDiff.Properties); + + foreach (string section in ListSectionNames) + { + var lines = new List(); + if (sharedData.ListSections.TryGetValue(section, out List? sharedLines)) + lines.AddRange(sharedLines); + if (sliceDiff.ListSections.TryGetValue(section, out List? diffLines)) + lines.AddRange(diffLines); + if (lines.Count > 0) + combined.ListSections[section] = lines; + } + + return combined; + } + + private static string? GetSliceIdentity(SliceData slice) + => slice.SliceDimensions.Count == 0 + ? null + : string.Join("\n", slice.SliceDimensions.OrderBy(static line => line, StringComparer.OrdinalIgnoreCase)); + + /// Computes the ordered intersection of multiple lists (preserves order from first). + private static List IntersectAllOrdered(List> lists) + { + if (lists.Count == 0) return []; + if (lists.Count == 1) return new List(lists[0]); + var sets = lists.Skip(1).Select(l => new HashSet(l, StringComparer.Ordinal)).ToList(); + return lists[0].Where(item => sets.All(s => s.Contains(item))).ToList(); + } + + /// + /// Splits a path-section's lines into "groups": an unindented (indent-0) line + /// plus every following line whose indent is greater than zero. Each group is + /// the unit of compression — the unindented line is a path or directory prefix, + /// and the indented lines below it are continuations (children whose paths + /// share that prefix, or @-metadata attached to a leaf). + /// + /// + /// Path sections cannot be intersected line-by-line: an indented continuation + /// has no meaning without its preceding indent-0 header. Multi-TFM merging + /// must compare these groups whole. + /// + internal static List> GroupByIndentation(List lines) + { + var groups = new List>(); + List? current = null; + foreach (string line in lines) + { + bool isIndented = line.Length > 0 && line[0] == ' '; + if (!isIndented) + { + current = [line]; + groups.Add(current); + } + else + { + // Indented continuation: attach to the most recent group, or start + // a new group if none exists (defensive; shouldn't normally happen). + if (current == null) + { + current = [line]; + groups.Add(current); + } + else + { + current.Add(line); + } + } + } + return groups; + } + + /// Joins a group's lines with \n for use as a hash/equality key. + private static string StringifyGroup(List group) => string.Join("\n", group); + + /// Flattens a list of groups back into a flat line list. + private static List FlattenGroups(List> groups) + { + var result = new List(); + foreach (List g in groups) result.AddRange(g); + return result; + } + + /// Computes the ordered intersection of grouped lists (preserves group order from first). + private static List> IntersectAllOrderedGroups(List>> groupedLists) + { + if (groupedLists.Count == 0) return []; + if (groupedLists.Count == 1) return new List>(groupedLists[0]); + var sets = groupedLists.Skip(1) + .Select(gl => new HashSet(gl.Select(StringifyGroup), StringComparer.Ordinal)) + .ToList(); + return groupedLists[0].Where(g => sets.All(s => s.Contains(StringifyGroup(g)))).ToList(); + } + + private static void WriteBanner(TextWriter writer) + { + writer.WriteLine(CacheFormat.VersionHeader); + writer.WriteLine(); + writer.WriteLine("# This file caches language service data to improve the performance of C# Dev Kit."); + writer.WriteLine("# It is not intended for manual editing. It can safely be deleted and will be"); + writer.WriteLine("# regenerated automatically. For more information, see https://aka.ms/lscache"); + writer.WriteLine("#"); + writer.WriteLine("# To control where cache files are stored, use the following VS Code setting:"); + writer.WriteLine("# \"dotnet.projectsystem.cacheInProjectFolder\": true"); + } + + internal sealed class SliceData + { + public List ProjectLines { get; } = []; + public List SliceDimensions { get; } = []; + public List Properties { get; } = []; + public Dictionary> ListSections { get; } = new(StringComparer.Ordinal); + public bool IsPrimary { get; set; } + + public string? GetSliceDimension(string name) + { + foreach (string line in this.SliceDimensions) + { + int equalsIndex = line.IndexOf('='); + if (equalsIndex > 0 && string.Equals(line.Substring(0, equalsIndex), name, StringComparison.Ordinal)) + return line.Substring(equalsIndex + 1); + } + + return null; + } + + public string? GetTargetFramework() + => this.GetSliceDimension(ProjectProperties.TargetFramework) ?? GetValue(this.Properties, ProjectProperties.TargetFramework); + + public string? GetTargetFrameworkIdentifier() + => GetValue(this.Properties, ProjectProperties.TargetFrameworkIdentifier); + + public string? GetTargetFrameworkVersion() + => GetValue(this.Properties, ProjectProperties.TargetFrameworkVersion); + + private static string? GetValue(List lines, string name) + { + foreach (string line in lines) + { + int equalsIndex = line.IndexOf('='); + if (equalsIndex > 0 && string.Equals(line.Substring(0, equalsIndex), name, StringComparison.Ordinal)) + return line.Substring(equalsIndex + 1); + } + + return null; + } + } +} diff --git a/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/ProjectDataWriter.cs b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/ProjectDataWriter.cs new file mode 100644 index 0000000000000..32ca2c53c7d42 --- /dev/null +++ b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/ProjectDataWriter.cs @@ -0,0 +1,2124 @@ +// 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.Buffers; +using System.Collections.Frozen; +using System.Runtime.ExceptionServices; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Build.Framework; +using Microsoft.NET.ProjectData; + +namespace Microsoft.NET.ProjectData.Tasks; + +/// +/// Builds the textual project-data content for a single slice and writes it atomically. +/// All methods are pure (no MSBuild dependency) so they can be unit-tested directly +/// without mocking MSBuild infrastructure; is the only +/// member that does file I/O. +/// +internal static class ProjectDataWriter +{ + private const string NewLine = "\n"; + + // Portable-path prefix for entries rooted in an SDK targeting/runtime ref pack. + // Reader expands these via FrameworkList.xml at read time. + internal const string DotNetPacksPrefix = PathSentinels.Dotnet + "/packs/"; + internal const string NetFxRefPrefix = PathSentinels.NetFxRef + "/"; + internal const string NuGetPrefix = PathSentinels.Nuget + "/"; + internal const string MissingNetFrameworkReferenceAssembliesReason = "MissingNetFrameworkReferenceAssemblies"; + + // Roslyn repo/toolset CSharp targets suppress CS8002 for .NETCoreApp because strong naming is ignored there. + // Normalize it so caches stay stable when DTB imports SDK inbox targets instead. + private const string NetCoreAppIgnoredStrongNameWarning = "8002"; + private static readonly StringComparer PathComparer = StringComparers.Paths; + + private static int ComparePortablePaths(string? left, string? right) + { + int comparison = StringComparer.OrdinalIgnoreCase.Compare(left, right); + return comparison != 0 ? comparison : StringComparer.Ordinal.Compare(left, right); + } + + /// + /// Capabilities that are universal to managed projects and add no + /// filtering value. Excluding them keeps cache files smaller and avoids noise + /// in capability-based queries. + /// + private static readonly FrozenSet ExcludedCapabilities = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "AllTargetOutputGroups", + "AppServicePublish", + "AspNetCoreInProcessHosting", + "AssemblyReferences", + "BuildWindowsDesktopTarget", + "COMReferences", + "CSharp", + "DeclaredSourceItems", + "DotNetCoreRazorConfiguration", + "DynamicDependentFile", + "DynamicFileNesting", + "GenerateDocumentationFile", + "LanguageService", + "Managed", + "NetSdkOCIImageBuild", + "OutputGroups", + "ProjectReferences", + "ReferencesFolder", + "RelativePathDerivedDefaultNamespace", + "SharedProjectReferences", + "SingleFileGenerators", + "SupportHierarchyContextSvc", + "SupportsComputeRunCommand", + "SupportsTypeScriptNuGet", + "UserSourceItems", + "VisualStudioWellKnownOutputGroups", + "WebNestingDefaults", + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + + /// + /// Properties that must never appear in the cache file. These are + /// environment-specific values injected at read time by the snapshot + /// factory; writing them would produce stale/machine-local data. + /// + private static readonly FrozenSet ExcludedProperties = new HashSet(StringComparer.OrdinalIgnoreCase) + { + ProjectProperties.SolutionPath, + }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + + /// + /// Properties that must always be written, even when empty/whitespace. + /// Generated from server/src/Microsoft.NET.ProjectData.Generators/project-data-schema.json by DataModelSchemaGenerator. + /// + private static readonly FrozenSet RequiredProperties = ProjectProperties.Required.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + + // Builds the complete content for one slice. When writeHeader=true the + // version/banner/[project] header is included (single-target, or any single + // file consumed directly); when false only the [project] section onward + // is emitted (multi-TFM inner-build slices, merged later by ProjectDataMerger). + public static string BuildContent( + string projectFilePath, + bool writeHeader, + bool isPrimary, + bool lastDtbSucceeded, + ITaskItem[]? sliceDimensions, + ITaskItem[]? properties, + string[]? commandLineArguments, + ITaskItem[]? sourceFiles, + ITaskItem[]? metadataReferences, + ITaskItem[]? analyzerReferences, + string[]? analyzerConfigFiles, + string[]? additionalFiles, + ITaskItem[]? embeddedResources = null, + ITaskItem[]? projectReferences = null, + string[]? capabilities = null, + ITaskItem[]? sdkKnownAnalyzerPacks = null, + ITaskItem[]? sdkAnalyzerConfigPolicy = null, + Action? duplicateItemReporter = null) + { + using var writer = new StringWriter(); + writer.NewLine = NewLine; + WriteContent( + writer, + projectFilePath, + writeHeader, + isPrimary, + lastDtbSucceeded, + sliceDimensions, + properties, + commandLineArguments, + sourceFiles, + metadataReferences, + analyzerReferences, + analyzerConfigFiles, + additionalFiles, + embeddedResources, + projectReferences, + capabilities, + sdkKnownAnalyzerPacks, + sdkAnalyzerConfigPolicy, + duplicateItemReporter); + + return writer.ToString(); + } + + public static void WriteContent( + TextWriter writer, + string projectFilePath, + bool writeHeader, + bool isPrimary, + bool lastDtbSucceeded, + ITaskItem[]? sliceDimensions, + ITaskItem[]? properties, + string[]? commandLineArguments, + ITaskItem[]? sourceFiles, + ITaskItem[]? metadataReferences, + ITaskItem[]? analyzerReferences, + string[]? analyzerConfigFiles, + string[]? additionalFiles, + ITaskItem[]? embeddedResources = null, + ITaskItem[]? projectReferences = null, + string[]? capabilities = null, + ITaskItem[]? sdkKnownAnalyzerPacks = null, + ITaskItem[]? sdkAnalyzerConfigPolicy = null, + Action? duplicateItemReporter = null) + { + CachePathResolver resolver = new CachePathResolver(projectFilePath); + + if (writeHeader) + { + writer.WriteLine(CacheFormat.VersionHeader); + writer.WriteLine(); + writer.WriteLine("# This file caches language service data to improve the performance of C# Dev Kit."); + writer.WriteLine("# It is not intended for manual editing. It can safely be deleted and will be"); + writer.WriteLine("# regenerated automatically. For more information, see https://aka.ms/lscache"); + writer.WriteLine("#"); + writer.WriteLine("# To control where cache files are stored, use the following VS Code setting:"); + writer.WriteLine("# \"dotnet.projectsystem.cacheInProjectFolder\": true"); + } + + // [project] + if (writeHeader) writer.WriteLine(); + writer.WriteLine(CacheFormat.SectionHeader(CacheFormat.Sections.Project)); + writer.Write(CacheFormat.ProjectHeaderPrefix); writer.WriteLine(resolver.ToPortable(projectFilePath)); + writer.Write(CacheFormat.LanguagePrefix); writer.WriteLine("C#"); + if (isPrimary) writer.WriteLine(CacheFormat.PrimaryMarker); + if (lastDtbSucceeded) writer.WriteLine(CacheFormat.LastDtbSucceededMarker); + + // [sliceDimensions] + List> sliceKvps = ToSortedKvps(sliceDimensions); + if (sliceKvps.Count > 0) + { + writer.WriteLine(); + writer.WriteLine(CacheFormat.SectionHeader(CacheFormat.Sections.SliceDimensions)); + foreach (KeyValuePair kvp in sliceKvps) + { + writer.Write(kvp.Key); + writer.Write('='); + writer.WriteLine(kvp.Value); + } + } + + // [properties] — sorted OrdinalIgnoreCase; values MakePortable'd. + // Empty / unset values are skipped for optional properties, but required + // properties (from project-data-schema.json) are always written — even when + // empty — so the reader can distinguish "not set" from "set to empty". + // Properties in the ExcludedProperties set are never written (defense-in-depth). + List> propKvps = ToSortedKvps(properties); + if (propKvps.Count > 0) + { + bool wroteHeader = false; + foreach (KeyValuePair kvp in propKvps) + { + string value = kvp.Value ?? string.Empty; + if (value == "*Undefined*") continue; + if (ExcludedProperties.Contains(kvp.Key)) continue; + if (string.IsNullOrWhiteSpace(value) && !RequiredProperties.Contains(kvp.Key)) continue; + if (!wroteHeader) + { + writer.WriteLine(); + writer.WriteLine(CacheFormat.SectionHeader(CacheFormat.Sections.Properties)); + wroteHeader = true; + } + writer.Write(kvp.Key); + writer.Write('='); + writer.WriteLine(resolver.MakePortable(value)); + } + } + + var targetFramework = new TargetFramework( + GetItemValue(sliceDimensions, ProjectProperties.TargetFramework) ?? GetItemValue(properties, ProjectProperties.TargetFramework), + GetItemValue(properties, ProjectProperties.TargetFrameworkIdentifier), + GetItemValue(properties, ProjectProperties.TargetFrameworkVersion)); + + // [commandLineArguments] — order preserved; file-based args filtered out + // Required item type — always write header even if empty. + { + var filtered = new List(); + if (commandLineArguments != null) + { + foreach (var arg in commandLineArguments) + { + if (arg == null) continue; + if (IsFileArgument(arg)) continue; + if (IsMachineSpecificArgument(arg)) continue; + if (IsPlatformArgument(arg)) continue; + if (ShouldSkipCommandLineArgument(arg, targetFramework.Identifier)) continue; + string normalized = NormalizeCommandLineArgument(arg, targetFramework.Identifier); + if (!string.IsNullOrEmpty(normalized)) + { + filtered.Add(normalized); + } + } + } + writer.WriteLine(); + writer.WriteLine(CacheFormat.SectionHeader(CacheFormat.Sections.CommandLineArguments)); + foreach (var arg in filtered) + writer.WriteLine(resolver.MakePortable(arg)); + } + + // Path sections. + // Metadata and analyzer refs are pre-processed: shared-framework entries + // rooted in an SDK .App.Ref pack or a recognized NuGet ref pack are removed, + // and the pack name is added to [frameworkPacks] for the reader to expand via + // FrameworkList.xml. Workload-pack entries remain explicit because their + // versions do not follow the target framework's version scheme. + SortedSet frameworkPacks = new SortedSet(StringComparer.OrdinalIgnoreCase); + SortedSet sdkAnalyzerPacks = new SortedSet(StringComparer.OrdinalIgnoreCase); + SortedSet sdkAnalyzerConfigPolicyLines = BuildSdkAnalyzerConfigPolicy(sdkAnalyzerConfigPolicy, targetFramework); + List> preparedMetadataRefs = PrepareMetadataRefs( + metadataReferences, + resolver, + frameworkPacks, + targetFramework); + List preparedAnalyzerRefs = PrepareAnalyzerRefs( + analyzerReferences, + resolver, + frameworkPacks, + sdkAnalyzerPacks, + sdkKnownAnalyzerPacks, + targetFramework, + projectFilePath, + duplicateItemReporter); + List preparedAnalyzerConfigFiles = AnalyzerConfigFileFilter.Prepare( + analyzerConfigFiles, + resolver, + sourceFiles, + filterSdkAnalyzerConfigFiles: sdkAnalyzerConfigPolicyLines.Count > 0); + preparedAnalyzerConfigFiles = ToSortedDistinctPortablePaths( + preparedAnalyzerConfigFiles, + CacheFormat.Sections.AnalyzerConfigFiles, + projectFilePath, + duplicateItemReporter); + + EmitSourceFileSection(writer, sourceFiles, resolver, projectFilePath, duplicateItemReporter); + WriteFrameworkPacksSection(writer, frameworkPacks); + EmitMetadataRefSection(writer, preparedMetadataRefs); + WriteSdkAnalyzerPacksSection(writer, sdkAnalyzerPacks); + EmitSimplePathSectionRequired(writer, CacheFormat.SectionHeader(CacheFormat.Sections.AnalyzerReferences), preparedAnalyzerRefs); + WriteSdkAnalyzerConfigPolicySection(writer, sdkAnalyzerConfigPolicyLines); + EmitSimplePathSection(writer, CacheFormat.SectionHeader(CacheFormat.Sections.AnalyzerConfigFiles), preparedAnalyzerConfigFiles); + WritePreparedPathSection( + writer, + CacheFormat.SectionHeader(CacheFormat.Sections.AdditionalFiles), + PrepareDistinctPortablePaths(additionalFiles, resolver, CacheFormat.Sections.AdditionalFiles, projectFilePath, duplicateItemReporter), + required: false); + EmitEmbeddedResourceSection(writer, embeddedResources, resolver); + EmitProjectReferenceSection(writer, projectReferences, resolver, projectFilePath, duplicateItemReporter); + + // Capabilities — simple string list, one per line. + // Exclude well-known capabilities that are universal to managed projects + // and add no filtering value — they would just bloat every cache file. + if (capabilities is { Length: > 0 }) + { + // Deduplicate (MSBuild can emit duplicates), exclude banned, and sort for stable output. + var uniqueCaps = new SortedSet(StringComparer.OrdinalIgnoreCase); + foreach (string cap in capabilities) + { + if (!ExcludedCapabilities.Contains(cap)) + uniqueCaps.Add(cap); + } + + if (uniqueCaps.Count > 0) + { + writer.WriteLine(); + writer.WriteLine(CacheFormat.SectionHeader(CacheFormat.Sections.Capabilities)); + foreach (string cap in uniqueCaps) + writer.WriteLine(cap); + } + } + } + + // The major version this build understands, parsed from the writer's own version header. + // Forward-compatibility preservation only runs against an existing file of the same major. + private static readonly int CurrentMajorVersion = ForwardCompat.TryParseVersion(CacheFormat.VersionHeader, out int major, out _) + ? major + : throw new InvalidOperationException($"CacheFormat.VersionHeader '{CacheFormat.VersionHeader}' is not a valid 'version=[.]' header."); + + internal static readonly UTF8Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + + /// + /// Atomically writes arbitrary to via a + /// .tmp side-file, skipping the write when the existing file already has identical content + /// (so a no-op never touches the file and never churns watchers / git). No forward-compat + /// preservation is applied — this overload is for non-cache content. + /// + public static void AtomicWrite(string outputPath, string content, string? tempDirectory = null) + { + string normalized = NormalizeLineEndings(content); + byte[] bytes = Utf8NoBom.GetBytes(normalized); + WriteAtomicallyCore(outputPath, bytes, bytes.Length, preserveUnknownData: false, candidateTextFactory: null, tempDirectory); + } + + /// + /// Atomically writes cache content produced by to + /// . The existing file is read once (into a pooled buffer) and is + /// used both to preserve forward-compatible data a newer minor version may have written and to + /// decide whether the write can be skipped — a content match means the file is left untouched. + /// No SHA-256 / hash header is written; a no-op build never touches the file. + /// + /// + /// The candidate is rendered into pooled / buffers rather + /// than a plus a array, so the common no-op / + /// same-version path allocates nothing on the large-object heap. The candidate string is + /// materialized lazily only on the rare path where a newer minor version's data actually has to + /// be spliced in. + /// + public static void AtomicWriteStreamed(string outputPath, Action writeContent, string? tempDirectory = null) + { + using PooledCacheRender render = PooledCacheRender.Create(writeContent); + WriteAtomicallyCore(outputPath, render.Bytes, render.ByteLength, preserveUnknownData: true, candidateTextFactory: render.GetText, tempDirectory); + } + + /// + /// Shared write core: compares the candidate ( / + /// ) against the existing file, optionally splices in + /// forward-compatible data, and atomically replaces the file only when the final bytes differ + /// (or a legacy hash= header still needs stripping). The existing file is read into a + /// pooled buffer to stay off the large-object heap. + /// lazily produces the candidate as a string and is invoked only on the rare preservation path. + /// + private static void WriteAtomicallyCore( + string outputPath, + byte[] candidateBuffer, + int candidateLength, + bool preserveUnknownData, + Func? candidateTextFactory, + string? tempDirectory) + { + byte[]? rented = null; + try + { + int existingLength = 0; + int contentStart = 0; + bool existingHadHashLine = false; + bool existingPresent = false; + + if (TryGetFileLength(outputPath, out int fileLength) && fileLength > 0) + { + rented = ArrayPool.Shared.Rent(fileLength); + if (TryReadAll(outputPath, rented, fileLength, out existingLength)) + { + existingPresent = true; + contentStart = SkipLegacyHashLine(rented, existingLength, out existingHadHashLine); + } + } + + byte[] finalBuffer = candidateBuffer; + int finalLength = candidateLength; + + if (existingPresent) + { + int existingContentLength = existingLength - contentStart; + var existingContent = new ReadOnlySpan(rented!, contentStart, existingContentLength); + var candidateContent = new ReadOnlySpan(candidateBuffer, 0, candidateLength); + + // A cache's minor stamp marks payload compatibility, not the writer binary. If a newer + // writer emits no new data, retain the still-valid older stamp and leave the file + // untouched rather than churning every cache after each minor schema bump. + bool candidateMatchesExisting = existingContent.SequenceEqual(candidateContent) + || (preserveUnknownData + && ForwardCompat.MatchesExceptForOlderMinorVersion( + existingContent, + candidateContent, + CurrentMajorVersion)); + bool bufferWasMerged = false; + + // Only decode the existing/candidate bytes to strings (and run the full splice) when a + // byte-level probe says the existing file was authored by a NEWER minor — the one case + // where ForwardCompat.PreserveUnknownData can carry anything forward. On the common + // same-version change this skips two ~file-sized string allocations. + if (preserveUnknownData + && !candidateMatchesExisting + && ForwardCompat.ExistingHasNewerMinor(existingContent, candidateContent, CurrentMajorVersion)) + { + string existingText = Utf8NoBom.GetString(rented!, contentStart, existingContentLength); + string candidateText = candidateTextFactory!(); + string merged = ForwardCompat.PreserveUnknownData(existingText, candidateText, CurrentMajorVersion); + if (!ReferenceEquals(merged, candidateText)) + { + byte[] mergedBytes = Utf8NoBom.GetBytes(merged); + finalBuffer = mergedBytes; + finalLength = mergedBytes.Length; + bufferWasMerged = true; + } + } + + // Skip the write only when the final content already matches AND there is no stale legacy + // hash line to strip. The legacy-hash case forces exactly one rewrite per file. + bool finalMatchesExisting = bufferWasMerged + ? existingContent.SequenceEqual(new ReadOnlySpan(finalBuffer, 0, finalLength)) + : candidateMatchesExisting; + if (!existingHadHashLine && finalMatchesExisting) + { + return; + } + } + + AtomicReplace(outputPath, finalBuffer, finalLength, tempDirectory); + } + finally + { + if (rented != null) + ArrayPool.Shared.Return(rented); + } + } + + private static bool TryGetFileLength(string path, out int length) + { + length = 0; + try + { + long len = new FileInfo(path).Length; + if (len <= 0 || len > int.MaxValue) + return false; + length = (int)len; + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } + } + + private static bool TryReadAll(string path, byte[] buffer, int length, out int bytesRead) + { + bytesRead = 0; + try + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + int total = 0; + int read; + while (total < length && (read = stream.Read(buffer, total, length - total)) > 0) + total += read; + bytesRead = total; + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } + } + + /// + /// Returns the offset of the first byte after a leading legacy hash=... line, or 0 when + /// the file has no such line. Pre-regeneration cache files still start with this header; the + /// reader skips it too. The flag lets the writer force a one-time rewrite that strips it. + /// + private static int SkipLegacyHashLine(byte[] buffer, int length, out bool hadHashLine) + { + hadHashLine = false; + ReadOnlySpan prefix = "hash="u8; + if (length < prefix.Length) + return 0; + if (!new ReadOnlySpan(buffer, 0, prefix.Length).SequenceEqual(prefix)) + return 0; + + for (int i = prefix.Length; i < length; i++) + { + if (buffer[i] == (byte)'\n') + { + hadHashLine = true; + return i + 1; + } + } + + // A file that is nothing but a hash line (no newline) — treat the whole thing as the header. + hadHashLine = true; + return length; + } + + private static void AtomicReplace(string outputPath, byte[] content, int length, string? tempDirectory) + { + string? outputDir = Path.GetDirectoryName(outputPath); + if (outputDir != null) Directory.CreateDirectory(outputDir); + + // Keep the transient side-file out of the (committed, watched) project folder by preferring + // the intermediate output directory, falling back to the output directory when none is given + // or it is on a different volume. + string tempDir = ResolveTempDirectory(tempDirectory, outputPath, outputDir); + string tempPath = Path.Combine(tempDir, Path.GetFileName(outputPath) + "." + Guid.NewGuid().ToString("N") + ".tmp"); + try + { + using (var stream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 4096)) + stream.Write(content, 0, length); + + ReplaceOrMove(tempPath, outputPath); + } + finally + { + // Best-effort cleanup of the temp file (may already be gone after a successful move). + try { File.Delete(tempPath); } catch { } + } + } + + /// + /// Chooses the directory for the atomic-write temp side-file. Prefers + /// (the project's intermediate output directory) so transient .tmp files never appear next + /// to committed source. Falls back to the output directory when no temp directory is requested, + /// when it is on a different volume ( requires + /// the temp file and the destination to share a volume), or when it cannot be created. + /// + private static string ResolveTempDirectory(string? tempDirectory, string outputPath, string? outputDir) + { + string fallback = string.IsNullOrEmpty(outputDir) ? "." : outputDir!; + if (string.IsNullOrEmpty(tempDirectory)) + return fallback; + + try + { + string full = Path.GetFullPath(tempDirectory!); + StringComparison comparison = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (!string.Equals(Path.GetPathRoot(full), Path.GetPathRoot(Path.GetFullPath(outputPath)), comparison)) + return fallback; + + Directory.CreateDirectory(full); + return full; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + return fallback; + } + } + + private static string NormalizeLineEndings(string content) + => content.IndexOf('\r') < 0 + ? content + : content.Replace("\r\n", "\n").Replace('\r', '\n'); + + private static void ReplaceOrMove(string tempPath, string outputPath) + { + try + { + File.Replace(tempPath, outputPath, null); + } + catch (FileNotFoundException) + { + try + { + File.Move(tempPath, outputPath); + } + catch (IOException moveException) + { + try + { + File.Replace(tempPath, outputPath, null); + } + catch (FileNotFoundException) + { + ExceptionDispatchInfo.Capture(moveException).Throw(); + throw; + } + } + } + } + + // Returns true for command-line args that represent file inputs. These are + // excluded from [commandLineArguments] because CPS puts them in dedicated sections. + internal static bool IsFileArgument(string arg) + { + if (arg.StartsWith("/reference:", StringComparison.OrdinalIgnoreCase)) return true; + if (arg.StartsWith("/analyzer:", StringComparison.OrdinalIgnoreCase)) return true; + if (arg.StartsWith("/analyzerconfig:", StringComparison.OrdinalIgnoreCase)) return true; + if (arg.StartsWith("/additionalfile:", StringComparison.OrdinalIgnoreCase)) return true; + if (arg.StartsWith("/sourcelink:", StringComparison.OrdinalIgnoreCase)) return true; + if (arg.StartsWith("/embed:", StringComparison.OrdinalIgnoreCase)) return true; + if (arg.StartsWith("/resource:", StringComparison.OrdinalIgnoreCase)) return true; + // Bare source-file paths go into [sourceFiles]. On Unix, absolute source + // paths start with "/" and would otherwise be mistaken for compiler switches. + if (IsRootedSourceFileArgument(arg)) return true; + if (IsPortableSourceFileArgument(arg)) return true; + if (arg.Length > 0 && arg[0] != '/' && arg[0] != '-') return true; + return false; + } + + // Returns true for compiler switches that vary per machine but have no effect + // on compilation output. Including them would cause cache churn across locales. + internal static bool IsMachineSpecificArgument(string arg) + { + // /preferreduilang: controls the language of diagnostic messages, + // not the compilation result. It varies by OS locale. + if (arg.StartsWith("/preferreduilang:", StringComparison.OrdinalIgnoreCase)) return true; + if (arg.StartsWith("-preferreduilang:", StringComparison.OrdinalIgnoreCase)) return true; + return false; + } + + internal static bool IsPlatformArgument(string arg) + => arg.StartsWith("/platform:", StringComparison.OrdinalIgnoreCase) + || arg.StartsWith("-platform:", StringComparison.OrdinalIgnoreCase); + + internal static string NormalizeCommandLineArgument(string arg, string? targetFrameworkIdentifier) + { + if (!string.Equals(targetFrameworkIdentifier, ".NETCoreApp", StringComparison.OrdinalIgnoreCase)) + { + return arg; + } + + const string slashNoWarn = "/nowarn:"; + const string dashNoWarn = "-nowarn:"; + string prefix; + if (arg.StartsWith(slashNoWarn, StringComparison.OrdinalIgnoreCase)) + { + prefix = arg.Substring(0, slashNoWarn.Length); + } + else if (arg.StartsWith(dashNoWarn, StringComparison.OrdinalIgnoreCase)) + { + prefix = arg.Substring(0, dashNoWarn.Length); + } + else + { + return arg; + } + + string warnings = arg.Substring(prefix.Length); + foreach (string warning in warnings.Split(',', ';')) + { + if (string.Equals(warning.Trim(), NetCoreAppIgnoredStrongNameWarning, StringComparison.OrdinalIgnoreCase)) + { + return arg; + } + } + + return string.IsNullOrEmpty(warnings) + ? prefix + NetCoreAppIgnoredStrongNameWarning + : arg + "," + NetCoreAppIgnoredStrongNameWarning; + } + + internal static bool ShouldSkipCommandLineArgument(string arg, string? targetFrameworkIdentifier) + { + return string.Equals(targetFrameworkIdentifier, ".NETFramework", StringComparison.OrdinalIgnoreCase) + && (arg.StartsWith("/platform:", StringComparison.OrdinalIgnoreCase) + || arg.StartsWith("-platform:", StringComparison.OrdinalIgnoreCase)); + } + + internal static bool TryValidateNetFrameworkReferences( + string projectFilePath, + ITaskItem[]? sliceDimensions, + ITaskItem[]? properties, + ITaskItem[]? metadataReferences, + out string unsupportedReason) + { + unsupportedReason = string.Empty; + var targetFramework = new TargetFramework( + GetItemValue(sliceDimensions, ProjectProperties.TargetFramework) ?? GetItemValue(properties, ProjectProperties.TargetFramework), + GetItemValue(properties, ProjectProperties.TargetFrameworkIdentifier), + GetItemValue(properties, ProjectProperties.TargetFrameworkVersion)); + if (!targetFramework.IsNetFramework) + { + return true; + } + + if (metadataReferences == null || metadataReferences.Length == 0) + { + unsupportedReason = MissingNetFrameworkReferenceAssembliesReason; + return false; + } + + string projectDirectory = Path.GetDirectoryName(projectFilePath) ?? string.Empty; + CachePathResolver resolver = new CachePathResolver(projectFilePath); + bool foundCanonicalReferenceAssembly = false; + + foreach (ITaskItem item in metadataReferences) + { + if (item == null || string.IsNullOrWhiteSpace(item.ItemSpec)) + { + continue; + } + + string absolutePath = Path.IsPathRooted(item.ItemSpec) + ? Path.GetFullPath(item.ItemSpec) + : Path.GetFullPath(Path.Combine(projectDirectory, item.ItemSpec)); + string portable = resolver.ToPortable(absolutePath); + + if (TryExtractNetFrameworkReferenceAssembly(portable, targetFramework) is not null) + { + foundCanonicalReferenceAssembly = true; + if (!File.Exists(absolutePath)) + { + unsupportedReason = MissingNetFrameworkReferenceAssembliesReason; + return false; + } + + continue; + } + + if (IsNetFrameworkCoreAssemblyName(Path.GetFileName(item.ItemSpec)) && !File.Exists(absolutePath)) + { + unsupportedReason = MissingNetFrameworkReferenceAssembliesReason; + return false; + } + } + + if (!foundCanonicalReferenceAssembly) + { + unsupportedReason = MissingNetFrameworkReferenceAssembliesReason; + return false; + } + + return true; + } + + private static bool IsRootedSourceFileArgument(string arg) + { + if (!Path.IsPathRooted(arg)) return false; + string extension = Path.GetExtension(arg); + return string.Equals(extension, ".cs", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsPortableSourceFileArgument(string arg) + { + if (!arg.StartsWith(PathSentinels.Path, StringComparison.OrdinalIgnoreCase) + && !arg.StartsWith(PathSentinels.Nuget, StringComparison.OrdinalIgnoreCase) + && !arg.StartsWith(PathSentinels.Dotnet, StringComparison.OrdinalIgnoreCase) + && !arg.StartsWith(PathSentinels.NetSdk, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + string extension = Path.GetExtension(arg); + return string.Equals(extension, ".cs", StringComparison.OrdinalIgnoreCase); + } + + private static void WritePreparedPathSection(TextWriter writer, string header, List portables, bool required) + { + if (portables.Count == 0 && !required) return; + writer.WriteLine(); + writer.WriteLine(header); + if (portables.Count > 0) + { + portables.Sort(ComparePortablePaths); + EmitCompressed(writer, portables, 0); + } + } + + private static List PrepareDistinctPortablePaths( + ITaskItem[]? items, + CachePathResolver resolver, + string section, + string projectFilePath, + Action? duplicateItemReporter) + { + if (items == null || items.Length == 0) return []; + + var portables = new List(items.Length); + var seenPortables = new HashSet(PathComparer); + foreach (ITaskItem item in items) + { + if (item == null) continue; + string path = item.ItemSpec; + if (!string.IsNullOrEmpty(path)) + { + AddDistinctPortablePath(portables, seenPortables, resolver.ToPortable(path), section, projectFilePath, duplicateItemReporter); + } + } + + return portables; + } + + private static List PrepareDistinctPortablePaths( + string[]? items, + CachePathResolver resolver, + string section, + string projectFilePath, + Action? duplicateItemReporter) + { + if (items == null || items.Length == 0) return []; + + var portables = new List(items.Length); + var seenPortables = new HashSet(PathComparer); + foreach (string item in items) + { + if (!string.IsNullOrEmpty(item)) + { + AddDistinctPortablePath(portables, seenPortables, resolver.ToPortable(item), section, projectFilePath, duplicateItemReporter); + } + } + + return portables; + } + + private static void EmitSourceFileSection( + TextWriter writer, + ITaskItem[]? items, + CachePathResolver resolver, + string projectFilePath, + Action? duplicateItemReporter) + { + if (items == null || items.Length == 0) + { + // Required item type — write empty section header + writer.WriteLine(); + writer.WriteLine(CacheFormat.SectionHeader(CacheFormat.Sections.SourceFiles)); + return; + } + + var sortedPaths = new List(items.Length); + var lookup = new Dictionary(PathComparer); + foreach (ITaskItem item in items) + { + if (item == null) continue; + string path = item.ItemSpec; + if (string.IsNullOrEmpty(path)) continue; + + string portable = resolver.ToPortable(path); + // First occurrence wins. Two ``ITaskItem``s can collapse to the same portable + // form (e.g. case variations on case-insensitive file systems, or wildcards + // overlapping explicit ``Include``s with metadata). Without this dedup the + // trie writer emits the path twice with its metadata block duplicated. + // ``Dictionary.TryAdd`` is netstandard2.1+ so use the ``ContainsKey`` shape. + if (!lookup.ContainsKey(portable)) + { + lookup.Add(portable, item); + sortedPaths.Add(portable); + } + else if (duplicateItemReporter is not null) + { + duplicateItemReporter(new ProjectDataDuplicateItemDiagnostic(projectFilePath, CacheFormat.Sections.SourceFiles, portable)); + } + } + + sortedPaths.Sort(ComparePortablePaths); + if (sortedPaths.Count == 0) return; + + writer.WriteLine(); + writer.WriteLine(CacheFormat.SectionHeader(CacheFormat.Sections.SourceFiles)); + EmitCompressedWithMetadata(writer, sortedPaths, 0, "", lookup, EmitSourceFileMetadata); + } + + private static void EmitProjectReferenceSection( + TextWriter writer, + ITaskItem[]? items, + CachePathResolver resolver, + string projectFilePath, + Action? duplicateItemReporter) + { + writer.WriteLine(); + writer.WriteLine(CacheFormat.SectionHeader(CacheFormat.Sections.ProjectReferences)); + if (items is null || items.Length == 0) + { + return; + } + + var sortedPaths = new List(items.Length); + var lookup = new Dictionary(PathComparer); + foreach (ITaskItem item in items) + { + if (item is null || string.IsNullOrEmpty(item.ItemSpec)) + { + continue; + } + + string portable = resolver.ToPortable(item.ItemSpec); + if (lookup.ContainsKey(portable)) + { + duplicateItemReporter?.Invoke(new ProjectDataDuplicateItemDiagnostic(projectFilePath, CacheFormat.Sections.ProjectReferences, portable)); + continue; + } + + lookup.Add(portable, item); + sortedPaths.Add(portable); + } + + sortedPaths.Sort(ComparePortablePaths); + EmitCompressedWithMetadata(writer, sortedPaths, 0, "", lookup, EmitProjectReferenceMetadata); + } + + // Emits a pre-built (already portable + sorted) string-path section. + private static void EmitSimplePathSection(TextWriter writer, string header, List portables) + { + if (portables.Count == 0) return; + writer.WriteLine(); + writer.WriteLine(header); + EmitCompressed(writer, portables, 0); + } + + // Required variant: always writes the header, even when empty. + private static void EmitSimplePathSectionRequired(TextWriter writer, string header, List portables) + { + writer.WriteLine(); + writer.WriteLine(header); + if (portables.Count > 0) + EmitCompressed(writer, portables, 0); + } + + // Converts metadataReferences ITaskItem[] to a sorted portable form. Entries + // rooted in an SDK .App.Ref pack or a recognized NuGet framework ref pack are diverted into + // and dropped from the result; .NET Framework + // reference assemblies remain in metadataReferences as canonical + // /vX.Y.Z/*.dll entries. + internal static List> PrepareMetadataRefs( + ITaskItem[]? items, CachePathResolver resolver, SortedSet frameworkPacks) + => PrepareMetadataRefs(items, resolver, frameworkPacks, default); + + internal static List> PrepareMetadataRefs( + ITaskItem[]? items, + CachePathResolver resolver, + SortedSet frameworkPacks, + TargetFramework targetFramework) + { + var result = new List>(); + if (items == null || items.Length == 0) return result; + foreach (ITaskItem item in items) + { + if (item == null) continue; + string path = item.ItemSpec; + if (string.IsNullOrEmpty(path)) continue; + string portable = resolver.ToPortable(path); + string? netFrameworkReferenceAssembly = TryExtractNetFrameworkReferenceAssembly(portable, targetFramework); + if (netFrameworkReferenceAssembly != null) + { + portable = NetFxRefPrefix + netFrameworkReferenceAssembly; + } + + string? packName = TryExtractRefPackName(portable); + if (packName != null) + { + frameworkPacks.Add(packName); + continue; + } + packName = TryExtractNuGetRefPackName(item, portable, targetFramework); + if (packName != null) + { + // Always emit to [frameworkPacks] regardless of resolution location. + // The same canonical pack (e.g. Microsoft.NETCore.App.Ref) may resolve from + // /packs/ on one machine and / on another depending on which + // SDKs/targeting packs are installed. Classifying by location produces + // environment-dependent lscache churn. The reader probes both locations. + frameworkPacks.Add(packName); + continue; + } + result.Add(new KeyValuePair(portable, item)); + } + result.Sort(static (a, b) => ComparePortablePaths(a.Key, b.Key)); + return result; + } + + // Converts analyzerReferences string[] to a sorted portable form, + // diverting entries rooted in an SDK .App.Ref pack or a recognized NuGet framework ref pack into the + // set (and dropping them from the result). + internal static List PrepareAnalyzerRefs( + ITaskItem[]? items, CachePathResolver resolver, SortedSet frameworkPacks) + => PrepareAnalyzerRefs(items, resolver, frameworkPacks, default); + + internal static List PrepareAnalyzerRefs( + ITaskItem[]? items, + CachePathResolver resolver, + SortedSet frameworkPacks, + TargetFramework targetFramework) + => PrepareAnalyzerRefs(items, resolver, frameworkPacks, new SortedSet(StringComparer.OrdinalIgnoreCase), sdkKnownAnalyzerPacks: null, targetFramework); + + internal static List PrepareAnalyzerRefs( + ITaskItem[]? items, + CachePathResolver resolver, + SortedSet frameworkPacks, + SortedSet sdkAnalyzerPacks, + ITaskItem[]? sdkKnownAnalyzerPacks, + TargetFramework targetFramework, + string projectFilePath = "", + Action? duplicateItemReporter = null) + { + if (items == null || items.Length == 0) return []; + + var result = new List(items.Length); + var seenResult = new HashSet(PathComparer); + foreach (ITaskItem item in items) + { + if (item == null) continue; + string path = item.ItemSpec; + if (string.IsNullOrEmpty(path)) continue; + string portable = resolver.ToPortable(path); + string? packName = TryExtractRefPackName(portable); + if (packName != null) + { + frameworkPacks.Add(packName); + continue; + } + packName = TryExtractNuGetRefPackName(item, portable, targetFramework); + if (packName != null) + { + // See PrepareMetadataRefs for rationale: classify by canonical pack name, + // not resolution location. + frameworkPacks.Add(packName); + continue; + } + string? sdkAnalyzerPackName = TryExtractSdkAnalyzerPackName(item, portable, sdkKnownAnalyzerPacks, targetFramework); + if (sdkAnalyzerPackName != null) + { + sdkAnalyzerPacks.Add(sdkAnalyzerPackName); + continue; + } + AddDistinctPortablePath(result, seenResult, portable, CacheFormat.Sections.AnalyzerReferences, projectFilePath, duplicateItemReporter); + } + result.Sort(ComparePortablePaths); + return result; + } + + private static List ToSortedDistinctPortablePaths( + IEnumerable paths, + string section, + string projectFilePath, + Action? duplicateItemReporter) + { + List result = []; + HashSet seenResult = new(PathComparer); + foreach (string path in paths) + { + AddDistinctPortablePath(result, seenResult, path, section, projectFilePath, duplicateItemReporter); + } + + result.Sort(ComparePortablePaths); + return result; + } + + private static void AddDistinctPortablePath( + List paths, + HashSet seenPaths, + string portablePath, + string section, + string projectFilePath, + Action? duplicateItemReporter) + { + if (seenPaths.Add(portablePath)) + { + paths.Add(portablePath); + } + else + { + if (duplicateItemReporter is not null) + { + duplicateItemReporter(new ProjectDataDuplicateItemDiagnostic(projectFilePath, section, portablePath)); + } + } + } + + internal static SortedSet BuildSdkAnalyzerConfigPolicy(ITaskItem[]? items, TargetFramework targetFramework) + { + var policies = new SortedSet(StringComparer.OrdinalIgnoreCase); + if (items == null || items.Length == 0) return policies; + + foreach (ITaskItem item in items) + { + if (item == null) continue; + if (!string.Equals(item.ItemSpec, "Microsoft.NET.Sdk", StringComparison.OrdinalIgnoreCase)) continue; + if (IsTrue(GetMetadataValue(item, "SkipGlobalAnalyzerConfigForPackage"))) continue; + + // Only emit each policy line when the SDK actually applies the corresponding + // analyzer pack. The SDK gates NetAnalyzer DLLs on `$(EnableNETAnalyzers)` and + // CodeStyle DLLs on `$(EnforceCodeStyleInBuild) And '$(Language)' == 'C#'` + // (Microsoft.NET.Sdk.Analyzers.targets). Emitting the policy unconditionally + // produces orphan entries that reference packs with zero DLLs in + // `[analyzerReferences]`. + if (IsTrue(GetMetadataValue(item, "EnableNETAnalyzers"))) + { + policies.Add(BuildNetAnalyzersPolicyLine(item, targetFramework)); + } + + string? languageSegment = TryGetSdkCodeStyleLanguageSegment(GetMetadataValue(item, "Language")); + if (languageSegment != null && IsTrue(GetMetadataValue(item, "EnforceCodeStyleInBuild"))) + { + policies.Add(BuildCodeStylePolicyLine(item, languageSegment, targetFramework)); + } + } + + return policies; + } + + // Emits the [frameworkPacks] section, listing pack names only (no version). + // The reader expands each pack via /packs///data/FrameworkList.xml + // into managed metadata references and CS analyzer references. + internal static void WriteFrameworkPacksSection(StringBuilder sb, SortedSet packs) + { + using var writer = new StringWriter(sb); + WriteFrameworkPacksSection(writer, packs); + } + + internal static void WriteFrameworkPacksSection(TextWriter writer, SortedSet packs) + { + if (packs.Count == 0) return; + writer.WriteLine(); + writer.WriteLine(CacheFormat.SectionHeader(CacheFormat.Sections.FrameworkPacks)); + foreach (string name in packs) writer.WriteLine(name); + } + + internal static void WriteSdkAnalyzerPacksSection(TextWriter writer, SortedSet packs) + { + if (packs.Count == 0) return; + writer.WriteLine(); + writer.WriteLine(CacheFormat.SectionHeader(CacheFormat.Sections.SdkAnalyzerPacks)); + foreach (string name in packs) writer.WriteLine(name); + } + + internal static void WriteSdkAnalyzerConfigPolicySection(TextWriter writer, SortedSet policies) + { + if (policies.Count == 0) return; + writer.WriteLine(); + writer.WriteLine(CacheFormat.SectionHeader(CacheFormat.Sections.SdkAnalyzerConfigPolicy)); + foreach (string policy in policies) writer.WriteLine(policy); + } + + // Returns the ref-pack name if is rooted under + // /packs///... and the pack follows the shared-framework + // .App.Ref naming convention; otherwise null. Workload packs use independent + // version schemes and must remain explicit metadata/analyzer references. + internal static string? TryExtractRefPackName(string? portablePath) + { + if (portablePath == null) return null; + if (!portablePath.StartsWith(DotNetPacksPrefix, StringComparison.OrdinalIgnoreCase)) return null; + int nameStart = DotNetPacksPrefix.Length; + int nameEnd = portablePath.IndexOf('/', nameStart); + if (nameEnd <= nameStart) return null; + string packName = portablePath.Substring(nameStart, nameEnd - nameStart); + if (!packName.EndsWith(".App.Ref", StringComparison.OrdinalIgnoreCase)) return null; + // Require at least one more '/' after the version segment so we don't + // misclassify /packs/Foo/Bar (no file under it). + int verEnd = portablePath.IndexOf('/', nameEnd + 1); + if (verEnd < 0) return null; + return packName; + } + + // Returns the canonical targeting-pack package name when the item is rooted under + // ///... and MSBuild marked it as a framework-reference asset. + internal static string? TryExtractNuGetRefPackName(ITaskItem item, string? portablePath, TargetFramework targetFramework) + { + if (portablePath == null) return null; + const string NuGetPrefix = PathSentinels.Nuget + "/"; + if (!portablePath.StartsWith(NuGetPrefix, StringComparison.OrdinalIgnoreCase)) return null; + + int packageStart = NuGetPrefix.Length; + int packageEnd = portablePath.IndexOf('/', packageStart); + if (packageEnd <= packageStart) return null; + + int versionStart = packageEnd + 1; + int versionEnd = portablePath.IndexOf('/', versionStart); + if (versionEnd <= versionStart) return null; + + string packageId = portablePath.Substring(packageStart, packageEnd - packageStart); + string packageVersion = portablePath.Substring(versionStart, versionEnd - versionStart); + string pathUnderPackage = portablePath.Substring(versionEnd + 1); + + if (!IsNuGetRefPackAssetPath(pathUnderPackage, targetFramework)) return null; + + string? canonicalPackageId = TryGetKnownNuGetFrameworkPackPackageId(packageId); + if (canonicalPackageId == null) + { + return null; + } + + string metadataPackageId = item.GetMetadata("NuGetPackageId"); + string metadataPackageVersion = item.GetMetadata("NuGetPackageVersion"); + + if ((!string.IsNullOrWhiteSpace(metadataPackageId) + && !string.Equals(metadataPackageId, packageId, StringComparison.OrdinalIgnoreCase)) + || (!string.IsNullOrWhiteSpace(metadataPackageVersion) + && !string.Equals(metadataPackageVersion, packageVersion, StringComparison.OrdinalIgnoreCase))) + { + return null; + } + + // Always return the canonical package id from `TryGetKnownNuGetFrameworkPackPackageId`, + // never the case-as-NuGet-emitted `metadataPackageId`. NuGet preserves the casing + // from the package's own `.nuspec`, which has historically varied across .NET + // SDK versions and feeds (e.g. `microsoft.netcore.app.ref` vs `Microsoft.NETCore.App.Ref`). + // Echoing that casing through to the cache reintroduces the very environment + // dependence this PR is eliminating. The canonical id is the single source of + // truth used elsewhere in the writer and reader. + return canonicalPackageId; + } + + internal static string? TryExtractSdkAnalyzerPackName(ITaskItem item, string? portablePath, ITaskItem[]? sdkKnownAnalyzerPacks, TargetFramework targetFramework) + { + if (portablePath == null) return null; + const string NuGetPrefix = PathSentinels.Nuget + "/"; + if (!portablePath.StartsWith(NuGetPrefix, StringComparison.OrdinalIgnoreCase)) return null; + + int packageStart = NuGetPrefix.Length; + int packageEnd = portablePath.IndexOf('/', packageStart); + if (packageEnd <= packageStart) return null; + + int versionStart = packageEnd + 1; + int versionEnd = portablePath.IndexOf('/', versionStart); + if (versionEnd <= versionStart) return null; + + string packageId = portablePath.Substring(packageStart, packageEnd - packageStart); + string packageVersion = portablePath.Substring(versionStart, versionEnd - versionStart); + string pathUnderPackage = portablePath.Substring(versionEnd + 1); + if (!IsNuGetAnalyzerAssetPath(pathUnderPackage)) return null; + + string metadataPackageId = item.GetMetadata("NuGetPackageId"); + string metadataPackageVersion = item.GetMetadata("NuGetPackageVersion"); + if (!string.Equals(metadataPackageId, packageId, StringComparison.OrdinalIgnoreCase) + || !string.Equals(metadataPackageVersion, packageVersion, StringComparison.OrdinalIgnoreCase) + || !IsSdkKnownAnalyzerPack(sdkKnownAnalyzerPacks, metadataPackageId, targetFramework)) + { + return null; + } + + return metadataPackageId; + } + + internal static string? TryExtractNetFrameworkReferenceAssembly(string? portablePath, TargetFramework targetFramework) + { + if (portablePath == null || string.IsNullOrWhiteSpace(targetFramework.VersionString)) + { + return null; + } + + if (portablePath.StartsWith(NetFxRefPrefix, StringComparison.OrdinalIgnoreCase)) + { + string netFxRelative = portablePath.Substring(NetFxRefPrefix.Length); + int netFxVersionEnd = netFxRelative.IndexOf('/'); + string netFxVersion = netFxVersionEnd >= 0 ? netFxRelative.Substring(0, netFxVersionEnd) : netFxRelative; + return IsSameNetFrameworkVersion(netFxVersion, targetFramework.VersionString!) && IsNetFrameworkReferenceAssemblyPath(netFxRelative) + ? "v" + targetFramework.VersionString + netFxRelative.Substring(netFxVersion.Length) + : null; + } + + if (!portablePath.StartsWith(NuGetPrefix, StringComparison.OrdinalIgnoreCase)) + { + return TryExtractNetFrameworkReferenceAssemblyFromFrameworkRootPath(portablePath, targetFramework.VersionString!); + } + + int packageStart = NuGetPrefix.Length; + int packageEnd = portablePath.IndexOf('/', packageStart); + if (packageEnd <= packageStart) + { + return null; + } + + string packageId = portablePath.Substring(packageStart, packageEnd - packageStart); + if (!IsNetFrameworkReferenceAssembliesPackage(packageId)) + { + return null; + } + + int versionStart = packageEnd + 1; + int versionEnd = portablePath.IndexOf('/', versionStart); + if (versionEnd <= versionStart) + { + return null; + } + + string pathUnderPackage = portablePath.Substring(versionEnd + 1); + const string buildPrefix = "build/.NETFramework/"; + if (!pathUnderPackage.StartsWith(buildPrefix, StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + int frameworkVersionStart = buildPrefix.Length; + int frameworkVersionEnd = pathUnderPackage.IndexOf('/', frameworkVersionStart); + if (frameworkVersionEnd <= frameworkVersionStart) + { + return null; + } + + string packageFrameworkVersion = pathUnderPackage.Substring(frameworkVersionStart, frameworkVersionEnd - frameworkVersionStart); + string packageRelativePath = pathUnderPackage.Substring(frameworkVersionStart); + return IsSameNetFrameworkVersion(packageFrameworkVersion, targetFramework.VersionString!) && IsNetFrameworkReferenceAssemblyPath(packageRelativePath) + ? "v" + targetFramework.VersionString + packageRelativePath.Substring(packageFrameworkVersion.Length) + : null; + } + + private static bool IsNuGetRefPackAssetPath(string pathUnderPackage, TargetFramework targetFramework) + { + if (string.IsNullOrWhiteSpace(targetFramework.Alias)) return false; + + string refPrefix = "ref/" + targetFramework.Alias + "/"; + if (pathUnderPackage.StartsWith(refPrefix, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (!string.IsNullOrWhiteSpace(targetFramework.VersionString)) + { + refPrefix = "ref/net" + targetFramework.VersionString + "/"; + if (pathUnderPackage.StartsWith(refPrefix, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return pathUnderPackage.StartsWith("analyzers/", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsNuGetAnalyzerAssetPath(string pathUnderPackage) + => pathUnderPackage.StartsWith("analyzers/", StringComparison.OrdinalIgnoreCase); + + private static bool IsSdkKnownAnalyzerPack(ITaskItem[]? sdkKnownAnalyzerPacks, string packageId, TargetFramework targetFramework) + { + if (sdkKnownAnalyzerPacks == null || string.IsNullOrWhiteSpace(packageId)) return false; + + foreach (ITaskItem item in sdkKnownAnalyzerPacks) + { + if (item == null) continue; + string knownPackageId = item.GetMetadata("PackageId"); + if (string.IsNullOrWhiteSpace(knownPackageId)) + { + knownPackageId = item.ItemSpec; + } + + if (!string.Equals(knownPackageId, packageId, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + string knownTargetFramework = item.GetMetadata(ProjectProperties.TargetFramework); + if (!string.IsNullOrWhiteSpace(knownTargetFramework) + && !string.IsNullOrWhiteSpace(targetFramework.Alias) + && !string.Equals(knownTargetFramework, targetFramework.Alias, StringComparison.OrdinalIgnoreCase) + && !IsSameMajorVersion(knownTargetFramework, targetFramework.Version)) + { + continue; + } + + return true; + } + + return false; + } + + /// + /// Checks whether a TFM alias from SDK metadata (e.g. "net8.0", "netcoreapp3.1") has the + /// same major version as the given parsed version. Handles both net and netcoreapp prefixes. + /// + private static bool IsSameMajorVersion(string sdkTargetFramework, Version? targetVersion) + { + if (targetVersion == null) return false; + + ReadOnlySpan span = sdkTargetFramework.AsSpan(); + if (span.StartsWith("netcoreapp", StringComparison.OrdinalIgnoreCase)) + span = span.Slice("netcoreapp".Length); + else if (span.StartsWith("net", StringComparison.OrdinalIgnoreCase)) + span = span.Slice("net".Length); + else + return false; + + // Extract digits up to the first non-digit (e.g. '.' or '-') + int end = 0; + while (end < span.Length && char.IsDigit(span[end])) end++; + return end > 0 && int.TryParse(span.Slice(0, end).ToString(), out int major) && major == targetVersion.Major; + } + + private static bool IsNetFrameworkCoreAssemblyName(string? fileName) + => string.Equals(fileName, "mscorlib.dll", StringComparison.OrdinalIgnoreCase) + || string.Equals(fileName, "System.dll", StringComparison.OrdinalIgnoreCase) + || string.Equals(fileName, "System.Core.dll", StringComparison.OrdinalIgnoreCase); + + private static bool IsNetFrameworkReferenceAssembliesPackage(string packageId) + => string.Equals(packageId, "microsoft.netframework.referenceassemblies", StringComparison.OrdinalIgnoreCase) + || packageId.StartsWith("microsoft.netframework.referenceassemblies.net", StringComparison.OrdinalIgnoreCase); + + private static bool IsNetFrameworkReferenceAssemblyPath(string relativePath) + => relativePath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) + && relativePath.IndexOf('\\') < 0 + && !relativePath.Contains("../", StringComparison.Ordinal) + && !Path.GetFileName(relativePath).Contains("..", StringComparison.Ordinal); + + private static string? TryExtractNetFrameworkReferenceAssemblyFromFrameworkRootPath(string portablePath, string targetVersion) + { + const string frameworkMarker = ".NETFramework/"; + int frameworkMarkerIndex = portablePath.IndexOf(frameworkMarker, StringComparison.OrdinalIgnoreCase); + if (frameworkMarkerIndex < 0) + { + return null; + } + + string relative = portablePath.Substring(frameworkMarkerIndex + frameworkMarker.Length); + int versionEnd = relative.IndexOf('/'); + if (versionEnd <= 0) + { + return null; + } + + string version = relative.Substring(0, versionEnd); + return IsSameNetFrameworkVersion(version, targetVersion) && IsNetFrameworkReferenceAssemblyPath(relative) + ? "v" + targetVersion + relative.Substring(version.Length) + : null; + } + + /// + /// Compares a version extracted from a file path (e.g. "v4.7.2") with the target framework + /// version string (e.g. "4.7.2"). Tolerates a leading "v" on either side. + /// + private static bool IsSameNetFrameworkVersion(string pathVersion, string targetFrameworkVersion) + { + string left = pathVersion.StartsWith("v", StringComparison.OrdinalIgnoreCase) ? pathVersion.Substring(1) : pathVersion; + string right = targetFrameworkVersion.StartsWith("v", StringComparison.OrdinalIgnoreCase) ? targetFrameworkVersion.Substring(1) : targetFrameworkVersion; + return Version.TryParse(left, out Version? leftVersion) + && Version.TryParse(right, out Version? rightVersion) + && leftVersion.Major == rightVersion.Major + && (leftVersion.Minor < 0 ? 0 : leftVersion.Minor) == (rightVersion.Minor < 0 ? 0 : rightVersion.Minor) + && (leftVersion.Build < 0 ? 0 : leftVersion.Build) == (rightVersion.Build < 0 ? 0 : rightVersion.Build); + } + + private static string BuildNetAnalyzersPolicyLine(ITaskItem item, TargetFramework targetFramework) + { + var builder = new StringBuilder("Microsoft.NET.Sdk/analyzers"); + string analysisLevel = GetMetadataValue(item, "AnalysisLevel"); + string effectiveAnalysisLevel = GetMetadataValue(item, "EffectiveAnalysisLevel"); + string canonicalAnalysisLevel = CanonicalizeAnalysisLevel(analysisLevel, effectiveAnalysisLevel, targetFramework, out string parsedAnalysisLevelSuffix); + string analysisLevelSuffix = GetMetadataValue(item, "AnalysisLevelSuffix"); + string codeAnalysisTreatWarningsAsErrors = GetMetadataValue(item, "CodeAnalysisTreatWarningsAsErrors"); + string effectiveCodeAnalysisTreatWarningsAsErrors = GetMetadataValue(item, "EffectiveCodeAnalysisTreatWarningsAsErrors"); + + AppendPolicyValue(builder, "AnalysisLevel", canonicalAnalysisLevel); + AppendPolicyValue(builder, "AnalysisMode", GetMetadataValue(item, "AnalysisMode")); + + SplitAnalysisLevel(canonicalAnalysisLevel, out _, out string parsedCanonicalAnalysisLevelSuffix); + string effectiveAnalysisLevelSuffix = !string.IsNullOrWhiteSpace(analysisLevelSuffix) ? analysisLevelSuffix : parsedAnalysisLevelSuffix; + if (!StringEquals(effectiveAnalysisLevelSuffix, parsedCanonicalAnalysisLevelSuffix)) + { + AppendPolicyValue(builder, "AnalysisLevelSuffix", effectiveAnalysisLevelSuffix); + } + + string rulesVersion = GetMetadataValue(item, "MicrosoftCodeAnalysisNetAnalyzersRulesVersion"); + if (!StringEquals(rulesVersion, TrimTrailingDotZero(effectiveAnalysisLevel))) + { + AppendPolicyValue(builder, "MicrosoftCodeAnalysisNetAnalyzersRulesVersion", rulesVersion); + } + + AppendPolicyValue(builder, "CodeAnalysisTreatWarningsAsErrors", codeAnalysisTreatWarningsAsErrors); + if (!StringEquals(effectiveCodeAnalysisTreatWarningsAsErrors, codeAnalysisTreatWarningsAsErrors)) + { + AppendPolicyValue(builder, "EffectiveCodeAnalysisTreatWarningsAsErrors", effectiveCodeAnalysisTreatWarningsAsErrors); + } + + return builder.ToString(); + } + + private static string BuildCodeStylePolicyLine(ITaskItem item, string languageSegment, TargetFramework targetFramework) + { + var builder = new StringBuilder("Microsoft.NET.Sdk/codestyle/"); + builder.Append(languageSegment); + + string analysisLevel = GetMetadataValue(item, "AnalysisLevel"); + string analysisMode = GetMetadataValue(item, "AnalysisMode"); + string analysisLevelSuffix = GetMetadataValue(item, "AnalysisLevelSuffix"); + string effectiveAnalysisLevel = GetMetadataValue(item, "EffectiveAnalysisLevel"); + string canonicalAnalysisLevel = CanonicalizeAnalysisLevel(analysisLevel, effectiveAnalysisLevel, targetFramework, out string parsedAnalysisLevelSuffix); + string analysisLevelStyle = GetMetadataValue(item, "AnalysisLevelStyle"); + string analysisModeStyle = GetMetadataValue(item, "AnalysisModeStyle"); + string analysisLevelSuffixStyle = GetMetadataValue(item, "AnalysisLevelSuffixStyle"); + + AppendPolicyValue(builder, "AnalysisLevel", canonicalAnalysisLevel); + AppendPolicyValue(builder, "AnalysisMode", analysisMode); + + if (!StringEquals(analysisLevelStyle, analysisLevel)) + { + AppendPolicyValue(builder, "AnalysisLevelStyle", analysisLevelStyle); + } + + if (!StringEquals(analysisModeStyle, analysisMode)) + { + AppendPolicyValue(builder, "AnalysisModeStyle", analysisModeStyle); + } + + SplitAnalysisLevel(canonicalAnalysisLevel, out _, out string parsedCanonicalAnalysisLevelSuffix); + string effectiveAnalysisLevelSuffix = !string.IsNullOrWhiteSpace(analysisLevelSuffix) ? analysisLevelSuffix : parsedAnalysisLevelSuffix; + if (!StringEquals(effectiveAnalysisLevelSuffix, parsedCanonicalAnalysisLevelSuffix)) + { + AppendPolicyValue(builder, "AnalysisLevelSuffix", effectiveAnalysisLevelSuffix); + } + + string styleFallbackSuffix = string.IsNullOrWhiteSpace(analysisLevelStyle) ? effectiveAnalysisLevelSuffix : parsedAnalysisLevelSuffix; + SplitAnalysisLevel(string.IsNullOrWhiteSpace(analysisLevelStyle) ? analysisLevel : analysisLevelStyle, out _, out string parsedAnalysisLevelSuffixStyle); + if (!StringEquals(analysisLevelSuffixStyle, parsedAnalysisLevelSuffixStyle) && !StringEquals(analysisLevelSuffixStyle, styleFallbackSuffix)) + { + AppendPolicyValue(builder, "AnalysisLevelSuffixStyle", analysisLevelSuffixStyle); + } + + return builder.ToString(); + } + + private static string CanonicalizeAnalysisLevel(string analysisLevel, string effectiveAnalysisLevel, TargetFramework targetFramework, out string parsedAnalysisLevelSuffix) + { + SplitAnalysisLevel(analysisLevel, out string analysisLevelPrefix, out parsedAnalysisLevelSuffix); + string analysisLevelCore = string.IsNullOrWhiteSpace(analysisLevelPrefix) ? analysisLevel : analysisLevelPrefix; + return IsDefaultAnalysisLevel(analysisLevelCore, effectiveAnalysisLevel, targetFramework) + ? string.Empty + : analysisLevel; + } + + private static bool IsDefaultAnalysisLevel(string analysisLevelCore, string effectiveAnalysisLevel, TargetFramework targetFramework) + { + if (string.IsNullOrWhiteSpace(analysisLevelCore)) + { + return true; + } + + if (targetFramework.Version == null) + { + return false; + } + + if (targetFramework.IsNetFramework) + { + return string.Equals(analysisLevelCore, "latest", StringComparison.OrdinalIgnoreCase) + && (string.IsNullOrWhiteSpace(effectiveAnalysisLevel) + || string.Equals(effectiveAnalysisLevel, "latest", StringComparison.OrdinalIgnoreCase)); + } + + if (string.Equals(analysisLevelCore, "latest", StringComparison.OrdinalIgnoreCase)) + { + return string.IsNullOrWhiteSpace(effectiveAnalysisLevel) + || string.Equals(effectiveAnalysisLevel, "latest", StringComparison.OrdinalIgnoreCase) + || VersionGreaterThanOrEquals(effectiveAnalysisLevel, targetFramework.Version); + } + + return VersionEquals(analysisLevelCore, targetFramework.Version); + } + + private static string? TryGetKnownNuGetFrameworkPackPackageId(string packageId) + { + if (string.Equals(packageId, "microsoft.netcore.app.ref", StringComparison.OrdinalIgnoreCase)) + { + return "Microsoft.NETCore.App.Ref"; + } + + if (string.Equals(packageId, "microsoft.aspnetcore.app.ref", StringComparison.OrdinalIgnoreCase)) + { + return "Microsoft.AspNetCore.App.Ref"; + } + + if (string.Equals(packageId, "microsoft.windowsdesktop.app.ref", StringComparison.OrdinalIgnoreCase)) + { + return "Microsoft.WindowsDesktop.App.Ref"; + } + + return null; + } + + internal static string CanonicalizeSdkAnalyzerConfigPolicyLine(string line, string? targetFrameworkIdentifier, string? targetFrameworkVersion) + { + if (!line.StartsWith("Microsoft.NET.Sdk/analyzers", StringComparison.OrdinalIgnoreCase) + && !line.StartsWith("Microsoft.NET.Sdk/codestyle/", StringComparison.OrdinalIgnoreCase)) + { + return line; + } + + string[] parts = line.Split('|'); + int analysisLevelIndex = FindPolicyValueIndex(parts, "AnalysisLevel"); + if (analysisLevelIndex < 0) + { + return line; + } + + var targetFramework = new TargetFramework(alias: null, targetFrameworkIdentifier, targetFrameworkVersion); + string analysisLevel = parts[analysisLevelIndex].Substring("AnalysisLevel=".Length); + string canonicalAnalysisLevel = CanonicalizeAnalysisLevel(analysisLevel, effectiveAnalysisLevel: string.Empty, targetFramework, out string parsedAnalysisLevelSuffix); + if (StringEquals(canonicalAnalysisLevel, analysisLevel)) + { + return line; + } + + var canonicalParts = new List(parts.Length + 1); + for (int i = 0; i < parts.Length; i++) + { + if (i == analysisLevelIndex) + { + if (!string.IsNullOrWhiteSpace(canonicalAnalysisLevel)) + { + canonicalParts.Add("AnalysisLevel=" + EscapePolicyValue(canonicalAnalysisLevel)); + } + + continue; + } + + canonicalParts.Add(parts[i]); + } + + if (!string.IsNullOrWhiteSpace(parsedAnalysisLevelSuffix) + && FindPolicyValueIndex(parts, "AnalysisLevelSuffix") < 0) + { + canonicalParts.Add("AnalysisLevelSuffix=" + EscapePolicyValue(parsedAnalysisLevelSuffix)); + } + + return string.Join("|", canonicalParts); + } + + private static int FindPolicyValueIndex(string[] parts, string name) + { + string prefix = name + "="; + for (int i = 1; i < parts.Length; i++) + { + if (parts[i].StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + return i; + } + } + + return -1; + } + + private static void AppendPolicyValue(StringBuilder builder, string name, string value) + { + if (string.IsNullOrWhiteSpace(value) || string.Equals(value, "*Undefined*", StringComparison.Ordinal)) + { + return; + } + + builder.Append('|'); + builder.Append(name); + builder.Append('='); + builder.Append(EscapePolicyValue(value.Trim())); + } + + private static string EscapePolicyValue(string value) + { + return value + .Replace("%", "%25") + .Replace("|", "%7C") + .Replace("=", "%3D") + .Replace("\r", string.Empty) + .Replace("\n", string.Empty); + } + + private static string GetMetadataValue(ITaskItem item, string name) + => item.GetMetadata(name) ?? string.Empty; + + private static bool IsTrue(string value) + => string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); + + private static bool StringEquals(string? left, string? right) + => string.Equals(left ?? string.Empty, right ?? string.Empty, StringComparison.OrdinalIgnoreCase); + + private static string TrimTrailingDotZero(string value) + { + while (value.EndsWith(".0", StringComparison.Ordinal)) + { + value = value.Substring(0, value.Length - 2); + } + + return value; + } + + private static void SplitAnalysisLevel(string analysisLevel, out string prefix, out string suffix) + { + int separator = analysisLevel.IndexOf('-'); + if (separator <= 0 || separator == analysisLevel.Length - 1) + { + prefix = string.Empty; + suffix = string.Empty; + return; + } + + prefix = analysisLevel.Substring(0, separator); + suffix = analysisLevel.Substring(separator + 1); + } + + private static string? TryGetSdkCodeStyleLanguageSegment(string language) + { + if (string.Equals(language, "C#", StringComparison.OrdinalIgnoreCase) + || string.Equals(language, "CSharp", StringComparison.OrdinalIgnoreCase) + || string.Equals(language, "cs", StringComparison.OrdinalIgnoreCase)) + { + return "cs"; + } + + return null; + } + + private static bool VersionEquals(string value, Version version) + { + if (!Version.TryParse(value, out Version? parsedVersion)) + { + return int.TryParse(value, out int major) + && major == version.Major + && version.Minor == 0; + } + + int minor = parsedVersion.Minor < 0 ? 0 : parsedVersion.Minor; + return parsedVersion.Major == version.Major && minor == version.Minor; + } + + private static bool VersionGreaterThanOrEquals(string value, Version version) + { + if (!Version.TryParse(value, out Version? parsedVersion)) + { + return int.TryParse(value, out int major) + && major >= version.Major + && version.Minor == 0; + } + + int minor = parsedVersion.Minor < 0 ? 0 : parsedVersion.Minor; + return parsedVersion.Major > version.Major + || (parsedVersion.Major == version.Major && minor >= version.Minor); + } + + /// + /// Bundles the MSBuild-evaluated target framework properties for a project slice, + /// pre-computing a parsed from the version string. + /// + internal readonly struct TargetFramework + { + /// The TFM alias, e.g. net8.0, net472. From $(TargetFramework). + public string? Alias { get; } + + /// E.g. .NETCoreApp, .NETFramework. From $(TargetFrameworkIdentifier). + public string? Identifier { get; } + + /// E.g. 8.0, 4.8. From $(TargetFrameworkVersion), with the leading "v" stripped. + public string? VersionString { get; } + + /// Parsed form of . Null when the raw value is missing or malformed. + public Version? Version { get; } + + /// Whether the target framework identifier is .NETFramework. + public bool IsNetFramework => string.Equals(this.Identifier, ".NETFramework", StringComparison.OrdinalIgnoreCase); + + public TargetFramework(string? alias, string? identifier, string? version) + { + this.Alias = alias; + this.Identifier = identifier; + + if (!string.IsNullOrWhiteSpace(version)) + { + this.VersionString = version!.StartsWith("v", StringComparison.OrdinalIgnoreCase) ? version.Substring(1) : version; + this.Version = Version.TryParse(this.VersionString, out Version v) ? v : null; + } + } + } + + private static string? GetItemValue(ITaskItem[]? items, string itemSpec) + { + if (items == null) return null; + foreach (ITaskItem item in items) + { + if (item == null) continue; + if (!string.Equals(item.ItemSpec, itemSpec, StringComparison.OrdinalIgnoreCase)) continue; + + string value = item.GetMetadata("Value"); + return string.IsNullOrWhiteSpace(value) ? null : value; + } + + return null; + } + + private static void EmitMetadataRefSection(TextWriter writer, List> portableItems) + { + // Required item type — always write header + writer.WriteLine(); + writer.WriteLine(CacheFormat.SectionHeader(CacheFormat.Sections.MetadataReferences)); + if (portableItems.Count == 0) return; + + var sortedPaths = new List(portableItems.Count); + var lookup = new Dictionary(PathComparer); + foreach (KeyValuePair kvp in portableItems) + { + // First occurrence wins. See ``EmitSourceFileSection`` for the rationale — + // two upstream items can collapse to the same portable form. + if (!lookup.ContainsKey(kvp.Key)) + { + lookup.Add(kvp.Key, kvp.Value); + sortedPaths.Add(kvp.Key); + } + } + + EmitCompressedWithMetadata(writer, sortedPaths, 0, "", lookup, EmitMetadataReferenceMetadata); + } + + // Trie-based path compression: groups paths by directory segment so each + // directory is emitted exactly once with its files and subdirectories nested + // under it. Output is wire-compatible with CacheFileReader.ExpandCompressedPaths + // (lines ending in '/' are directory headers pushed onto an indent stack). + internal static void EmitCompressed(StringBuilder sb, List paths, int indent) + { + using var writer = new StringWriter(sb); + EmitCompressed(writer, paths, indent); + } + + private static void EmitCompressed(TextWriter writer, IEnumerable paths, int indent) + { + PathTrieNode root = BuildTrie(paths); + EmitTrie(writer, root, indent, lookup: null, emitMetadata: null); + } + + private static void EmitCompressedWithMetadata( + TextWriter writer, List paths, int indent, + string accPrefix, Dictionary lookup, + Action emitMetadata) + { + // accPrefix is unused here: the trie carries each leaf's full portable + // path directly so the metadata lookup does not need a running prefix. + _ = accPrefix; + PathTrieNode root = BuildTrie(paths); + EmitTrie(writer, root, indent, lookup, emitMetadata); + } + + // Builds a directory-segment trie from already-sorted portable paths. + // Sentinel tokens (e.g. "", "") naturally land as a single + // first segment because they contain no '/' before the next separator. + private static PathTrieNode BuildTrie(IEnumerable paths) + { + var root = new PathTrieNode(); + foreach (string path in paths) + { + if (string.IsNullOrEmpty(path)) continue; + string[] segments = path.Split('/'); + PathTrieNode node = root; + for (int i = 0; i < segments.Length; i++) + { + string seg = segments[i]; + if (seg.Length == 0) + { + // Skip empty segments from a leading '/' or doubled separator. + continue; + } + bool isLeaf = i == segments.Length - 1; + if (isLeaf) + { + node.Files.Add(new PathTrieLeaf(seg, path)); + } + else + { + if (!node.Directories.TryGetValue(seg, out PathTrieNode? child)) + { + child = new PathTrieNode(); + node.Directories.Add(seg, child); + } + node = child; + } + } + } + return root; + } + + private static void EmitTrie( + TextWriter writer, PathTrieNode node, int indent, + Dictionary? lookup, + Action? emitMetadata) + { + var directories = new List<(string DisplayName, PathTrieNode? Dir, PathTrieLeaf? File)>(node.Directories.Count); + var files = new List<(string DisplayName, PathTrieLeaf File)>(node.Files.Count); + + foreach (KeyValuePair kvp in node.Directories) + { + if (TryCollapseSingleFileSubtree(kvp.Value, kvp.Key, out PathTrieLeaf leaf, out string leafDisplayName)) + { + directories.Add((leafDisplayName, null, leaf)); + continue; + } + + // Collapse chains of single-child directories so a/b/c/ emits on one line + // when each intermediate has exactly one directory child and no files. + string name = kvp.Key; + PathTrieNode target = kvp.Value; + while (target.Files.Count == 0 && target.Directories.Count == 1) + { + KeyValuePair only = target.Directories.First(); + name = name + "/" + only.Key; + target = only.Value; + } + directories.Add((name, target, null)); + } + foreach (PathTrieLeaf leaf in node.Files) + { + files.Add((leaf.Name, leaf)); + } + + directories.Sort(static (a, b) => ComparePortablePaths(a.DisplayName, b.DisplayName)); + files.Sort(static (a, b) => ComparePortablePaths(a.DisplayName, b.DisplayName)); + + // Emit directory-origin entries first and direct files second, with + // deterministic ordinal-ignore-case ordering within each group. A + // collapsed single-file subtree is still a directory-origin entry, so + // package paths under / keep package-name alphabetical order. + foreach ((string displayName, PathTrieNode? dir, PathTrieLeaf? file) in directories) + { + WriteIndent(writer, indent); + if (dir is not null) + { + writer.Write(displayName); + writer.WriteLine('/'); + EmitTrie(writer, dir, indent + 1, lookup, emitMetadata); + } + else + { + PathTrieLeaf leaf = file!.Value; + writer.WriteLine(displayName); + if (lookup != null && emitMetadata != null + && lookup.TryGetValue(leaf.FullPath, out ITaskItem? refItem)) + { + emitMetadata(writer, indent + 1, refItem); + } + } + } + + foreach ((string displayName, PathTrieLeaf leaf) in files) + { + WriteIndent(writer, indent); + writer.WriteLine(displayName); + if (lookup != null && emitMetadata != null + && lookup.TryGetValue(leaf.FullPath, out ITaskItem? refItem)) + { + emitMetadata(writer, indent + 1, refItem); + } + } + } + + private static bool TryCollapseSingleFileSubtree( + PathTrieNode node, string prefix, + out PathTrieLeaf leaf, out string displayName) + { + while (node.Files.Count == 0 && node.Directories.Count == 1) + { + KeyValuePair only = node.Directories.First(); + prefix = prefix + "/" + only.Key; + node = only.Value; + } + + if (node.Files.Count == 1 && node.Directories.Count == 0) + { + leaf = node.Files[0]; + displayName = prefix + "/" + leaf.Name; + return true; + } + + leaf = default; + displayName = ""; + return false; + } + + private sealed class PathTrieNode + { + public SortedDictionary Directories { get; } = + new(PathComparer); + public List Files { get; } = new(); + } + + private readonly struct PathTrieLeaf(string name, string fullPath) + { + public string Name { get; } = name; + public string FullPath { get; } = fullPath; + } + + private static void EmitSourceFileMetadata(TextWriter writer, int indent, ITaskItem item) + { + string link = item.GetMetadata(ProjectItems.Compile.Link); + if (!string.IsNullOrWhiteSpace(link)) + { + WriteIndent(writer, indent); + writer.Write("@link="); + writer.WriteLine(link.Replace('\\', '/')); + } + } + + private static void EmitProjectReferenceMetadata(TextWriter writer, int indent, ITaskItem item) + { + string referenceOutputAssembly = item.GetMetadata(ProjectItems.ProjectReference.ReferenceOutputAssembly); + if (string.Equals(referenceOutputAssembly, "false", StringComparison.OrdinalIgnoreCase)) + { + WriteIndent(writer, indent); + writer.WriteLine("@ReferenceOutputAssembly=false"); + } + } + + private static void EmitEmbeddedResourceSection(TextWriter writer, ITaskItem[]? items, CachePathResolver resolver) + { + if (items == null || items.Length == 0) return; + + var sortedPaths = new List(items.Length); + var lookup = new Dictionary(PathComparer); + foreach (ITaskItem item in items) + { + if (item == null) continue; + string path = item.ItemSpec; + if (string.IsNullOrEmpty(path)) continue; + + string portable = resolver.ToPortable(path); + if (!lookup.ContainsKey(portable)) + { + sortedPaths.Add(portable); + lookup[portable] = item; + } + } + + sortedPaths.Sort(ComparePortablePaths); + if (sortedPaths.Count == 0) return; + + writer.WriteLine(); + writer.WriteLine(CacheFormat.SectionHeader(CacheFormat.Sections.EmbeddedResources)); + EmitCompressedWithMetadata(writer, sortedPaths, 0, "", lookup, EmitEmbeddedResourceMetadata); + } + + private static void EmitEmbeddedResourceMetadata(TextWriter writer, int indent, ITaskItem item) + { + string generator = item.GetMetadata(ProjectItems.EmbeddedResource.Generator); + if (!string.IsNullOrEmpty(generator)) + { + WriteIndent(writer, indent); + writer.Write("@Generator="); + writer.WriteLine(generator); + } + + string lastGenOutput = item.GetMetadata(ProjectItems.EmbeddedResource.LastGenOutput); + if (!string.IsNullOrEmpty(lastGenOutput)) + { + WriteIndent(writer, indent); + writer.Write("@LastGenOutput="); + writer.WriteLine(lastGenOutput); + } + + string customToolNamespace = item.GetMetadata(ProjectItems.EmbeddedResource.CustomToolNamespace); + if (!string.IsNullOrEmpty(customToolNamespace)) + { + WriteIndent(writer, indent); + writer.Write("@CustomToolNamespace="); + writer.WriteLine(customToolNamespace); + } + } + + private static void EmitMetadataReferenceMetadata(TextWriter writer, int indent, ITaskItem item) + { + string aliases = item.GetMetadata("Aliases"); + if (!string.IsNullOrEmpty(aliases) && aliases != "global") + { + WriteIndent(writer, indent); + writer.Write("@aliases="); + writer.WriteLine(aliases); + } + + string embedStr = item.GetMetadata("EmbedInteropTypes"); + if (string.Equals(embedStr, "true", StringComparison.OrdinalIgnoreCase)) + { + WriteIndent(writer, indent); + writer.WriteLine("@embedInteropTypes"); + } + } + + private static void WriteIndent(TextWriter writer, int count) + { + for (int i = 0; i < count; i++) writer.Write(' '); + } + + private static List> ToSortedKvps(ITaskItem[]? items) + { + var list = new List>(); + if (items == null) return list; + foreach (ITaskItem item in items) + { + if (item == null) continue; + list.Add(new KeyValuePair( + item.ItemSpec ?? string.Empty, + item.GetMetadata("Value") ?? string.Empty)); + } + list.Sort(static (a, b) => StringComparer.OrdinalIgnoreCase.Compare(a.Key, b.Key)); + return list; + } +} + +internal readonly struct ProjectDataDuplicateItemDiagnostic +{ + public ProjectDataDuplicateItemDiagnostic(string projectFilePath, string section, string itemSpec) + { + this.ProjectFilePath = projectFilePath; + this.Section = section; + this.ItemSpec = itemSpec; + } + + public string ProjectFilePath { get; } + public string Section { get; } + public string ItemSpec { get; } +} diff --git a/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/StringComparers.cs b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/StringComparers.cs new file mode 100644 index 0000000000000..d8c681fab9b07 --- /dev/null +++ b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/StringComparers.cs @@ -0,0 +1,19 @@ +// 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.Runtime.InteropServices; + +namespace Microsoft.NET.ProjectData.Tasks; + +internal static class StringComparers +{ + public static StringComparer Paths { get; } = + RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? StringComparer.Ordinal : StringComparer.OrdinalIgnoreCase; +} + +internal static class StringComparisons +{ + public static StringComparison Paths { get; } = + RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; +} diff --git a/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/ValidateProjectDataPackagesTask.cs b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/ValidateProjectDataPackagesTask.cs new file mode 100644 index 0000000000000..f188b577851f4 --- /dev/null +++ b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/ValidateProjectDataPackagesTask.cs @@ -0,0 +1,894 @@ +// 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.Json; +using Microsoft.Build.Framework; +using NuGet.Versioning; + +namespace Microsoft.NET.ProjectData.Tasks; + +/// +/// Validates that evaluated package references agree with the resolved restore graph and that +/// resolved package folders still exist. +/// +public sealed class ValidateProjectDataPackagesTask : Microsoft.Build.Utilities.Task +{ + [Required] + public string ProjectFilePath { get; set; } = string.Empty; + + public string AssetsFile { get; set; } = string.Empty; + + public string TargetFramework { get; set; } = string.Empty; + + public string TargetFrameworkMoniker { get; set; } = string.Empty; + + public bool ManagePackageVersionsCentrally { get; set; } + + public bool CentralPackageTransitivePinningEnabled { get; set; } + + public ITaskItem[] PackageReferences { get; set; } = []; + + public ITaskItem[] PackageVersions { get; set; } = []; + + public ITaskItem[] ResolvedPackages { get; set; } = []; + + public override bool Execute() + { + Dictionary resolvedPackagesById = this.GetResolvedPackagesById(); + Dictionary centralVersionsById = this.GetCentralVersionsById(); + RestoreGraphRequests? restoredRequests = this.GetRestoreGraphRequests(); + Dictionary? restoredRequestedVersionsById = restoredRequests?.DirectRequests; + var currentPackageIds = new HashSet( + this.PackageReferences.Select(static packageReference => packageReference.ItemSpec), + StringComparer.OrdinalIgnoreCase); + List missingPackages = []; + List missingRequestedVersions = []; + List incompatiblePackages = []; + List staleRequestedVersions = []; + List incompatibleCentralTransitiveVersions = []; + List staleCentralTransitiveVersions = []; + bool centralTransitivePinningModeChanged = restoredRequests?.CentralPackageTransitivePinningEnabled is bool restoredPinningEnabled && + restoredPinningEnabled != this.CentralPackageTransitivePinningEnabled; + + foreach (ITaskItem packageReference in this.PackageReferences) + { + if (string.Equals(packageReference.GetMetadata("IsImplicitlyDefined"), "true", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + string packageId = packageReference.ItemSpec; + if (!resolvedPackagesById.TryGetValue(packageId, out ITaskItem? resolvedPackage)) + { + missingPackages.Add(packageId); + continue; + } + + string requestedVersion = this.GetRequestedVersion(packageReference, centralVersionsById); + if (string.IsNullOrWhiteSpace(requestedVersion)) + { + missingRequestedVersions.Add(packageId); + continue; + } + + string resolvedVersion = GetResolvedVersion(resolvedPackage); + if (!VersionRange.TryParse(requestedVersion, out VersionRange? requestedRange)) + { + incompatiblePackages.Add($"{packageId} (invalid requested version '{requestedVersion}')"); + continue; + } + + if (restoredRequestedVersionsById is not null) + { + if (!restoredRequestedVersionsById.TryGetValue(packageId, out string? restoredRequestedVersion)) + { + staleRequestedVersions.Add($"{packageId} (current request '{requestedVersion}', missing from restored requests)"); + } + else if (!VersionRange.TryParse(restoredRequestedVersion, out VersionRange? restoredRequestedRange) || + !requestedRange.Equals(restoredRequestedRange)) + { + staleRequestedVersions.Add($"{packageId} (current request '{requestedVersion}', restored request '{restoredRequestedVersion}')"); + } + else if (restoredRequests!.DirectAssetSelections.TryGetValue(packageId, out PackageAssetSelection? restoredAssetSelection)) + { + PackageAssetSelection currentAssetSelection = GetCurrentAssetSelection(packageReference); + if (!currentAssetSelection.Equals(restoredAssetSelection)) + { + staleRequestedVersions.Add( + $"{packageId} (current assets '{currentAssetSelection}', restored assets '{restoredAssetSelection}')"); + } + } + } + + if (!NuGetVersion.TryParse(resolvedVersion, out NuGetVersion? resolvedNuGetVersion)) + { + incompatiblePackages.Add($"{packageId} (requested '{requestedVersion}', invalid resolved version '{resolvedVersion}')"); + } + else if (!requestedRange.Satisfies(resolvedNuGetVersion)) + { + incompatiblePackages.Add($"{packageId} (requested '{requestedVersion}', resolved '{resolvedVersion}')"); + } + else if (requestedRange.Float is not null && !requestedRange.Float.Satisfies(resolvedNuGetVersion)) + { + incompatiblePackages.Add($"{packageId} (requested '{requestedVersion}', resolved '{resolvedVersion}')"); + } + } + + if (this.CentralPackageTransitivePinningEnabled && restoredRequests is not null) + { + var activeCentralTransitiveVersionsById = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (KeyValuePair centralVersion in centralVersionsById) + { + if (!currentPackageIds.Contains(centralVersion.Key) && + restoredRequests.ResolvedVersions.TryGetValue(centralVersion.Key, out string? centralResolvedVersion) && + (restoredRequests.CentralTransitiveRequests.ContainsKey(centralVersion.Key) || + !restoredRequests.CentralVersions.TryGetValue(centralVersion.Key, out string? restoredCentralVersion) || + !AreEquivalentVersionRanges(centralVersion.Value, restoredCentralVersion))) + { + activeCentralTransitiveVersionsById[centralVersion.Key] = centralVersion.Value; + this.ValidateCentralTransitiveVersion( + centralVersion.Key, + centralVersion.Value, + centralResolvedVersion, + incompatibleCentralTransitiveVersions); + } + } + + foreach (KeyValuePair currentRequest in activeCentralTransitiveVersionsById) + { + if (!restoredRequests.CentralTransitiveRequests.TryGetValue(currentRequest.Key, out string? restoredRequest)) + { + staleCentralTransitiveVersions.Add($"{currentRequest.Key} (current request '{currentRequest.Value}', missing from restored central transitive requests)"); + } + else if (!AreEquivalentVersionRanges(currentRequest.Value, restoredRequest)) + { + staleCentralTransitiveVersions.Add($"{currentRequest.Key} (current request '{currentRequest.Value}', restored request '{restoredRequest}')"); + } + } + + foreach (KeyValuePair restoredRequest in restoredRequests.CentralTransitiveRequests) + { + if (!activeCentralTransitiveVersionsById.ContainsKey(restoredRequest.Key)) + { + staleCentralTransitiveVersions.Add($"{restoredRequest.Key} (restored request '{restoredRequest.Value}', no current active central transitive pin)"); + } + } + } + + if (restoredRequestedVersionsById is not null) + { + foreach (KeyValuePair restoredRequest in restoredRequestedVersionsById) + { + if (!currentPackageIds.Contains(restoredRequest.Key) && + !restoredRequests!.AutoReferencedRequestIds.Contains(restoredRequest.Key)) + { + staleRequestedVersions.Add($"{restoredRequest.Key} (restored request '{restoredRequest.Value}', no current PackageReference)"); + } + } + } + + if (missingPackages.Count > 0) + { + this.Log.LogError( + "ProjectData: cannot write project data for '{0}' because the restore graph does not contain declared PackageReference items: {1}. Run restore successfully before ProjectDataBuild.", + this.ProjectFilePath, + string.Join("; ", missingPackages.OrderBy(static packageId => packageId, StringComparer.OrdinalIgnoreCase))); + } + + if (missingRequestedVersions.Count > 0) + { + this.Log.LogError( + "ProjectData: cannot write project data for '{0}' because declared PackageReference items have no evaluated version request: {1}. Restore may be stale or central package version metadata may be missing. Run restore successfully before ProjectDataBuild.", + this.ProjectFilePath, + string.Join("; ", missingRequestedVersions.OrderBy(static packageId => packageId, StringComparer.OrdinalIgnoreCase))); + } + + if (incompatiblePackages.Count > 0) + { + this.Log.LogError( + "ProjectData: cannot write project data for '{0}' because declared PackageReference versions are not satisfied by the restore graph: {1}. Run restore successfully before ProjectDataBuild.", + this.ProjectFilePath, + string.Join("; ", incompatiblePackages.OrderBy(static package => package, StringComparer.OrdinalIgnoreCase))); + } + + if (staleRequestedVersions.Count > 0) + { + this.Log.LogError( + "ProjectData: cannot write project data for '{0}' because declared PackageReference requests differ from the restore graph: {1}. Run restore successfully before ProjectDataBuild.", + this.ProjectFilePath, + string.Join("; ", staleRequestedVersions.OrderBy(static package => package, StringComparer.OrdinalIgnoreCase))); + } + + if (incompatibleCentralTransitiveVersions.Count > 0) + { + this.Log.LogError( + "ProjectData: cannot write project data for '{0}' because central transitive package version requests are not satisfied by the restore graph: {1}. Run restore successfully before ProjectDataBuild.", + this.ProjectFilePath, + string.Join("; ", incompatibleCentralTransitiveVersions.OrderBy(static package => package, StringComparer.OrdinalIgnoreCase))); + } + + if (staleCentralTransitiveVersions.Count > 0) + { + this.Log.LogError( + "ProjectData: cannot write project data for '{0}' because central transitive package version requests differ from the restore graph: {1}. Run restore successfully before ProjectDataBuild.", + this.ProjectFilePath, + string.Join("; ", staleCentralTransitiveVersions.OrderBy(static package => package, StringComparer.OrdinalIgnoreCase))); + } + + if (centralTransitivePinningModeChanged) + { + this.Log.LogError( + "ProjectData: cannot write project data for '{0}' because central transitive package pinning mode differs from the restore graph: current '{1}', restored '{2}'. Run restore successfully before ProjectDataBuild.", + this.ProjectFilePath, + this.CentralPackageTransitivePinningEnabled, + restoredRequests!.CentralPackageTransitivePinningEnabled); + } + + return !this.Log.HasLoggedErrors; + } + + private void ValidateCentralTransitiveVersion( + string packageId, + string requestedVersion, + string resolvedVersion, + List incompatiblePackages) + { + if (!VersionRange.TryParse(requestedVersion, out VersionRange? requestedRange)) + { + incompatiblePackages.Add($"{packageId} (invalid requested version '{requestedVersion}')"); + return; + } + + if (!NuGetVersion.TryParse(resolvedVersion, out NuGetVersion? resolvedNuGetVersion)) + { + incompatiblePackages.Add($"{packageId} (requested '{requestedVersion}', invalid resolved version '{resolvedVersion}')"); + } + else if (!requestedRange.Satisfies(resolvedNuGetVersion) || + (requestedRange.Float is not null && !requestedRange.Float.Satisfies(resolvedNuGetVersion))) + { + incompatiblePackages.Add($"{packageId} (requested '{requestedVersion}', resolved '{resolvedVersion}')"); + } + } + + private Dictionary GetResolvedPackagesById() + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (ITaskItem resolvedPackage in this.ResolvedPackages) + { + string packageId = resolvedPackage.GetMetadata("Name"); + if (string.IsNullOrWhiteSpace(packageId)) + { + packageId = GetIdentityPart(resolvedPackage.ItemSpec); + } + + if (!string.IsNullOrWhiteSpace(packageId)) + { + result[packageId] = resolvedPackage; + } + + string packagePath = resolvedPackage.GetMetadata("Path"); + if (string.IsNullOrWhiteSpace(packagePath)) + { + this.Log.LogError( + "ProjectData: cannot write project data for '{0}' because resolved package '{1}' has no package path. Run restore successfully before ProjectDataBuild.", + this.ProjectFilePath, + resolvedPackage.ItemSpec); + } + else if (!Directory.Exists(packagePath)) + { + this.Log.LogError( + "ProjectData: cannot write project data for '{0}' because package files are missing: {1} at {2}. Run restore successfully before ProjectDataBuild.", + this.ProjectFilePath, + resolvedPackage.ItemSpec, + packagePath); + } + } + + return result; + } + + private Dictionary GetCentralVersionsById() + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (ITaskItem packageVersion in this.PackageVersions) + { + string version = packageVersion.GetMetadata("Version"); + if (!string.IsNullOrWhiteSpace(packageVersion.ItemSpec) && !string.IsNullOrWhiteSpace(version)) + { + result[packageVersion.ItemSpec] = version; + } + } + + return result; + } + + private RestoreGraphRequests? GetRestoreGraphRequests() + { + if (string.IsNullOrWhiteSpace(this.AssetsFile) || string.IsNullOrWhiteSpace(this.TargetFramework)) + { + return null; + } + + try + { + using FileStream stream = File.OpenRead(this.AssetsFile); + using JsonDocument assetsFile = JsonDocument.Parse(stream); + if (!TryGetProperty(assetsFile.RootElement, "project", out JsonElement project) || + !TryGetProperty(project, "frameworks", out JsonElement frameworks) || + !TryGetProperty(frameworks, this.TargetFramework, out JsonElement framework)) + { + this.Log.LogError( + "ProjectData: cannot write project data for '{0}' because restore graph '{1}' does not contain dependency requests for target framework '{2}'. Run restore successfully before ProjectDataBuild.", + this.ProjectFilePath, + this.AssetsFile, + this.TargetFramework); + return null; + } + + var directRequests = new Dictionary(StringComparer.OrdinalIgnoreCase); + var directAssetSelections = new Dictionary(StringComparer.OrdinalIgnoreCase); + var autoReferencedRequestIds = new HashSet(StringComparer.OrdinalIgnoreCase); + if (framework.ValueKind != JsonValueKind.Object) + { + this.LogAssetsFileError($"target framework '{this.TargetFramework}' must be a JSON object"); + return null; + } + + bool hasCentralPackageEvidence = this.ManagePackageVersionsCentrally || + this.CentralPackageTransitivePinningEnabled || + this.PackageVersions.Length > 0 || + TryGetProperty(framework, "centralPackageVersions", out _) || + TryGetProperty(assetsFile.RootElement, "centralTransitiveDependencyGroups", out _); + bool? restoredPinningEnabled = this.GetRestoredCentralPackageTransitivePinningMode(project, hasCentralPackageEvidence); + if (hasCentralPackageEvidence && restoredPinningEnabled is null) + { + return null; + } + + if (!TryGetProperty(framework, "dependencies", out JsonElement dependencies)) + { + dependencies = default; + } + else if (dependencies.ValueKind != JsonValueKind.Object) + { + this.LogAssetsFileError($"dependency requests for target framework '{this.TargetFramework}' must be a JSON object"); + return null; + } + + if (dependencies.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty dependency in dependencies.EnumerateObject()) + { + if (!TryGetStringProperty(dependency.Value, "target", out string target)) + { + this.LogAssetsFileError($"dependency request '{dependency.Name}' for target framework '{this.TargetFramework}' has no string target"); + return null; + } + + if (!string.Equals(target, "Package", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!TryGetStringProperty(dependency.Value, "version", out string version) || + string.IsNullOrWhiteSpace(version)) + { + this.LogAssetsFileError($"package dependency request '{dependency.Name}' for target framework '{this.TargetFramework}' has no string version"); + return null; + } + + if (TryGetBooleanProperty(dependency.Value, "autoReferenced", out bool autoReferenced) && + autoReferenced) + { + autoReferencedRequestIds.Add(dependency.Name); + } + + directRequests[dependency.Name] = version; + if (!this.TryGetRestoredAssetSelection(dependency, this.TargetFramework, out PackageAssetSelection? assetSelection)) + { + return null; + } + + directAssetSelections[dependency.Name] = assetSelection; + } + } + + Dictionary? restoredCentralVersions = this.GetRestoredCentralVersions(framework); + Dictionary? centralTransitiveRequests = this.GetRestoredCentralTransitiveRequests( + assetsFile.RootElement, + directRequests); + Dictionary? resolvedVersions = this.GetRestoredResolvedVersions(assetsFile.RootElement); + return restoredCentralVersions is null || centralTransitiveRequests is null || resolvedVersions is null + ? null + : new RestoreGraphRequests( + directRequests, + directAssetSelections, + autoReferencedRequestIds, + restoredCentralVersions, + centralTransitiveRequests, + resolvedVersions, + restoredPinningEnabled); + } + catch (IOException ex) + { + this.LogAssetsFileError(ex.Message); + } + catch (UnauthorizedAccessException ex) + { + this.LogAssetsFileError(ex.Message); + } + catch (JsonException ex) + { + this.LogAssetsFileError(ex.Message); + } + + return null; + } + + private bool TryGetRestoredAssetSelection( + JsonProperty dependency, + string targetFramework, + out PackageAssetSelection assetSelection) + { + PackageAssetFlags include = PackageAssetFlags.All; + if (TryGetProperty(dependency.Value, "include", out JsonElement includeElement)) + { + if (includeElement.ValueKind != JsonValueKind.String) + { + this.LogAssetsFileError($"package dependency request '{dependency.Name}' for target framework '{targetFramework}' has no string include assets"); + assetSelection = null!; + return false; + } + + include = ParseRestoredAssetFlags(includeElement.GetString(), PackageAssetFlags.All); + } + + PackageAssetFlags suppressParent = PackageAssetSelection.DefaultSuppressParent; + if (TryGetProperty(dependency.Value, "suppressParent", out JsonElement suppressParentElement)) + { + if (suppressParentElement.ValueKind != JsonValueKind.String) + { + this.LogAssetsFileError($"package dependency request '{dependency.Name}' for target framework '{targetFramework}' has no string private assets"); + assetSelection = null!; + return false; + } + + suppressParent = ParseRestoredAssetFlags(suppressParentElement.GetString(), PackageAssetSelection.DefaultSuppressParent); + } + + assetSelection = new PackageAssetSelection(include, suppressParent); + return true; + } + + private bool? GetRestoredCentralPackageTransitivePinningMode(JsonElement project, bool required) + { + if (!TryGetProperty(project, "restore", out JsonElement restore)) + { + if (required) + { + this.LogAssetsFileError("restore settings do not contain central transitive package pinning mode"); + } + + return null; + } + + if (restore.ValueKind != JsonValueKind.Object) + { + this.LogAssetsFileError("restore settings must be a JSON object"); + return null; + } + + if (!TryGetProperty(restore, "CentralPackageTransitivePinningEnabled", out JsonElement pinningEnabled)) + { + // NuGet 6.13 writes the property only when enabled; omission records false. + return required ? false : null; + } + + if (pinningEnabled.ValueKind != JsonValueKind.True && + pinningEnabled.ValueKind != JsonValueKind.False) + { + this.LogAssetsFileError("central transitive package pinning mode in restore settings must be a JSON boolean"); + return null; + } + + return pinningEnabled.GetBoolean(); + } + + private Dictionary? GetRestoredCentralVersions(JsonElement framework) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!this.CentralPackageTransitivePinningEnabled || + !TryGetProperty(framework, "centralPackageVersions", out JsonElement centralVersions)) + { + return result; + } + + if (centralVersions.ValueKind != JsonValueKind.Object) + { + this.LogAssetsFileError($"central package versions for target framework '{this.TargetFramework}' must be a JSON object"); + return null; + } + + foreach (JsonProperty centralVersion in centralVersions.EnumerateObject()) + { + if (centralVersion.Value.ValueKind != JsonValueKind.String || + string.IsNullOrWhiteSpace(centralVersion.Value.GetString())) + { + this.LogAssetsFileError($"central package version '{centralVersion.Name}' for target framework '{this.TargetFramework}' has no string version"); + return null; + } + + result[centralVersion.Name] = centralVersion.Value.GetString()!; + } + + return result; + } + + private Dictionary? GetRestoredCentralTransitiveRequests( + JsonElement assetsFile, + IReadOnlyDictionary directRequests) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!this.CentralPackageTransitivePinningEnabled || + !TryGetProperty(assetsFile, "centralTransitiveDependencyGroups", out JsonElement groups)) + { + return result; + } + + if (groups.ValueKind != JsonValueKind.Object) + { + this.LogAssetsFileError("central transitive dependency groups must be a JSON object"); + return null; + } + + if (!this.TryGetTargetFrameworkGroup(groups, out JsonElement group)) + { + return result; + } + + if (group.ValueKind != JsonValueKind.Object) + { + this.LogAssetsFileError($"central transitive dependency group for target framework '{this.TargetFramework}' must be a JSON object"); + return null; + } + + foreach (JsonProperty dependency in group.EnumerateObject()) + { + if (directRequests.ContainsKey(dependency.Name)) + { + continue; + } + + if (!TryGetStringProperty(dependency.Value, "version", out string version) || + string.IsNullOrWhiteSpace(version)) + { + this.LogAssetsFileError($"central transitive dependency request '{dependency.Name}' for target framework '{this.TargetFramework}' has no string version"); + return null; + } + + result[dependency.Name] = version; + } + + return result; + } + + private Dictionary? GetRestoredResolvedVersions(JsonElement assetsFile) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (!this.CentralPackageTransitivePinningEnabled) + { + return result; + } + + if (!TryGetProperty(assetsFile, "targets", out JsonElement targets) || + targets.ValueKind != JsonValueKind.Object) + { + this.LogAssetsFileError("resolved target graphs must be a JSON object"); + return null; + } + + if (!this.TryGetTargetFrameworkGroup(targets, out JsonElement target)) + { + this.LogAssetsFileError($"resolved target graphs do not contain target framework '{this.TargetFramework}'"); + return null; + } + + if (target.ValueKind != JsonValueKind.Object) + { + this.LogAssetsFileError($"resolved target graph for target framework '{this.TargetFramework}' must be a JSON object"); + return null; + } + + foreach (JsonProperty dependency in target.EnumerateObject()) + { + if (!TryGetStringProperty(dependency.Value, "type", out string dependencyType)) + { + this.LogAssetsFileError($"resolved dependency '{dependency.Name}' for target framework '{this.TargetFramework}' has no string type"); + return null; + } + + if (!string.Equals(dependencyType, "package", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + int separatorIndex = dependency.Name.LastIndexOf('/'); + if (separatorIndex <= 0 || separatorIndex == dependency.Name.Length - 1) + { + this.LogAssetsFileError($"resolved package identity '{dependency.Name}' for target framework '{this.TargetFramework}' has no concrete version"); + return null; + } + + result[dependency.Name.Substring(0, separatorIndex)] = dependency.Name.Substring(separatorIndex + 1); + } + + return result; + } + + private bool TryGetTargetFrameworkGroup(JsonElement groups, out JsonElement group) + { + if (!string.IsNullOrWhiteSpace(this.TargetFrameworkMoniker) && + TryGetProperty(groups, this.TargetFrameworkMoniker, out group)) + { + return true; + } + + return TryGetProperty(groups, this.TargetFramework, out group); + } + + private void LogAssetsFileError(string message) + { + this.Log.LogError( + "ProjectData: cannot write project data for '{0}' because restore graph '{1}' could not be read: {2}. Run restore successfully before ProjectDataBuild.", + this.ProjectFilePath, + this.AssetsFile, + message); + } + + private string GetRequestedVersion(ITaskItem packageReference, IReadOnlyDictionary centralVersionsById) + { + string versionOverride = packageReference.GetMetadata("VersionOverride"); + if (this.ManagePackageVersionsCentrally && !string.IsNullOrWhiteSpace(versionOverride)) + { + return versionOverride; + } + + string version = packageReference.GetMetadata("Version"); + if (!string.IsNullOrWhiteSpace(version)) + { + return version; + } + + return this.ManagePackageVersionsCentrally && + centralVersionsById.TryGetValue(packageReference.ItemSpec, out string? centralVersion) + ? centralVersion + : string.Empty; + } + + private static bool TryGetProperty(JsonElement element, string propertyName, out JsonElement value) + { + if (element.ValueKind != JsonValueKind.Object) + { + value = default; + return false; + } + + foreach (JsonProperty property in element.EnumerateObject()) + { + if (string.Equals(property.Name, propertyName, StringComparison.OrdinalIgnoreCase)) + { + value = property.Value; + return true; + } + } + + value = default; + return false; + } + + private static bool TryGetBooleanProperty(JsonElement element, string propertyName, out bool value) + { + if (element.ValueKind == JsonValueKind.Object && + element.TryGetProperty(propertyName, out JsonElement property) && + (property.ValueKind == JsonValueKind.True || property.ValueKind == JsonValueKind.False)) + { + value = property.GetBoolean(); + return true; + } + + value = false; + return false; + } + + private static bool TryGetStringProperty(JsonElement element, string propertyName, out string value) + { + if (element.ValueKind == JsonValueKind.Object && + element.TryGetProperty(propertyName, out JsonElement property) && + property.ValueKind == JsonValueKind.String) + { + value = property.GetString() ?? string.Empty; + return true; + } + + value = string.Empty; + return false; + } + + private static bool AreEquivalentVersionRanges(string left, string right) + => VersionRange.TryParse(left, out VersionRange? leftRange) && + VersionRange.TryParse(right, out VersionRange? rightRange) && + leftRange.Equals(rightRange); + + private static PackageAssetSelection GetCurrentAssetSelection(ITaskItem packageReference) + { + PackageAssetFlags include = ParseEvaluatedAssetFlags( + packageReference.GetMetadata("IncludeAssets"), + PackageAssetFlags.All); + PackageAssetFlags exclude = ParseEvaluatedAssetFlags( + packageReference.GetMetadata("ExcludeAssets"), + PackageAssetFlags.None); + PackageAssetFlags suppressParent = ParseEvaluatedAssetFlags( + packageReference.GetMetadata("PrivateAssets"), + PackageAssetSelection.DefaultSuppressParent); + return new PackageAssetSelection(include & ~exclude, suppressParent); + } + + private static PackageAssetFlags ParseEvaluatedAssetFlags(string? value, PackageAssetFlags defaultValue) + => ParseAssetFlags(value, defaultValue, ';', expandBuildTransitive: true); + + private static PackageAssetFlags ParseRestoredAssetFlags(string? value, PackageAssetFlags defaultValue) + => ParseAssetFlags(value, defaultValue, ',', expandBuildTransitive: false); + + private static PackageAssetFlags ParseAssetFlags( + string? value, + PackageAssetFlags defaultValue, + char separator, + bool expandBuildTransitive) + { + if (string.IsNullOrWhiteSpace(value)) + { + return defaultValue; + } + + PackageAssetFlags result = PackageAssetFlags.None; + bool hasToken = false; + foreach (string part in value!.Split([separator], StringSplitOptions.RemoveEmptyEntries)) + { + string token = part.Trim(); + if (token.Length == 0) + { + continue; + } + + hasToken = true; + switch (token.ToLowerInvariant()) + { + case "all": + result |= PackageAssetFlags.All; + break; + case "runtime": + result |= PackageAssetFlags.Runtime; + break; + case "compile": + result |= PackageAssetFlags.Compile; + break; + case "build": + result |= PackageAssetFlags.Build; + break; + case "contentfiles": + result |= PackageAssetFlags.ContentFiles; + break; + case "native": + result |= PackageAssetFlags.Native; + break; + case "analyzers": + result |= PackageAssetFlags.Analyzers; + break; + case "buildtransitive": + result |= PackageAssetFlags.BuildTransitive; + if (expandBuildTransitive) + { + result |= PackageAssetFlags.Build; + } + break; + } + } + + return hasToken ? result : defaultValue; + } + + private static string GetResolvedVersion(ITaskItem resolvedPackage) + { + string version = resolvedPackage.GetMetadata("Version"); + if (!string.IsNullOrWhiteSpace(version)) + { + return version; + } + + int separatorIndex = resolvedPackage.ItemSpec.LastIndexOf('/'); + return separatorIndex >= 0 ? resolvedPackage.ItemSpec.Substring(separatorIndex + 1) : string.Empty; + } + + private static string GetIdentityPart(string resolvedPackageIdentity) + { + int separatorIndex = resolvedPackageIdentity.LastIndexOf('/'); + return separatorIndex >= 0 ? resolvedPackageIdentity.Substring(0, separatorIndex) : resolvedPackageIdentity; + } + + private sealed class RestoreGraphRequests + { + public RestoreGraphRequests( + Dictionary directRequests, + Dictionary directAssetSelections, + HashSet autoReferencedRequestIds, + Dictionary centralVersions, + Dictionary centralTransitiveRequests, + Dictionary resolvedVersions, + bool? centralPackageTransitivePinningEnabled) + { + this.DirectRequests = directRequests; + this.DirectAssetSelections = directAssetSelections; + this.AutoReferencedRequestIds = autoReferencedRequestIds; + this.CentralVersions = centralVersions; + this.CentralTransitiveRequests = centralTransitiveRequests; + this.ResolvedVersions = resolvedVersions; + this.CentralPackageTransitivePinningEnabled = centralPackageTransitivePinningEnabled; + } + + public Dictionary DirectRequests { get; } + + public Dictionary DirectAssetSelections { get; } + + public HashSet AutoReferencedRequestIds { get; } + + public Dictionary CentralVersions { get; } + + public Dictionary CentralTransitiveRequests { get; } + + public Dictionary ResolvedVersions { get; } + + public bool? CentralPackageTransitivePinningEnabled { get; } + } + + [Flags] + private enum PackageAssetFlags + { + None = 0, + Runtime = 1 << 0, + Compile = 1 << 1, + Build = 1 << 2, + ContentFiles = 1 << 3, + Native = 1 << 4, + Analyzers = 1 << 5, + BuildTransitive = 1 << 6, + All = Runtime | Compile | Build | ContentFiles | Native | Analyzers | BuildTransitive, + } + + private sealed class PackageAssetSelection : IEquatable + { + public const PackageAssetFlags DefaultSuppressParent = + PackageAssetFlags.Build | PackageAssetFlags.ContentFiles | PackageAssetFlags.Analyzers; + + public PackageAssetSelection(PackageAssetFlags include, PackageAssetFlags suppressParent) + { + this.Include = include; + this.SuppressParent = suppressParent; + } + + public PackageAssetFlags Include { get; } + + public PackageAssetFlags SuppressParent { get; } + + public bool Equals(PackageAssetSelection? other) + => other is not null && + this.Include == other.Include && + this.SuppressParent == other.SuppressParent; + + public override bool Equals(object? obj) => this.Equals(obj as PackageAssetSelection); + + public override int GetHashCode() => ((int)this.Include * 397) ^ (int)this.SuppressParent; + + public override string ToString() => $"include={this.Include}; private={this.SuppressParent}"; + } +} diff --git a/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/WriteProjectDataBuildReceiptTask.cs b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/WriteProjectDataBuildReceiptTask.cs new file mode 100644 index 0000000000000..acec847aba9a0 --- /dev/null +++ b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/WriteProjectDataBuildReceiptTask.cs @@ -0,0 +1,42 @@ +// 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; + +/// +/// Writes a completion receipt after the outer project-level ProjectDataBuild target completes. +/// +public sealed class WriteProjectDataBuildReceiptTask : Microsoft.Build.Utilities.Task +{ + [Required] + public string ReceiptDirectory { get; set; } = string.Empty; + + [Required] + public string AttemptId { get; set; } = string.Empty; + + [Required] + public string ProjectFilePath { get; set; } = string.Empty; + + public override bool Execute() + { + try + { + string receiptPath = ProjectDataBuildReceipt.Write(this.ReceiptDirectory, this.AttemptId, this.ProjectFilePath); + this.Log.LogMessage(MessageImportance.Low, "ProjectData: wrote completed receipt for {0}: {1}", this.ProjectFilePath, receiptPath); + return true; + } + catch (Exception ex) when (ex is not OutOfMemoryException and not StackOverflowException) + { + this.Log.LogError( + "ProjectData: failed to write completed receipt for {0} in attempt {1}: {2}", + this.ProjectFilePath, + this.AttemptId, + ex.Message); + return false; + } + } +} diff --git a/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/WriteProjectDataSliceTask.cs b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/WriteProjectDataSliceTask.cs new file mode 100644 index 0000000000000..1a2f7477ab676 --- /dev/null +++ b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/WriteProjectDataSliceTask.cs @@ -0,0 +1,272 @@ +// 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; + +/// +/// Thin MSBuild wrapper around : declares the MSBuild +/// inputs as properties, calls the writer, and swallows any exception so a writer +/// fault never fails the build. +/// +public sealed class WriteProjectDataSliceTask : Microsoft.Build.Utilities.Task +{ + [Required] + public string ProjectFilePath { get; set; } = string.Empty; + + /// + /// Absolute path of the cache file. When empty, the user-folder cache path is + /// computed from via . + /// + public string OutputPath { get; set; } = string.Empty; + + /// + /// Project intermediate output directory ($(IntermediateOutputPath)). Transient + /// .tmp side-files for the atomic write are placed here (when on the same volume as the + /// output) so they never appear next to committed source. Optional; falls back to the output + /// directory. + /// + public string IntermediateOutputPath { get; set; } = string.Empty; + + /// + /// Optional override for the repo-scoped donor index path. When empty, the task resolves + /// <git-common-dir>\dotnet-projectdata\lscache-donor-index.json from . + /// + public string DonorCacheIndexPath { get; set; } = string.Empty; + + /// + /// Optional override for the logical workspace root recorded in the donor index. + /// + public string DonorCacheWorkspaceRoot { get; set; } = string.Empty; + + public bool WriteHeader { get; set; } + public bool IsPrimary { get; set; } + public bool LastDtbSucceeded { get; set; } + + /// + /// True when the invoking build forced CoreCompile to run so that the compiler command + /// line is always captured. The authoritative ProjectDataBuild graph sets + /// NonExistentFile in _PrepareProjectDataBuild for exactly this reason, so an + /// empty there genuinely means the project produces no C# + /// compilation and should be marked unsupported. + /// + /// When false — for example the opportunistic EnableProjectDataOnBuild hook running on an + /// ordinary incremental build — an empty instead means + /// CoreCompile was skipped as up-to-date (its AfterTargets hook still fires). That + /// is a perfectly good project, so the task must leave any existing project-data file and + /// unsupported marker untouched rather than poison the shared cache with a spurious + /// CompilerCommandLineArgumentsEmpty marker. + /// + public bool CoreCompileForced { get; set; } + + public ITaskItem[]? SliceDimensions { get; set; } + public ITaskItem[]? Properties { get; set; } + public string[]? CommandLineArguments { get; set; } + public ITaskItem[]? SourceFiles { get; set; } + public ITaskItem[]? MetadataReferences { get; set; } + public ITaskItem[]? AnalyzerReferences { get; set; } + public string[]? AnalyzerConfigFiles { get; set; } + public string[]? AdditionalFiles { get; set; } + public ITaskItem[]? EmbeddedResources { get; set; } + public ITaskItem[]? ProjectReferences { get; set; } + public string[]? Capabilities { get; set; } + public ITaskItem[]? SdkKnownAnalyzerPacks { get; set; } + public ITaskItem[]? SdkAnalyzerConfigPolicy { get; set; } + + [Output] + public bool Succeeded { get; set; } + + [Output] + public string ResolvedOutputPath { get; set; } = string.Empty; + + public override bool Execute() + { + try + { + this.Succeeded = false; + this.ResolvedOutputPath = this.ResolveOutputPath(); + + if (!ProjectDataWriter.TryValidateNetFrameworkReferences( + this.ProjectFilePath, + this.SliceDimensions, + this.Properties, + this.MetadataReferences, + out string unsupportedReason)) + { + if (this.ShouldDeleteOutputOnValidationFailure()) + { + this.DeleteFileIfExists(this.ResolvedOutputPath); + } + + if (this.IsPrimary) + { + UnsupportedProjectDataMarker.Write(this.ProjectFilePath, unsupportedReason); + } + + this.Log.LogMessage(MessageImportance.Low, + "ProjectData: skipped writing project-data file for {0}: {1}.", this.ProjectFilePath, unsupportedReason); + return true; + } + + if (this.CommandLineArguments == null || this.CommandLineArguments.Length == 0) + { + // An empty compiler command line is only authoritative when the build forced + // CoreCompile to run (the ProjectDataBuild graph). On an ordinary incremental build, + // the EnableProjectDataOnBuild hook fires AfterTargets="CoreCompile" even when + // CoreCompile was skipped as up-to-date, leaving @(CscCommandLineArgs) empty for a + // perfectly good project. Poisoning the shared cache with an unsupported marker (or + // deleting the good project-data file) in that case makes projects silently vanish on + // the next non-forced workspace refresh. Leave existing state untouched instead. + if (!this.CoreCompileForced) + { + this.Log.LogMessage(MessageImportance.Low, + "ProjectData: leaving project-data for {0} untouched; CscCommandLineArgs was empty but CoreCompile was not forced (likely skipped as up-to-date).", + this.ProjectFilePath); + return true; + } + + if (this.ShouldDeleteOutputOnValidationFailure()) + { + this.DeleteFileIfExists(this.ResolvedOutputPath); + } + + if (this.IsPrimary) + { + UnsupportedProjectDataMarker.Write(this.ProjectFilePath, "CompilerCommandLineArgumentsEmpty"); + } + + this.Log.LogMessage(MessageImportance.Low, + "ProjectData: skipped writing project-data file for {0} because CscCommandLineArgs was empty.", + this.ProjectFilePath); + return true; + } + + ProjectDataWriter.AtomicWriteStreamed( + this.ResolvedOutputPath, + writer => ProjectDataWriter.WriteContent( + writer, + this.ProjectFilePath, + this.WriteHeader, + this.IsPrimary, + this.LastDtbSucceeded, + this.SliceDimensions, + this.Properties, + this.CommandLineArguments, + this.SourceFiles, + this.MetadataReferences, + this.AnalyzerReferences, + this.AnalyzerConfigFiles, + this.AdditionalFiles, + this.EmbeddedResources, + this.ProjectReferences, + this.Capabilities, + this.SdkKnownAnalyzerPacks, + this.SdkAnalyzerConfigPolicy, + this.ReportDuplicateItem), + this.IntermediateOutputPath); + this.Succeeded = true; + UnsupportedProjectDataMarker.Delete(this.ProjectFilePath); + this.RecordDonorIndexEntryIfFinalCache(); + + this.Log.LogMessage(MessageImportance.Low, + "ProjectData: wrote {0}.", this.ResolvedOutputPath); + } + catch (Exception ex) + { + // Cache-write failures should not break the user's build, but they must be + // visible at default verbosity so the user knows the cache might be stale. + // ``LogMessage(Low)`` was invisible under ``-v:minimal`` (the default for + // ``dotnet build``) and hid real diagnostics; ``LogWarning`` matches the + // .NET SDK convention for non-fatal task failures. Catch all exception + // types — narrowing previously caused legitimate DTB scenarios (e.g. a + // ``KeyNotFoundException`` from a missing slice property) to fail the + // build instead of degrading to a stale-cache warning. + this.Log.LogWarning( + "ProjectData: failed to write project-data file for {0}: {1}", + this.ProjectFilePath, + ex.Message); + } + return true; + } + + private string ResolveOutputPath() + => string.IsNullOrEmpty(this.OutputPath) + ? UserFolderCachePath.Compute(this.ProjectFilePath) + : this.OutputPath; + + private void RecordDonorIndexEntryIfFinalCache() + { + if (!this.WriteHeader || !this.IsPrimary) + { + return; + } + + this.RecordDonorIndexEntry(); + } + + private void RecordDonorIndexEntry() + { + ProjectDataDonorWriteOptions options = new() + { + IndexPath = string.IsNullOrEmpty(this.DonorCacheIndexPath) ? null : this.DonorCacheIndexPath, + WorkspaceRoot = string.IsNullOrEmpty(this.DonorCacheWorkspaceRoot) ? null : this.DonorCacheWorkspaceRoot, + }; + + bool recorded = ProjectDataDonorIndex.TryRecordWrite(this.ProjectFilePath, this.ResolvedOutputPath, options, out string? message); + if (recorded && !string.IsNullOrEmpty(message)) + { + this.Log.LogMessage(MessageImportance.Low, "ProjectData: {0}", message); + } + else if (!recorded && !string.IsNullOrEmpty(message)) + { + this.Log.LogMessage(MessageImportance.Low, "ProjectData: failed to update donor index for {0}: {1}", this.ProjectFilePath, message); + } + } + + private void ReportDuplicateItem(ProjectDataDuplicateItemDiagnostic diagnostic) + { + this.Log.LogWarning( + "ProjectData: duplicate {0} item in {1}: {2}. The duplicate entry was omitted from {3}.", + diagnostic.Section, + diagnostic.ProjectFilePath, + diagnostic.ItemSpec, + this.ResolvedOutputPath); + } + + private bool ShouldDeleteOutputOnValidationFailure() + { + if (!this.IsPrimary) + { + return true; + } + + string projectFolderOutputPath = Path.GetFullPath(this.ProjectFilePath) + ".lscache"; + return !PathsEqual(this.ResolvedOutputPath, projectFolderOutputPath); + } + + private static bool PathsEqual(string left, string right) + => string.Equals( + Path.GetFullPath(left), + Path.GetFullPath(right), + StringComparisons.Paths); + + private void DeleteFileIfExists(string path) + { + if (string.IsNullOrEmpty(path)) + { + return; + } + + try + { + File.Delete(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + this.Log.LogWarning("ProjectData: failed to delete stale output {0}: {1}", path, ex.Message); + } + } +} diff --git a/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/WriteUnsupportedProjectDataMarkerTask.cs b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/WriteUnsupportedProjectDataMarkerTask.cs new file mode 100644 index 0000000000000..7a801300b6851 --- /dev/null +++ b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/WriteUnsupportedProjectDataMarkerTask.cs @@ -0,0 +1,49 @@ +// 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.Security.Cryptography; +using Microsoft.Build.Framework; +using Microsoft.NET.ProjectData; + +namespace Microsoft.NET.ProjectData.Tasks; + +/// +/// Writes the user-cache marker that records a project as intentionally unsupported by ProjectData. +/// +public sealed class WriteUnsupportedProjectDataMarkerTask : Microsoft.Build.Utilities.Task +{ + [Required] + public string ProjectFilePath { get; set; } = string.Empty; + + public string Reason { get; set; } = string.Empty; + + [Output] + public string MarkerPath { get; set; } = string.Empty; + + public override bool Execute() + { + try + { + this.MarkerPath = UnsupportedProjectDataMarker.Write(this.ProjectFilePath, this.Reason); + this.Log.LogMessage( + MessageImportance.Low, + "ProjectData: wrote unsupported marker for {0}: {1} ({2})", + this.ProjectFilePath, + this.MarkerPath, + this.Reason); + } + catch (Exception ex) when (IsRecoverableMarkerWriteException(ex)) + { + this.Log.LogWarning( + "ProjectData: failed to write unsupported marker for {0}: {1}", + this.ProjectFilePath, + ex.Message); + } + + return true; + } + + private static bool IsRecoverableMarkerWriteException(Exception ex) + => ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException or InvalidOperationException or CryptographicException; +} diff --git a/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/build/Microsoft.NET.ProjectData.Schema.props b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/build/Microsoft.NET.ProjectData.Schema.props new file mode 100644 index 0000000000000..7b9c0ff41ee94 --- /dev/null +++ b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/build/Microsoft.NET.ProjectData.Schema.props @@ -0,0 +1,24 @@ + + + + + <_ProjectDataProperties Include="AssemblyName" > $(AssemblyName) + <_ProjectDataProperties Include="BaseIntermediateOutputPath" > $(BaseIntermediateOutputPath) + <_ProjectDataProperties Include="BaseOutputPath" > $(BaseOutputPath) + <_ProjectDataProperties Include="CompilerGeneratedFilesOutputPath" > $(CompilerGeneratedFilesOutputPath) + <_ProjectDataProperties Include="IsTestProject" > $(IsTestProject) + <_ProjectDataProperties Include="MaxSupportedLangVersion" > $(MaxSupportedLangVersion) + <_ProjectDataProperties Include="OutputType" > $(OutputType) + <_ProjectDataProperties Include="ProjectAssetsFile" > $(ProjectAssetsFile) + <_ProjectDataProperties Include="RootNamespace" > $(RootNamespace) + <_ProjectDataProperties Include="RunAnalyzers" > $(RunAnalyzers) + <_ProjectDataProperties Include="RunAnalyzersDuringLiveAnalysis" > $(RunAnalyzersDuringLiveAnalysis) + <_ProjectDataProperties Include="SolutionPath" > $(SolutionPath) + <_ProjectDataProperties Include="TargetFramework" > $(TargetFramework) + <_ProjectDataProperties Include="TargetFrameworkIdentifier" > $(TargetFrameworkIdentifier) + <_ProjectDataProperties Include="TargetFrameworkVersion" > $(TargetFrameworkVersion) + <_ProjectDataProperties Include="TargetPath" > $(TargetPath) + <_ProjectDataProperties Include="TargetRefPath" > $(TargetRefPath) + <_ProjectDataProperties Include="TemporaryDependencyNodeTargetIdentifier" > $(TargetFramework) + + diff --git a/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/build/Microsoft.NET.ProjectData.targets b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/build/Microsoft.NET.ProjectData.targets new file mode 100644 index 0000000000000..6442af004b963 --- /dev/null +++ b/src/LanguageServer/ProjectData/Microsoft.NET.ProjectData.Tasks/build/Microsoft.NET.ProjectData.targets @@ -0,0 +1,635 @@ + + + + + + + + + + + + + + + + + <_ProjectDataTaskAssembly>$(MSBuildThisFileDirectory)Microsoft.NET.ProjectData.Tasks.dll + + + + + + false + + + false + + + true + + + <_ProjectDataTargetFrameworkSupported Condition="'$(TargetFrameworkIdentifier)' == '' or + '$(TargetFrameworkIdentifier)' != '.NETFramework' or + '$(UsingMicrosoftNETSdk)' == 'true'">true + + <_ProjectDataCanWriteOutput Condition="'$(UsingMicrosoftNETSdk)' == 'true' and + '$(UsingMicrosoftNoTargetsSdk)' != 'true' and + '$(ExcludeFromBuild)' != 'true' and + '$(_ProjectDataTargetFrameworkSupported)' == 'true' and + ('$(Language)' == 'C#' or + ('$(TargetFrameworks)' != '' and '$(TargetFramework)' == '' and '$(MSBuildProjectExtension)' == '.csproj'))">true + <_ProjectDataCanRunBuildTargets Condition="'$(UsingMicrosoftNETSdk)' == 'true' and + '$(UsingMicrosoftNoTargetsSdk)' != 'true' and + '$(ExcludeFromBuild)' != 'true'">true + <_ProjectDataShouldWriteUnsupportedMarker Condition="'$(_ProjectDataCanWriteOutput)' != 'true'">true + <_ProjectDataUnsupportedReason Condition="'$(UsingMicrosoftNETSdk)' != 'true'">UsingMicrosoftNETSdkFalse + <_ProjectDataUnsupportedReason Condition="'$(_ProjectDataUnsupportedReason)' == '' and '$(UsingMicrosoftNoTargetsSdk)' == 'true'">MicrosoftBuildNoTargetsSdk + <_ProjectDataUnsupportedReason Condition="'$(_ProjectDataUnsupportedReason)' == '' and '$(ExcludeFromBuild)' == 'true'">ExcludeFromBuildTrue + <_ProjectDataUnsupportedReason Condition="'$(_ProjectDataUnsupportedReason)' == '' and '$(_ProjectDataTargetFrameworkSupported)' != 'true'">UnsupportedTargetFramework + <_ProjectDataUnsupportedReason Condition="'$(_ProjectDataUnsupportedReason)' == '' and '$(Language)' != '' and '$(Language)' != 'C#'">LanguageNotCSharp + <_ProjectDataUnsupportedReason Condition="'$(_ProjectDataUnsupportedReason)' == '' and '$(_ProjectDataShouldWriteUnsupportedMarker)' == 'true'">CannotWriteProjectData + + + <_ProjectDataPath Condition="'$(EnableProjectDataInProjectFolder)' == 'true'">$(MSBuildProjectFullPath).lscache + <_ProjectDataAppendRuntimeIdentifierToIntermediateOutputPath Condition="'$(RuntimeIdentifier)' != '' and + '$(_UsingDefaultRuntimeIdentifier)' != 'true' and + ('$(AppendRuntimeIdentifierToOutputPath)' == 'true' or + ('$(AppendRuntimeIdentifierToOutputPath)' == '' and '$(UsingNETSdkDefaults)' == 'true'))">true + <_ProjectDataSlicePath>$(IntermediateOutputPath)$(MSBuildProjectFile).slice + <_ProjectDataSlicePath Condition="'$(TargetFrameworks)' != '' and '$(TargetFramework)' != '' and '$(_ProjectDataAppendRuntimeIdentifierToIntermediateOutputPath)' != 'true'">$(BaseIntermediateOutputPath)$(Configuration)/$(TargetFramework)/$(MSBuildProjectFile).slice + <_ProjectDataSlicePath Condition="'$(TargetFrameworks)' != '' and '$(TargetFramework)' != '' and '$(_ProjectDataAppendRuntimeIdentifierToIntermediateOutputPath)' == 'true'">$(BaseIntermediateOutputPath)$(Configuration)/$(TargetFramework)/$(RuntimeIdentifier)/$(MSBuildProjectFile).slice + <_ProjectDataUserFolderStampPath>$(IntermediateOutputPath)$(MSBuildProjectFile).lscache.stamp + <_ProjectDataPreserveExistingProjectFolderOutput Condition="'$(EnableProjectDataInProjectFolder)' == 'true' and '$(OS)' != 'Windows_NT'">true + <_ProjectDataPreserveExistingProjectFolderOutput Condition="'$(_ProjectDataPreserveExistingProjectFolderOutput)' == ''">false + <_ProjectDataMergeOutputs>$(_ProjectDataPath) + <_ProjectDataMergeOutputs Condition="'$(_ProjectDataBuildForce)' == 'true'">$(_ProjectDataMergeOutputs);__NonExistentSubDir__\__NonExistentFile__ + + + <_ProjectDataMergeAfterTargets Condition="'$(IsCrossTargetingBuild)' == 'true'">DispatchToInnerBuilds + + + true + + + + + <_ProjectDataBuildInnerTargets>_DeleteUnsupportedProjectData;_WriteUnsupportedProjectDataMarker;_ValidateProjectDataAssetsFile;ResolveAssemblyReferencesDesignTime;ResolveProjectReferencesDesignTime;ResolvePackageDependenciesDesignTime;_ValidateProjectDataResolvedPackages;_PrepareProjectDataBuild;Compile;_ValidateProjectDataMetadataReferences;_ProjectDataCollectFrameworkReferences;_WriteProjectData;_WriteProjectDataUserFolder;_WriteProjectDataSlice + <_ProjectDataBuildUnsupportedTargets>_DeleteUnsupportedProjectData;_WriteUnsupportedProjectDataMarker + + + <_ProjectDataBuildAssetsFile>$(MSBuildProjectExtensionsPath)project.assets.json + <_ProjectDataNuGetGeneratedPropsFile>$(MSBuildProjectExtensionsPath)$(MSBuildProjectFile).nuget.g.props + <_ProjectDataNuGetGeneratedTargetsFile>$(MSBuildProjectExtensionsPath)$(MSBuildProjectFile).nuget.g.targets + + <_ProjectDataNuGetGeneratedPropsImported Condition="'$(RestoreSuccess)' == 'True'">true + + <_ProjectDataBuildDependsOn Condition="'$(_ProjectDataCanRunBuildTargets)' == 'true' and '$(_ProjectDataCanWriteOutput)' == 'true' and '$(IsCrossTargetingBuild)' == 'true'">_PrepareProjectDataOuterBuild;DispatchToInnerBuilds + <_ProjectDataBuildDependsOn Condition="'$(_ProjectDataCanRunBuildTargets)' == 'true' and '$(_ProjectDataCanWriteOutput)' == 'true' and '$(_ProjectDataBuildDependsOn)' == ''">$(_ProjectDataBuildInnerTargets) + <_ProjectDataBuildDependsOn Condition="'$(_ProjectDataShouldWriteUnsupportedMarker)' == 'true' and '$(_ProjectDataBuildDependsOn)' == ''">$(_ProjectDataBuildUnsupportedTargets) + + + + + + + + + + <_ProjectDataOuterBuildActive>true + + $(_ProjectDataBuildInnerTargets) + + + + + + + + + + + + + + + <_ProjectDataResolvedAssetsFile Condition="'$(_ProjectDataResolvedAssetsFile)' == '' and '$(ProjectAssetsFile)' != ''">$(ProjectAssetsFile) + <_ProjectDataResolvedAssetsFile Condition="'$(_ProjectDataResolvedAssetsFile)' == ''">$(_ProjectDataBuildAssetsFile) + + + + + + + + + + + + + + + + <_ProjectDataFrameworkMetadataReference Include="@(ReferencePathWithRefAssemblies)" + Condition="'%(ReferencePathWithRefAssemblies.FrameworkReferenceName)' != '' or + ('$(TargetFrameworkIdentifier)' == '.NETStandard' and '%(ReferencePathWithRefAssemblies.Filename)%(ReferencePathWithRefAssemblies.Extension)' == 'netstandard.dll')" /> + + + + + + + + + + + + + <_ProjectDataBuildActive>true + true + $(IntermediateOutputPath)projectdata.force-corecompile.never + + true + + + + + + + + + <_ProjectDataSdkKnownAnalyzerPack Include="@(KnownILLinkPack)"> + %(KnownILLinkPack.Identity) + %(KnownILLinkPack.ILLinkPackVersion) + %(KnownILLinkPack.TargetFramework) + + + + + + + <_ProjectDataSourceFiles Include="@(Compile)" + Condition="'%(Compile.DefiningProjectName)%(Compile.DefiningProjectExtension)' != 'Microsoft.NET.GenerateAssemblyInfo.targets'" /> + + <_ProjectDataSdkAnalyzerConfigPolicy Remove="@(_ProjectDataSdkAnalyzerConfigPolicy)" /> + <_ProjectDataSdkAnalyzerConfigPolicy Include="Microsoft.NET.Sdk"> + $(Language) + $(SkipGlobalAnalyzerConfigForPackage) + + $(EnableNETAnalyzers) + $(EnforceCodeStyleInBuild) + $(AnalysisLevel) + $(AnalysisLevelSuffix) + $(AnalysisMode) + $(EffectiveAnalysisLevel) + $(MicrosoftCodeAnalysisNetAnalyzersRulesVersion) + $(CodeAnalysisTreatWarningsAsErrors) + $(EffectiveCodeAnalysisTreatWarningsAsErrors) + $(AnalysisLevelStyle) + $(AnalysisLevelSuffixStyle) + $(AnalysisModeStyle) + $(EffectiveAnalysisLevelStyle) + + + + + + <_ProjectDataTargetFramework Include="$(TargetFrameworks)" /> + + <_ProjectDataExpectedSlice Include="@(_ProjectDataTargetFramework->'$(BaseIntermediateOutputPath)$(Configuration)/%(Identity)/$(MSBuildProjectFile).slice')" + Condition="'$(_ProjectDataAppendRuntimeIdentifierToIntermediateOutputPath)' != 'true'" /> + <_ProjectDataExpectedSlice Include="@(_ProjectDataTargetFramework->'$(BaseIntermediateOutputPath)$(Configuration)/%(Identity)/$(RuntimeIdentifier)/$(MSBuildProjectFile).slice')" + Condition="'$(_ProjectDataAppendRuntimeIdentifierToIntermediateOutputPath)' == 'true'" /> + + + + + + + <_ProjectDataSliceDimensions Include="TargetFramework"> + $(TargetFramework) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +