File: ProjectDataWriter.cs
Web Access
Project: Microsoft.NET.ProjectData.Tasks.csproj (Microsoft.NET.ProjectData.Tasks)
// 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;

/// <summary>
/// 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; <see cref="AtomicWrite"/> is the only
/// member that does file I/O.
/// </summary>
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);
	}

	/// <summary>
	/// 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.
	/// </summary>
	private static readonly FrozenSet<string> ExcludedCapabilities = new HashSet<string>(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);

	/// <summary>
	/// 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.
	/// </summary>
	private static readonly FrozenSet<string> ExcludedProperties = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
	{
		ProjectProperties.SolutionPath,
	}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);

	/// <summary>
	/// Properties that must always be written, even when empty/whitespace.
	/// Generated from <c>server/src/Microsoft.NET.ProjectData.Generators/project-data-schema.json</c> by <c>DataModelSchemaGenerator</c>.
	/// </summary>
	private static readonly FrozenSet<string> 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<ProjectDataDuplicateItemDiagnostic>? 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<ProjectDataDuplicateItemDiagnostic>? 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<KeyValuePair<string, string>> sliceKvps = ToSortedKvps(sliceDimensions);
		if (sliceKvps.Count > 0)
		{
			writer.WriteLine();
			writer.WriteLine(CacheFormat.SectionHeader(CacheFormat.Sections.SliceDimensions));
			foreach (KeyValuePair<string, string> 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<KeyValuePair<string, string>> propKvps = ToSortedKvps(properties);
		if (propKvps.Count > 0)
		{
			bool wroteHeader = false;
			foreach (KeyValuePair<string, string> 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<string>();
			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<string> frameworkPacks = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
		SortedSet<string> sdkAnalyzerPacks = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
		SortedSet<string> sdkAnalyzerConfigPolicyLines = BuildSdkAnalyzerConfigPolicy(sdkAnalyzerConfigPolicy, targetFramework);
		List<KeyValuePair<string, ITaskItem>> preparedMetadataRefs = PrepareMetadataRefs(
			metadataReferences,
			resolver,
			frameworkPacks,
			targetFramework);
		List<string> preparedAnalyzerRefs = PrepareAnalyzerRefs(
			analyzerReferences,
			resolver,
			frameworkPacks,
			sdkAnalyzerPacks,
			sdkKnownAnalyzerPacks,
			targetFramework,
			projectFilePath,
			duplicateItemReporter);
		List<string> 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<string>(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=<major>[.<minor>]' header.");

	internal static readonly UTF8Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);

	/// <summary>
	/// Atomically writes arbitrary <paramref name="content"/> to <paramref name="outputPath"/> via a
	/// <c>.tmp</c> 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.
	/// </summary>
	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);
	}

	/// <summary>
	/// Atomically writes cache content produced by <paramref name="writeContent"/> to
	/// <paramref name="outputPath"/>. 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.
	/// </summary>
	/// <remarks>
	/// The candidate is rendered into pooled <see cref="char"/>/<see cref="byte"/> buffers rather
	/// than a <see cref="string"/> plus a <see cref="byte"/> 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.
	/// </remarks>
	public static void AtomicWriteStreamed(string outputPath, Action<TextWriter> writeContent, string? tempDirectory = null)
	{
		using PooledCacheRender render = PooledCacheRender.Create(writeContent);
		WriteAtomicallyCore(outputPath, render.Bytes, render.ByteLength, preserveUnknownData: true, candidateTextFactory: render.GetText, tempDirectory);
	}

	/// <summary>
	/// Shared write core: compares the candidate (<paramref name="candidateBuffer"/> /
	/// <paramref name="candidateLength"/>) against the existing file, optionally splices in
	/// forward-compatible data, and atomically replaces the file only when the final bytes differ
	/// (or a legacy <c>hash=</c> header still needs stripping). The existing file is read into a
	/// pooled buffer to stay off the large-object heap. <paramref name="candidateTextFactory"/>
	/// lazily produces the candidate as a string and is invoked only on the rare preservation path.
	/// </summary>
	private static void WriteAtomicallyCore(
		string outputPath,
		byte[] candidateBuffer,
		int candidateLength,
		bool preserveUnknownData,
		Func<string>? 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<byte>.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<byte>(rented!, contentStart, existingContentLength);
				var candidateContent = new ReadOnlySpan<byte>(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<byte>(finalBuffer, 0, finalLength))
					: candidateMatchesExisting;
				if (!existingHadHashLine && finalMatchesExisting)
				{
					return;
				}
			}

			AtomicReplace(outputPath, finalBuffer, finalLength, tempDirectory);
		}
		finally
		{
			if (rented != null)
				ArrayPool<byte>.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;
		}
	}

	/// <summary>
	/// Returns the offset of the first byte after a leading legacy <c>hash=...</c> 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.
	/// </summary>
	private static int SkipLegacyHashLine(byte[] buffer, int length, out bool hadHashLine)
	{
		hadHashLine = false;
		ReadOnlySpan<byte> prefix = "hash="u8;
		if (length < prefix.Length)
			return 0;
		if (!new ReadOnlySpan<byte>(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 { }
		}
	}

	/// <summary>
	/// Chooses the directory for the atomic-write temp side-file. Prefers <paramref name="tempDirectory"/>
	/// (the project's intermediate output directory) so transient <c>.tmp</c> 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 (<see cref="File.Replace(string, string, string)"/> requires
	/// the temp file and the destination to share a volume), or when it cannot be created.
	/// </summary>
	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:<locale> 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<string> 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<string> PrepareDistinctPortablePaths(
		ITaskItem[]? items,
		CachePathResolver resolver,
		string section,
		string projectFilePath,
		Action<ProjectDataDuplicateItemDiagnostic>? duplicateItemReporter)
	{
		if (items == null || items.Length == 0) return [];

		var portables = new List<string>(items.Length);
		var seenPortables = new HashSet<string>(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<string> PrepareDistinctPortablePaths(
		string[]? items,
		CachePathResolver resolver,
		string section,
		string projectFilePath,
		Action<ProjectDataDuplicateItemDiagnostic>? duplicateItemReporter)
	{
		if (items == null || items.Length == 0) return [];

		var portables = new List<string>(items.Length);
		var seenPortables = new HashSet<string>(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<ProjectDataDuplicateItemDiagnostic>? 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<string>(items.Length);
		var lookup = new Dictionary<string, ITaskItem>(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<ProjectDataDuplicateItemDiagnostic>? duplicateItemReporter)
	{
		writer.WriteLine();
		writer.WriteLine(CacheFormat.SectionHeader(CacheFormat.Sections.ProjectReferences));
		if (items is null || items.Length == 0)
		{
			return;
		}

		var sortedPaths = new List<string>(items.Length);
		var lookup = new Dictionary<string, ITaskItem>(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<string> 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<string> 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
	// <paramref name="frameworkPacks"/> and dropped from the result; .NET Framework
	// reference assemblies remain in metadataReferences as canonical
	// <NETFXREF>/vX.Y.Z/*.dll entries.
	internal static List<KeyValuePair<string, ITaskItem>> PrepareMetadataRefs(
		ITaskItem[]? items, CachePathResolver resolver, SortedSet<string> frameworkPacks)
		=> PrepareMetadataRefs(items, resolver, frameworkPacks, default);

	internal static List<KeyValuePair<string, ITaskItem>> PrepareMetadataRefs(
		ITaskItem[]? items,
		CachePathResolver resolver,
		SortedSet<string> frameworkPacks,
		TargetFramework targetFramework)
	{
		var result = new List<KeyValuePair<string, ITaskItem>>();
		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
				// <DOTNET>/packs/ on one machine and <NUGET>/ 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<string, ITaskItem>(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
	// <paramref name="frameworkPacks"/> set (and dropping them from the result).
	internal static List<string> PrepareAnalyzerRefs(
		ITaskItem[]? items, CachePathResolver resolver, SortedSet<string> frameworkPacks)
		=> PrepareAnalyzerRefs(items, resolver, frameworkPacks, default);

	internal static List<string> PrepareAnalyzerRefs(
		ITaskItem[]? items,
		CachePathResolver resolver,
		SortedSet<string> frameworkPacks,
		TargetFramework targetFramework)
		=> PrepareAnalyzerRefs(items, resolver, frameworkPacks, new SortedSet<string>(StringComparer.OrdinalIgnoreCase), sdkKnownAnalyzerPacks: null, targetFramework);

	internal static List<string> PrepareAnalyzerRefs(
		ITaskItem[]? items,
		CachePathResolver resolver,
		SortedSet<string> frameworkPacks,
		SortedSet<string> sdkAnalyzerPacks,
		ITaskItem[]? sdkKnownAnalyzerPacks,
		TargetFramework targetFramework,
		string projectFilePath = "",
		Action<ProjectDataDuplicateItemDiagnostic>? duplicateItemReporter = null)
	{
		if (items == null || items.Length == 0) return [];

		var result = new List<string>(items.Length);
		var seenResult = new HashSet<string>(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<string> ToSortedDistinctPortablePaths(
		IEnumerable<string> paths,
		string section,
		string projectFilePath,
		Action<ProjectDataDuplicateItemDiagnostic>? duplicateItemReporter)
	{
		List<string> result = [];
		HashSet<string> 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<string> paths,
		HashSet<string> seenPaths,
		string portablePath,
		string section,
		string projectFilePath,
		Action<ProjectDataDuplicateItemDiagnostic>? duplicateItemReporter)
	{
		if (seenPaths.Add(portablePath))
		{
			paths.Add(portablePath);
		}
		else
		{
			if (duplicateItemReporter is not null)
			{
				duplicateItemReporter(new ProjectDataDuplicateItemDiagnostic(projectFilePath, section, portablePath));
			}
		}
	}

	internal static SortedSet<string> BuildSdkAnalyzerConfigPolicy(ITaskItem[]? items, TargetFramework targetFramework)
	{
		var policies = new SortedSet<string>(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 <DOTNET>/packs/<PackName>/<HighestCompat>/data/FrameworkList.xml
	// into managed metadata references and CS analyzer references.
	internal static void WriteFrameworkPacksSection(StringBuilder sb, SortedSet<string> packs)
	{
		using var writer = new StringWriter(sb);
		WriteFrameworkPacksSection(writer, packs);
	}

	internal static void WriteFrameworkPacksSection(TextWriter writer, SortedSet<string> 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<string> 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<string> 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 <paramref name="portablePath"/> is rooted under
	// <DOTNET>/packs/<PackName>/<Version>/... and the pack follows the shared-framework
	// <Name>.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 <DOTNET>/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
	// <NUGET>/<PackageId>/<Version>/... 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;
	}

	/// <summary>
	/// Checks whether a TFM alias from SDK metadata (e.g. <c>"net8.0"</c>, <c>"netcoreapp3.1"</c>) has the
	/// same major version as the given parsed version. Handles both <c>net</c> and <c>netcoreapp</c> prefixes.
	/// </summary>
	private static bool IsSameMajorVersion(string sdkTargetFramework, Version? targetVersion)
	{
		if (targetVersion == null) return false;

		ReadOnlySpan<char> 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;
	}

	/// <summary>
	/// Compares a version extracted from a file path (e.g. <c>"v4.7.2"</c>) with the target framework
	/// version string (e.g. <c>"4.7.2"</c>). Tolerates a leading <c>"v"</c> on either side.
	/// </summary>
	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<string>(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);
	}

	/// <summary>
	/// Bundles the MSBuild-evaluated target framework properties for a project slice,
	/// pre-computing a parsed <see cref="System.Version"/> from the version string.
	/// </summary>
	internal readonly struct TargetFramework
	{
		/// <summary>The TFM alias, e.g. <c>net8.0</c>, <c>net472</c>. From <c>$(TargetFramework)</c>.</summary>
		public string? Alias { get; }

		/// <summary>E.g. <c>.NETCoreApp</c>, <c>.NETFramework</c>. From <c>$(TargetFrameworkIdentifier)</c>.</summary>
		public string? Identifier { get; }

		/// <summary>E.g. <c>8.0</c>, <c>4.8</c>. From <c>$(TargetFrameworkVersion)</c>, with the leading <c>"v"</c> stripped.</summary>
		public string? VersionString { get; }

		/// <summary>Parsed form of <see cref="VersionString"/>. Null when the raw value is missing or malformed.</summary>
		public Version? Version { get; }

		/// <summary>Whether the target framework identifier is <c>.NETFramework</c>.</summary>
		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<KeyValuePair<string, ITaskItem>> 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<string>(portableItems.Count);
		var lookup = new Dictionary<string, ITaskItem>(PathComparer);
		foreach (KeyValuePair<string, ITaskItem> 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<string> paths, int indent)
	{
		using var writer = new StringWriter(sb);
		EmitCompressed(writer, paths, indent);
	}

	private static void EmitCompressed(TextWriter writer, IEnumerable<string> paths, int indent)
	{
		PathTrieNode root = BuildTrie(paths);
		EmitTrie(writer, root, indent, lookup: null, emitMetadata: null);
	}

	private static void EmitCompressedWithMetadata(
		TextWriter writer, List<string> paths, int indent,
		string accPrefix, Dictionary<string, ITaskItem> lookup,
		Action<TextWriter, int, ITaskItem> 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. "<NUGET>", "<DOTNET>") naturally land as a single
	// first segment because they contain no '/' before the next separator.
	private static PathTrieNode BuildTrie(IEnumerable<string> 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<string, ITaskItem>? lookup,
		Action<TextWriter, int, ITaskItem>? 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<string, PathTrieNode> 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<string, PathTrieNode> 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 <NUGET>/ 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<string, PathTrieNode> 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<string, PathTrieNode> Directories { get; } =
			new(PathComparer);
		public List<PathTrieLeaf> 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<string>(items.Length);
		var lookup = new Dictionary<string, ITaskItem>(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<KeyValuePair<string, string>> ToSortedKvps(ITaskItem[]? items)
	{
		var list = new List<KeyValuePair<string, string>>();
		if (items == null) return list;
		foreach (ITaskItem item in items)
		{
			if (item == null) continue;
			list.Add(new KeyValuePair<string, string>(
				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; }
}