diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index dd415c86cc845..f7222d9948a42 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -92,7 +92,6 @@ When starting any task or answering any question about this repo:
### Doc Update Obligation
Every task that changes code must end with a doc pass:
-- Added or moved files? → Update `.github/memory/FILE_MAP.md` (top-level) and the matching `.github/instructions/.instructions.md` (directory detail).
- Changed a public interface, diagnostic ID, or API? → Update the relevant `.github/instructions/.instructions.md` and `PublicAPI.Unshipped.txt`.
- Hit something surprising or undocumented? → Ask the user how they want it documented.
- Established a new pattern? → Repo-wide → `.github/memory/CONVENTIONS.md`; layer-specific → the matching `.github/instructions/.instructions.md`.
@@ -124,6 +123,8 @@ Write the plan to `plan.md` in your session folder (see the session context) and
**Post the plan and wait for approval before writing the implementing diff** — the plan is meant to be reviewed now, not after a large diff already exists.
+If a pull request already exists when work pauses for plan approval, update its description or add a comment that clearly states the implementation is **not complete**, summarizes what remains, and links to the Copilot session containing the plan and approval request so the user can provide further instructions.
+
Then implement, keeping the diff **scoped and reviewable** — prefer the smallest change that fully addresses the task over a broad refactor. If the plan changes materially while implementing, update it rather than silently diverging. Only after the plan's **Acceptance** and **Validation** are satisfied (and the Definition of Done below passes) is the work "done."
Trivial changes don't need a written plan — go straight to the Definition of Done.
diff --git a/.github/instructions/IDE.instructions.md b/.github/instructions/IDE.instructions.md
index 12a3115d48505..b96a454a7d689 100644
--- a/.github/instructions/IDE.instructions.md
+++ b/.github/instructions/IDE.instructions.md
@@ -15,6 +15,7 @@ Roslyn uses a **layered service architecture** built on MEF (Managed Extensibili
- **LanguageServer** (`src/LanguageServer/`): Shared LSP protocol implementation and Roslyn LSP executable (`roslyn-language-server`)
- **EditorFeatures** (`src/EditorFeatures/`): VS Editor integration and text manipulation
- **VisualStudio** (`src/VisualStudio/`): Visual Studio-specific implementations
+- **VisualStudio integration-test harness** (`src/VisualStudio/IntegrationTest/Harness/`): shared integration-test infrastructure
- **EditorConfig templates** (`src/VisualStudio/EditorConfig/`): item templates, generation wizard, context-menu command, VSIX projects, and Visual Studio insertion setup
- The setup insertion component is `Templates.Editorconfig.Setup`, but its SWR package identity must remain `Templates.Editorconfig.SolutionFile.Setup` because existing Visual Studio template packages depend on that ID.
@@ -104,3 +105,4 @@ var methodDecl = generator.MethodDeclaration("MyMethod", ...);
- **ImportingConstructor must be marked `[Obsolete]`** with `MefConstruction.ImportingConstructorMessage`
- **Language services must be exported with a specific language name** — don't use generic exports for both C#/VB
- **Workspace changes must use immutable updates** — `Workspace.SetCurrentSolution()`
+- **MSBuild project extensions are stored with a leading `.`.** `ProjectFileExtensionRegistry` accepts registration and lookup values with or without the dot, but its enumeration API returns the canonical dot-prefixed form.
diff --git a/.github/instructions/Razor.instructions.md b/.github/instructions/Razor.instructions.md
index 39d949aa8ce67..d011147d2f8a1 100644
--- a/.github/instructions/Razor.instructions.md
+++ b/.github/instructions/Razor.instructions.md
@@ -36,6 +36,9 @@ their original sub-tree layout
fields through the constructor. When adding a new field, thread it through every existing
`With*` method. Prefer computing derived data via extension methods (e.g.,
`GetUnusedDirectives()`) rather than storing computed results as fields.
+- **Razor engine concurrency**: Hosts can process multiple documents concurrently through one
+ `RazorProjectEngine`. Phase and pass instances are shared, so keep per-document state in locals
+ or an execution context rather than mutable instance fields.
- **Razor documents in Roslyn**: Stored as additional documents. Resolve via
`solution.GetDocumentIdsWithFilePath(filePath)` then `solution.GetAdditionalDocument(documentId)`.
- **Razor documents with virtual URIs**: Remote Razor document classification preserves the full
@@ -72,7 +75,7 @@ When adding a new `IRemote*Service` and `Remote*Service`:
3. Register in
`src\Razor\src\Razor\src\Microsoft.CodeAnalysis.Razor.Workspaces\Remote\RazorServices.cs`
(add to `MessagePackServices` or `JsonServices`).
-4. **Add an entry to `eng\targets\RazorServices.props`** (at the Roslyn repo root, not under
+4. **Add an entry to the repo-root `eng\targets\RazorServices.props` file** (not under
`src\Razor`):
`Include="Microsoft.VisualStudio.Razor.{ShortName}"` with
`ClassName="{FullTypeName}+Factory"`. The `ShortName` is your interface name with
diff --git a/.github/memory/API_MAP.md b/.github/memory/API_MAP.md
index 33c342fa441ce..200c5d8de515d 100644
--- a/.github/memory/API_MAP.md
+++ b/.github/memory/API_MAP.md
@@ -13,6 +13,7 @@ Repo-wide entry points and the formal public-API tracking rules. Layer-specific
| Command | Purpose |
|---------|---------|
| `build.sh` / `Build.cmd` | Full solution build (Arcade). |
+| `dotnet restore ` | Restores dependencies and, on the first restore in a clone, validates checkout line endings and Windows long-path support. A successful validation is cached at `artifacts/developer-settings-validated.txt`. |
| `dotnet build Compilers.slnf` | Compiler-only build. |
| `dotnet build Ide.slnf` | IDE-only build. |
| `dotnet build Razor.slnf` | Razor compiler & tooling-only build. |
diff --git a/.github/memory/CONVENTIONS.md b/.github/memory/CONVENTIONS.md
index a02d617d3b403..03ad842b25712 100644
--- a/.github/memory/CONVENTIONS.md
+++ b/.github/memory/CONVENTIONS.md
@@ -47,6 +47,12 @@ var symbolInfo = semanticModel.GetSymbolInfo(expression, cancellationToken);
- Judge change size by cognitive load and validation boundaries, not an arbitrary line count; generated and mechanical updates may be large while still representing one focused change.
- A change is complete only after applicable formatting, analyzers, affected builds, targeted tests, generated/resource/API updates, final diff review, and documentation freshness work are complete. The canonical ordered checklist is the **Definition of Done** in `.github/copilot-instructions.md`.
+### CodeAnalysis testing-library dependencies
+
+- Compatible internal repository build and test projects reference the testing-library projects under `src/RoslynSdk/Microsoft.CodeAnalysis.Testing` so source changes are exercised directly.
+- The testing-library projects do not copy NuGet runtime dependencies into their .NET Framework output directories. Final test projects resolve and copy the unified dependency graph.
+- Roslyn SDK samples and Visual Studio SDK project templates retain NuGet package references because they model standalone consumers outside the repository source graph.
+
## Patterns Explicitly Avoided
- **No `TODO` or `TODO2` comments** — CI correctness leg flags `TODO`. Track follow-up work as a GitHub issue and link it in code (e.g. `// https://github.com/dotnet/roslyn/issues/NNNN`). Existing `TODO2` markers are a frozen baseline from when enforcement started, not a pattern to follow.
diff --git a/.github/memory/FILE_MAP.md b/.github/memory/FILE_MAP.md
index a336e0be03676..db070369037ee 100644
--- a/.github/memory/FILE_MAP.md
+++ b/.github/memory/FILE_MAP.md
@@ -23,7 +23,7 @@ This file is a **top-level map only**. For per-area directory detail, read the m
| `Features/`, `EditorFeatures/` | ide | IDE feature logic and editor integration. |
| `Analyzers/`, `CodeStyle/` | ide | IDE0xxx code-style analyzers & fixes. |
| `LanguageServer/` | ide | LSP server. |
-| `VisualStudio/` | ide | VS language services, UI, and EditorConfig template/wizard/command packaging under `EditorConfig/`. |
+| `VisualStudio/` | ide | VS language services, UI, EditorConfig template/wizard/command packaging under `EditorConfig/`, and integration-test infrastructure under `IntegrationTest/`. |
| `Razor/src/` | razor | Razor compiler + tooling (own sub-tree layout). |
| `Scripting/`, `Interactive/` | — | C#/VB scripting engine and REPL. |
| `RoslynAnalyzers/` | — | Shipping `Microsoft.CodeAnalysis.*` analyzer packages. |
@@ -34,6 +34,6 @@ This file is a **top-level map only**. For per-area directory detail, read the m
| Path | Status | Purpose |
|------|--------|---------|
-| `eng/` | Config / Generated | Arcade build engineering. Pipeline definitions and templates live in `eng/pipelines/`; `eng/common/` is DARC-synced and must not be hand-edited. `eng/generate-compiler-code.cs` regenerates compiler code. |
+| `eng/` | Config / Generated | Arcade build engineering. Shared project and solution build targets live in `eng/targets/`; pipeline definitions and templates live in `eng/pipelines/`; `eng/common/` is DARC-synced and must not be hand-edited. `eng/generate-compiler-code.cs` regenerates compiler code. |
| `docs/` | Active | Contributor & design docs. New docs use kebab-case filenames in the right subdirectory. |
| Root | Config | Entry points & solution filters: `build.sh`/`Build.cmd`, `test.sh`/`Test.cmd`, `Roslyn.slnx`, `Compilers.slnf`, `Ide.slnf`, `Razor.slnf`, `global.json`, `Directory.*.props/targets`, `Directory.Packages.props`. |
diff --git a/.github/memory/TESTING_STRATEGY.md b/.github/memory/TESTING_STRATEGY.md
index aa0c0f49770b5..eb448bc9826d7 100644
--- a/.github/memory/TESTING_STRATEGY.md
+++ b/.github/memory/TESTING_STRATEGY.md
@@ -26,6 +26,11 @@ Frameworks: xUnit with Roslyn test utilities.
- Prefer raw string literals (`"""..."""`) over verbatim strings for test source code.
- Keep tests focused: use `.Single()` rather than asserting a count then indexing.
+- Analyzer testing-library tests should use `ReferenceAssemblies.Default` for
+ the stable .NET Core 3.1 reference surface. Use an explicit
+ `ReferenceAssemblies` value, such as `ReferenceAssemblies.Net.Net100`, when
+ the scenario intentionally depends on a newer or specific framework API
+ surface.
- For issue-linked changes, add a `WorkItem` attribute next to the test
attribute, e.g. `[Fact, WorkItem("https://github.com/dotnet/roslyn/issues/1234")]`
or `[Theory, WorkItem("https://github.com/dotnet/roslyn/issues/1234")]`.
diff --git a/.github/memory/testing/ide.md b/.github/memory/testing/ide.md
index 7da57bf11ffd8..2f32170883d79 100644
--- a/.github/memory/testing/ide.md
+++ b/.github/memory/testing/ide.md
@@ -35,3 +35,7 @@ public class MyTests
test source code.
- Keep tests focused — avoid unnecessary intermediary assertions; use `.Single()`
rather than asserting a count then indexing.
+- Language Server orchestration tests can pass additional MEF parts to
+ `LanguageServerTestComposition.GetSharedExportProvider`. A controllable
+ `PartNotDiscoverable` project loader can provide deterministic design-time
+ build timing and results without invoking MSBuild.
diff --git a/.github/skills/code-review/SKILL.md b/.github/skills/code-review/SKILL.md
index 780e96c9e3a3a..0c1cddd9cc5d9 100644
--- a/.github/skills/code-review/SKILL.md
+++ b/.github/skills/code-review/SKILL.md
@@ -19,6 +19,12 @@ Use this skill when:
## Review Process
+### Review Progress Updates
+
+1. Print your plan before beginning
+2. After each step of your plan, print a quick result and your next step
+3. Print something at least every few minutes so humans can audit activity
+
### Step 0: Gather Code Context (No PR Narrative Yet)
Before analyzing anything, collect as much relevant **code** context as you can. **Critically, do NOT read the PR description, linked issues, or existing review comments yet.** You must form your own independent assessment of what the code does, why it might be needed, what problems it has, and whether the approach is sound — before being exposed to the author's framing. Reading the author's narrative first anchors your judgment and makes you less likely to find real problems.
diff --git a/.github/skills/update-agent-docs/SKILL.md b/.github/skills/update-agent-docs/SKILL.md
index 265d704dd1105..bda46c73b9dd0 100644
--- a/.github/skills/update-agent-docs/SKILL.md
+++ b/.github/skills/update-agent-docs/SKILL.md
@@ -12,13 +12,11 @@ Run at the end of every task that changes code. This is not optional.
## Checklist
-**Files added or moved?** → Update `.github/memory/FILE_MAP.md` (top-level area) and the matching `.github/instructions/.instructions.md` (directory detail).
-
**Memory file added, removed, renamed, or had its purpose change?** → Update `.github/memory/INDEX.md` and any memory files that reference it.
-**Public API changed?** → Update the matching `.github/instructions/.instructions.md` (Compiler/IDE) and the owning project's `PublicAPI.Unshipped.txt` (RS0016 enforces this). `API_MAP.md` covers only repo-wide entry points.
+**Public API changed?** → Update the owning project's `PublicAPI.Unshipped.txt` (RS0016 enforces this). `API_MAP.md` covers only repo-wide entry points.
-**New compiler error code, IDE diagnostic ID, or resource string added?** → Reflect it in the matching `.github/instructions/.instructions.md`; ensure `ErrorCode.cs` / `IDEDiagnosticIds.cs` / `.resx` (+ `/t:UpdateXlf`) are consistent.
+**New compiler error code, IDE diagnostic ID, or resource string added?** → Ensure `ErrorCode.cs` / `IDEDiagnosticIds.cs` / `.resx` (+ `/t:UpdateXlf`) are consistent.
**New pattern established?** → If repo-wide, add to `.github/memory/CONVENTIONS.md`; if layer-specific, add to the matching `.github/instructions/.instructions.md`.
diff --git a/Directory.Solution.targets b/Directory.Solution.targets
new file mode 100644
index 0000000000000..d80e355640d85
--- /dev/null
+++ b/Directory.Solution.targets
@@ -0,0 +1,3 @@
+
+
+
diff --git a/Ide.slnf b/Ide.slnf
index ac6262769bd96..23f2469411bad 100644
--- a/Ide.slnf
+++ b/Ide.slnf
@@ -232,7 +232,6 @@
"src\\VisualStudio\\Setup\\Roslyn.VisualStudio.Setup.csproj",
"src\\VisualStudio\\TestUtilities2\\Microsoft.VisualStudio.LanguageServices.Test.Utilities2.vbproj",
"src\\VisualStudio\\VisualBasic\\Impl\\Microsoft.VisualStudio.LanguageServices.VisualBasic.vbproj",
- "src\\VisualStudio\\VisualStudioDiagnosticsToolWindow\\Roslyn.VisualStudio.DiagnosticsWindow.csproj",
"src\\VisualStudio\\Xaml\\Impl\\Microsoft.VisualStudio.LanguageServices.Xaml.csproj",
"src\\Workspaces\\CSharpTest\\Microsoft.CodeAnalysis.CSharp.Workspaces.UnitTests.csproj",
"src\\Workspaces\\CSharp\\Portable\\Microsoft.CodeAnalysis.CSharp.Workspaces.csproj",
diff --git a/Roslyn.slnx b/Roslyn.slnx
index cf192af52ef08..1ed1f08313c37 100644
--- a/Roslyn.slnx
+++ b/Roslyn.slnx
@@ -462,7 +462,6 @@
-
@@ -497,7 +496,6 @@
-
@@ -544,6 +542,8 @@
+
+
diff --git a/azure-pipelines-integration.yml b/azure-pipelines-integration.yml
index d44cf5ab3a721..bab19587cae2c 100644
--- a/azure-pipelines-integration.yml
+++ b/azure-pipelines-integration.yml
@@ -8,7 +8,6 @@ trigger:
- release/*
- features/*
- demos/*
- - dev/*
exclude:
# Since the version of VS on the integration VM images are a moving target,
# we are unable to reliably run integration tests on servicing branches.
@@ -26,6 +25,7 @@ pr:
- release/*
- features/*
- demos/*
+ - dev/*
exclude:
# Since the version of VS on the integration VM images are a moving target,
# we are unable to reliably run integration tests on servicing branches.
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index 8c10056daa80f..1293fe6449606 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -6,7 +6,6 @@ trigger:
- release/*
- features/*
- demos/*
-- dev/*
# Branches that trigger builds on PR
pr:
@@ -18,6 +17,7 @@ pr:
- release/*
- features/*
- demos/*
+ - dev/*
paths:
exclude:
- docs/*
@@ -540,7 +540,7 @@ stages:
steps:
- template: eng/pipelines/checkout-unix-task.yml
- - script: ./eng/build.sh --solution Roslyn.slnx --restore --build --configuration Debug --prepareMachine --ci --binaryLog --runanalyzers --warnaserror /p:RoslynEnforceCodeStyle=true
+ - script: ./eng/build.sh --solution Roslyn.slnx --restore --build --configuration Debug --prepareMachine --ci --binaryLog --runanalyzers --warnaserror
displayName: Build with analyzers
- template: eng/pipelines/publish-logs.yml
diff --git a/eng/Packages.props b/eng/Packages.props
index 4cfb755db62ed..6f1eb97d5afb4 100644
--- a/eng/Packages.props
+++ b/eng/Packages.props
@@ -1,7 +1,6 @@
- 1.1.3-beta1.26059.1
<_BasicReferenceAssembliesVersion>1.8.8
5.9.0
@@ -184,6 +183,7 @@
-->
+
@@ -287,13 +287,7 @@
-
-
-
-
-
-
@@ -349,8 +343,6 @@
vs-extension-testing
-->
-
-
diff --git a/eng/Version.Details.props b/eng/Version.Details.props
index 6a80f6308c817..7700a4d142f0e 100644
--- a/eng/Version.Details.props
+++ b/eng/Version.Details.props
@@ -23,7 +23,7 @@ This file should be imported by eng/Versions.props
10.0.10
10.0.10
10.0.10
- 3.0.0-rc.2.26453.107
+ 3.0.0-rc.2.26458.109
10.0.10
10.0.10
10.0.10
diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml
index d0d8f38d8c7bf..f69acc6ec967c 100644
--- a/eng/Version.Details.xml
+++ b/eng/Version.Details.xml
@@ -1,15 +1,15 @@
-
+
https://github.com/dotnet/roslyn
92a36efa9ae1a26a14872f87798526a1c3dd050b
-
+
https://github.com/dotnet/dotnet
- a12253cf4283fcf4a54a7d2732aae2e8821f833a
+ c65c25f0c9889e06a5d70e275c741d8430fa353a
diff --git a/eng/build.ps1 b/eng/build.ps1
index f8f3e55816a65..8558dbbc08f39 100644
--- a/eng/build.ps1
+++ b/eng/build.ps1
@@ -659,7 +659,6 @@ function Deploy-VsixViaTool() {
"Microsoft.VisualStudio.RazorExtension.Dependencies.vsix",
"Microsoft.VisualStudio.RazorExtension.vsix",
"ExpressionEvaluatorPackage.vsix",
- "Roslyn.VisualStudio.DiagnosticsWindow.vsix",
"Microsoft.VisualStudio.IntegrationTest.Setup.vsix")
foreach ($vsixFileName in $orderedVsixFileNames) {
diff --git a/eng/config/PublishData.json b/eng/config/PublishData.json
index 855c1f0fcaeac..b98f756aba9e4 100644
--- a/eng/config/PublishData.json
+++ b/eng/config/PublishData.json
@@ -42,6 +42,7 @@
"Microsoft.Net.Compilers.Toolset": "arcade",
"Microsoft.Net.Compilers.Toolset.Arm64": "arcade",
"Microsoft.Net.Compilers.Toolset.Framework": "arcade",
+ "Microsoft.NET.ProjectData.Tasks": "arcade",
"Microsoft.CodeAnalysis.BuildClient": "arcade",
"Microsoft.CodeAnalysis.Contracts": "arcade",
"Microsoft.CodeAnalysis.Debugging": "arcade",
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/eng/make-bootstrap.ps1 b/eng/make-bootstrap.ps1
index 950d3cf3f8dbd..3adeca20c6548 100644
--- a/eng/make-bootstrap.ps1
+++ b/eng/make-bootstrap.ps1
@@ -57,7 +57,7 @@ try {
# MSBuildAdditionalCommandLineArgs=
$args = "/p:TreatWarningsAsErrors=true /warnaserror /nologo /nodeReuse:false /p:Configuration=$configuration /v:m";
$args += " /p:RunAnalyzersDuringBuild=false /bl:$binaryLogFilePath"
- $args += " /t:Pack /p:RoslynEnforceCodeStyle=false /p:DotNetUseShippingVersions=true /p:InitialDefineConstants=BOOTSTRAP"
+ $args += " /t:Pack /p:DotNetUseShippingVersions=true /p:InitialDefineConstants=BOOTSTRAP"
$args += " /p:PackageOutputPath=$output /p:NgenOptimization=false /p:PublishWindowsPdb=false"
if ($ci) {
diff --git a/eng/targets/DeveloperSettings.targets b/eng/targets/DeveloperSettings.targets
new file mode 100644
index 0000000000000..71785bedc9111
--- /dev/null
+++ b/eng/targets/DeveloperSettings.targets
@@ -0,0 +1,32 @@
+
+
+
+
+
+ <_RoslynDeveloperSettingsValidationCacheFile>$(MSBuildThisFileDirectory)..\..\artifacts\developer-settings-validated.txt
+
+
+
+
+ <_RoslynBuildRunsOnWindows>$([MSBuild]::IsOSPlatform('Windows'))
+ <_RoslynSourceHasCarriageReturns>$([System.IO.File]::ReadAllText('$(MSBuildThisFileFullPath)').Contains($([System.Char]::ConvertFromUtf32(13))))
+ <_RoslynLongPathsEnabled Condition="'$(_RoslynBuildRunsOnWindows)' == 'True'">$([MSBuild]::GetRegistryValueFromView('HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\FileSystem', 'LongPathsEnabled', null, RegistryView.Registry64, RegistryView.Registry32))
+
+
+
+
+
+
+
+
+
+
diff --git a/eng/targets/Imports.targets b/eng/targets/Imports.targets
index f3dd6a232b286..a2e902ef623c5 100644
--- a/eng/targets/Imports.targets
+++ b/eng/targets/Imports.targets
@@ -3,6 +3,7 @@
+
+
+ true
+ false
+
+
+
+
+ $(WarningsNotAsErrors);IDE0055
+
+
512
@@ -45,23 +57,11 @@
impact the correctness of the output, so we avoid the performance overhead where it's easy to do so.
-->
false
- true
+ true
true
-
-
- false
- true
-
-
$(NoWarn);Nullable
@@ -183,15 +183,7 @@
-
-
-
-
- <_RoslynLongPathsEnabled>$([MSBuild]::GetRegistryValueFromView('HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\FileSystem', 'LongPathsEnabled', null, RegistryView.Registry64, RegistryView.Registry32))
-
-
-
-
+
- $(WarningsNotAsErrors);IDE0055
-
-
diff --git a/eng/test-build-correctness.ps1 b/eng/test-build-correctness.ps1
index 22d5a79164be3..fe5eb070e9325 100644
--- a/eng/test-build-correctness.ps1
+++ b/eng/test-build-correctness.ps1
@@ -58,7 +58,7 @@ try {
}
Write-Host "Building Roslyn"
- & eng/build.ps1 -restore -build -bootstrapDir:$bootstrapDir -ci:$ci -prepareMachine:$prepareMachine -runAnalyzers:$true -configuration:$configuration -pack -binaryLog -useGlobalNuGetCache:$false -warnAsError:$true -properties:"/p:RoslynEnforceCodeStyle=true"
+ & eng/build.ps1 -restore -build -bootstrapDir:$bootstrapDir -ci:$ci -prepareMachine:$prepareMachine -runAnalyzers:$true -configuration:$configuration -pack -binaryLog -useGlobalNuGetCache:$false -warnAsError:$true
Test-LastExitCode
Subst-TempDir
diff --git a/eng/test-determinism.ps1 b/eng/test-determinism.ps1
index 5e890f395b32f..08a9f2afa1933 100644
--- a/eng/test-determinism.ps1
+++ b/eng/test-determinism.ps1
@@ -28,9 +28,6 @@ $script:skipList = @(
# Added to work around https://github.com/dotnet/roslyn/issues/48417
"Microsoft.CodeAnalysis.EditorFeatures2.UnitTests.dll",
- # Work around XLF issues https://github.com/dotnet/roslyn/issues/58840
- "Roslyn.VisualStudio.DiagnosticsWindow.dll.key",
-
# Work around the same XLF ResXFileRef determinism issue in Razor's VSIX package resources.
"Microsoft.VisualStudio.RazorExtension.dll.key",
@@ -319,4 +316,3 @@ catch {
finally {
Pop-Location
}
-
diff --git a/eng/test-rebuild.ps1 b/eng/test-rebuild.ps1
index 459be1ffeb953..5cc95502e21ca 100644
--- a/eng/test-rebuild.ps1
+++ b/eng/test-rebuild.ps1
@@ -58,7 +58,6 @@ try {
" --exclude net472\Microsoft.VisualStudio.LanguageServices.Implementation.dll" +
" --exclude net472\Microsoft.VisualStudio.LanguageServices.VisualBasic.dll" +
" --exclude net472\Roslyn.Hosting.Diagnostics.dll" +
- " --exclude net472\Roslyn.VisualStudio.DiagnosticsWindow.dll" +
# Match Roslyn's existing WindowsDesktop VS package exclusions, which still rebuild with output differences.
" --exclude net472\Microsoft.VisualStudio.RazorExtension.dll" +
# BuildValidator cannot reliably replay Roslyn SDK WindowsDesktop VS extension builds because
diff --git a/eng/validate-roslyn-sdk-samples.ps1 b/eng/validate-roslyn-sdk-samples.ps1
index 0f6cfbc3e27e7..f5fb2456a580d 100644
--- a/eng/validate-roslyn-sdk-samples.ps1
+++ b/eng/validate-roslyn-sdk-samples.ps1
@@ -62,7 +62,7 @@ $buildArgs = @(
"-c", $configuration
"--no-incremental"
"--warnaserror"
- "/p:RoslynEnforceCodeStyle=true"
+ "/p:RunAnalyzersDuringBuild=true"
)
if ($ci) {
diff --git a/eng/validate-rules-missing-documentation.ps1 b/eng/validate-rules-missing-documentation.ps1
index b38dfb2cedeee..4452b625e6504 100644
--- a/eng/validate-rules-missing-documentation.ps1
+++ b/eng/validate-rules-missing-documentation.ps1
@@ -13,7 +13,7 @@ try {
$prepareMachine = $ci
$projectFilePath = Join-Path $RepoRoot "src\Features\Core\Portable\Microsoft.CodeAnalysis.Features.csproj"
- Exec-DotNet "build $projectFilePath -t:GenerateRulesMissingDocumentation -p:RoslynEnforceCodeStyle=false -p:RunAnalyzersDuringBuild=false -p:ContinuousIntegrationBuild=$ci -c Release"
+ Exec-DotNet "build $projectFilePath -t:GenerateRulesMissingDocumentation -p:RunAnalyzersDuringBuild=false -p:ContinuousIntegrationBuild=$ci -c Release"
}
catch {
Write-Host $_
diff --git a/src/Analyzers/CSharp/Tests/RemoveUnnecessaryCast/RemoveUnnecessaryCastTests.cs b/src/Analyzers/CSharp/Tests/RemoveUnnecessaryCast/RemoveUnnecessaryCastTests.cs
index c1a03e26f0954..b76a4effb16e8 100644
--- a/src/Analyzers/CSharp/Tests/RemoveUnnecessaryCast/RemoveUnnecessaryCastTests.cs
+++ b/src/Analyzers/CSharp/Tests/RemoveUnnecessaryCast/RemoveUnnecessaryCastTests.cs
@@ -13668,4 +13668,34 @@ static void Main(string[] args)
LanguageVersion = LanguageVersion.CSharp14,
ReferenceAssemblies = ReferenceAssemblies.Net.Net90,
}.RunAsync();
+
+#if NET
+ [Fact]
+ public Task RemoveNativeIntegerCastsWithNet100References()
+ => new VerifyCS.Test
+ {
+ ReferenceAssemblies = ReferenceAssemblies.Net.Net100,
+ TestCode = """
+ using System;
+
+ public class C
+ {
+ public nuint FromIntPtr(IntPtr x) => (nuint)[|(nint)|]x;
+ public int ToInt(IntPtr x) => (int)[|(nint)|]x;
+ public nint FromUIntPtr(UIntPtr x) => (nint)[|(nuint)|]x;
+ }
+ """,
+ FixedCode = """
+ using System;
+
+ public class C
+ {
+ public nuint FromIntPtr(IntPtr x) => (nuint)x;
+ public int ToInt(IntPtr x) => (int)x;
+ public nint FromUIntPtr(UIntPtr x) => (nint)x;
+ }
+ """,
+ LanguageVersion = LanguageVersion.CSharp9,
+ }.RunAsync();
+#endif
}
diff --git a/src/CodeStyle/CSharp/Tests/Microsoft.CodeAnalysis.CSharp.CodeStyle.UnitTests.csproj b/src/CodeStyle/CSharp/Tests/Microsoft.CodeAnalysis.CSharp.CodeStyle.UnitTests.csproj
index 8ba99a0fbfa50..6f87d4e66a2fe 100644
--- a/src/CodeStyle/CSharp/Tests/Microsoft.CodeAnalysis.CSharp.CodeStyle.UnitTests.csproj
+++ b/src/CodeStyle/CSharp/Tests/Microsoft.CodeAnalysis.CSharp.CodeStyle.UnitTests.csproj
@@ -14,7 +14,6 @@
-
@@ -24,6 +23,7 @@
+
diff --git a/src/CodeStyle/Core/Tests/UnitTestUtilities/Microsoft.CodeAnalysis.CodeStyle.UnitTestUtilities.csproj b/src/CodeStyle/Core/Tests/UnitTestUtilities/Microsoft.CodeAnalysis.CodeStyle.UnitTestUtilities.csproj
index 45847a5466ee8..2407f394aad88 100644
--- a/src/CodeStyle/Core/Tests/UnitTestUtilities/Microsoft.CodeAnalysis.CodeStyle.UnitTestUtilities.csproj
+++ b/src/CodeStyle/Core/Tests/UnitTestUtilities/Microsoft.CodeAnalysis.CodeStyle.UnitTestUtilities.csproj
@@ -37,11 +37,11 @@
-
-
-
-
-
+
+
+
+
+
diff --git a/src/CodeStyle/VisualBasic/Tests/Microsoft.CodeAnalysis.VisualBasic.CodeStyle.UnitTests.vbproj b/src/CodeStyle/VisualBasic/Tests/Microsoft.CodeAnalysis.VisualBasic.CodeStyle.UnitTests.vbproj
index ccbbabaa7aa1c..1530dcc0a6c9b 100644
--- a/src/CodeStyle/VisualBasic/Tests/Microsoft.CodeAnalysis.VisualBasic.CodeStyle.UnitTests.vbproj
+++ b/src/CodeStyle/VisualBasic/Tests/Microsoft.CodeAnalysis.VisualBasic.CodeStyle.UnitTests.vbproj
@@ -14,14 +14,12 @@
-
-
-
+
diff --git a/src/Compilers/CSharp/Portable/FlowAnalysis/LocalDataFlowPass.cs b/src/Compilers/CSharp/Portable/FlowAnalysis/LocalDataFlowPass.cs
index fec058592d072..fafb90711f92c 100644
--- a/src/Compilers/CSharp/Portable/FlowAnalysis/LocalDataFlowPass.cs
+++ b/src/Compilers/CSharp/Portable/FlowAnalysis/LocalDataFlowPass.cs
@@ -61,7 +61,8 @@ protected LocalDataFlowPass(
protected abstract int AddVariable(VariableIdentifier identifier);
- ///
+ /// Get the slot for a variable, if the slot already exists.
+ ///
/// Locals are given slots when their declarations are encountered. We only need give slots
/// to local variables, out parameters, and the "this" variable of a struct constructs.
/// Other variables are not given slots, and are therefore not tracked by the analysis. This
@@ -71,7 +72,7 @@ protected LocalDataFlowPass(
/// variables that occur before the variable is declared, as those are reported in an
/// earlier phase as "use before declaration". That allows us to avoid giving slots to local
/// variables before processing their declarations.
- ///
+ ///
protected int VariableSlot(Symbol symbol, int containingSlot = 0)
{
// Skip LocalStoreTracker from data flow analysis.
diff --git a/src/Compilers/CSharp/Portable/FlowAnalysis/NullableWalker.cs b/src/Compilers/CSharp/Portable/FlowAnalysis/NullableWalker.cs
index 8eeb55385009e..bd3db42417777 100644
--- a/src/Compilers/CSharp/Portable/FlowAnalysis/NullableWalker.cs
+++ b/src/Compilers/CSharp/Portable/FlowAnalysis/NullableWalker.cs
@@ -568,6 +568,8 @@ protected override bool ConvertInsufficientExecutionStackExceptionToCancelledByS
protected override bool TryGetVariable(VariableIdentifier identifier, out int slot)
{
+ // Only the owning symbol should be used to look up the slot.
+ Debug.Assert(GetOwningSymbolAndContainingSlot(identifier.Symbol, identifier.ContainingSlot) == ((object)identifier.Symbol, identifier.ContainingSlot));
return _variables.TryGetValue(identifier, out slot);
}
@@ -757,6 +759,10 @@ void enforceMemberNotNull(SyntaxNode? syntaxOpt, LocalState state)
void checkMemberStateOnConstructorExit(MethodSymbol constructor, Symbol member, LocalState state, int thisSlot, Location? exitLocation, ImmutableArray membersWithStateEnforcedByRequiredMembers, bool forcePropertyAnalysis)
{
+#if DEBUG
+ var discardedUseSiteInfo = CompoundUseSiteInfo.Discarded;
+ Debug.Assert(AccessCheck.IsSymbolAccessible(member, within: constructor.ContainingType, ref discardedUseSiteInfo));
+#endif
var isStatic = !constructor.RequiresInstanceReceiver();
if (member.IsStatic != isStatic)
{
@@ -855,6 +861,10 @@ void makeNotNullMembersMaybeNull()
{
foreach (var member in getMembersNeedingDefaultInitialState())
{
+#if DEBUG
+ var discardedUseSiteInfo = CompoundUseSiteInfo.Discarded;
+ Debug.Assert(AccessCheck.IsSymbolAccessible(member, within: method.ContainingType, ref discardedUseSiteInfo));
+#endif
if (member.IsStatic != method.IsStatic)
{
continue;
@@ -863,7 +873,9 @@ void makeNotNullMembersMaybeNull()
var memberToInitialize = member;
switch (member)
{
- case PropertySymbol { IsRequired: true }:
+ case PropertySymbol { IsRequired: true } requiredProperty when GetAccessibleBackingField(requiredProperty, method.ContainingType) is null:
+ // Visit a required property, unless it has a backing field which is accessible in the current context,
+ // in which case we expect the field to be visited in another iteration of this loop.
break;
case PropertySymbol:
// skip any manually implemented non-required properties.
@@ -967,19 +979,20 @@ static ImmutableArray membersToBeInitialized(NamedTypeSymbol containingT
(includeAllMembers: false, includeCurrentTypeRequiredMembers: true, includeBaseRequiredMembers: false)
=> containingType.GetMembersUnordered().SelectManyAsArray(
- predicate: SymbolExtensions.IsRequired,
- selector: symbol => getAllMembersToBeDefaulted(symbol, filterOverridingProperties: true)),
+ predicate: static (symbol, _) => symbol.IsRequired(),
+ selector: static (symbol, containingType) => getAllMembersToBeDefaulted(containingType, symbol, filterOverridingProperties: true),
+ containingType),
(includeAllMembers: false, includeCurrentTypeRequiredMembers: true, includeBaseRequiredMembers: true)
- => containingType.AllRequiredMembers.SelectManyAsArray(static kvp => getAllMembersToBeDefaulted(kvp.Value, filterOverridingProperties: true)),
+ => containingType.AllRequiredMembers.SelectManyAsArray(static (kvp, containingType) => getAllMembersToBeDefaulted(containingType, kvp.Value, filterOverridingProperties: true), arg: containingType),
(includeAllMembers: true, includeCurrentTypeRequiredMembers: _, includeBaseRequiredMembers: false)
=> containingType.GetMembersUnordered().SelectManyAsArray(
- selector: symbol =>
+ selector: static (symbol, containingType) =>
{
- var symbolToInitialize = getFieldSymbolToBeInitialized(symbol);
+ var symbolToInitialize = getFieldSymbolToBeInitialized(containingType, symbol);
var prop = symbolToInitialize as PropertySymbol ?? (symbolToInitialize as FieldSymbol)?.AssociatedSymbol as PropertySymbol;
- if (prop is not null && isFilterableOverrideOfAbstractProperty(prop))
+ if (prop is not null && isFilterableOverrideOfAbstractProperty(containingType, prop))
{
return OneOrMany.Empty;
}
@@ -987,7 +1000,8 @@ static ImmutableArray membersToBeInitialized(NamedTypeSymbol containingT
{
return OneOrMany.Create(symbolToInitialize);
}
- }),
+ },
+ arg: containingType),
(includeAllMembers: true, includeCurrentTypeRequiredMembers: true, includeBaseRequiredMembers: true)
=> getAllTypeAndRequiredMembers(containingType),
@@ -996,7 +1010,7 @@ static ImmutableArray membersToBeInitialized(NamedTypeSymbol containingT
=> throw ExceptionUtilities.Unreachable(),
};
- static ImmutableArray getAllTypeAndRequiredMembers(TypeSymbol containingType)
+ static ImmutableArray getAllTypeAndRequiredMembers(NamedTypeSymbol containingType)
{
var members = containingType.GetMembersUnordered();
var baseRequiredMembers = containingType.BaseTypeNoUseSiteDiagnostics?.AllRequiredMembers ?? ImmutableSegmentedDictionary.Empty;
@@ -1018,19 +1032,19 @@ static ImmutableArray getAllTypeAndRequiredMembers(TypeSymbol containing
if (requiredMember is PropertySymbol { IsAbstract: true } abstractProperty)
{
if (members.FirstOrDefault(static (thisMember, baseMember) => thisMember.IsOverride && (object)thisMember.GetOverriddenMember() == baseMember, requiredMember) is { } overridingMember
- && isFilterableOverrideOfAbstractProperty((PropertySymbol)overridingMember))
+ && isFilterableOverrideOfAbstractProperty(containingType, (PropertySymbol)overridingMember))
{
continue;
}
}
- builder.AddRange(getAllMembersToBeDefaulted(requiredMember, filterOverridingProperties: false));
+ builder.AddRange(getAllMembersToBeDefaulted(containingType, requiredMember, filterOverridingProperties: false));
}
return builder.ToImmutableAndFree();
}
- static OneOrMany getAllMembersToBeDefaulted(Symbol requiredMember, bool filterOverridingProperties)
+ static OneOrMany getAllMembersToBeDefaulted(NamedTypeSymbol containingType, Symbol requiredMember, bool filterOverridingProperties)
{
Debug.Assert(requiredMember.IsRequired());
@@ -1042,19 +1056,23 @@ static OneOrMany getAllMembersToBeDefaulted(Symbol requiredMember, bool
{
var property = (PropertySymbol)requiredMember;
- if (filterOverridingProperties && isFilterableOverrideOfAbstractProperty(property))
+ if (filterOverridingProperties && isFilterableOverrideOfAbstractProperty(containingType, property))
{
return OneOrMany.Empty;
}
- var @return = OneOrMany.Create(getFieldSymbolToBeInitialized(property));
+ var @return = OneOrMany.Create(getFieldSymbolToBeInitialized(containingType, property));
// If the set method is null (ie missing), that's an error, but we'll recover as best we can
+ var discardedUseSiteInfo = CompoundUseSiteInfo.Discarded;
foreach (var notNullMemberName in (property.SetMethod?.NotNullMembers ?? property.NotNullMembers))
{
foreach (var member in property.ContainingType.GetMembers(notNullMemberName))
{
- @return = @return.Add(getFieldSymbolToBeInitialized(member));
+ if (AccessCheck.IsSymbolAccessible(member, containingType, ref discardedUseSiteInfo))
+ {
+ @return = @return.Add(getFieldSymbolToBeInitialized(containingType, member));
+ }
}
}
@@ -1062,10 +1080,10 @@ static OneOrMany getAllMembersToBeDefaulted(Symbol requiredMember, bool
}
}
- static Symbol getFieldSymbolToBeInitialized(Symbol requiredMember)
- => requiredMember is SourcePropertySymbolBase { BackingField: { } backingField } ? backingField : requiredMember;
+ static Symbol getFieldSymbolToBeInitialized(NamedTypeSymbol containingType, Symbol requiredMember)
+ => requiredMember is PropertySymbol property && GetAccessibleBackingField(property, containingType) is { } backingField ? backingField : requiredMember;
- static bool isFilterableOverrideOfAbstractProperty(PropertySymbol property)
+ static bool isFilterableOverrideOfAbstractProperty(NamedTypeSymbol containingType, PropertySymbol property)
{
// If this is an override of an abstract property, and the overridden property has the same nullable
// annotation as us, we can skip default-initializing the property because the chained constructor
@@ -1075,8 +1093,8 @@ static bool isFilterableOverrideOfAbstractProperty(PropertySymbol property)
return false;
}
- var symbolAnnotations = property is SourcePropertySymbolBase { UsesFieldKeyword: true, BackingField: { } field }
- ? field!.FlowAnalysisAnnotations
+ var symbolAnnotations = GetAccessibleBackingField(property, containingType) is { } field
+ ? field.FlowAnalysisAnnotations
: property.GetFlowAnalysisAnnotations();
var symbolType = ApplyUnconditionalAnnotations(property.TypeWithAnnotations, symbolAnnotations);
if (!symbolType.NullableAnnotation.IsNotAnnotated())
@@ -1093,6 +1111,25 @@ static bool isFilterableOverrideOfAbstractProperty(PropertySymbol property)
}
}
+ ///
+ /// A property's backing field should only influence behavior of nullable analysis when the field is accessible.
+ /// For example, when the member being analyzed has the same containing type as the property.
+ ///
+ /// Note: this refers to accessibility only in the "accessibility modifier" sense.
+ /// This method may return a non-null symbol even if the field cannot be referenced by name in the current context.
+ ///
+ /// Containing type of the member currently being analyzed.
+ private static SynthesizedBackingFieldSymbol? GetAccessibleBackingField(PropertySymbol property, NamedTypeSymbol containingType)
+ {
+ if (property is not SourcePropertySymbolBase { BackingField: { } backingField })
+ return null;
+
+ var discardedUseSiteInfo = CompoundUseSiteInfo.Discarded;
+ return AccessCheck.IsSymbolAccessible(backingField, within: containingType, ref discardedUseSiteInfo)
+ ? backingField
+ : null;
+ }
+
private void EnforceMemberNotNullOnMember(SyntaxNode? syntaxOpt, LocalState state, MethodSymbol method, string memberName)
{
foreach (var member in method.ContainingType.GetMembers(memberName))
@@ -2267,6 +2304,16 @@ protected override int GetOrCreateSlot(Symbol symbol, int containingSlot = 0, bo
if (containingSlot > 0 && !IsSlotMember(containingSlot, symbol))
return -1;
+ (symbol, containingSlot) = GetOwningSymbolAndContainingSlot(symbol, containingSlot);
+ return base.GetOrCreateSlot(symbol, containingSlot, forceSlotEvenIfEmpty, createIfMissing);
+ }
+
+ ///
+ /// If the incoming symbol+containingSlot pair shares a slot,
+ /// returns the owning pair. Otherwise returns the arguments.
+ ///
+ private (Symbol symbol, int containingSlot) GetOwningSymbolAndContainingSlot(Symbol symbol, int containingSlot)
+ {
// Primary constructor parameter and its backing field share the slot when
// we are dealing with 'this' instance.
if (symbol is ParameterSymbol { ContainingSymbol: SynthesizedPrimaryConstructor primaryConstructor } parameter &&
@@ -2291,6 +2338,7 @@ protected override int GetOrCreateSlot(Symbol symbol, int containingSlot = 0, bo
}
// Share a slot between backing field and associated property/event in the context of a constructor which owns initialization of that backing field.
+ // https://github.com/dotnet/roslyn/issues/84987: extend this sharing to more methods where the backing field is accessible
if (this._symbol is MethodSymbol constructor
&& constructor.IsConstructor()
&& constructor.IsStatic == symbol.IsStatic)
@@ -2304,17 +2352,25 @@ protected override int GetOrCreateSlot(Symbol symbol, int containingSlot = 0, bo
// - property initializer on normal auto-property
// - property assignment on getter-only auto-property.
// Example test: NullableReferenceTypesTests.ConstructorUsesStateFromInitializers will fail without this.
- if (symbol is SynthesizedBackingFieldSymbol { AssociatedSymbol: SourcePropertySymbolBase { UsesFieldKeyword: false } property })
- symbol = property;
- // If symbol is a property that uses field keyword, then use field to determine initial state and to own the slot.
- else if (symbol is SourcePropertySymbolBase { UsesFieldKeyword: true, BackingField: { } backingField })
- symbol = backingField;
+ if (symbol is SynthesizedBackingFieldSymbol { AssociatedProperty.UsesFieldKeyword: false } backingField)
+ {
+ symbol = backingField.AssociatedProperty;
+ }
+ // If symbol is a property that uses field keyword, and property is not null-resilient, then use field to determine initial state and to own the slot.
+ else if (symbol is SourcePropertySymbolBase { UsesFieldKeyword: true } property
+ && GetAccessibleBackingField(property, constructor.ContainingType) is { } accessibleBackingField
+ && !IsAssociatedPropertyNullResilient(accessibleBackingField))
+ {
+ symbol = accessibleBackingField;
+ }
else if (symbol is SourceEventFieldSymbol eventField)
+ {
symbol = eventField.AssociatedSymbol;
+ }
}
}
- return base.GetOrCreateSlot(symbol, containingSlot, forceSlotEvenIfEmpty, createIfMissing);
+ return (symbol, containingSlot);
}
private void VisitAndUnsplitAll(ImmutableArray nodes) where T : BoundNode
@@ -11307,6 +11363,13 @@ private void VisitThisOrBaseReference(BoundExpression node)
return null;
}
+ private bool IsAssociatedPropertyNullResilient(SynthesizedBackingFieldSymbol backingField)
+ {
+ // Only possible to answer this question if a null resilience inference is not currently in progress.
+ Debug.Assert(_getterNullResilienceData is null);
+ return backingField.InfersNullableAnnotation && backingField.GetInferredNullableAnnotation() == NullableAnnotation.Annotated;
+ }
+
private bool IsPropertyOutputMoreStrictThanInput(PropertySymbol property)
{
var type = property.TypeWithAnnotations;
diff --git a/src/Compilers/CSharp/Portable/Microsoft.CodeAnalysis.CSharp.csproj b/src/Compilers/CSharp/Portable/Microsoft.CodeAnalysis.CSharp.csproj
index 9dccec2deeff3..29a1ac30d47ca 100644
--- a/src/Compilers/CSharp/Portable/Microsoft.CodeAnalysis.CSharp.csproj
+++ b/src/Compilers/CSharp/Portable/Microsoft.CodeAnalysis.CSharp.csproj
@@ -84,6 +84,7 @@
+
diff --git a/src/Compilers/CSharp/Portable/Parser/LanguageParser.cs b/src/Compilers/CSharp/Portable/Parser/LanguageParser.cs
index ec03351c26000..3b941779cfaf1 100644
--- a/src/Compilers/CSharp/Portable/Parser/LanguageParser.cs
+++ b/src/Compilers/CSharp/Portable/Parser/LanguageParser.cs
@@ -875,8 +875,8 @@ private bool IsPossibleNamespaceMemberDeclaration()
case SyntaxKind.NamespaceKeyword:
return true;
case SyntaxKind.IdentifierToken:
- // `allowMembers: false`: A type member such as 'partial int M()' cannot start a namespace body.
- return this.IsPartialModifierInDeclarationHead(allowMembers: false);
+ // `onlyForTypeDeclarations: true`: A type member such as 'partial int M()' cannot start a namespace body.
+ return this.IsCurrentTokenDefinitelyPartialModifier(onlyForTypeDeclarations: true);
default:
return IsPossibleStartOfTypeDeclaration(this.CurrentToken.Kind);
}
@@ -1369,9 +1369,9 @@ private void ParseModifiers(SyntaxListBuilder tokens, bool forTopLevelStatements
switch (newMod)
{
case DeclarationModifiers.Partial:
- // `allowMembers: true`: ParseModifiers is shared by types and members, such as
+ // `onlyForTypeDeclarations: false`: ParseModifiers is shared by types and members, such as
// 'partial class C' and 'partial void M()'.
- if (!this.IsPartialModifierInDeclarationHead(allowMembers: true))
+ if (!this.IsCurrentTokenDefinitelyPartialModifier(onlyForTypeDeclarations: false))
return;
modTok = ConvertToKeyword(this.EatToken());
@@ -1399,18 +1399,6 @@ private void ParseModifiers(SyntaxListBuilder tokens, bool forTopLevelStatements
break;
}
- case DeclarationModifiers.File:
- if (!parseAsModifier(MessageID.IDS_FeatureFileTypes, out modTok))
- return;
-
- break;
-
- case DeclarationModifiers.Closed:
- if (!parseAsModifier(MessageID.IDS_FeatureClosedClasses, out modTok))
- return;
-
- break;
-
case DeclarationModifiers.Async:
if (!ShouldContextualKeywordBeTreatedAsModifier(parsingStatementNotDeclaration: false))
{
@@ -1420,17 +1408,33 @@ private void ParseModifiers(SyntaxListBuilder tokens, bool forTopLevelStatements
modTok = ConvertToKeyword(this.EatToken());
break;
+ case DeclarationModifiers.File:
+ case DeclarationModifiers.Closed:
case DeclarationModifiers.Required:
- if (!parseAsModifier(MessageID.IDS_FeatureRequiredMembers, out modTok))
- return;
-
- break;
-
case DeclarationModifiers.Safe:
- if (!parseAsModifier(MessageID.IDS_FeatureUnsafeEvolution, out modTok))
- return;
+ {
+ var requiredFeature = newMod switch
+ {
+ DeclarationModifiers.File => MessageID.IDS_FeatureFileTypes,
+ DeclarationModifiers.Closed => MessageID.IDS_FeatureClosedClasses,
+ DeclarationModifiers.Required => MessageID.IDS_FeatureRequiredMembers,
+ DeclarationModifiers.Safe => MessageID.IDS_FeatureUnsafeEvolution,
+ _ => throw ExceptionUtilities.UnexpectedValue(newMod),
+ };
+
+ // Outside top-level statements, an enabled contextual modifier is unambiguous.
+ // Otherwise, it may be an identifier, so use the usual contextual-keyword heuristic.
+ var needsDisambiguation = !IsFeatureEnabled(requiredFeature) || forTopLevelStatements;
+ if (needsDisambiguation &&
+ !ShouldContextualKeywordBeTreatedAsModifier(parsingStatementNotDeclaration: false))
+ {
+ return;
+ }
- break;
+ // LangVersion errors for contextual modifiers are given during binding.
+ modTok = ConvertToKeyword(EatToken());
+ break;
+ }
default:
modTok = this.EatToken();
@@ -1477,23 +1481,6 @@ bool shouldConsumeRefAtTopLevel()
SyntaxKind.RecordKeyword or SyntaxKind.UnionKeyword or SyntaxKind.ExtensionKeyword ||
this.IsTypeDeclarationStart();
}
-
- bool parseAsModifier(MessageID requiredFeature, [NotNullWhen(true)] out SyntaxToken? modTok)
- {
- // When 'requiredFeature' is enabled, the associated contextual keyword is always a keyword if not escaped. Otherwise, we reuse the async detection
- // machinery to make a conservative guess as to whether the user meant it to be a keyword, so that they get a good langver
- // diagnostic and all the machinery to upgrade their project kicks in. The only exception to this rule is top level statements,
- // where the user could conceivably have a local with the same name as the modifier. For these locations, we need to disambiguate as well.
- if ((!IsFeatureEnabled(requiredFeature) || forTopLevelStatements) && !ShouldContextualKeywordBeTreatedAsModifier(parsingStatementNotDeclaration: false))
- {
- modTok = null;
- return false;
- }
-
- // LangVersion errors for contextual modifiers are given during binding.
- modTok = ConvertToKeyword(EatToken());
- return true;
- }
}
private void ParseAccessorModifiers(SyntaxListBuilder tokens)
@@ -1533,10 +1520,10 @@ private bool ShouldContextualKeywordBeTreatedAsModifier(bool parsingStatementNot
// If 'partial' starts a declaration, the preceding token is also a modifier,
// as in 'closed partial ref struct'.
- // `allowMembers: true`: The preceding modifier may belong to either a type or a member, such as
+ // `onlyForTypeDeclarations: false`: The preceding modifier may belong to either a type or a member, such as
// 'public partial class C' or 'public partial void M()'.
if (!parsingStatementNotDeclaration &&
- this.IsPartialModifierInDeclarationHead(allowMembers: true))
+ this.IsCurrentTokenDefinitelyPartialModifier(onlyForTypeDeclarations: false))
{
return true;
}
@@ -1605,16 +1592,9 @@ private bool ShouldContextualKeywordBeTreatedAsModifier(bool parsingStatementNot
return true;
}
- // "TOKEN TypeName class". In this case, we just have an incomplete member before
- // an existing type declaration. Treat this 'TOKEN' as a keyword.
- if (IsTypeDeclarationStart())
- {
- return true;
- }
-
- // "TOKEN TypeName namespace". In this case, we just have an incomplete member before
- // an existing namespace declaration. Treat this 'TOKEN' as a keyword.
- if (currentTokenKind == SyntaxKind.NamespaceKeyword)
+ // "TOKEN TypeName class" or "TOKEN TypeName namespace". In this case, we just have
+ // an incomplete member before an existing declaration. Treat this 'TOKEN' as a keyword.
+ if (this.IsTypeOrNamespaceDeclarationStart())
{
return true;
}
@@ -1628,6 +1608,9 @@ private bool ShouldContextualKeywordBeTreatedAsModifier(bool parsingStatementNot
return false;
}
+ private bool IsTypeOrNamespaceDeclarationStart()
+ => this.IsTypeDeclarationStart() || this.CurrentToken.Kind == SyntaxKind.NamespaceKeyword;
+
private static bool IsNonContextualModifier(SyntaxToken nextToken)
{
return !SyntaxFacts.IsContextualKeyword(nextToken.ContextualKind) && GetModifierExcludingScoped(nextToken) != DeclarationModifiers.None;
@@ -1648,9 +1631,10 @@ private bool IsEnabledRecordOrUnionKeyword(SyntaxToken token)
}
///
- /// Classifies partial in a declaration head, including misplaced forms for binding to diagnose.
+ /// Determines whether the current token is definitely a partial modifier,
+ /// including misplaced forms for binding to diagnose.
///
- private bool IsPartialModifierInDeclarationHead(bool allowMembers)
+ private bool IsCurrentTokenDefinitelyPartialModifier(bool onlyForTypeDeclarations)
{
if (this.CurrentToken.ContextualKind != SyntaxKind.PartialKeyword)
return false;
@@ -1660,92 +1644,83 @@ private bool IsPartialModifierInDeclarationHead(bool allowMembers)
if (this.IsUnambiguousAnonymousFunctionModifierListFollowedByOpenParen())
return false;
- using var _ = this.GetDisposableResetPoint(resetOnDispose: true);
+ return isPartialModifierInTypeOrNamespaceDeclaration() ||
+ isPartialModifierInMemberDeclaration();
- // Consume the 'partial' being classified before scanning the declaration head.
- // Otherwise, scanning that same token as a possible type would recurse through IsTrueIdentifier.
- this.EatToken(); // partial
+ bool isPartialModifierInTypeOrNamespaceDeclaration()
+ {
+ using var _ = this.GetDisposableResetPoint(resetOnDispose: true);
- var partialConstructorsEnabled = IsFeatureEnabled(MessageID.IDS_FeaturePartialEventsAndConstructors);
+ Debug.Assert(this.CurrentToken.ContextualKind == SyntaxKind.PartialKeyword);
- // Look through following modifiers for the declaration head. A contextual modifier may
- // instead begin the member itself, such as 'ref' in 'partial ref int M()'.
- while (GetModifierExcludingScoped(this.CurrentToken) != DeclarationModifiers.None)
- {
- // For example, 'async' starts the return type in 'partial async M()'.
- if (shouldStopSkippingModifiers())
- return true;
+ // Type and namespace declarations are straightforward: skip the modifier list and
+ // require a well-known declaration keyword such as 'class', 'struct', or 'namespace'.
+ while (GetModifierExcludingScoped(this.CurrentToken) != DeclarationModifiers.None)
+ this.EatToken();
- this.EatToken();
+ return this.IsTypeOrNamespaceDeclarationStart();
}
- // 'partial public class C' reaches 'class' here.
- if (this.IsTypeDeclarationStart())
- return true;
+ bool isPartialModifierInMemberDeclaration()
+ {
+ if (onlyForTypeDeclarations)
+ return false;
- // Parse 'partial public namespace N' as a namespace so binding can report the misplaced
- // modifier instead of producing cascading parser errors.
- if (this.CurrentToken.Kind == SyntaxKind.NamespaceKeyword)
- return true;
+ using var _ = this.GetDisposableResetPoint(resetOnDispose: true);
- return isMemberDeclarationStart();
+ Debug.Assert(this.CurrentToken.ContextualKind == SyntaxKind.PartialKeyword);
- bool shouldStopSkippingModifiers()
- {
- if (!allowMembers)
- return false;
+ // Consume the 'partial' and determine if what follows is definitively a member.
+ this.EatToken();
- // A non-contextual modifier proves that the initial 'partial' belongs to a member.
- if (this.CurrentToken.Kind != SyntaxKind.IdentifierToken)
- return true;
+ // With partial constructors enabled, 'partial Identifier(' starts a constructor.
+ // In earlier versions, 'partial' is the return type and the identifier is the member name.
+ if (isIdentifierFollowedByOpenParen(peekIndex: 0))
+ return IsFeatureEnabled(MessageID.IDS_FeaturePartialEventsAndConstructors);
- // A different contextual modifier may instead start the member's return type,
- // such as 'async' in 'partial async M()'.
- if (this.CurrentToken.ContextualKind != SyntaxKind.PartialKeyword)
- return isMemberDeclarationStart();
+ while (GetModifierExcludingScoped(this.CurrentToken) != DeclarationModifiers.None)
+ {
+ // Before a non-contextual modifier, as in 'partial static', the initial
+ // 'partial' is unambiguously a modifier.
+ if (this.CurrentToken.Kind != SyntaxKind.IdentifierToken)
+ return true;
- // In C# 14, prefer the duplicate-modifier constructor interpretation of
- // 'partial partial C()' over treating the second 'partial' as a return type.
- if (canStartPartialConstructor(peekIndex: 1))
- return true;
+ // A contextual modifier followed by 'Identifier(' either starts a method
+ // return type, as in 'partial async C()', or is another modifier on a partial
+ // constructor, as in 'partial partial C()'. Either way, the initial 'partial' is
+ // a modifier. For the latter form in C# 14, scanning the second 'partial' as a type
+ // reenters this helper and classifies it as a modifier, so IsTypeFollowedByMemberName()
+ // returns false.
+ if (isIdentifierFollowedByOpenParen(peekIndex: 1))
+ return true;
- // Process longer modifier chains one token at a time rather than recursively
- // scanning each 'partial' as a possible type.
- return this.PeekToken(1).ContextualKind != SyntaxKind.PartialKeyword &&
- isMemberDeclarationStart();
- }
+ // A contextual modifier may otherwise be the member's return type, such as
+ // the second 'partial' in 'partial partial P { get; }'.
+ if (this.IsTypeFollowedByMemberName())
+ return true;
- bool isMemberDeclarationStart()
- {
- // Namespace lookahead must not accept member-only forms such as 'partial int M()'.
- if (!allowMembers)
- return false;
+ this.EatToken();
+ }
- // 'event' cannot begin another member form, so parse 'partial event' as an event in every
- // language version. Binding reports the feature diagnostic when necessary.
+ // No modifier-like token remains, so the current token must start the member itself.
+
+ // 'event' cannot begin another member form, so parse 'partial event' as an event in
+ // every language version. Binding reports the feature diagnostic when necessary.
if (this.CurrentToken.Kind == SyntaxKind.EventKeyword)
return true;
- // 'implicit' and 'explicit' can only start conversion operators, so 'partial' is a modifier
- // even when the operator declaration is incomplete.
+ // 'implicit' and 'explicit' can only start conversion operators, so 'partial' is a
+ // modifier even when the operator declaration is incomplete.
if (this.CurrentToken.Kind is SyntaxKind.ImplicitKeyword or SyntaxKind.ExplicitKeyword)
return true;
- // Before partial constructors, 'partial C()' is a method returning 'partial'. Only prefer
- // the constructor interpretation when the feature is enabled.
- if (canStartPartialConstructor(peekIndex: 0))
- return true;
-
// Otherwise, require a return type followed by a member name, as in 'partial int M()'.
return this.IsTypeFollowedByMemberName();
}
- // The surrounding checks establish the preceding 'partial' modifier. This returns true only
- // when partial constructors are enabled and the following tokens can start the constructor.
- bool canStartPartialConstructor(int peekIndex)
+ bool isIdentifierFollowedByOpenParen(int peekIndex)
{
- return partialConstructorsEnabled &&
- this.PeekToken(peekIndex).Kind == SyntaxKind.IdentifierToken &&
+ return this.PeekToken(peekIndex).Kind == SyntaxKind.IdentifierToken &&
this.PeekToken(peekIndex + 1).Kind == SyntaxKind.OpenParenToken;
}
}
@@ -6045,7 +6020,7 @@ private bool IsTrueIdentifier()
{
if (this.CurrentToken.Kind == SyntaxKind.IdentifierToken)
{
- if (!IsCurrentTokenPartialKeywordOfPartialMemberOrType() &&
+ if (!IsCurrentTokenDefinitelyPartialModifier(onlyForTypeDeclarations: false) &&
!IsCurrentTokenQueryKeywordInQuery() &&
!IsCurrentTokenWhereOfConstraintClause())
{
@@ -6092,7 +6067,7 @@ private SyntaxToken ParseIdentifierToken(ErrorCode code = ErrorCode.ERR_Identifi
// show the correct parameter help in this case. So, when we see "partial" we check if it's being used
// as an identifier or as a contextual keyword. If it's the latter then we bail out. See
// Bug: vswhidbey/542125
- if (IsCurrentTokenPartialKeywordOfPartialMemberOrType() || IsCurrentTokenQueryKeywordInQuery())
+ if (this.IsCurrentTokenDefinitelyPartialModifier(onlyForTypeDeclarations: false) || IsCurrentTokenQueryKeywordInQuery())
{
var result = CreateMissingIdentifierToken();
result = this.AddError(result, ErrorCode.ERR_InvalidExprTerm, this.CurrentToken.Text);
@@ -6119,13 +6094,6 @@ private bool IsCurrentTokenQueryKeywordInQuery()
return this.IsInQuery && this.IsCurrentTokenQueryContextualKeyword;
}
- private bool IsCurrentTokenPartialKeywordOfPartialMemberOrType()
- {
- // `allowMembers: true`: Identifier parsing must stop before a type or member declaration, such as
- // 'partial class C' or 'partial public void M()'.
- return this.IsPartialModifierInDeclarationHead(allowMembers: true);
- }
-
private bool IsCurrentTokenFieldInKeywordContext()
{
return CurrentToken.ContextualKind == SyntaxKind.FieldKeyword &&
@@ -6196,7 +6164,7 @@ private TypeParameterSyntax ParseTypeParameter()
}
if (this.IsCurrentTokenWhereOfConstraintClause() ||
- this.IsCurrentTokenPartialKeywordOfPartialMemberOrType())
+ this.IsCurrentTokenDefinitelyPartialModifier(onlyForTypeDeclarations: false))
{
return _syntaxFactory.TypeParameter(
attrs,
diff --git a/src/Compilers/CSharp/Portable/Symbols/AbstractTypeMap.cs b/src/Compilers/CSharp/Portable/Symbols/AbstractTypeMap.cs
index ee43c93bf9e79..1614226389f90 100644
--- a/src/Compilers/CSharp/Portable/Symbols/AbstractTypeMap.cs
+++ b/src/Compilers/CSharp/Portable/Symbols/AbstractTypeMap.cs
@@ -58,29 +58,34 @@ internal NamedTypeSymbol SubstituteNamedType(NamedTypeSymbol previous)
NamedTypeSymbol newConstructedFrom = SubstituteTypeDeclaration(oldConstructedFrom);
ImmutableArray oldTypeArguments = previous.TypeArgumentsWithAnnotationsNoUseSiteDiagnostics;
- bool changed = !ReferenceEquals(oldConstructedFrom, newConstructedFrom);
- var newTypeArguments = ArrayBuilder.GetInstance(oldTypeArguments.Length);
+ ArrayBuilder newTypeArguments = null;
for (int i = 0; i < oldTypeArguments.Length; i++)
{
var oldArgument = oldTypeArguments[i];
var newArgument = oldArgument.SubstituteType(this);
- if (!changed && !oldArgument.IsSameAs(newArgument))
+ if (newTypeArguments is null)
{
- changed = true;
+ if (oldArgument.IsSameAs(newArgument))
+ {
+ continue;
+ }
+
+ newTypeArguments = ArrayBuilder.GetInstance(oldTypeArguments.Length);
+ newTypeArguments.AddRange(oldTypeArguments, i);
}
newTypeArguments.Add(newArgument);
}
- if (!changed)
+ if (newTypeArguments is null && ReferenceEquals(oldConstructedFrom, newConstructedFrom))
{
- newTypeArguments.Free();
return previous;
}
- return newConstructedFrom.ConstructIfGeneric(newTypeArguments.ToImmutableAndFree()).WithTupleDataFrom(previous);
+ var substitutedArguments = newTypeArguments is null ? oldTypeArguments : newTypeArguments.ToImmutableAndFree();
+ return newConstructedFrom.ConstructIfGeneric(substitutedArguments).WithTupleDataFrom(previous);
}
///
diff --git a/src/Compilers/CSharp/Portable/Symbols/Synthesized/SynthesizedBackingFieldSymbol.cs b/src/Compilers/CSharp/Portable/Symbols/Synthesized/SynthesizedBackingFieldSymbol.cs
index b22ceb3c73313..169c5532b80c6 100644
--- a/src/Compilers/CSharp/Portable/Symbols/Synthesized/SynthesizedBackingFieldSymbol.cs
+++ b/src/Compilers/CSharp/Portable/Symbols/Synthesized/SynthesizedBackingFieldSymbol.cs
@@ -116,6 +116,9 @@ protected override OneOrMany> GetAttributeDeclar
public override Symbol AssociatedSymbol
=> _property;
+ internal SourcePropertySymbolBase AssociatedProperty
+ => _property;
+
public override ImmutableArray Locations
=> _property.Locations;
diff --git a/src/Compilers/CSharp/Portable/xlf/CSharpResources.cs.xlf b/src/Compilers/CSharp/Portable/xlf/CSharpResources.cs.xlf
index f5b63a1c997e7..766d509bbbbc4 100644
--- a/src/Compilers/CSharp/Portable/xlf/CSharpResources.cs.xlf
+++ b/src/Compilers/CSharp/Portable/xlf/CSharpResources.cs.xlf
@@ -249,7 +249,7 @@
Invalid '{0}' value: '{1}'. Accepted values are: {2}
- Invalid '{0}' value: '{1}'. Accepted values are: {2}
+ Neplatná hodnota {0}: {1}. Přijímané hodnoty jsou: {2}
{2} is a comma-separated list.
@@ -2979,7 +2979,7 @@
{0} is not a valid C# coalesce operation
- {0} is not a valid C# coalesce operation
+ {0} není platná operace slučování v jazyce C#.
diff --git a/src/Compilers/CSharp/Portable/xlf/CSharpResources.de.xlf b/src/Compilers/CSharp/Portable/xlf/CSharpResources.de.xlf
index 555a5bd3f1de2..01a8c487db8dc 100644
--- a/src/Compilers/CSharp/Portable/xlf/CSharpResources.de.xlf
+++ b/src/Compilers/CSharp/Portable/xlf/CSharpResources.de.xlf
@@ -249,7 +249,7 @@
Invalid '{0}' value: '{1}'. Accepted values are: {2}
- Invalid '{0}' value: '{1}'. Accepted values are: {2}
+ Ungültiger '{0}' Wert: '{1}'. Akzeptierte Werte sind: {2}
{2} is a comma-separated list.
@@ -2979,7 +2979,7 @@
{0} is not a valid C# coalesce operation
- {0} is not a valid C# coalesce operation
+ {0} ist kein gültiger C#-Zusammenfügungsvorgang
diff --git a/src/Compilers/CSharp/Portable/xlf/CSharpResources.es.xlf b/src/Compilers/CSharp/Portable/xlf/CSharpResources.es.xlf
index 652257a064c6b..b9db17e29413e 100644
--- a/src/Compilers/CSharp/Portable/xlf/CSharpResources.es.xlf
+++ b/src/Compilers/CSharp/Portable/xlf/CSharpResources.es.xlf
@@ -249,7 +249,7 @@
Invalid '{0}' value: '{1}'. Accepted values are: {2}
- Invalid '{0}' value: '{1}'. Accepted values are: {2}
+ Valor de '{0}' no válido: '{1}'. Los valores aceptados son: {2}
{2} is a comma-separated list.
@@ -2979,7 +2979,7 @@
{0} is not a valid C# coalesce operation
- {0} is not a valid C# coalesce operation
+ {0} no es una operación de fusión de C# válida
diff --git a/src/Compilers/CSharp/Portable/xlf/CSharpResources.fr.xlf b/src/Compilers/CSharp/Portable/xlf/CSharpResources.fr.xlf
index ad1f71d8a3428..5196c638c90d0 100644
--- a/src/Compilers/CSharp/Portable/xlf/CSharpResources.fr.xlf
+++ b/src/Compilers/CSharp/Portable/xlf/CSharpResources.fr.xlf
@@ -249,7 +249,7 @@
Invalid '{0}' value: '{1}'. Accepted values are: {2}
- Invalid '{0}' value: '{1}'. Accepted values are: {2}
+ Valeur '{0}' non valide : '{1}'. Les valeurs acceptées sont : {2}
{2} is a comma-separated list.
@@ -2979,7 +2979,7 @@
{0} is not a valid C# coalesce operation
- {0} is not a valid C# coalesce operation
+ {0} n’est pas une opération valide de coalescence C#
diff --git a/src/Compilers/CSharp/Portable/xlf/CSharpResources.it.xlf b/src/Compilers/CSharp/Portable/xlf/CSharpResources.it.xlf
index 0dd09b67a4378..e572ab7630c63 100644
--- a/src/Compilers/CSharp/Portable/xlf/CSharpResources.it.xlf
+++ b/src/Compilers/CSharp/Portable/xlf/CSharpResources.it.xlf
@@ -249,7 +249,7 @@
Invalid '{0}' value: '{1}'. Accepted values are: {2}
- Invalid '{0}' value: '{1}'. Accepted values are: {2}
+ Valore '{0}' non valido: '{1}'. I valori accettati sono: {2}
{2} is a comma-separated list.
@@ -2979,7 +2979,7 @@
{0} is not a valid C# coalesce operation
- {0} is not a valid C# coalesce operation
+ {0} non è un'operazione di coalescenza C# valida
diff --git a/src/Compilers/CSharp/Portable/xlf/CSharpResources.ja.xlf b/src/Compilers/CSharp/Portable/xlf/CSharpResources.ja.xlf
index 32c65c1b534b0..191bcc82e540f 100644
--- a/src/Compilers/CSharp/Portable/xlf/CSharpResources.ja.xlf
+++ b/src/Compilers/CSharp/Portable/xlf/CSharpResources.ja.xlf
@@ -249,7 +249,7 @@
Invalid '{0}' value: '{1}'. Accepted values are: {2}
- Invalid '{0}' value: '{1}'. Accepted values are: {2}
+ 無効な '{0}' 値: '{1}'。指定できる値: {2}
{2} is a comma-separated list.
@@ -2979,7 +2979,7 @@
{0} is not a valid C# coalesce operation
- {0} is not a valid C# coalesce operation
+ {0} は C# の有効な結合操作ではありません
diff --git a/src/Compilers/CSharp/Portable/xlf/CSharpResources.ko.xlf b/src/Compilers/CSharp/Portable/xlf/CSharpResources.ko.xlf
index 8ee0d06437638..d6bd425297be6 100644
--- a/src/Compilers/CSharp/Portable/xlf/CSharpResources.ko.xlf
+++ b/src/Compilers/CSharp/Portable/xlf/CSharpResources.ko.xlf
@@ -249,7 +249,7 @@
Invalid '{0}' value: '{1}'. Accepted values are: {2}
- Invalid '{0}' value: '{1}'. Accepted values are: {2}
+ 잘못된 '{0}' 값: '{1}'. 허용되는 값: {2}
{2} is a comma-separated list.
@@ -2979,7 +2979,7 @@
{0} is not a valid C# coalesce operation
- {0} is not a valid C# coalesce operation
+ {0}은(는) 유효한 C# 병합 연산이 아님
diff --git a/src/Compilers/CSharp/Portable/xlf/CSharpResources.pl.xlf b/src/Compilers/CSharp/Portable/xlf/CSharpResources.pl.xlf
index 1b18cfe72af46..6532ed31b1956 100644
--- a/src/Compilers/CSharp/Portable/xlf/CSharpResources.pl.xlf
+++ b/src/Compilers/CSharp/Portable/xlf/CSharpResources.pl.xlf
@@ -249,7 +249,7 @@
Invalid '{0}' value: '{1}'. Accepted values are: {2}
- Invalid '{0}' value: '{1}'. Accepted values are: {2}
+ Nieprawidłowa wartość '{0}': '{1}'. Akceptowane wartości to: {2}
{2} is a comma-separated list.
@@ -2979,7 +2979,7 @@
{0} is not a valid C# coalesce operation
- {0} is not a valid C# coalesce operation
+ {0} nie jest prawidłową operacją łączenia języka C#
diff --git a/src/Compilers/CSharp/Portable/xlf/CSharpResources.pt-BR.xlf b/src/Compilers/CSharp/Portable/xlf/CSharpResources.pt-BR.xlf
index 4449727b87478..77bf0656e922c 100644
--- a/src/Compilers/CSharp/Portable/xlf/CSharpResources.pt-BR.xlf
+++ b/src/Compilers/CSharp/Portable/xlf/CSharpResources.pt-BR.xlf
@@ -249,7 +249,7 @@
Invalid '{0}' value: '{1}'. Accepted values are: {2}
- Invalid '{0}' value: '{1}'. Accepted values are: {2}
+ Valor de '{0}' inválido: '{1}'. Os valores aceitos são: {2}
{2} is a comma-separated list.
@@ -2979,7 +2979,7 @@
{0} is not a valid C# coalesce operation
- {0} is not a valid C# coalesce operation
+ {0} não é uma operação de união C# válida
diff --git a/src/Compilers/CSharp/Portable/xlf/CSharpResources.ru.xlf b/src/Compilers/CSharp/Portable/xlf/CSharpResources.ru.xlf
index 86f7452dea76a..2d037fefd4de6 100644
--- a/src/Compilers/CSharp/Portable/xlf/CSharpResources.ru.xlf
+++ b/src/Compilers/CSharp/Portable/xlf/CSharpResources.ru.xlf
@@ -249,7 +249,7 @@
Invalid '{0}' value: '{1}'. Accepted values are: {2}
- Invalid '{0}' value: '{1}'. Accepted values are: {2}
+ Недопустимое значение '{0}': '{1}'. Допустимые значения: {2}
{2} is a comma-separated list.
@@ -2979,7 +2979,7 @@
{0} is not a valid C# coalesce operation
- {0} is not a valid C# coalesce operation
+ {0} не является допустимой операцией объединения в C#
diff --git a/src/Compilers/CSharp/Portable/xlf/CSharpResources.tr.xlf b/src/Compilers/CSharp/Portable/xlf/CSharpResources.tr.xlf
index 7b759f9bc123e..b84269e9988df 100644
--- a/src/Compilers/CSharp/Portable/xlf/CSharpResources.tr.xlf
+++ b/src/Compilers/CSharp/Portable/xlf/CSharpResources.tr.xlf
@@ -249,7 +249,7 @@
Invalid '{0}' value: '{1}'. Accepted values are: {2}
- Invalid '{0}' value: '{1}'. Accepted values are: {2}
+ Geçersiz '{0}' değeri: '{1}'. Kabul edilen değerler şunlardır: {2}
{2} is a comma-separated list.
@@ -2979,7 +2979,7 @@
{0} is not a valid C# coalesce operation
- {0} is not a valid C# coalesce operation
+ {0}, geçerli bir C# birleştirme işlemi değil
diff --git a/src/Compilers/CSharp/Portable/xlf/CSharpResources.zh-Hans.xlf b/src/Compilers/CSharp/Portable/xlf/CSharpResources.zh-Hans.xlf
index 6f96cc3ad60fb..90178d46c3087 100644
--- a/src/Compilers/CSharp/Portable/xlf/CSharpResources.zh-Hans.xlf
+++ b/src/Compilers/CSharp/Portable/xlf/CSharpResources.zh-Hans.xlf
@@ -249,7 +249,7 @@
Invalid '{0}' value: '{1}'. Accepted values are: {2}
- Invalid '{0}' value: '{1}'. Accepted values are: {2}
+ “{0}”值无效:“{1}”。接受的值为: {2}
{2} is a comma-separated list.
@@ -2979,7 +2979,7 @@
{0} is not a valid C# coalesce operation
- {0} is not a valid C# coalesce operation
+ {0} 不是有效的 C# 联合操作
diff --git a/src/Compilers/CSharp/Portable/xlf/CSharpResources.zh-Hant.xlf b/src/Compilers/CSharp/Portable/xlf/CSharpResources.zh-Hant.xlf
index b47af0d003fa2..945b5a29c58d1 100644
--- a/src/Compilers/CSharp/Portable/xlf/CSharpResources.zh-Hant.xlf
+++ b/src/Compilers/CSharp/Portable/xlf/CSharpResources.zh-Hant.xlf
@@ -249,7 +249,7 @@
Invalid '{0}' value: '{1}'. Accepted values are: {2}
- Invalid '{0}' value: '{1}'. Accepted values are: {2}
+ 無效的 '{0}' 值: '{1}'。接受的值為: {2}
{2} is a comma-separated list.
@@ -2979,7 +2979,7 @@
{0} is not a valid C# coalesce operation
- {0} is not a valid C# coalesce operation
+ {0} 不是有效的 C# 合併作業
diff --git a/src/Compilers/CSharp/Test/CommandLine/CommandLineTests.cs b/src/Compilers/CSharp/Test/CommandLine/CommandLineTests.cs
index ce08e43bc5b85..9f3d6b82c1496 100644
--- a/src/Compilers/CSharp/Test/CommandLine/CommandLineTests.cs
+++ b/src/Compilers/CSharp/Test/CommandLine/CommandLineTests.cs
@@ -12044,6 +12044,30 @@ private struct S
CleanupAllGeneratedFiles(dir.Path);
}
+ [Fact]
+ public void RefOnly_EmbeddedDebugInformation()
+ {
+ var dir = Temp.CreateDirectory();
+ var src = dir.CreateFile("a.cs");
+ src.WriteAllText("public class C { }");
+
+ var outWriter = new StringWriter(CultureInfo.InvariantCulture);
+ var csc = CreateCSharpCompiler(null, dir.Path,
+ new[] { "/nologo", "/target:library", "/out:a.dll", "/refonly", "/debug:embedded", "/deterministic", "a.cs" });
+
+ Assert.Equal(0, csc.Run(outWriter));
+ Assert.Equal("", outWriter.ToString());
+
+ using (var peReader = new PEReader(File.OpenRead(Path.Combine(dir.Path, "a.dll"))))
+ {
+ AssertEx.Equal(
+ new[] { DebugDirectoryEntryType.Reproducible },
+ peReader.ReadDebugDirectory().Select(entry => entry.Type));
+ }
+
+ CleanupAllGeneratedFiles(dir.Path);
+ }
+
[Fact]
public void CompilingCodeWithInvalidPreProcessorSymbolsShouldProvideDiagnostics()
{
diff --git a/src/Compilers/CSharp/Test/Emit/Emit/CompilationEmitTests.cs b/src/Compilers/CSharp/Test/Emit/Emit/CompilationEmitTests.cs
index 8a48d0e565349..553c03cb74bf4 100644
--- a/src/Compilers/CSharp/Test/Emit/Emit/CompilationEmitTests.cs
+++ b/src/Compilers/CSharp/Test/Emit/Emit/CompilationEmitTests.cs
@@ -2709,19 +2709,40 @@ void verifyEmbeddedDebugInfo(MemoryStream stream, DebugDirectoryEntryType[] expe
}
[Fact]
- public void EmitMetadataOnly_DisallowEmbeddingPdb()
+ public void EmitMetadataOnly_IgnoreEmbeddedPdb()
{
CSharpCompilation comp = CreateEmptyCompilation("", references: new[] { MscorlibRef },
- options: TestOptions.DebugDll);
+ options: TestOptions.DebugDll.WithDeterministic(true));
using (var output = new MemoryStream())
{
- Assert.Throws(() => comp.Emit(output,
+ var result = comp.Emit(output,
options: EmitOptions.Default.WithEmitMetadataOnly(true)
- .WithDebugInformationFormat(DebugInformationFormat.Embedded)));
+ .WithDebugInformationFormat(DebugInformationFormat.Embedded));
+
+ Assert.True(result.Success);
+ using var peReader = new PEReader(output.ToImmutable());
+ AssertEx.Equal(
+ new[] { DebugDirectoryEntryType.Reproducible },
+ peReader.ReadDebugDirectory().Select(entry => entry.Type));
}
}
+ [Fact]
+ public void EmitMetadataOnly_IgnorePdb()
+ {
+ CSharpCompilation comp = CreateEmptyCompilation("", references: new[] { MscorlibRef },
+ options: TestOptions.DebugDll.WithDeterministic(true));
+
+ var output = comp.EmitToArray(
+ EmitOptions.Default.WithEmitMetadataOnly(true).WithDebugInformationFormat(DebugInformationFormat.Pdb));
+
+ using var peReader = new PEReader(output);
+ AssertEx.Equal(
+ new[] { DebugDirectoryEntryType.Reproducible },
+ peReader.ReadDebugDirectory().Select(entry => entry.Type));
+ }
+
[Fact]
public void EmitMetadata()
{
diff --git a/src/Compilers/CSharp/Test/Emit3/FieldKeywordTests.cs b/src/Compilers/CSharp/Test/Emit3/FieldKeywordTests.cs
index 16e8f3139539f..395d7688c8675 100644
--- a/src/Compilers/CSharp/Test/Emit3/FieldKeywordTests.cs
+++ b/src/Compilers/CSharp/Test/Emit3/FieldKeywordTests.cs
@@ -11444,8 +11444,6 @@ public string Prop
[InlineData("#pragma warning disable 8603 // Possible null reference return.")]
public void Nullable_Resilient_ChainedConstructor_01(string directive)
{
- // Since the backing field is nullable, a chained constructor isn't expected to put it into non-null state.
- // The property has maybe-null state in "caller" constructor since it shares a slot with the field.
var source = $$"""
#nullable enable
@@ -11454,7 +11452,7 @@ class C
public C(bool ignored) { Prop = "a"; }
public C() : this(false)
{
- Prop.ToString(); // 1
+ Prop.ToString();
}
public string Prop
@@ -11466,10 +11464,7 @@ public string Prop
""";
var comp = CreateCompilation(source);
- comp.VerifyEmitDiagnostics(
- // (8,9): warning CS8602: Dereference of a possibly null reference.
- // Prop.ToString(); // 1
- Diagnostic(ErrorCode.WRN_NullReferenceReceiver, "Prop").WithLocation(8, 9));
+ comp.VerifyEmitDiagnostics();
var prop = comp.GetMember("C.Prop");
Assert.Equal(NullableAnnotation.Annotated, prop.BackingField.GetInferredNullableAnnotation());
@@ -11487,7 +11482,7 @@ class C
public C(bool ignored) { Prop = "a"; }
public C() : this(false)
{
- Prop.ToString(); // 1
+ Prop.ToString();
}
public string Prop
@@ -11498,10 +11493,7 @@ public string Prop
""";
var comp = CreateCompilation(source);
- comp.VerifyEmitDiagnostics(
- // (8,9): warning CS8602: Dereference of a possibly null reference.
- // Prop.ToString(); // 1
- Diagnostic(ErrorCode.WRN_NullReferenceReceiver, "Prop").WithLocation(8, 9));
+ comp.VerifyEmitDiagnostics();
var prop = comp.GetMember("C.Prop");
Assert.Equal(NullableAnnotation.Annotated, prop.BackingField.GetInferredNullableAnnotation());
@@ -12351,10 +12343,12 @@ public string Value2
Diagnostic(ErrorCode.ERR_AssgReadonlyProp, "Value1").WithArguments("S.Value1").WithLocation(23, 16));
}
- [Fact]
- public void Nullable_Accessor_FieldStateAffectedByAssigningThis()
+ [Theory]
+ [InlineData("field")]
+ [InlineData(@"field ?? ""a""")]
+ public void Nullable_Accessor_FieldStateAffectedByAssigningThis(string getterReturnValue)
{
- var source = """
+ var source = $$"""
#nullable enable
public struct S
@@ -12366,7 +12360,7 @@ public string Prop
field = "a";
this = default;
field.ToString(); // 1
- return field;
+ return {{getterReturnValue}};
}
set
{
@@ -12387,23 +12381,25 @@ public string Prop
Diagnostic(ErrorCode.WRN_NullReferenceReceiver, "field").WithLocation(18, 13));
}
- [Fact]
- public void Nullable_Constructor_FieldStateAffectedByAssigningThis()
+ [Theory]
+ [InlineData("field")]
+ [InlineData(@"field ?? ""a""")]
+ public void Nullable_Constructor_FieldStateAffectedByAssigningThis(string getterReturnValue)
{
- var source = """
+ var source = $$"""
#nullable enable
public struct S
{
public string Prop
{
- get => field;
+ get => {{getterReturnValue}};
set => field = value;
}
public static string StaticProp
{
- get => field;
+ get => {{getterReturnValue}};
set => field = value;
} = "b";
@@ -12422,13 +12418,20 @@ public S() : this("a")
}
""";
var comp = CreateCompilation(source);
- comp.VerifyEmitDiagnostics(
- // (26,9): warning CS8602: Dereference of a possibly null reference.
- // Prop.ToString(); // 1
- Diagnostic(ErrorCode.WRN_NullReferenceReceiver, "Prop").WithLocation(26, 9));
+ if (getterReturnValue == "field")
+ {
+ comp.VerifyEmitDiagnostics(
+ // (26,9): warning CS8602: Dereference of a possibly null reference.
+ // Prop.ToString(); // 1
+ Diagnostic(ErrorCode.WRN_NullReferenceReceiver, "Prop").WithLocation(26, 9));
+ }
+ else
+ {
+ comp.VerifyEmitDiagnostics();
+ }
}
- [Fact, WorkItem("https://github.com/dotnet/roslyn/issues/77991")]
+ [Fact, WorkItem("https://github.com/dotnet/roslyn/issues/84987")]
public void Nullable_OrdinaryMethod_FieldStateAffectedByAssigningStruct()
{
var source = """
@@ -12447,12 +12450,16 @@ public static string StaticProp
get => field;
set => field = value;
} = "b";
-
+
public void M(S s)
{
s.Prop.ToString();
StaticProp.ToString();
+ s.Prop = null; // 1
+ s.Prop.ToString(); // 2
+
+ s.Prop = null; // 3
s = default;
s.Prop.ToString(); // expected warning is missing
StaticProp.ToString();
@@ -12460,10 +12467,19 @@ public void M(S s)
}
""";
var comp = CreateCompilation(source);
- comp.VerifyEmitDiagnostics();
- }
-
- [Fact, WorkItem("https://github.com/dotnet/roslyn/issues/77991")]
+ comp.VerifyEmitDiagnostics(
+ // (22,18): warning CS8625: Cannot convert null literal to non-nullable reference type.
+ // s.Prop = null; // 1
+ Diagnostic(ErrorCode.WRN_NullAsNonNullable, "null").WithLocation(22, 18),
+ // (23,9): warning CS8602: Dereference of a possibly null reference.
+ // s.Prop.ToString(); // 2
+ Diagnostic(ErrorCode.WRN_NullReferenceReceiver, "s.Prop").WithLocation(23, 9),
+ // (25,18): warning CS8625: Cannot convert null literal to non-nullable reference type.
+ // s.Prop = null; // 3
+ Diagnostic(ErrorCode.WRN_NullAsNonNullable, "null").WithLocation(25, 18));
+ }
+
+ [Fact, WorkItem("https://github.com/dotnet/roslyn/issues/84987")]
public void Nullable_Accessor_OtherPropStateAffectedByAssigningStruct()
{
var source = """
@@ -12500,7 +12516,7 @@ public static string StaticProp
comp.VerifyEmitDiagnostics();
}
- [Fact, WorkItem("https://github.com/dotnet/roslyn/issues/77991")]
+ [Fact, WorkItem("https://github.com/dotnet/roslyn/issues/84987")]
public void Nullable_PropInitializer_PropStateAffectedByAssigningStruct()
{
var source = """
@@ -12549,27 +12565,27 @@ public struct S
{
public S()
{
- Resilient.ToString(); // 3
+ Resilient.ToString();
NonResilient.ToString(); // 4
}
public S(bool ignored) : this()
{
- Resilient.ToString(); // 5
+ Resilient.ToString();
NonResilient.ToString();
}
public S((bool, bool) ignored2)
{
this = new();
- Resilient.ToString(); // 6
+ Resilient.ToString();
NonResilient.ToString();
}
public S((bool, bool, bool) ignored3)
{
this = default;
- Resilient.ToString(); // 7
+ Resilient.ToString();
NonResilient.ToString(); // 8
}
@@ -12591,21 +12607,9 @@ public string NonResilient
// (6,5): error CS0200: Property or indexer 'S.NonResilient' cannot be assigned to -- it is read only
// NonResilient = "b" // 2
Diagnostic(ErrorCode.ERR_AssgReadonlyProp, "NonResilient").WithArguments("S.NonResilient").WithLocation(6, 5),
- // (13,9): warning CS8602: Dereference of a possibly null reference.
- // Resilient.ToString(); // 3
- Diagnostic(ErrorCode.WRN_NullReferenceReceiver, "Resilient").WithLocation(13, 9),
// (14,9): warning CS8602: Dereference of a possibly null reference.
// NonResilient.ToString(); // 4
Diagnostic(ErrorCode.WRN_NullReferenceReceiver, "NonResilient").WithLocation(14, 9),
- // (19,9): warning CS8602: Dereference of a possibly null reference.
- // Resilient.ToString(); // 5
- Diagnostic(ErrorCode.WRN_NullReferenceReceiver, "Resilient").WithLocation(19, 9),
- // (26,9): warning CS8602: Dereference of a possibly null reference.
- // Resilient.ToString(); // 6
- Diagnostic(ErrorCode.WRN_NullReferenceReceiver, "Resilient").WithLocation(26, 9),
- // (33,9): warning CS8602: Dereference of a possibly null reference.
- // Resilient.ToString(); // 7
- Diagnostic(ErrorCode.WRN_NullReferenceReceiver, "Resilient").WithLocation(33, 9),
// (34,9): warning CS8602: Dereference of a possibly null reference.
// NonResilient.ToString(); // 8
Diagnostic(ErrorCode.WRN_NullReferenceReceiver, "NonResilient").WithLocation(34, 9));
@@ -12729,39 +12733,696 @@ static S()
}
[Fact, WorkItem("https://github.com/dotnet/roslyn/issues/77991")]
- public void Nullable_Resilient_InitialStateInConstructor()
+ public void Nullable_Resilient_InitialStateInConstructor_01()
+ {
+ var source = """
+ #nullable enable
+
+ // empty marker types to allow distinct constructor signatures
+ public class D1;
+ public class D2;
+ public class D3;
+ public class D4;
+ public class D5;
+ public class D6;
+
+ public class C
+ {
+ public string Prop1 => field ??= "a";
+ public string Prop2 => field;
+
+ public C(bool ignored)
+ {
+ Prop1.ToString();
+ Prop2.ToString(); // 1
+ }
+
+ public C() : this(false)
+ {
+ Prop1.ToString();
+ Prop2.ToString();
+ }
+
+ public C(D1 d1) // 2
+ {
+ }
+
+ public C(D2 d2) : this(false)
+ {
+ }
+
+ public C(D3 d3) // 3
+ {
+ Prop1 = null;
+ Prop2 = null; // 4
+
+ if (string.Empty == "")
+ {
+ Prop1.ToString(); // 5
+ Prop2.ToString(); // 6
+ }
+ }
+
+ public C(D4 d4) : this(false) // 7
+ {
+ Prop1 = null;
+ Prop2 = null; // 8
+
+ if (string.Empty == "")
+ {
+ Prop1.ToString(); // 9
+ Prop2.ToString(); // 10
+ }
+ }
+
+ public C(D5 d5)
+ {
+ Prop1 = "a";
+ Prop2 = "a";
+
+ Prop1.ToString();
+ Prop2.ToString();
+ }
+
+ public C(D6 d6) : this(false)
+ {
+ Prop1 = "a";
+ Prop2 = "a";
+
+ Prop1.ToString();
+ Prop2.ToString();
+ }
+
+ public void M()
+ {
+ Prop1.ToString();
+ Prop2.ToString();
+ }
+ }
+ """;
+ var comp = CreateCompilation(source);
+ comp.VerifyEmitDiagnostics(
+ // (19,9): warning CS8602: Dereference of a possibly null reference.
+ // Prop2.ToString(); // 1
+ Diagnostic(ErrorCode.WRN_NullReferenceReceiver, "Prop2").WithLocation(19, 9),
+ // (28,12): warning CS9264: Non-nullable property 'Prop2' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier, or declaring the property as nullable, or safely handling the case where 'field' is null in the 'get' accessor.
+ // public C(D1 d1) // 2
+ Diagnostic(ErrorCode.WRN_UninitializedNonNullableBackingField, "C").WithArguments("property", "Prop2").WithLocation(28, 12),
+ // (36,12): warning CS9264: Non-nullable property 'Prop2' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier, or declaring the property as nullable, or safely handling the case where 'field' is null in the 'get' accessor.
+ // public C(D3 d3) // 3
+ Diagnostic(ErrorCode.WRN_UninitializedNonNullableBackingField, "C").WithArguments("property", "Prop2").WithLocation(36, 12),
+ // (39,17): warning CS8625: Cannot convert null literal to non-nullable reference type.
+ // Prop2 = null; // 4
+ Diagnostic(ErrorCode.WRN_NullAsNonNullable, "null").WithLocation(39, 17),
+ // (43,13): warning CS8602: Dereference of a possibly null reference.
+ // Prop1.ToString(); // 5
+ Diagnostic(ErrorCode.WRN_NullReferenceReceiver, "Prop1").WithLocation(43, 13),
+ // (44,13): warning CS8602: Dereference of a possibly null reference.
+ // Prop2.ToString(); // 6
+ Diagnostic(ErrorCode.WRN_NullReferenceReceiver, "Prop2").WithLocation(44, 13),
+ // (48,12): warning CS9264: Non-nullable property 'Prop2' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier, or declaring the property as nullable, or safely handling the case where 'field' is null in the 'get' accessor.
+ // public C(D4 d4) : this(false) // 7
+ Diagnostic(ErrorCode.WRN_UninitializedNonNullableBackingField, "C").WithArguments("property", "Prop2").WithLocation(48, 12),
+ // (51,17): warning CS8625: Cannot convert null literal to non-nullable reference type.
+ // Prop2 = null; // 8
+ Diagnostic(ErrorCode.WRN_NullAsNonNullable, "null").WithLocation(51, 17),
+ // (55,13): warning CS8602: Dereference of a possibly null reference.
+ // Prop1.ToString(); // 9
+ Diagnostic(ErrorCode.WRN_NullReferenceReceiver, "Prop1").WithLocation(55, 13),
+ // (56,13): warning CS8602: Dereference of a possibly null reference.
+ // Prop2.ToString(); // 10
+ Diagnostic(ErrorCode.WRN_NullReferenceReceiver, "Prop2").WithLocation(56, 13));
+ }
+
+ [Fact, WorkItem("https://github.com/dotnet/roslyn/issues/77991")]
+ public void Nullable_Resilient_InitialStateInConstructor_02()
+ {
+ // Test behaviors of AllowNull attribute on various null-resilient properties.
+ var source = """
+ #nullable enable
+ using System.Diagnostics.CodeAnalysis;
+
+ // empty marker types to allow distinct constructor signatures
+ public class D1;
+ public class D2;
+ public class D3;
+ public class D4;
+ public class D5;
+ public class D6;
+
+ public class C
+ {
+ [AllowNull]
+ public string Prop1 => field ??= "a";
+
+ public string Prop2 { get => field ??= "a"; set; }
+
+ [AllowNull]
+ public string Prop3 { get => field ??= "a"; set; }
+
+ public C(bool ignored)
+ {
+ Prop1.ToString();
+ Prop2.ToString();
+ Prop3.ToString();
+ }
+
+ public C() : this(false)
+ {
+ Prop1.ToString();
+ Prop2.ToString();
+ Prop3.ToString();
+ }
+
+ public C(D1 d1)
+ {
+ }
+
+ public C(D2 d2) : this(false)
+ {
+ }
+
+ public C(D3 d3)
+ {
+ Prop1 = null;
+ Prop2 = null; // 1
+ Prop3 = null;
+
+ if (string.Empty == "")
+ {
+ Prop1.ToString();
+ Prop2.ToString(); // 2
+ Prop3.ToString();
+ }
+ }
+
+ public C(D4 d4) : this(false)
+ {
+ Prop1 = null;
+ Prop2 = null; // 3
+ Prop3 = null;
+
+ if (string.Empty == "")
+ {
+ Prop1.ToString();
+ Prop2.ToString(); // 4
+ Prop3.ToString();
+ }
+ }
+
+ public C(D5 d5)
+ {
+ Prop1 = "a";
+ Prop2 = "a";
+ Prop3 = "a";
+
+ Prop1.ToString();
+ Prop2.ToString();
+ Prop3.ToString();
+ }
+
+ public C(D6 d6) : this(false)
+ {
+ Prop1 = "a";
+ Prop2 = "a";
+ Prop3 = "a";
+
+ Prop1.ToString();
+ Prop2.ToString();
+ Prop3.ToString();
+ }
+
+ public void M()
+ {
+ Prop1.ToString();
+ }
+ }
+ """;
+ var comp = CreateCompilation([source, AllowNullAttributeDefinition]);
+ comp.VerifyEmitDiagnostics(
+ // (47,17): warning CS8625: Cannot convert null literal to non-nullable reference type.
+ // Prop2 = null; // 1
+ Diagnostic(ErrorCode.WRN_NullAsNonNullable, "null").WithLocation(47, 17),
+ // (53,13): warning CS8602: Dereference of a possibly null reference.
+ // Prop2.ToString(); // 2
+ Diagnostic(ErrorCode.WRN_NullReferenceReceiver, "Prop2").WithLocation(53, 13),
+ // (61,17): warning CS8625: Cannot convert null literal to non-nullable reference type.
+ // Prop2 = null; // 3
+ Diagnostic(ErrorCode.WRN_NullAsNonNullable, "null").WithLocation(61, 17),
+ // (67,13): warning CS8602: Dereference of a possibly null reference.
+ // Prop2.ToString(); // 4
+ Diagnostic(ErrorCode.WRN_NullReferenceReceiver, "Prop2").WithLocation(67, 13));
+ }
+
+ [Fact, WorkItem("https://github.com/dotnet/roslyn/issues/77991")]
+ public void Nullable_Resilient_InitialStateInConstructor_03()
{
+ // Test behaviors of MaybeNull attribute on a null-resilient property
var source = """
#nullable enable
+ using System.Diagnostics.CodeAnalysis;
+
+ // empty marker types to allow distinct constructor signatures
+ public class D1;
+ public class D2;
+ public class D3;
+ public class D4;
+ public class D5;
+ public class D6;
public class C
{
- public string Prop => field ??= "a";
+ [MaybeNull]
+ public string Prop1 => field ??= "a";
public C(bool ignored)
{
- Prop.ToString(); // unexpected warning
+ Prop1.ToString(); // 1
}
public C() : this(false)
{
- Prop.ToString(); // unexpected warning
+ Prop1.ToString(); // 2
+ }
+
+ public C(D1 d1)
+ {
+ }
+
+ public C(D2 d2) : this(false)
+ {
+ }
+
+ public C(D3 d3)
+ {
+ Prop1 = null;
+ if (string.Empty == "")
+ {
+ Prop1.ToString(); // 3
+ }
+ }
+
+ public C(D4 d4) : this(false)
+ {
+ Prop1 = null;
+ if (string.Empty == "")
+ {
+ Prop1.ToString(); // 4
+ }
+ }
+
+ public C(D5 d5)
+ {
+ Prop1 = "a";
+ Prop1.ToString();
+ }
+
+ public C(D6 d6) : this(false)
+ {
+ Prop1 = "a";
+ Prop1.ToString();
}
public void M()
{
+ Prop1.ToString(); // 3
+ }
+ }
+ """;
+ var comp = CreateCompilation([source, MaybeNullAttributeDefinition]);
+ comp.VerifyEmitDiagnostics(
+ // (19,9): warning CS8602: Dereference of a possibly null reference.
+ // Prop1.ToString(); // 1
+ Diagnostic(ErrorCode.WRN_NullReferenceReceiver, "Prop1").WithLocation(19, 9),
+ // (24,9): warning CS8602: Dereference of a possibly null reference.
+ // Prop1.ToString(); // 2
+ Diagnostic(ErrorCode.WRN_NullReferenceReceiver, "Prop1").WithLocation(24, 9),
+ // (40,13): warning CS8602: Dereference of a possibly null reference.
+ // Prop1.ToString(); // 3
+ Diagnostic(ErrorCode.WRN_NullReferenceReceiver, "Prop1").WithLocation(40, 13),
+ // (49,13): warning CS8602: Dereference of a possibly null reference.
+ // Prop1.ToString(); // 4
+ Diagnostic(ErrorCode.WRN_NullReferenceReceiver, "Prop1").WithLocation(49, 13),
+ // (67,9): warning CS8602: Dereference of a possibly null reference.
+ // Prop1.ToString(); // 3
+ Diagnostic(ErrorCode.WRN_NullReferenceReceiver, "Prop1").WithLocation(67, 9));
+ }
+
+ [Fact, WorkItem("https://github.com/dotnet/roslyn/issues/77991")]
+ public void Nullable_Resilient_InitialStateInConstructor_04()
+ {
+ // Test behaviors of a required null-resilient property
+ var source = """
+ #nullable enable
+
+ // empty marker types to allow distinct constructor signatures
+ public class D1;
+ public class D2;
+ public class D3;
+ public class D4;
+
+ public class C
+ {
+ public required string Prop1 { get => field ??= "a"; set; }
+
+ public C(bool ignored)
+ {
+ Prop1.ToString();
+ }
+
+ public C() : this(false)
+ {
+ Prop1.ToString();
+ }
+
+ public C(D1 d)
+ {
+ }
+
+ public C(D2 d) : this(false)
+ {
+ }
+
+ public C(D3 d)
+ {
+ Prop1 = "a";
+
+ if (string.Empty == "")
+ {
+ Prop1.ToString();
+ }
+ }
+
+ public C(D4 d) : this(false)
+ {
+ Prop1 = "a";
+
+ if (string.Empty == "")
+ {
+ Prop1.ToString();
+ }
+ }
+
+ public void M()
+ {
+ Prop1.ToString();
+ }
+ }
+ """;
+ var comp = CreateCompilation([source, RequiredMemberAttribute, CompilerFeatureRequiredAttribute, SetsRequiredMembersAttribute]);
+ comp.VerifyEmitDiagnostics();
+ }
+
+ [Fact, WorkItem("https://github.com/dotnet/roslyn/issues/77991")]
+ public void Nullable_Resilient_InitialStateInConstructor_05()
+ {
+ // Test behaviors of a required null-resilient property with SetsRequiredMembers on constructors
+ var source = """
+ #nullable enable
+ using System.Diagnostics.CodeAnalysis;
+
+ // empty marker types to allow distinct constructor signatures
+ public class D1;
+ public class D2;
+ public class D3;
+ public class D4;
+ public class D5;
+ public class D6;
+ public class D7;
+ public class D8;
+
+ public class C
+ {
+ public required string Prop1 { get => field ??= "a"; set; }
+
+ [SetsRequiredMembers]
+ public C(bool ignored)
+ {
+ Prop1.ToString();
+ }
+
+ public C(int ignored)
+ {
+ Prop1.ToString();
+ }
+
+ [SetsRequiredMembers]
+ public C(D1 d) : this(false)
+ {
+ Prop1.ToString();
+ }
+
+ [SetsRequiredMembers]
+ public C(D2 d) : this(0)
+ {
+ Prop1.ToString();
+ }
+
+ [SetsRequiredMembers]
+ public C(D3 d)
+ {
+ }
+
+ [SetsRequiredMembers]
+ public C(D4 d) : this(false)
+ {
+ }
+
+ [SetsRequiredMembers]
+ public C(D5 d) : this(0)
+ {
+ }
+
+ [SetsRequiredMembers]
+ public C(D6 d)
+ {
+ Prop1 = "a";
+ }
+
+ [SetsRequiredMembers]
+ public C(D7 d) : this(false)
+ {
+ Prop1 = "a";
+ }
+
+ [SetsRequiredMembers]
+ public C(D8 d) : this(0)
+ {
+ Prop1 = "a";
+ }
+
+ public void M()
+ {
+ Prop1.ToString();
+ }
+ }
+ """;
+ var comp = CreateCompilation([source, RequiredMemberAttribute, CompilerFeatureRequiredAttribute, SetsRequiredMembersAttribute]);
+ comp.VerifyEmitDiagnostics();
+ }
+
+ [Fact, WorkItem("https://github.com/dotnet/roslyn/issues/84957")]
+ public void Repro_84957()
+ {
+ var source = """
+ #nullable enable
+ internal class Goo {
+ private Bar? _bang1;
+
+ public Goo() {
+ Wiz1 = Bang1;
+ Wiz2 = Bang2;
+ Wiz3 = Bang3;
+ Wiz4 = Bang4;
+ Wiz5 = Bang5;
+ }
+
+ public Bar Wiz1 { get; set; }
+ public Bar Wiz2 { get; set; }
+ public Bar Wiz3 { get; set; }
+ public Bar Wiz4 { get; set; }
+ public Bar Wiz5 { get; set; }
+
+ public Bar Bang1 => _bang1 ??= new Bar();
+
+ public Bar Bang2 => field ??= new Bar();
+
+ public Bar Bang3 => (field ??= new Bar())!;
+
+ public Bar Bang4 {
+ get {
+ field ??= new Bar();
+ return field;
+ }
+ }
+
+ public Bar Bang5 {
+ get {
+ field = new Bar();
+ return field;
+ }
+ }
+ }
+
+ public class Bar { };
+ """;
+ var comp = CreateCompilation(source);
+ comp.VerifyEmitDiagnostics();
+ }
+
+ [Fact, WorkItem("https://github.com/dotnet/roslyn/issues/84987")]
+ public void Nullable_InheritedState_01()
+ {
+ var source = """
+ #nullable enable
+ struct S
+ {
+ public string Prop { get => field; set; }
+ public void M()
+ {
+ this = default;
+ this.Prop.ToString(); // expected warning missing
+ }
+
+ public S()
+ {
+ this = default;
+ Prop.ToString(); // 1
+ }
+
+ public S(bool ignored1)
+ {
+ S s1 = default;
+ this = s1;
+ Prop.ToString(); // 2
+ }
+
+ public S((bool, bool) ignored2)
+ {
+ S s1 = default;
+ s1.Prop = "a";
+ this = s1;
Prop.ToString();
}
}
""";
var comp = CreateCompilation(source);
comp.VerifyEmitDiagnostics(
- // (9,9): warning CS8602: Dereference of a possibly null reference.
- // Prop.ToString(); // unexpected warning
- Diagnostic(ErrorCode.WRN_NullReferenceReceiver, "Prop").WithLocation(9, 9),
// (14,9): warning CS8602: Dereference of a possibly null reference.
- // Prop.ToString(); // unexpected warning
- Diagnostic(ErrorCode.WRN_NullReferenceReceiver, "Prop").WithLocation(14, 9));
+ // Prop.ToString(); // 1
+ Diagnostic(ErrorCode.WRN_NullReferenceReceiver, "Prop").WithLocation(14, 9),
+ // (21,9): warning CS8602: Dereference of a possibly null reference.
+ // Prop.ToString(); // 2
+ Diagnostic(ErrorCode.WRN_NullReferenceReceiver, "Prop").WithLocation(21, 9));
+ }
+
+ [Fact, WorkItem("https://github.com/dotnet/roslyn/issues/84987")]
+ public void Nullable_InheritedState_02()
+ {
+ // Slot is only shared between property and backing field of 'this' variable
+ var source = """
+ #nullable enable
+ struct S()
+ {
+ public string Prop { get => field; set; } = "a";
+
+ public class Inner
+ {
+ void M(S s)
+ {
+ s.Prop.ToString();
+ s = default;
+ s.Prop.ToString(); // expected warning missing
+ }
+
+ public Inner(S s)
+ {
+ s.Prop.ToString();
+ s = default;
+ s.Prop.ToString(); // expected warning missing
+ }
+ }
+ }
+ """;
+ var comp = CreateCompilation(source);
+ comp.VerifyEmitDiagnostics();
+ }
+
+ [Fact, WorkItem("https://github.com/dotnet/roslyn/issues/84987")]
+ public void Nullable_InheritedState_03()
+ {
+ var source = """
+ #nullable enable
+ struct S
+ {
+ public T Prop { get => field; set; } = default!;
+ public static void Use(T t) { }
+
+ void M()
+ {
+ Use(this.Prop);
+ this = default;
+ Use(this.Prop); // expected warning missing
+ Use(default(T)); // 1
+ }
+
+ public S()
+ {
+ Use(this.Prop);
+ this = default;
+ Use(default(T)); // 2
+ }
+ }
+ """;
+ var comp = CreateCompilation(source);
+ comp.VerifyEmitDiagnostics(
+ // (12,13): warning CS8604: Possible null reference argument for parameter 't' in 'void S.Use(T t)'.
+ // Use(default(T)); // 1
+ Diagnostic(ErrorCode.WRN_NullReferenceArgument, "default(T)").WithArguments("t", "void S.Use(T t)").WithLocation(12, 13),
+ // (19,13): warning CS8604: Possible null reference argument for parameter 't' in 'void S.Use(T t)'.
+ // Use(default(T)); // 2
+ Diagnostic(ErrorCode.WRN_NullReferenceArgument, "default(T)").WithArguments("t", "void S.Use(T t)").WithLocation(19, 13));
+ }
+
+ [Fact, WorkItem("https://github.com/dotnet/roslyn/issues/84987")]
+ public void Nullable_InheritedState_04()
+ {
+ var source = """
+ #nullable enable
+ struct S()
+ {
+ public T Prop { get => field; set; } = default!;
+
+ public class Inner
+ {
+ public static void Use(T t) { }
+
+ void M(S s)
+ {
+ Use(s.Prop);
+ s = default;
+ Use(s.Prop); // expected warning missing
+ Use(default(T)); // 1
+ }
+
+ public Inner(S s)
+ {
+ Use(s.Prop);
+ s = default;
+ Use(s.Prop); // expected warning missing
+ Use(default(T)); // 2
+ }
+ }
+ }
+ """;
+ var comp = CreateCompilation(source);
+ comp.VerifyEmitDiagnostics(
+ // (15,17): warning CS8604: Possible null reference argument for parameter 't' in 'void Inner.Use(T t)'.
+ // Use(default(T)); // 1
+ Diagnostic(ErrorCode.WRN_NullReferenceArgument, "default(T)").WithArguments("t", "void Inner.Use(T t)").WithLocation(15, 17),
+ // (23,17): warning CS8604: Possible null reference argument for parameter 't' in 'void Inner.Use(T t)'.
+ // Use(default(T)); // 2
+ Diagnostic(ErrorCode.WRN_NullReferenceArgument, "default(T)").WithArguments("t", "void Inner.Use(T t)").WithLocation(23, 17));
}
[Theory, WorkItem("https://github.com/dotnet/roslyn/issues/78592")]
@@ -12993,9 +13654,8 @@ void verify2(MetadataReference reference)
[Fact, WorkItem("https://github.com/dotnet/roslyn/issues/80504")]
public void SetsRequiredMembersInSubtype()
{
- var source = """
+ var source1 = """
#nullable enable
- using System.Diagnostics.CodeAnalysis;
public class Foo {
public required string Bar {
@@ -13005,20 +13665,39 @@ public required string Bar {
}
}
}
+ """;
- public class FooDerivative : Foo {
+ var source2 = """
+ #nullable enable
+ using System.Diagnostics.CodeAnalysis;
+ public class FooDerivative : Foo {
[SetsRequiredMembers]
public FooDerivative() {
}
}
""";
- var comp = CreateCompilation(source, targetFramework: TargetFramework.Net100);
+ var comp = CreateCompilation([source1, source2], targetFramework: TargetFramework.Net100);
comp.VerifyEmitDiagnostics(
- // (16,12): warning CS8618: Non-nullable property 'Bar' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
+ // (6,12): warning CS8618: Non-nullable property 'Bar' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
// public FooDerivative() {
- Diagnostic(ErrorCode.WRN_UninitializedNonNullableField, "FooDerivative").WithArguments("property", "Bar").WithLocation(16, 12));
+ Diagnostic(ErrorCode.WRN_UninitializedNonNullableField, "FooDerivative").WithArguments("property", "Bar").WithLocation(6, 12));
+
+ var comp1 = CreateCompilation(source1, targetFramework: TargetFramework.Net100);
+ comp1.VerifyEmitDiagnostics();
+
+ verify2(comp1.ToMetadataReference());
+ verify2(comp1.EmitToImageReference());
+
+ void verify2(MetadataReference reference)
+ {
+ var comp2 = CreateCompilation(source2, references: [reference], targetFramework: TargetFramework.Net100);
+ comp2.VerifyEmitDiagnostics(
+ // (6,12): warning CS8618: Non-nullable property 'Bar' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
+ // public FooDerivative() {
+ Diagnostic(ErrorCode.WRN_UninitializedNonNullableField, "FooDerivative").WithArguments("property", "Bar").WithLocation(6, 12));
+ }
}
private class TestAnalyzer1 : DiagnosticAnalyzer
diff --git a/src/Compilers/CSharp/Test/Symbol/Symbols/RequiredMembersTests.cs b/src/Compilers/CSharp/Test/Symbol/Symbols/RequiredMembersTests.cs
index c543924a079c7..f4a11a725e939 100644
--- a/src/Compilers/CSharp/Test/Symbol/Symbols/RequiredMembersTests.cs
+++ b/src/Compilers/CSharp/Test/Symbol/Symbols/RequiredMembersTests.cs
@@ -4845,6 +4845,200 @@ public Derived()
comp.VerifyDiagnostics(expectedDiagnostics);
}
+ [Fact, CompilerTrait(CompilerFeature.NullableReferenceTypes)]
+ [WorkItem(6754, "https://github.com/dotnet/csharplang/issues/6754")]
+ public void RequiredMemberSuppressesNullabilityWarnings_MemberNotNull_ChainedBaseConstructor_06()
+ {
+ // Base required property initializes a private field.
+ // Derived constructors have SetsRequiredMembers and demonstrate behavior with/without initializing base required property.
+ var @base = """
+using System.Diagnostics.CodeAnalysis;
+#nullable enable
+public class Base
+{
+ private string _field;
+ public string Field => _field;
+ public required string Property { get => _field; [MemberNotNull(nameof(_field))] set => _field = value; }
+
+ public Base() { }
+}
+""";
+
+ var derived = """
+using System;
+using System.Diagnostics.CodeAnalysis;
+#nullable enable
+
+var d = new Derived("property");
+Console.Write(d.Field);
+Console.Write(' ');
+
+d = new Derived();
+try
+{
+ d.Field.ToString();
+}
+catch (NullReferenceException)
+{
+ Console.Write("NullReferenceException");
+}
+
+class Derived : Base
+{
+ [SetsRequiredMembers]
+ public Derived()
+ {
+ }
+
+ [SetsRequiredMembers]
+ public Derived(string value)
+ {
+ Property = value;
+ }
+}
+""";
+
+ var comp = CreateCompilationWithRequiredMembers(new[] { @base, derived, MemberNotNullAttributeDefinition });
+ comp.VerifyDiagnostics(
+ // 1.cs(22,12): warning CS8618: Non-nullable property 'Property' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
+ // public Derived()
+ Diagnostic(ErrorCode.WRN_UninitializedNonNullableField, "Derived").WithArguments("property", "Property").WithLocation(22, 12)
+ );
+ CompileAndVerify(comp, expectedOutput: "property NullReferenceException");
+
+ var baseComp = CreateCompilationWithRequiredMembers(new[] { @base, MemberNotNullAttributeDefinition });
+ comp = CreateCompilation(derived, new[] { baseComp.EmitToImageReference() });
+ comp.VerifyDiagnostics(
+ // 1.cs(22,12): warning CS8618: Non-nullable property 'Property' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
+ // public Derived()
+ Diagnostic(ErrorCode.WRN_UninitializedNonNullableField, "Derived").WithArguments("property", "Property").WithLocation(22, 12)
+ );
+ CompileAndVerify(comp, expectedOutput: "property NullReferenceException");
+ }
+
+ [Fact, CompilerTrait(CompilerFeature.NullableReferenceTypes)]
+ [WorkItem(6754, "https://github.com/dotnet/csharplang/issues/6754")]
+ public void RequiredMemberSuppressesNullabilityWarnings_MemberNotNull_ChainedBaseConstructor_07()
+ {
+ // Base required property, with nullable type, initializes a private field.
+ // Derived constructors have SetsRequiredMembers and demonstrate behavior with/without initializing base required property.
+ // Note: this is a safety hole. However, the user has to go quite off into the weeds to fall into it.
+ var @base = """
+using System.Diagnostics.CodeAnalysis;
+#nullable enable
+public class Base
+{
+ private string _field;
+ public string Field => _field;
+ public required string? Property { get => _field; [MemberNotNull(nameof(_field))] set => _field = value ?? "property"; }
+
+ public Base() { }
+}
+""";
+
+ var derived = """
+using System;
+using System.Diagnostics.CodeAnalysis;
+#nullable enable
+
+var d = new Derived(null);
+Console.Write(d.Field);
+Console.Write(' ');
+
+d = new Derived();
+try
+{
+ d.Field.ToString();
+}
+catch (NullReferenceException)
+{
+ Console.Write("NullReferenceException");
+}
+
+class Derived : Base
+{
+ [SetsRequiredMembers]
+ public Derived()
+ {
+ }
+
+ [SetsRequiredMembers]
+ public Derived(string? value)
+ {
+ Property = value;
+ }
+}
+""";
+
+ var comp = CreateCompilationWithRequiredMembers(new[] { @base, derived, MemberNotNullAttributeDefinition });
+ comp.VerifyDiagnostics();
+ CompileAndVerify(comp, expectedOutput: "property NullReferenceException");
+
+ var baseComp = CreateCompilationWithRequiredMembers(new[] { @base, MemberNotNullAttributeDefinition });
+ comp = CreateCompilation(derived, new[] { baseComp.EmitToImageReference() });
+ comp.VerifyDiagnostics();
+ CompileAndVerify(comp, expectedOutput: "property NullReferenceException");
+ }
+
+ [Fact, CompilerTrait(CompilerFeature.NullableReferenceTypes)]
+ [WorkItem(6754, "https://github.com/dotnet/csharplang/issues/6754")]
+ public void RequiredMemberSuppressesNullabilityWarnings_MemberNotNull_ChainedBaseConstructor_08()
+ {
+ // Base required property initializes a private field.
+ // Derived is a nested type of the base and therefore can access the base private field.
+ // Derived constructors have SetsRequiredMembers and demonstrate behavior with/without initializing base required property.
+ var source = """
+using System;
+using System.Diagnostics.CodeAnalysis;
+#nullable enable
+
+var d = new Base.Derived("property");
+Console.Write(d.Field);
+Console.Write(' ');
+
+d = new Base.Derived();
+try
+{
+ d.Field.ToString();
+}
+catch (NullReferenceException)
+{
+ Console.Write("NullReferenceException");
+}
+
+public class Base
+{
+ private string _field;
+ public string Field => _field;
+ public required string Property { get => _field; [MemberNotNull(nameof(_field))] set => _field = value; }
+
+ public Base() { }
+
+ public class Derived : Base
+ {
+ [SetsRequiredMembers]
+ public Derived()
+ {
+ }
+
+ [SetsRequiredMembers]
+ public Derived(string value)
+ {
+ Property = value;
+ }
+ }
+}
+""";
+
+ var comp = CreateCompilationWithRequiredMembers(new[] { source, MemberNotNullAttributeDefinition });
+ comp.VerifyDiagnostics(
+ // 0.cs(30,16): warning CS8618: Non-nullable property 'Property' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.
+ // public Derived()
+ Diagnostic(ErrorCode.WRN_UninitializedNonNullableField, "Derived").WithArguments("property", "Property").WithLocation(30, 16)
+ );
+ CompileAndVerify(comp, expectedOutput: "property NullReferenceException");
+ }
+
[Fact, CompilerTrait(CompilerFeature.NullableReferenceTypes)]
public void RequiredMemberSuppressesNullabilityWarnings_ChainedConstructor_01()
{
diff --git a/src/Compilers/CSharp/Test/Symbol/Symbols/Source/TypeMapTests.cs b/src/Compilers/CSharp/Test/Symbol/Symbols/Source/TypeMapTests.cs
index 221ac874926f2..0b5b9ff2dadf1 100644
--- a/src/Compilers/CSharp/Test/Symbol/Symbols/Source/TypeMapTests.cs
+++ b/src/Compilers/CSharp/Test/Symbol/Symbols/Source/TypeMapTests.cs
@@ -11,13 +11,201 @@
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.CSharp.Test.Utilities;
using Microsoft.CodeAnalysis.PooledObjects;
+using Microsoft.CodeAnalysis.Test.Utilities;
using Microsoft.CodeAnalysis.Text;
+using Roslyn.Test.Utilities;
using Xunit;
namespace Microsoft.CodeAnalysis.CSharp.UnitTests
{
public class TypeMapTests : CSharpTestBase
{
+ [Theory]
+ [InlineData("C")]
+ [InlineData("C")]
+ [InlineData("C")]
+ [InlineData("C")]
+ public void SubstituteNamedType_NoChange(string type)
+ {
+ var (map, previous, _) = CreateSubstitution(type, type);
+
+ Assert.Same(previous, map.SubstituteNamedType(previous));
+ }
+
+ [Theory]
+ [InlineData("C", "C")]
+ [InlineData("C", "C")]
+ [InlineData("C", "C")]
+ [InlineData("C", "C")]
+ [InlineData("C", "C")]
+ [InlineData("C>", "C>")]
+ [InlineData("Outer.C>", "Outer.C>")]
+ public void SubstituteNamedType_ChangedArguments(string type, string substitutedType)
+ {
+ var (map, previous, expected) = CreateSubstitution(type, substitutedType);
+
+ var actual = map.SubstituteNamedType(previous);
+
+ Assert.NotSame(previous, actual);
+ Assert.True(TypeSymbol.Equals(expected, actual, TypeCompareKind.ConsiderEverything));
+ Assert.Same(previous.OriginalDefinition, actual.OriginalDefinition);
+ Assert.True(TypeSymbol.Equals(actual, map.SubstituteNamedType(actual), TypeCompareKind.ConsiderEverything));
+ }
+
+ [Theory]
+ [InlineData("Outer.C", "Outer.C")]
+ [InlineData("Outer.C", "Outer.C")]
+ [InlineData("Outer.C", "Outer.C")]
+ [InlineData("Outer.C", "Outer.C")]
+ public void SubstituteNamedType_ContainingTypeOnly(string type, string substitutedType)
+ {
+ var (map, previous, expected) = CreateSubstitution(type, substitutedType);
+
+ var actual = map.SubstituteNamedType(previous);
+
+ Assert.NotSame(previous, actual);
+ Assert.True(TypeSymbol.Equals(expected, actual, TypeCompareKind.ConsiderEverything));
+ Assert.Same(previous.OriginalDefinition, actual.OriginalDefinition);
+ Assert.Equal(SpecialType.System_Int32, actual.ContainingType.TypeArguments().Single().SpecialType);
+ var oldArguments = previous.TypeArgumentsWithAnnotationsNoUseSiteDiagnostics;
+ var newArguments = actual.TypeArgumentsWithAnnotationsNoUseSiteDiagnostics;
+ Assert.Equal(oldArguments.Length, newArguments.Length);
+ for (int i = 0; i < oldArguments.Length; i++)
+ {
+ Assert.True(oldArguments[i].Equals(newArguments[i], TypeCompareKind.ConsiderEverything));
+ }
+ }
+
+ private static (TypeMap map, NamedTypeSymbol previous, NamedTypeSymbol expected) CreateSubstitution(string type, string substitutedType)
+ {
+ var compilation = CreateCompilation($$"""
+ public class C { }
+ public class C { }
+ public class C { }
+ public class C { }
+ public class Outer
+ {
+ public class C { }
+ public class C { }
+ public class C { }
+ public class C { }
+ }
+ public class Context
+ {
+ public {{type}} Previous { get; set; }
+ public {{substitutedType}} Expected { get; set; }
+ }
+ """);
+ compilation.VerifyEmitDiagnostics();
+ var context = compilation.GetTypeByMetadataName("Context`1");
+ var map = new TypeMap(context.TypeParameters,
+ ImmutableArray.Create(TypeWithAnnotations.Create(compilation.GetSpecialType(SpecialType.System_Int32))));
+ var previous = (NamedTypeSymbol)((PropertySymbol)context.GetMembers("Previous").Single()).Type;
+ var expected = (NamedTypeSymbol)((PropertySymbol)context.GetMembers("Expected").Single()).Type;
+ return (map, previous, expected);
+ }
+
+ [Fact]
+ public void SubstituteNamedType_TupleNamesAndNullableArguments()
+ {
+ var compilation = CreateCompilation("""
+ #nullable enable
+ public class C { }
+ public class Context where T : class
+ {
+ public C second)> Previous => throw null!;
+ public C second)> Expected => throw null!;
+ }
+ """, targetFramework: TargetFramework.NetCoreApp);
+ compilation.VerifyEmitDiagnostics();
+ var context = compilation.GetTypeByMetadataName("Context`1");
+ var previous = (NamedTypeSymbol)((PropertySymbol)context.GetMembers("Previous").Single()).Type;
+ var expected = (NamedTypeSymbol)((PropertySymbol)context.GetMembers("Expected").Single()).Type;
+ var map = new TypeMap(context.TypeParameters,
+ ImmutableArray.Create(TypeWithAnnotations.Create(compilation.GetSpecialType(SpecialType.System_Object), NullableAnnotation.NotAnnotated)));
+
+ var actual = map.SubstituteNamedType(previous);
+
+ Assert.True(TypeSymbol.Equals(expected, actual, TypeCompareKind.ConsiderEverything));
+ var arguments = actual.TypeArgumentsWithAnnotationsNoUseSiteDiagnostics;
+ Assert.Equal(NullableAnnotation.Annotated, arguments[0].NullableAnnotation);
+ var tuple = (NamedTypeSymbol)arguments[1].Type;
+ Assert.Equal(new[] { "first", "second" }, tuple.TupleElementNames);
+ Assert.Equal(NullableAnnotation.Annotated, tuple.TupleElements[0].TypeWithAnnotations.NullableAnnotation);
+ Assert.Equal(SpecialType.System_Object, tuple.TupleElements[0].Type.SpecialType);
+ }
+
+ [Fact]
+ public void SubstituteNamedType_NullabilityOnly()
+ {
+ var compilation = CreateCompilation("""
+ #nullable enable
+ public class C { }
+ public class Context where T : class
+ {
+ public C Previous => throw null!;
+ public C Expected => throw null!;
+ }
+ """);
+ compilation.VerifyEmitDiagnostics();
+ var context = compilation.GetTypeByMetadataName("Context`1");
+ var previous = (NamedTypeSymbol)((PropertySymbol)context.GetMembers("Previous").Single()).Type;
+ var expected = (NamedTypeSymbol)((PropertySymbol)context.GetMembers("Expected").Single()).Type;
+ var map = new TypeMap(context.TypeParameters,
+ ImmutableArray.Create(TypeWithAnnotations.Create(context.TypeParameters.Single(), NullableAnnotation.Annotated)));
+
+ var actual = map.SubstituteNamedType(previous);
+
+ Assert.NotSame(previous, actual);
+ Assert.True(TypeSymbol.Equals(expected, actual, TypeCompareKind.ConsiderEverything));
+ var oldArgument = previous.TypeArgumentsWithAnnotationsNoUseSiteDiagnostics[1];
+ var newArgument = actual.TypeArgumentsWithAnnotationsNoUseSiteDiagnostics[1];
+ Assert.Same(oldArgument.Type, newArgument.Type);
+ Assert.Equal(NullableAnnotation.NotAnnotated, oldArgument.NullableAnnotation);
+ Assert.Equal(NullableAnnotation.Annotated, newArgument.NullableAnnotation);
+ Assert.False(oldArgument.IsSameAs(newArgument));
+ }
+
+ [Fact]
+ public void SubstituteNamedType_CustomModifierOnly()
+ {
+ var compilation = CreateCompilation("""
+ public class C { }
+ public class Modifier { }
+ """);
+ compilation.VerifyEmitDiagnostics();
+ var definition = compilation.GetTypeByMetadataName("C`2");
+ var modifier = compilation.GetTypeByMetadataName("Modifier`1");
+ var intType = compilation.GetSpecialType(SpecialType.System_Int32);
+ var stringType = compilation.GetSpecialType(SpecialType.System_String);
+ var previous = definition.Construct(ImmutableArray.Create(
+ TypeWithAnnotations.Create(stringType, NullableAnnotation.Annotated),
+ TypeWithAnnotations.Create(intType, customModifiers: ImmutableArray.Create(
+ CSharpCustomModifier.CreateOptional(modifier),
+ CSharpCustomModifier.CreateRequired(stringType)))));
+ var map = new TypeMap(modifier.TypeParameters, ImmutableArray.Create(TypeWithAnnotations.Create(intType)));
+
+ var actual = map.SubstituteNamedType(previous);
+
+ Assert.NotSame(previous, actual);
+ var arguments = actual.TypeArgumentsWithAnnotationsNoUseSiteDiagnostics;
+ Assert.Same(stringType, arguments[0].Type);
+ Assert.Equal(NullableAnnotation.Annotated, arguments[0].NullableAnnotation);
+ Assert.Same(intType, arguments[1].Type);
+ Assert.Collection(arguments[1].CustomModifiers,
+ m =>
+ {
+ Assert.True(m.IsOptional);
+ Assert.True(TypeSymbol.Equals(modifier.Construct(intType), ((CSharpCustomModifier)m).ModifierSymbol, TypeCompareKind.ConsiderEverything));
+ },
+ m =>
+ {
+ Assert.False(m.IsOptional);
+ Assert.Same(stringType, ((CSharpCustomModifier)m).ModifierSymbol);
+ });
+ Assert.False(previous.TypeArgumentsWithAnnotationsNoUseSiteDiagnostics[1].IsSameAs(arguments[1]));
+ }
+
// take a type of the form Something and return the type X.
private TypeSymbol TypeArg(TypeSymbol t)
{
diff --git a/src/Compilers/CSharp/Test/Syntax/Parsing/ModifierParserRecoveryTests.Partial.cs b/src/Compilers/CSharp/Test/Syntax/Parsing/ModifierParserRecoveryTests.Partial.cs
index 07d3b60890056..c0c2ec72f093e 100644
--- a/src/Compilers/CSharp/Test/Syntax/Parsing/ModifierParserRecoveryTests.Partial.cs
+++ b/src/Compilers/CSharp/Test/Syntax/Parsing/ModifierParserRecoveryTests.Partial.cs
@@ -2,7 +2,6 @@
// 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.Linq;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.CSharp.Test.Utilities;
using Microsoft.CodeAnalysis.Test.Utilities;
@@ -410,21 +409,6 @@ public void PartialPartial_TopLevel(LanguageVersion languageVersion)
EOF();
}
- [Theory]
- [InlineData(LanguageVersion.CSharp13)]
- [InlineData(LanguageVersion.Preview)]
- public void ManyPartialModifiers_MakesProgress(LanguageVersion languageVersion)
- {
- var modifiers = string.Concat(Enumerable.Repeat("partial ", 10_000));
- var source = $"class C {{ {modifiers}int M(); }}";
-
- var root = SyntaxFactory.ParseSyntaxTree(
- source,
- options: TestOptions.Regular.WithLanguageVersion(languageVersion)).GetRoot();
-
- Assert.Equal(source, root.ToFullString());
- }
-
[Fact]
public void Partial_ConversionOperators()
{
diff --git a/src/Compilers/Core/Portable/CodeAnalysisResources.resx b/src/Compilers/Core/Portable/CodeAnalysisResources.resx
index 1374a9b50349b..e3b38c6d7101d 100644
--- a/src/Compilers/Core/Portable/CodeAnalysisResources.resx
+++ b/src/Compilers/Core/Portable/CodeAnalysisResources.resx
@@ -312,9 +312,6 @@
Including private members should not be used when emitting to the secondary assembly output.
-
- Embedding PDB is not allowed when emitting metadata.
-
Cannot target net module when emitting ref assembly.
diff --git a/src/Compilers/Core/Portable/CommandLine/CommonCompiler.cs b/src/Compilers/Core/Portable/CommandLine/CommonCompiler.cs
index 788e2defd7fcb..8e8ab1b089384 100644
--- a/src/Compilers/Core/Portable/CommandLine/CommonCompiler.cs
+++ b/src/Compilers/Core/Portable/CommandLine/CommonCompiler.cs
@@ -1320,6 +1320,7 @@ private void CompileAndEmit(
diagnostics.Add(MessageProvider.CreateDiagnostic(MessageProvider.FTL_InvalidInputFileName, Location.None, finalPdbFilePath));
}
+ bool emitPdb = Arguments.EmitPdb && !emitOptions.EmitMetadataOnly;
var moduleBeingBuilt = compilation.CheckOptionsAndCreateModuleBuilder(
diagnostics,
Arguments.ManifestResources,
@@ -1338,7 +1339,7 @@ private void CompileAndEmit(
{
success = compilation.CompileMethods(
moduleBeingBuilt,
- Arguments.EmitPdb,
+ emitPdb,
diagnostics,
filterOpt: null,
cancellationToken: cancellationToken);
@@ -1457,7 +1458,7 @@ private void CompileAndEmit(
if (success)
{
var peStreamProvider = new CompilerEmitStreamProvider(this, finalPeFilePath);
- var pdbStreamProviderOpt = Arguments.EmitPdbFile ? new CompilerEmitStreamProvider(this, finalPdbFilePath) : null;
+ var pdbStreamProviderOpt = emitPdb && Arguments.EmitPdbFile ? new CompilerEmitStreamProvider(this, finalPdbFilePath) : null;
string? finalRefPeFilePath = Arguments.OutputRefFilePath;
var refPeStreamProviderOpt = finalRefPeFilePath != null ? new CompilerEmitStreamProvider(this, finalRefPeFilePath) : null;
diff --git a/src/Compilers/Core/Portable/Compilation/Compilation.cs b/src/Compilers/Core/Portable/Compilation/Compilation.cs
index a34131a2d4e91..679c01896f10a 100644
--- a/src/Compilers/Core/Portable/Compilation/Compilation.cs
+++ b/src/Compilers/Core/Portable/Compilation/Compilation.cs
@@ -2887,12 +2887,6 @@ internal EmitResult Emit(
options = options.WithIncludePrivateMembers(true);
}
- if (options?.DebugInformationFormat == DebugInformationFormat.Embedded &&
- options?.EmitMetadataOnly == true)
- {
- throw new ArgumentException(CodeAnalysisResources.EmbeddingPdbUnexpectedWhenEmittingMetadata, nameof(metadataPEStream));
- }
-
if (this.Options.OutputKind == OutputKind.NetModule)
{
if (metadataPEStream != null)
@@ -2964,6 +2958,7 @@ internal EmitResult Emit(
options = options ?? EmitOptions.Default.WithIncludePrivateMembers(metadataPEStream == null);
bool embedPdb = options.DebugInformationFormat == DebugInformationFormat.Embedded;
+ bool emitPdb = !options.EmitMetadataOnly && (pdbStream != null || embedPdb);
Debug.Assert(!embedPdb || pdbStream == null);
Debug.Assert(metadataPEStream == null || !options.IncludePrivateMembers); // you may not use a secondary stream and include private members together
@@ -2989,7 +2984,7 @@ internal EmitResult Emit(
{
success = CompileMethods(
moduleBeingBuilt,
- emittingPdb: pdbStream != null || embedPdb,
+ emittingPdb: emitPdb,
diagnostics: diagnostics,
filterOpt: null,
cancellationToken: cancellationToken);
@@ -3038,7 +3033,7 @@ internal EmitResult Emit(
moduleBeingBuilt,
new SimpleEmitStreamProvider(peStream),
(metadataPEStream != null) ? new SimpleEmitStreamProvider(metadataPEStream) : null,
- (pdbStream != null) ? new SimpleEmitStreamProvider(pdbStream) : null,
+ (emitPdb && pdbStream != null) ? new SimpleEmitStreamProvider(pdbStream) : null,
rebuildData,
testData?.SymWriterFactory,
diagnostics,
@@ -3271,10 +3266,16 @@ internal bool SerializeToPeStream(
// PDB Stream provider should not be given if PDB is to be embedded into the PE file:
Debug.Assert(moduleBeingBuilt.DebugInformationFormat != DebugInformationFormat.Embedded || pdbStreamProvider == null);
+ Debug.Assert(!emitOptions.EmitMetadataOnly || pdbStreamProvider == null);
string? pePdbFilePath = emitOptions.PdbFilePath;
- if (moduleBeingBuilt.DebugInformationFormat == DebugInformationFormat.Embedded || pdbStreamProvider != null)
+ if (emitOptions.EmitMetadataOnly)
+ {
+ // Metadata-only output has no associated PDB. Clear the path so the PE writer doesn't emit a CodeView entry.
+ pePdbFilePath = null;
+ }
+ else if (moduleBeingBuilt.DebugInformationFormat == DebugInformationFormat.Embedded || pdbStreamProvider != null)
{
pePdbFilePath = pePdbFilePath ?? FileNameUtilities.ChangeExtension(SourceModule.Name, "pdb");
}
diff --git a/src/Compilers/Core/Portable/Microsoft.CodeAnalysis.csproj b/src/Compilers/Core/Portable/Microsoft.CodeAnalysis.csproj
index c4b23686d08f1..761608d78563d 100644
--- a/src/Compilers/Core/Portable/Microsoft.CodeAnalysis.csproj
+++ b/src/Compilers/Core/Portable/Microsoft.CodeAnalysis.csproj
@@ -102,6 +102,7 @@
+
diff --git a/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.cs.xlf b/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.cs.xlf
index 47767b35f1bfd..d7d9cb838ca13 100644
--- a/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.cs.xlf
+++ b/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.cs.xlf
@@ -613,11 +613,6 @@
Při generování do sekundárního výstupu sestavení by se neměly zahrnovat privátní členy.
-
- Embedding PDB is not allowed when emitting metadata.
- Při generování metadat se vkládání PDB nepovoluje.
-
-
Cannot target net module when emitting ref assembly.
Když se generuje referenční sestavení, nejde cílit na síťový modul.
diff --git a/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.de.xlf b/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.de.xlf
index d8e9d4bf24ef5..beabb1d653dd3 100644
--- a/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.de.xlf
+++ b/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.de.xlf
@@ -613,11 +613,6 @@
Private Members sollten beim Ausgeben an die sekundäre Assemblyausgabe nicht einbezogen werden.
-
- Embedding PDB is not allowed when emitting metadata.
- Einbetten von PDB ist beim Ausgeben von Metadaten nicht zulässig.
-
-
Cannot target net module when emitting ref assembly.
Verweisen auf Netzmodul beim Ausgeben der Referenzassembly nicht möglich.
diff --git a/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.es.xlf b/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.es.xlf
index 40e9e72257754..74ac3f6b9974a 100644
--- a/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.es.xlf
+++ b/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.es.xlf
@@ -613,11 +613,6 @@
No se debe usar la inclusión de números privados al emitir a la salida del ensamblado secundario.
-
- Embedding PDB is not allowed when emitting metadata.
- No se permite insertar PDB al emitir metadatos.
-
-
Cannot target net module when emitting ref assembly.
El destino no puede ser el módulo al emitir el ensamblado de referencia.
diff --git a/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.fr.xlf b/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.fr.xlf
index 3859bae112fd2..ad49c217ebb5b 100644
--- a/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.fr.xlf
+++ b/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.fr.xlf
@@ -613,11 +613,6 @@
Les membres privés ne doivent pas être inclus durant l'émission vers la sortie de l'assembly secondaire.
-
- Embedding PDB is not allowed when emitting metadata.
- L'incorporation d'un fichier PDB n'est pas autorisée quand des métadonnées sont émises.
-
-
Cannot target net module when emitting ref assembly.
Impossible de cibler le module net quand un assembly de référence est émis.
diff --git a/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.it.xlf b/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.it.xlf
index b1b4509e20078..11d319088115a 100644
--- a/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.it.xlf
+++ b/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.it.xlf
@@ -613,11 +613,6 @@
È consigliabile non usare membri privati di inclusione quando si crea l'output secondario dell'assembly.
-
- Embedding PDB is not allowed when emitting metadata.
- L'incorporamento di PDB non è consentito quando si creano metadati.
-
-
Cannot target net module when emitting ref assembly.
Non è possibile impostare come destinazione il modulo quando si crea l'assembly di riferimento.
diff --git a/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.ja.xlf b/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.ja.xlf
index 37fbfbfe0b885..01305d4e61281 100644
--- a/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.ja.xlf
+++ b/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.ja.xlf
@@ -613,11 +613,6 @@
セカンダリ アセンブリ出力に対して生成している場合、プライベート メンバーのインクルードは使用しないでください。
-
- Embedding PDB is not allowed when emitting metadata.
- メタデータを生成している場合、PDB を埋め込むことは許可されていません。
-
-
Cannot target net module when emitting ref assembly.
参照アセンブリを生成している場合、ネット モジュールをターゲットにすることはできません。
diff --git a/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.ko.xlf b/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.ko.xlf
index 78ade61016c96..dd9eb42891588 100644
--- a/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.ko.xlf
+++ b/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.ko.xlf
@@ -613,11 +613,6 @@
보조 어셈블리 출력으로 내보낼 때 프라이빗 멤버를 포함해서는 안 됩니다.
-
- Embedding PDB is not allowed when emitting metadata.
- 메타데이터를 내보낼 때 PDB를 포함할 수 없습니다.
-
-
Cannot target net module when emitting ref assembly.
참조 어셈블리를 내보낼 때 NET 모듈을 대상으로 지정할 수 없습니다.
diff --git a/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.pl.xlf b/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.pl.xlf
index 66e1237d97ffc..e0e0da16e9573 100644
--- a/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.pl.xlf
+++ b/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.pl.xlf
@@ -613,11 +613,6 @@
Uwzględnianie prywatnych składowych nie powinno być używane podczas emitowania danych wyjściowych pomocniczego zestawu.
-
- Embedding PDB is not allowed when emitting metadata.
- Osadzanie pliku PDB nie jest dozwolone podczas emitowania metadanych.
-
-
Cannot target net module when emitting ref assembly.
Nie można ustawić modułu sieciowego jako docelowego podczas emitowania zestawu odwołania.
diff --git a/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.pt-BR.xlf b/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.pt-BR.xlf
index 203be65d6fbbf..efce097f14dfd 100644
--- a/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.pt-BR.xlf
+++ b/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.pt-BR.xlf
@@ -613,11 +613,6 @@
A inclusão de membros privados não deve ser usada ao emitir para a saída do assembly secundário.
-
- Embedding PDB is not allowed when emitting metadata.
- Não é permitido inserir PDB ao emitir metadados.
-
-
Cannot target net module when emitting ref assembly.
Não é possível destinar o módulo de rede ao emitir o assembly de referência.
diff --git a/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.ru.xlf b/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.ru.xlf
index aaedb7be6d24a..2d5a05badf9b6 100644
--- a/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.ru.xlf
+++ b/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.ru.xlf
@@ -613,11 +613,6 @@
Включение частных членов не следует использовать при выводе в выходные данные вторичной сборки.
-
- Embedding PDB is not allowed when emitting metadata.
- Внедрение PDB запрещено при выводе метаданных.
-
-
Cannot target net module when emitting ref assembly.
Не удается нацелиться на сетевой модуль при выводе базовой сборки.
diff --git a/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.tr.xlf b/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.tr.xlf
index d6dfc4a987d5e..8aaebede2b6da 100644
--- a/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.tr.xlf
+++ b/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.tr.xlf
@@ -613,11 +613,6 @@
İkincil bütünleştirilmiş kod çıktısına yayın yapılırken özel üyeleri ekleme özelliği kullanılmamalıdır.
-
- Embedding PDB is not allowed when emitting metadata.
- Meta veriler yayınlanırken PDB eklemeye izin verilmez.
-
-
Cannot target net module when emitting ref assembly.
Başvuru bütünleştirilmiş kodu yayınlanırken net modülü hedeflenemez.
diff --git a/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.zh-Hans.xlf b/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.zh-Hans.xlf
index cbb13cdc27e3c..efa5aa62e69b0 100644
--- a/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.zh-Hans.xlf
+++ b/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.zh-Hans.xlf
@@ -613,11 +613,6 @@
发出到辅助程序集输出时不应包含私有成员。
-
- Embedding PDB is not allowed when emitting metadata.
- 不允许在发出元数据时嵌入 PDB。
-
-
Cannot target net module when emitting ref assembly.
无法在发出引用程序集时将 Net 模块作为目标。
diff --git a/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.zh-Hant.xlf b/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.zh-Hant.xlf
index 29e049ff35590..c2b58aab49884 100644
--- a/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.zh-Hant.xlf
+++ b/src/Compilers/Core/Portable/xlf/CodeAnalysisResources.zh-Hant.xlf
@@ -613,11 +613,6 @@
發出至次要組件輸出時,應使用包含私人成員。
-
- Embedding PDB is not allowed when emitting metadata.
- 發出中繼資料時,不得內嵌 PDB。
-
-
Cannot target net module when emitting ref assembly.
發出參考組件時,目標不可以是網路模組。
diff --git a/src/Compilers/Test/Core/Compilation/CompilationExtensions.cs b/src/Compilers/Test/Core/Compilation/CompilationExtensions.cs
index b3727f092641e..eaa7b2e64eefe 100644
--- a/src/Compilers/Test/Core/Compilation/CompilationExtensions.cs
+++ b/src/Compilers/Test/Core/Compilation/CompilationExtensions.cs
@@ -61,7 +61,10 @@ internal static ImmutableArray EmitToArray(
{
var peStream = new MemoryStream();
- if (pdbStream == null && compilation.Options.OptimizationLevel == OptimizationLevel.Debug && options?.DebugInformationFormat != DebugInformationFormat.Embedded)
+ if (pdbStream == null &&
+ compilation.Options.OptimizationLevel == OptimizationLevel.Debug &&
+ options?.EmitMetadataOnly != true &&
+ options?.DebugInformationFormat != DebugInformationFormat.Embedded)
{
if (MonoHelpers.IsRunningOnMono() || PathUtilities.IsUnixLikePlatform)
{
diff --git a/src/Compilers/VisualBasic/Portable/xlf/VBResources.cs.xlf b/src/Compilers/VisualBasic/Portable/xlf/VBResources.cs.xlf
index f50abc1d81180..1226630494661 100644
--- a/src/Compilers/VisualBasic/Portable/xlf/VBResources.cs.xlf
+++ b/src/Compilers/VisualBasic/Portable/xlf/VBResources.cs.xlf
@@ -224,7 +224,7 @@
{0} is not a valid Visual Basic coalesce operation
- {0} is not a valid Visual Basic coalesce operation
+ {0} není platná operace slučování v jazyce Visual Basic.
diff --git a/src/Compilers/VisualBasic/Portable/xlf/VBResources.de.xlf b/src/Compilers/VisualBasic/Portable/xlf/VBResources.de.xlf
index aabd158b1e584..a859a59e3600f 100644
--- a/src/Compilers/VisualBasic/Portable/xlf/VBResources.de.xlf
+++ b/src/Compilers/VisualBasic/Portable/xlf/VBResources.de.xlf
@@ -224,7 +224,7 @@
{0} is not a valid Visual Basic coalesce operation
- {0} is not a valid Visual Basic coalesce operation
+ {0} ist kein gültiger Visual Basic-Zusammenfügungsvorgang
diff --git a/src/Compilers/VisualBasic/Portable/xlf/VBResources.es.xlf b/src/Compilers/VisualBasic/Portable/xlf/VBResources.es.xlf
index ba6fadea6bc37..5eb3ce1cd226d 100644
--- a/src/Compilers/VisualBasic/Portable/xlf/VBResources.es.xlf
+++ b/src/Compilers/VisualBasic/Portable/xlf/VBResources.es.xlf
@@ -224,7 +224,7 @@
{0} is not a valid Visual Basic coalesce operation
- {0} is not a valid Visual Basic coalesce operation
+ {0} no es una operación de fusión Visual Basic válida
diff --git a/src/Compilers/VisualBasic/Portable/xlf/VBResources.fr.xlf b/src/Compilers/VisualBasic/Portable/xlf/VBResources.fr.xlf
index 33f5211fed6e6..1d62242df0d9c 100644
--- a/src/Compilers/VisualBasic/Portable/xlf/VBResources.fr.xlf
+++ b/src/Compilers/VisualBasic/Portable/xlf/VBResources.fr.xlf
@@ -224,7 +224,7 @@
{0} is not a valid Visual Basic coalesce operation
- {0} is not a valid Visual Basic coalesce operation
+ {0} n’est pas une opération de coalescence Visual Basic valide
diff --git a/src/Compilers/VisualBasic/Portable/xlf/VBResources.it.xlf b/src/Compilers/VisualBasic/Portable/xlf/VBResources.it.xlf
index 7908fe319642f..0d899670b5b16 100644
--- a/src/Compilers/VisualBasic/Portable/xlf/VBResources.it.xlf
+++ b/src/Compilers/VisualBasic/Portable/xlf/VBResources.it.xlf
@@ -224,7 +224,7 @@
{0} is not a valid Visual Basic coalesce operation
- {0} is not a valid Visual Basic coalesce operation
+ {0} non è un'operazione di coalescenza Visual Basic valida
diff --git a/src/Compilers/VisualBasic/Portable/xlf/VBResources.ja.xlf b/src/Compilers/VisualBasic/Portable/xlf/VBResources.ja.xlf
index 831690b1f90f1..a1eab34225778 100644
--- a/src/Compilers/VisualBasic/Portable/xlf/VBResources.ja.xlf
+++ b/src/Compilers/VisualBasic/Portable/xlf/VBResources.ja.xlf
@@ -224,7 +224,7 @@
{0} is not a valid Visual Basic coalesce operation
- {0} is not a valid Visual Basic coalesce operation
+ {0} は Visual Basic の有効な結合操作ではありません
diff --git a/src/Compilers/VisualBasic/Portable/xlf/VBResources.ko.xlf b/src/Compilers/VisualBasic/Portable/xlf/VBResources.ko.xlf
index f9c2ef238e19a..d0a38324190ee 100644
--- a/src/Compilers/VisualBasic/Portable/xlf/VBResources.ko.xlf
+++ b/src/Compilers/VisualBasic/Portable/xlf/VBResources.ko.xlf
@@ -224,7 +224,7 @@
{0} is not a valid Visual Basic coalesce operation
- {0} is not a valid Visual Basic coalesce operation
+ {0}은(는) 유효한 Visual Basic 병합 연산이 아님
diff --git a/src/Compilers/VisualBasic/Portable/xlf/VBResources.pl.xlf b/src/Compilers/VisualBasic/Portable/xlf/VBResources.pl.xlf
index 45c317276520f..c3f1139838126 100644
--- a/src/Compilers/VisualBasic/Portable/xlf/VBResources.pl.xlf
+++ b/src/Compilers/VisualBasic/Portable/xlf/VBResources.pl.xlf
@@ -224,7 +224,7 @@
{0} is not a valid Visual Basic coalesce operation
- {0} is not a valid Visual Basic coalesce operation
+ {0} nie jest prawidłową operacją łączenia języka Visual Basic
diff --git a/src/Compilers/VisualBasic/Portable/xlf/VBResources.pt-BR.xlf b/src/Compilers/VisualBasic/Portable/xlf/VBResources.pt-BR.xlf
index 48970ef562e0f..fdca4d5462665 100644
--- a/src/Compilers/VisualBasic/Portable/xlf/VBResources.pt-BR.xlf
+++ b/src/Compilers/VisualBasic/Portable/xlf/VBResources.pt-BR.xlf
@@ -224,7 +224,7 @@
{0} is not a valid Visual Basic coalesce operation
- {0} is not a valid Visual Basic coalesce operation
+ {0} não é uma operação Visual Basic de união válida
diff --git a/src/Compilers/VisualBasic/Portable/xlf/VBResources.ru.xlf b/src/Compilers/VisualBasic/Portable/xlf/VBResources.ru.xlf
index 971072c1a59fb..2aa9047f0c94f 100644
--- a/src/Compilers/VisualBasic/Portable/xlf/VBResources.ru.xlf
+++ b/src/Compilers/VisualBasic/Portable/xlf/VBResources.ru.xlf
@@ -224,7 +224,7 @@
{0} is not a valid Visual Basic coalesce operation
- {0} is not a valid Visual Basic coalesce operation
+ {0} не является допустимой операцией объединения в Visual Basic
diff --git a/src/Compilers/VisualBasic/Portable/xlf/VBResources.tr.xlf b/src/Compilers/VisualBasic/Portable/xlf/VBResources.tr.xlf
index 900cfc0083029..98daea33bb0d8 100644
--- a/src/Compilers/VisualBasic/Portable/xlf/VBResources.tr.xlf
+++ b/src/Compilers/VisualBasic/Portable/xlf/VBResources.tr.xlf
@@ -224,7 +224,7 @@
{0} is not a valid Visual Basic coalesce operation
- {0} is not a valid Visual Basic coalesce operation
+ {0} geçerli bir Visual Basic birleştirme işlemi değil
diff --git a/src/Compilers/VisualBasic/Portable/xlf/VBResources.zh-Hans.xlf b/src/Compilers/VisualBasic/Portable/xlf/VBResources.zh-Hans.xlf
index e48f95959fad6..195af00295663 100644
--- a/src/Compilers/VisualBasic/Portable/xlf/VBResources.zh-Hans.xlf
+++ b/src/Compilers/VisualBasic/Portable/xlf/VBResources.zh-Hans.xlf
@@ -224,7 +224,7 @@
{0} is not a valid Visual Basic coalesce operation
- {0} is not a valid Visual Basic coalesce operation
+ {0} 不是有效的 Visual Basic 合并操作
diff --git a/src/Compilers/VisualBasic/Portable/xlf/VBResources.zh-Hant.xlf b/src/Compilers/VisualBasic/Portable/xlf/VBResources.zh-Hant.xlf
index 3d9a3dbcb23da..eebaa2718873e 100644
--- a/src/Compilers/VisualBasic/Portable/xlf/VBResources.zh-Hant.xlf
+++ b/src/Compilers/VisualBasic/Portable/xlf/VBResources.zh-Hant.xlf
@@ -224,7 +224,7 @@
{0} is not a valid Visual Basic coalesce operation
- {0} is not a valid Visual Basic coalesce operation
+ {0} 不是有效的 Visual Basic 合併作業
diff --git a/src/Compilers/VisualBasic/Test/CommandLine/CommandLineTests.vb b/src/Compilers/VisualBasic/Test/CommandLine/CommandLineTests.vb
index 48659b64f60fe..918abbef341ad 100644
--- a/src/Compilers/VisualBasic/Test/CommandLine/CommandLineTests.vb
+++ b/src/Compilers/VisualBasic/Test/CommandLine/CommandLineTests.vb
@@ -9568,6 +9568,29 @@ a
CleanupAllGeneratedFiles(dir.Path)
End Sub
+
+ Public Sub RefOnly_EmbeddedDebugInformation()
+ Dim dir = Temp.CreateDirectory()
+ Dim src = dir.CreateFile("a.vb")
+ src.WriteAllText("Public Class C
+End Class")
+
+ Dim outWriter = New StringWriter(CultureInfo.InvariantCulture)
+ Dim vbc = New MockVisualBasicCompiler(Nothing, dir.Path,
+ {"/define:_MYTYPE=""Empty"" ", "/nologo", "/target:library", "/out:a.dll", "/refonly", "/debug:embedded", "/deterministic", "a.vb"})
+
+ Assert.Equal(0, vbc.Run(outWriter))
+ Assert.Equal("", outWriter.ToString())
+
+ Using peReader = New PEReader(File.OpenRead(Path.Combine(dir.Path, "a.dll")))
+ AssertEx.Equal(
+ {DebugDirectoryEntryType.Reproducible},
+ peReader.ReadDebugDirectory().Select(Function(entry) entry.Type))
+ End Using
+
+ CleanupAllGeneratedFiles(dir.Path)
+ End Sub
+
'Output with known but different extension
diff --git a/src/Dependencies/Collections/Extensions/IEnumerableExtensions.cs b/src/Dependencies/Collections/Extensions/IEnumerableExtensions.cs
index 18ed50a9ef9df..7ad4fc17f689b 100644
--- a/src/Dependencies/Collections/Extensions/IEnumerableExtensions.cs
+++ b/src/Dependencies/Collections/Extensions/IEnumerableExtensions.cs
@@ -530,6 +530,19 @@ public static ImmutableArray SelectManyAsArray(th
return builder.ToImmutableAndFree();
}
+ public static ImmutableArray SelectManyAsArray(this IReadOnlyCollection? source, Func> selector, TArg arg)
+ {
+ if (source is null or { Count: 0 })
+ return [];
+
+ // Basic heuristic. Assume each element in the source adds one item to the result.
+ var builder = ArrayBuilder.GetInstance(source.Count);
+ foreach (var item in source)
+ selector(item, arg).AddRangeTo(builder);
+
+ return builder.ToImmutableAndFree();
+ }
+
public static ImmutableArray SelectManyAsArray(this IEnumerable? source, Func> selector)
{
if (!TryGetBuilder(source, useCountForBuilder: false, out var builder))
diff --git a/src/Dependencies/Collections/Extensions/ImmutableArrayExtensions.cs b/src/Dependencies/Collections/Extensions/ImmutableArrayExtensions.cs
index 185e75effe3b5..f88f01a0615f4 100644
--- a/src/Dependencies/Collections/Extensions/ImmutableArrayExtensions.cs
+++ b/src/Dependencies/Collections/Extensions/ImmutableArrayExtensions.cs
@@ -296,6 +296,29 @@ public static ImmutableArray SelectManyAsArray(this Imm
return builder.ToImmutableAndFree();
}
+ ///
+ /// Maps and flattens an immutable array to another immutable array.
+ ///
+ /// Type of the source array items
+ /// Type of the argument to pass to the selector.
+ /// Type of the transformed array items
+ /// The array to transform
+ /// A transform function to apply to each element.
+ /// If the array's length is 0, this will return an empty immutable array.
+ public static ImmutableArray SelectManyAsArray(this ImmutableArray array, Func> selector, TArg arg)
+ {
+ if (array.Length == 0)
+ return [];
+
+ var builder = ArrayBuilder.GetInstance();
+ foreach (var item in array)
+ {
+ selector(item, arg).AddRangeTo(builder);
+ }
+
+ return builder.ToImmutableAndFree();
+ }
+
///
/// Maps and flattens a subset of immutable array to another immutable array.
///
diff --git a/src/EditorFeatures/CSharpTest/Classification/SemanticClassifierTests.cs b/src/EditorFeatures/CSharpTest/Classification/SemanticClassifierTests.cs
index b64f9dd69b0f1..f099ec8310bc1 100644
--- a/src/EditorFeatures/CSharpTest/Classification/SemanticClassifierTests.cs
+++ b/src/EditorFeatures/CSharpTest/Classification/SemanticClassifierTests.cs
@@ -3286,71 +3286,6 @@ bool N(object x)
Parameter("x"),
Keyword("_"));
- [Theory, CombinatorialData]
- public Task UnusedUnderscoreParameterInLambda(TestHost testHost)
- => TestAsync("""
- class X
- {
- void N()
- {
- System.Func a = (int _) => 0;
- }
- }
- """,
- testHost,
- Namespace("System"),
- Delegate("Func"));
-
- [Theory, CombinatorialData]
- public Task UsedUnderscoreParameterInLambda(TestHost testHost)
- => TestAsync("""
- class X
- {
- void N()
- {
- System.Func a = (int _) => _;
- }
- }
- """,
- testHost,
- Namespace("System"),
- Delegate("Func"),
- Parameter("_"));
-
- [Theory, CombinatorialData]
- public Task DiscardsInLambda(TestHost testHost)
- => TestAsync("""
- class X
- {
- void N()
- {
- System.Func a = (int _, int _) => 0;
- }
- }
- """,
- testHost,
- Namespace("System"),
- Delegate("Func"),
- Keyword("_"),
- Keyword("_"));
-
- [Theory, CombinatorialData]
- public Task DiscardsInLambdaWithInferredType(TestHost testHost)
- => TestAsync("""
- class X
- {
- void N()
- {
- System.Func a = (_, _) => 0;
- }
- }
- """,
- testHost,
- Namespace("System"),
- Delegate("Func"),
- Keyword("_"),
- Keyword("_"));
-
[Theory, CombinatorialData]
public Task NativeInteger(TestHost testHost)
=> TestInMethodAsync(
diff --git a/src/EditorFeatures/CSharpTest/Classification/SyntacticClassifierTests_Preprocessor.cs b/src/EditorFeatures/CSharpTest/Classification/SyntacticClassifierTests_Preprocessor.cs
index 7c8279ecb7523..6366278342148 100644
--- a/src/EditorFeatures/CSharpTest/Classification/SyntacticClassifierTests_Preprocessor.cs
+++ b/src/EditorFeatures/CSharpTest/Classification/SyntacticClassifierTests_Preprocessor.cs
@@ -1102,7 +1102,7 @@ public Task DiscardInLambda(TestHost testHost)
=> TestInMethodAsync(
code: @"x = (_, _) => 1;",
testHost: testHost,
-expected: Classifications(Identifier("x"), Operators.Equals, Punctuation.OpenParen, Parameter("_"), Punctuation.Comma, Parameter("_"), Punctuation.CloseParen,
+expected: Classifications(Identifier("x"), Operators.Equals, Punctuation.OpenParen, Keyword("_"), Punctuation.Comma, Keyword("_"), Punctuation.CloseParen,
Operators.EqualsGreaterThan, Number("1"), Punctuation.Semicolon));
[Theory, CombinatorialData]
diff --git a/src/EditorFeatures/CSharpTest/Classification/TotalClassifierTests.cs b/src/EditorFeatures/CSharpTest/Classification/TotalClassifierTests.cs
index 1ae7cf91f4aec..76da4d3061272 100644
--- a/src/EditorFeatures/CSharpTest/Classification/TotalClassifierTests.cs
+++ b/src/EditorFeatures/CSharpTest/Classification/TotalClassifierTests.cs
@@ -2942,6 +2942,45 @@ void M()
], actualFormatted);
}
+ [WpfTheory]
+ [InlineData("System.Action lambda = (_, p) => { };", false)]
+ [InlineData("System.Action lambda = (int _, int p) => { };", false)]
+ [InlineData("System.Action lambda = (_, _, p) => { };", true)]
+ [InlineData("System.Action lambda = (int _, int _, int p) => { };", true)]
+ public async Task TestTotalClassifier_LambdaDiscards(string declaration, bool isDiscard)
+ {
+ using var workspace = EditorTestWorkspace.CreateCSharp($$"""
+ class C
+ {
+ void M()
+ {
+ {{declaration}}
+ }
+ }
+ """);
+ var document = workspace.Documents.First();
+ var provider = new TotalClassificationTaggerProvider(
+ workspace.GetService(),
+ workspace.GetService());
+
+ var buffer = document.GetTextBuffer();
+ using var tagger = provider.CreateTagger(document.GetTextView(), buffer);
+
+ var listenerProvider = workspace.ExportProvider.GetExportedValue();
+ await listenerProvider.GetWaiter(FeatureAttribute.Classification).ExpeditedWaitAsync();
+
+ var tags = tagger!.GetTags(new NormalizedSnapshotSpanCollection(buffer.CurrentSnapshot.GetFullSpan()));
+ var actual = tags.OrderBy(tag => tag.Span.Start.Position)
+ .Where(tag => tag.Span.GetText() is "_" or "p")
+ .Select(tag => new FormattedClassification(tag.Span.GetText(), tag.Tag.ClassificationType.Classification));
+
+ AssertEx.Equal(
+ isDiscard
+ ? [Keyword("_"), Keyword("_"), Parameter("p")]
+ : [Parameter("_"), Parameter("p")],
+ actual);
+ }
+
[WpfFact]
public void TestCopyPasteClassifier()
{
diff --git a/src/EditorFeatures/Core/InlineRename/UI/SmartRename/SmartRenameViewModel_Telemetry.cs b/src/EditorFeatures/Core/InlineRename/UI/SmartRename/SmartRenameViewModel_Telemetry.cs
index 37f769b0999cf..2615a24523025 100644
--- a/src/EditorFeatures/Core/InlineRename/UI/SmartRename/SmartRenameViewModel_Telemetry.cs
+++ b/src/EditorFeatures/Core/InlineRename/UI/SmartRename/SmartRenameViewModel_Telemetry.cs
@@ -45,7 +45,7 @@ private void PostTelemetry(bool isCommit)
if (_suggestionsPanelTelemetry is not null)
{
RoslynDebug.Assert(_suggestionsDropdownTelemetry is null);
- RoslynTelemetry.Log(FunctionId.Copilot_Rename, KeyValueLogMessage.Create(m =>
+ RoslynTelemetry.Current.Log(FunctionId.Copilot_Rename, KeyValueLogMessage.Create(m =>
{
m[nameof(isCommit)] = isCommit;
m["UseSuggestionsPanel"] = true;
@@ -61,7 +61,7 @@ private void PostTelemetry(bool isCommit)
else
{
RoslynDebug.Assert(_suggestionsDropdownTelemetry is not null);
- RoslynTelemetry.Log(FunctionId.Copilot_Rename, KeyValueLogMessage.Create(m =>
+ RoslynTelemetry.Current.Log(FunctionId.Copilot_Rename, KeyValueLogMessage.Create(m =>
{
m[nameof(isCommit)] = isCommit;
m["UseDropDown"] = true;
diff --git a/src/EditorFeatures/Core/Logging/FunctionIdOptions.cs b/src/EditorFeatures/Core/Logging/FunctionIdOptions.cs
deleted file mode 100644
index 3b120e1b8beae..0000000000000
--- a/src/EditorFeatures/Core/Logging/FunctionIdOptions.cs
+++ /dev/null
@@ -1,51 +0,0 @@
-// 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;
-using System.Collections.Concurrent;
-using System.Collections.Generic;
-using System.Linq;
-using Microsoft.CodeAnalysis.Options;
-
-namespace Microsoft.CodeAnalysis.Internal.Log;
-
-internal static class FunctionIdOptions
-{
- private static readonly ConcurrentDictionary> s_options = [];
-
- private static readonly Func> s_optionCreator = CreateOption;
-
- private static Option2 CreateOption(FunctionId id)
- {
- var name = id.ToString();
-
- // This local storage location can be set via vsregedit. Which is available on any VS Command Prompt.
- //
- // To enable logging:
- //
- // vsregedit set local [hive name] HKCU Roslyn\Internal\Performance\FunctionId [function name] dword 1
- //
- // To disable logging
- //
- // vsregedit delete local [hive name] HKCU Roslyn\Internal\Performance\FunctionId [function name]
- //
- // If you want to set it for the default hive, use "" as the hive name (i.e. an empty argument)
- return new("FunctionIdOptions_" + name, defaultValue: false);
- }
-
- private static IEnumerable GetFunctionIds()
- => Enum.GetValues();
-
- public static IEnumerable GetOptions()
- => GetFunctionIds().Select(GetOption);
-
- public static Option2 GetOption(FunctionId id)
- => s_options.GetOrAdd(id, s_optionCreator);
-
- public static Func CreateFunctionIsEnabledPredicate(IGlobalOptionService globalOptions)
- {
- var functionIdOptions = GetFunctionIds().ToDictionary(id => id, id => globalOptions.GetOption(GetOption(id)));
- return functionId => functionIdOptions[functionId];
- }
-}
diff --git a/src/EditorFeatures/Core/Microsoft.CodeAnalysis.EditorFeatures.csproj b/src/EditorFeatures/Core/Microsoft.CodeAnalysis.EditorFeatures.csproj
index e6c8576a0d315..d05826c64c982 100644
--- a/src/EditorFeatures/Core/Microsoft.CodeAnalysis.EditorFeatures.csproj
+++ b/src/EditorFeatures/Core/Microsoft.CodeAnalysis.EditorFeatures.csproj
@@ -85,7 +85,6 @@
-
diff --git a/src/EditorFeatures/Core/Remote/SolutionChecksumUpdater.cs b/src/EditorFeatures/Core/Remote/SolutionChecksumUpdater.cs
index 3b02fefbc3731..eb281e2270527 100644
--- a/src/EditorFeatures/Core/Remote/SolutionChecksumUpdater.cs
+++ b/src/EditorFeatures/Core/Remote/SolutionChecksumUpdater.cs
@@ -190,7 +190,7 @@ private async Task DispatchSynchronizeTextChangesAsync(
// Update aggregated telemetry with success status of sending the synchronization data.
var metricName = wasSynchronized.Value ? SynchronizeTextChangesStatusSucceededMetricName : SynchronizeTextChangesStatusFailedMetricName;
- RoslynTelemetry.Count(FunctionId.ChecksumUpdater_SynchronizeTextChangesStatus, metricName, 1);
+ RoslynTelemetry.Current.Count(FunctionId.ChecksumUpdater_SynchronizeTextChangesStatus, metricName, 1);
return;
diff --git a/src/EditorFeatures/Core/Suggestions/SuggestedActions/EditorSuggestedAction.cs b/src/EditorFeatures/Core/Suggestions/SuggestedActions/EditorSuggestedAction.cs
index 2c16d0f154d9b..c60d1fd45ac8b 100644
--- a/src/EditorFeatures/Core/Suggestions/SuggestedActions/EditorSuggestedAction.cs
+++ b/src/EditorFeatures/Core/Suggestions/SuggestedActions/EditorSuggestedAction.cs
@@ -101,7 +101,7 @@ private async Task InvokeAsync()
{
try
{
- using var _ = RoslynTelemetry.RecordBlockTime(FunctionId.SuggestedAction_Application_Summary, $"Total");
+ using var _ = RoslynTelemetry.Current.RecordBlockTime(FunctionId.SuggestedAction_Application_Summary, $"Total");
using var token = SourceProvider.OperationListener.BeginAsyncOperation($"{nameof(EditorSuggestedAction)}.{nameof(Invoke)}");
using var context = SourceProvider.UIThreadOperationExecutor.BeginExecute(
diff --git a/src/EditorFeatures/Core/Suggestions/SuggestedActionsSource_Async.cs b/src/EditorFeatures/Core/Suggestions/SuggestedActionsSource_Async.cs
index 4ac3c48dd1789..ecd7cc98e71a3 100644
--- a/src/EditorFeatures/Core/Suggestions/SuggestedActionsSource_Async.cs
+++ b/src/EditorFeatures/Core/Suggestions/SuggestedActionsSource_Async.cs
@@ -104,14 +104,14 @@ await document.Project.Solution.Services.GetRequiredService> GetCodeFixesAsync()
{
- using var _ = RoslynTelemetry.RecordBlockTime(FunctionId.SuggestedAction_Summary, $"Total.Pri{priority.GetPriorityInt()}.{nameof(GetCodeFixesAsync)}");
+ using var _ = RoslynTelemetry.Current.RecordBlockTime(FunctionId.SuggestedAction_Summary, $"Total.Pri{priority.GetPriorityInt()}.{nameof(GetCodeFixesAsync)}");
if (owner._codeFixService == null ||
!supportsFeatureService.SupportsCodeFixes(target.SubjectBuffer) ||
@@ -235,7 +235,7 @@ await document.Project.Solution.Services.GetRequiredService> GetRefactoringsAsync()
{
- using var _ = RoslynTelemetry.RecordBlockTime(FunctionId.SuggestedAction_Summary, $"Total.Pri{priority.GetPriorityInt()}.{nameof(GetRefactoringsAsync)}");
+ using var _ = RoslynTelemetry.Current.RecordBlockTime(FunctionId.SuggestedAction_Summary, $"Total.Pri{priority.GetPriorityInt()}.{nameof(GetRefactoringsAsync)}");
if (!selection.HasValue)
{
diff --git a/src/Features/Core/Portable/CodeFixes/Service/CodeFixService.cs b/src/Features/Core/Portable/CodeFixes/Service/CodeFixService.cs
index 94c468afe876f..08995922eee8f 100644
--- a/src/Features/Core/Portable/CodeFixes/Service/CodeFixService.cs
+++ b/src/Features/Core/Portable/CodeFixes/Service/CodeFixService.cs
@@ -95,11 +95,11 @@ private DiagnosticIdFilter GetShouldIncludeDiagnosticPredicate(
public async Task GetMostSevereFixAsync(
TextDocument document, TextSpan range, CodeActionRequestPriority? priority, CancellationToken cancellationToken)
{
- using var _ = RoslynTelemetry.RecordBlockTime(FunctionId.CodeFix_Summary, $"Pri{priority.GetPriorityInt()}.{nameof(GetMostSevereFixAsync)}");
+ using var _ = RoslynTelemetry.Current.RecordBlockTime(FunctionId.CodeFix_Summary, $"Pri{priority.GetPriorityInt()}.{nameof(GetMostSevereFixAsync)}");
ImmutableArray allDiagnostics;
- using (RoslynTelemetry.RecordBlockTime(
+ using (RoslynTelemetry.Current.RecordBlockTime(
FunctionId.CodeFix_Summary, $"Pri{priority.GetPriorityInt()}.{nameof(GetMostSevereFixAsync)}.{nameof(IDiagnosticAnalyzerService.GetDiagnosticsForSpanAsync)}"))
{
var service = document.Project.Solution.Services.GetRequiredService();
@@ -173,7 +173,7 @@ public async IAsyncEnumerable StreamFixesAsync(
CodeActionRequestPriority? priority,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
- using var _ = RoslynTelemetry.RecordBlockTime(FunctionId.CodeFix_Summary, $"Pri{priority.GetPriorityInt()}");
+ using var _ = RoslynTelemetry.Current.RecordBlockTime(FunctionId.CodeFix_Summary, $"Pri{priority.GetPriorityInt()}");
// We only need to compute suppression/configuration fixes when request priority is
// 'CodeActionPriorityRequest.Lowest' or no priority was provided at all (so all providers should run).
@@ -191,7 +191,7 @@ public async IAsyncEnumerable StreamFixesAsync(
// user-invoked diagnostic requests, for example, user invoked Ctrl + Dot operation for lightbulb.
ImmutableArray diagnostics;
- using (RoslynTelemetry.RecordBlockTime(
+ using (RoslynTelemetry.Current.RecordBlockTime(
FunctionId.CodeFix_Summary, $"Pri{priority.GetPriorityInt()}.{nameof(IDiagnosticAnalyzerService.GetDiagnosticsForSpanAsync)}"))
{
var service = document.Project.Solution.Services.GetRequiredService();
@@ -286,7 +286,7 @@ private static SortedDictionary> ConvertToMap(
{
cancellationToken.ThrowIfCancellationRequested();
- using var _ = RoslynTelemetry.RecordBlockTime(FunctionId.CodeFix_Summary, $"{nameof(GetDocumentFixAllForIdInSpanAsync)}");
+ using var _ = RoslynTelemetry.Current.RecordBlockTime(FunctionId.CodeFix_Summary, $"{nameof(GetDocumentFixAllForIdInSpanAsync)}");
ImmutableArray diagnostics;
if (textSpan is null)
@@ -295,7 +295,7 @@ private static SortedDictionary> ConvertToMap(
textSpan = new TextSpan(0, text.Length);
}
- using (RoslynTelemetry.RecordBlockTime(
+ using (RoslynTelemetry.Current.RecordBlockTime(
FunctionId.CodeFix_Summary, $"{nameof(GetDocumentFixAllForIdInSpanAsync)}.{nameof(IDiagnosticAnalyzerService.GetDiagnosticsForSpanAsync)}"))
{
var service = document.Project.Solution.Services.GetRequiredService();
@@ -534,7 +534,7 @@ private async IAsyncEnumerable StreamFixesAsync(
m[TelemetryKeys.LanguageName] = document.Project.Language;
}, (fixerName, document));
- using var _ = RoslynTelemetry.LogBlockTime(FunctionId.CodeFix_Delay, logMessage, CodeFixTelemetryDelay);
+ using var _ = RoslynTelemetry.Current.LogBlockTime(FunctionId.CodeFix_Delay, logMessage, CodeFixTelemetryDelay);
var codeFixCollection = await TryGetFixesOrConfigurationsAsync(
document, span, diagnostics, fixAllForInSpan, fixer,
diff --git a/src/Features/Core/Portable/CodeRefactorings/CodeRefactoringService.cs b/src/Features/Core/Portable/CodeRefactorings/CodeRefactoringService.cs
index 5db5d1f9817d4..5d4c2f818a009 100644
--- a/src/Features/Core/Portable/CodeRefactorings/CodeRefactoringService.cs
+++ b/src/Features/Core/Portable/CodeRefactorings/CodeRefactoringService.cs
@@ -183,7 +183,7 @@ public async Task> GetRefactoringsAsync(
CodeActionRequestPriority? priority,
CancellationToken cancellationToken)
{
- using (RoslynTelemetry.RecordBlockTime(FunctionId.CodeRefactoring_Summary, $"Pri{priority.GetPriorityInt()}"))
+ using (RoslynTelemetry.Current.RecordBlockTime(FunctionId.CodeRefactoring_Summary, $"Pri{priority.GetPriorityInt()}"))
using (Logger.LogBlock(FunctionId.Refactoring_CodeRefactoringService_GetRefactoringsAsync, cancellationToken))
{
using var _ = PooledDictionary.GetInstance(out var providerToIndex);
@@ -213,7 +213,7 @@ public async Task> GetRefactoringsAsync(
}, (providerName, document));
using (RoslynEventSource.LogInformationalBlock(FunctionId.Refactoring_CodeRefactoringService_GetRefactoringsAsync, providerName, cancellationToken))
- using (RoslynTelemetry.LogBlockTime(FunctionId.CodeRefactoring_Delay, logMessage, CodeRefactoringTelemetryDelay))
+ using (RoslynTelemetry.Current.LogBlockTime(FunctionId.CodeRefactoring_Delay, logMessage, CodeRefactoringTelemetryDelay))
{
var refactoring = await @this.GetRefactoringFromProviderAsync(
document, state, provider, cancellationToken).ConfigureAwait(false);
diff --git a/src/Features/Core/Portable/Diagnostics/Service/DiagnosticAnalyzerService_GetDiagnosticsForSpan.cs b/src/Features/Core/Portable/Diagnostics/Service/DiagnosticAnalyzerService_GetDiagnosticsForSpan.cs
index fa8deaf1224ee..4330ed9b6fd91 100644
--- a/src/Features/Core/Portable/Diagnostics/Service/DiagnosticAnalyzerService_GetDiagnosticsForSpan.cs
+++ b/src/Features/Core/Portable/Diagnostics/Service/DiagnosticAnalyzerService_GetDiagnosticsForSpan.cs
@@ -91,7 +91,7 @@ public async Task> GetDiagnosticsForSpanInProcess
using var _2 = ArrayBuilder.GetInstance(out var semanticSpanBasedAnalyzers);
using var _3 = ArrayBuilder.GetInstance(out var semanticDocumentBasedAnalyzers);
- using var _4 = RoslynTelemetry.RecordBlockTime(FunctionId.RequestDiagnostics_Summary, $"Pri{priority.GetPriorityInt()}");
+ using var _4 = RoslynTelemetry.Current.RecordBlockTime(FunctionId.RequestDiagnostics_Summary, $"Pri{priority.GetPriorityInt()}");
foreach (var analyzer in analyzers)
{
diff --git a/src/Features/Core/Portable/Diagnostics/Service/DocumentAnalysisExecutor.cs b/src/Features/Core/Portable/Diagnostics/Service/DocumentAnalysisExecutor.cs
index 9d2a2c92195ea..8996351b2c725 100644
--- a/src/Features/Core/Portable/Diagnostics/Service/DocumentAnalysisExecutor.cs
+++ b/src/Features/Core/Portable/Diagnostics/Service/DocumentAnalysisExecutor.cs
@@ -216,7 +216,7 @@ async ValueTask> GetSyntaxDiagnosticsInProcessAsy
if (_lazySyntaxDiagnostics == null)
{
- using var _ = RoslynTelemetry.RecordBlockTime(FunctionId.RequestDiagnostics_Summary, $"{nameof(GetSyntaxDiagnosticsInProcessAsync)}.{nameof(GetAnalysisResultInProcessAsync)}");
+ using var _ = RoslynTelemetry.Current.RecordBlockTime(FunctionId.RequestDiagnostics_Summary, $"{nameof(GetSyntaxDiagnosticsInProcessAsync)}.{nameof(GetAnalysisResultInProcessAsync)}");
var analysisScope = AnalysisScope.WithAnalyzers(_compilationBasedAnalyzersInAnalysisScope);
var syntaxDiagnostics = await GetAnalysisResultInProcessAsync(analysisScope).ConfigureAwait(false);
@@ -252,7 +252,7 @@ async ValueTask> GetSemanticDiagnosticsInProcessA
if (_lazySemanticDiagnostics == null)
{
- using var _ = RoslynTelemetry.RecordBlockTime(FunctionId.RequestDiagnostics_Summary, $"{nameof(GetSemanticDiagnosticsInProcessAsync)}.{nameof(GetAnalysisResultInProcessAsync)}");
+ using var _ = RoslynTelemetry.Current.RecordBlockTime(FunctionId.RequestDiagnostics_Summary, $"{nameof(GetSemanticDiagnosticsInProcessAsync)}.{nameof(GetAnalysisResultInProcessAsync)}");
var analysisScope = AnalysisScope.WithAnalyzers(_compilationBasedAnalyzersInAnalysisScope);
var semanticDiagnostics = await GetAnalysisResultInProcessAsync(analysisScope).ConfigureAwait(false);
diff --git a/src/Features/Core/Portable/Microsoft.CodeAnalysis.Features.csproj b/src/Features/Core/Portable/Microsoft.CodeAnalysis.Features.csproj
index 76729a862d298..4d99e37d1f090 100644
--- a/src/Features/Core/Portable/Microsoft.CodeAnalysis.Features.csproj
+++ b/src/Features/Core/Portable/Microsoft.CodeAnalysis.Features.csproj
@@ -109,7 +109,6 @@
-
diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.ProcessHost.UnitTests/Microsoft.CodeAnalysis.LanguageServer.ProcessHost.UnitTests.csproj b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.ProcessHost.UnitTests/Microsoft.CodeAnalysis.LanguageServer.ProcessHost.UnitTests.csproj
index 3da8f26d5ace1..ce5943f87ff0f 100644
--- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.ProcessHost.UnitTests/Microsoft.CodeAnalysis.LanguageServer.ProcessHost.UnitTests.csproj
+++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.ProcessHost.UnitTests/Microsoft.CodeAnalysis.LanguageServer.ProcessHost.UnitTests.csproj
@@ -25,10 +25,6 @@
-
-
-
-
-
-
-
diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/MiscellaneousFiles/CsprojInConeCheckerTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/MiscellaneousFiles/CsprojInConeCheckerTests.cs
new file mode 100644
index 0000000000000..dd3717c9dfeb2
--- /dev/null
+++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/MiscellaneousFiles/CsprojInConeCheckerTests.cs
@@ -0,0 +1,45 @@
+// 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.CodeAnalysis.LanguageServer.FileBasedPrograms;
+using Microsoft.CodeAnalysis.LanguageServer.Handler;
+using Microsoft.CodeAnalysis.Test.Utilities;
+using Roslyn.LanguageServer.Protocol;
+
+namespace Microsoft.CodeAnalysis.LanguageServer.UnitTests.FileBasedPrograms;
+
+public sealed class CsprojInConeCheckerTests : IDisposable
+{
+ private readonly TempRoot _tempRoot = new();
+
+ public void Dispose()
+ => _tempRoot.Dispose();
+
+ [Fact]
+ public void UsesCurrentWorkspaceFolders()
+ {
+ var initialWorkspace = _tempRoot.CreateDirectory();
+ var projectWorkspace = _tempRoot.CreateDirectory();
+ projectWorkspace.CreateFile("Project.csproj");
+ var sourceFile = projectWorkspace.CreateDirectory("src").CreateFile("Program.cs");
+ var initialFolder = CreateWorkspaceFolder(initialWorkspace.Path);
+ var projectFolder = CreateWorkspaceFolder(projectWorkspace.Path);
+ var tracker = new WorkspaceFolderTracker();
+ tracker.Update([initialFolder], removedFolders: null);
+ var checker = new CsprojInConeChecker(tracker);
+
+ Assert.False(checker.IsContainedInCsprojCone(sourceFile.Path));
+
+ tracker.Update([projectFolder], [initialFolder]);
+
+ Assert.True(checker.IsContainedInCsprojCone(sourceFile.Path));
+ }
+
+ private static WorkspaceFolder CreateWorkspaceFolder(string path)
+ => new()
+ {
+ DocumentUri = ProtocolConversions.CreateAbsoluteDocumentUri(path),
+ Name = Path.GetFileName(path),
+ };
+}
diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/MiscellaneousFiles/FileBasedProgramsEntryPointDiscoveryTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/MiscellaneousFiles/FileBasedProgramsEntryPointDiscoveryTests.cs
index 72c4c7b86628a..c6898727cdb0f 100644
--- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/MiscellaneousFiles/FileBasedProgramsEntryPointDiscoveryTests.cs
+++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/MiscellaneousFiles/FileBasedProgramsEntryPointDiscoveryTests.cs
@@ -7,6 +7,7 @@
using Microsoft.CodeAnalysis.FileBasedPrograms;
using Microsoft.CodeAnalysis.Host;
using Microsoft.CodeAnalysis.LanguageServer.FileBasedPrograms;
+using Microsoft.CodeAnalysis.LanguageServer.Handler;
using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Shared.TestHooks;
@@ -276,13 +277,48 @@ public async Task TestDiscovery_Option_EnableFileBasedPrograms_True()
DeferDeleteCacheDirectory(testLspServer, tempDir.Path);
var discovery = testLspServer.GetRequiredLspService();
- await discovery.FindAndLoadEntryPointsAsync();
+ await discovery.FindAndLoadEntryPointsAsync(CancellationToken.None);
await testLspServer.TestWorkspace.GetService().GetWaiter(FeatureAttribute.Workspace).ExpeditedWaitAsync();
var (workspace, document) = await GetRequiredLspWorkspaceAndDocumentAsync(CreateAbsoluteDocumentUri(appFile.Path), testLspServer);
Assert.Equal(WorkspaceKind.Host, workspace.Kind);
Assert.NotNull(document);
}
+ [Fact]
+ public async Task TestDiscovery_WorkspaceFoldersChangedRefreshes()
+ {
+ var removedWorkspace = _tempRoot.CreateDirectory();
+ var addedWorkspace = _tempRoot.CreateDirectory();
+
+ await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace: false, new InitializationOptions
+ {
+ ServerKind = WellKnownLspServerKinds.CSharpVisualBasicLspServer,
+ OptionUpdater = options => options.SetGlobalOption(LanguageServerProjectSystemOptionsStorage.EnableFileBasedPrograms, true),
+ WorkspaceFolders =
+ [
+ new() { DocumentUri = CreateAbsoluteDocumentUri(removedWorkspace.Path), Name = "removed" }
+ ]
+ });
+ DeferDeleteCacheDirectory(testLspServer, removedWorkspace.Path);
+ DeferDeleteCacheDirectory(testLspServer, addedWorkspace.Path);
+ await testLspServer.TestWorkspace.GetService().GetWaiter(FeatureAttribute.Workspace).ExpeditedWaitAsync();
+
+ var appText = "#!/usr/bin/env dotnet";
+ var removedApp = removedWorkspace.CreateFile("Removed.cs").WriteAllText(appText);
+ var addedApp = addedWorkspace.CreateFile("Added.cs").WriteAllText(appText);
+ var workspaceFolderTracker = testLspServer.GetRequiredLspService();
+ workspaceFolderTracker.Update(
+ [new() { DocumentUri = CreateAbsoluteDocumentUri(addedWorkspace.Path), Name = "added" }],
+ [new() { DocumentUri = CreateAbsoluteDocumentUri(removedWorkspace.Path), Name = "removed" }]);
+
+ await testLspServer.TestWorkspace.GetService().GetWaiter(FeatureAttribute.Workspace).ExpeditedWaitAsync();
+
+ AssertDocumentNotPersisted(testLspServer, CreateAbsoluteDocumentUri(removedApp.Path));
+ var (workspace, document) = await GetRequiredLspWorkspaceAndDocumentAsync(CreateAbsoluteDocumentUri(addedApp.Path), testLspServer);
+ Assert.Equal(WorkspaceKind.Host, workspace.Kind);
+ Assert.NotNull(document);
+ }
+
[Fact]
public async Task TestDiscovery_Option_EnableFileBasedPrograms_False()
{
@@ -309,7 +345,7 @@ public async Task TestDiscovery_Option_EnableFileBasedPrograms_False()
DeferDeleteCacheDirectory(testLspServer, tempDir.Path);
var discovery = testLspServer.GetRequiredLspService();
- await discovery.FindAndLoadEntryPointsAsync();
+ await discovery.FindAndLoadEntryPointsAsync(CancellationToken.None);
await testLspServer.TestWorkspace.GetService().GetWaiter(FeatureAttribute.Workspace).ExpeditedWaitAsync();
AssertDocumentNotPersisted(testLspServer, CreateAbsoluteDocumentUri(appFile.Path));
}
@@ -331,7 +367,7 @@ public async Task TestDiscovery_Option_EnableAutomaticDiscovery_False()
await using var testLspServer = await CreateDiscoveryTestServerAsync(tempDir.Path);
var discovery = testLspServer.GetRequiredLspService();
- await discovery.FindAndLoadEntryPointsAsync();
+ await discovery.FindAndLoadEntryPointsAsync(CancellationToken.None);
await testLspServer.TestWorkspace.GetService().GetWaiter(FeatureAttribute.Workspace).ExpeditedWaitAsync();
AssertDocumentNotPersisted(testLspServer, CreateAbsoluteDocumentUri(appFile.Path));
}
diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/MiscellaneousFiles/FileBasedProgramsWorkspaceTests.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/MiscellaneousFiles/FileBasedProgramsWorkspaceTests.cs
index 4dd3c4ec21418..5aa710d2c89f2 100644
--- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/MiscellaneousFiles/FileBasedProgramsWorkspaceTests.cs
+++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/MiscellaneousFiles/FileBasedProgramsWorkspaceTests.cs
@@ -5,6 +5,7 @@
using System.Collections.Immutable;
using System.Text;
using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Features.Workspaces;
using Microsoft.CodeAnalysis.LanguageServer.FileBasedPrograms;
using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace;
using Microsoft.CodeAnalysis.LanguageServer.HostWorkspace.FileWatching;
@@ -26,6 +27,44 @@ namespace Microsoft.CodeAnalysis.LanguageServer.UnitTests.FileBasedPrograms;
public sealed class FileBasedProgramsWorkspaceTests(ITestOutputHelper testOutputHelper) : AbstractLspMiscellaneousFilesWorkspaceTests(testOutputHelper)
{
+ [Fact]
+ public async Task GetOrLoadEntryPointDocument_NormalizesPath()
+ {
+ await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace: false, new InitializationOptions { ServerKind = WellKnownLspServerKinds.CSharpVisualBasicLspServer });
+ var projectSystem = (FileBasedProgramsProjectSystem)testLspServer.GetRequiredLspService();
+ var sourceFile = CreateTempDirectoryWithGlobalJson().CreateFile("SomeFile.cs");
+ var nonCanonicalPath = Path.Combine(Path.GetDirectoryName(sourceFile.Path)!, "directory", "..", Path.GetFileName(sourceFile.Path));
+ var sourceText = SourceText.From("Console.WriteLine(\"Hello World!\");");
+ var languageInformation = new LanguageInformation(LanguageNames.CSharp, "csx");
+
+ var documents = await projectSystem.GetOrLoadEntryPointDocumentAsync(
+ nonCanonicalPath, new SourceTextLoader(sourceText, nonCanonicalPath), languageInformation, SourceHashAlgorithms.Default, doDesignTimeBuild: false);
+ var document = Assert.Single(documents);
+ var documentsFromCanonicalPath = await projectSystem.GetOrLoadEntryPointDocumentAsync(
+ sourceFile.Path, new SourceTextLoader(sourceText, sourceFile.Path), languageInformation, SourceHashAlgorithms.Default, doDesignTimeBuild: false);
+
+ Assert.Equal(sourceFile.Path, document.FilePath);
+ Assert.Equal(document.Id, Assert.Single(documentsFromCanonicalPath).Id);
+ }
+
+ [ConditionalFact(typeof(WindowsOnly))]
+ public async Task GetOrLoadEntryPointDocument_MatchesPathCaseInsensitively()
+ {
+ await using var testLspServer = await CreateTestLspServerAsync(string.Empty, mutatingLspWorkspace: false, new InitializationOptions { ServerKind = WellKnownLspServerKinds.CSharpVisualBasicLspServer });
+ var projectSystem = (FileBasedProgramsProjectSystem)testLspServer.GetRequiredLspService();
+ var sourceFile = CreateTempDirectoryWithGlobalJson().CreateFile("SomeFile.cs");
+ var sourceText = SourceText.From("Console.WriteLine(\"Hello World!\");");
+ var languageInformation = new LanguageInformation(LanguageNames.CSharp, "csx");
+
+ var document = Assert.Single(await projectSystem.GetOrLoadEntryPointDocumentAsync(
+ sourceFile.Path, new SourceTextLoader(sourceText, sourceFile.Path), languageInformation, SourceHashAlgorithms.Default, doDesignTimeBuild: false));
+ var pathWithDifferentCasing = sourceFile.Path.ToUpperInvariant();
+ var documentsFromDifferentCasing = await projectSystem.GetOrLoadEntryPointDocumentAsync(
+ pathWithDifferentCasing, new SourceTextLoader(sourceText, pathWithDifferentCasing), languageInformation, SourceHashAlgorithms.Default, doDesignTimeBuild: false);
+
+ Assert.Equal(document.Id, Assert.Single(documentsFromDifferentCasing).Id);
+ }
+
[Theory, CombinatorialData]
public async Task TestFileBasedProgram_Simple(bool mutatingLspWorkspace)
{
@@ -108,6 +147,74 @@ public static class Util
model.GetDiagnostics().Verify();
}
+ [Theory, CombinatorialData]
+ public async Task TestFileBasedProgram_PackageRestoreWithExplicitArtifactsPath(bool mutatingLspWorkspace)
+ {
+ var tempDir = TempRoot.CreateDirectory();
+ tempDir.CreateFile("Directory.Build.props").WriteAllText("""
+
+
+ $(MSBuildThisFileDirectory)
+
+
+ """);
+
+ var sourceText = """
+ #:package Newtonsoft.Json@13.0.4
+ #:property PublishAot=false
+ Console.WriteLine(Newtonsoft.Json.JsonConvert.SerializeObject("Hello World!"));
+ """;
+ var sourceFile = tempDir.CreateFile("App.cs").WriteAllText(sourceText);
+ var sourceFileUri = ProtocolConversions.CreateAbsoluteDocumentUri(sourceFile.Path);
+
+ await using var testLspServer = await CreateTestLspServerAsync(
+ string.Empty,
+ mutatingLspWorkspace,
+ new InitializationOptions
+ {
+ ServerKind = WellKnownLspServerKinds.CSharpVisualBasicLspServer,
+ ClientCapabilities = new()
+ {
+ Workspace = new()
+ {
+ DidChangeWatchedFiles = new() { DynamicRegistration = true },
+ },
+ },
+ });
+ await testLspServer.OpenDocumentAsync(sourceFileUri, sourceText).ConfigureAwait(false);
+ await WaitForProjectLoad(sourceFileUri, testLspServer);
+
+ // Simulate VS Code relaying the file change produced by dotnet restore.
+ var projectAssetsFile = Assert.Single(
+ Directory.EnumerateFiles(tempDir.Path, "project.assets.json", SearchOption.AllDirectories));
+ var fileChangeTask = WaitForFileChangeAsync(projectAssetsFile, testLspServer);
+ await testLspServer.ExecuteNotificationAsync(
+ Methods.WorkspaceDidChangeWatchedFilesName,
+ new DidChangeWatchedFilesParams
+ {
+ Changes =
+ [
+ new FileEvent
+ {
+ Uri = ProtocolConversions.CreateAbsoluteDocumentUri(projectAssetsFile),
+ FileChangeType = FileChangeType.Changed,
+ },
+ ],
+ });
+ await fileChangeTask;
+ await WaitForProjectLoad(sourceFileUri, testLspServer);
+
+ var (workspace, document) = await GetRequiredLspWorkspaceAndDocumentAsync(sourceFileUri, testLspServer).ConfigureAwait(false);
+ Assert.Equal(WorkspaceKind.Host, workspace.Kind);
+ Assert.True(document.Project.State.HasAllInformation);
+ Assert.Contains(
+ document.Project.MetadataReferences,
+ static reference => StringComparer.OrdinalIgnoreCase.Equals(Path.GetFileName(reference.Display), "Newtonsoft.Json.dll"));
+
+ var model = await document.GetRequiredSemanticModelAsync(CancellationToken.None);
+ model.GetDiagnostics().Verify();
+ }
+
[Theory, CombinatorialData]
public async Task TestDirectiveWithoutTopLevelStatements_IsMiscellaneousFile(bool mutatingLspWorkspace)
{
@@ -203,7 +310,7 @@ public async Task TestMultipleFileBasedPrograms_WithWorkspaceDiscovery(bool muta
var globalOptions = testLspServer.TestWorkspace.ExportProvider.GetExportedValue();
globalOptions.SetGlobalOption(FileBasedAppsOptionsStorage.EnableAutomaticDiscovery, true);
var discovery = testLspServer.GetRequiredLspService();
- await discovery.FindAndLoadEntryPointsAsync();
+ await discovery.FindAndLoadEntryPointsAsync(CancellationToken.None);
await testLspServer.TestWorkspace.GetService().GetWaiter(FeatureAttribute.Workspace).ExpeditedWaitAsync();
// Verify all FBAs loaded successfully in the host workspace.
@@ -439,10 +546,11 @@ class C { }
// Adding a top-level statement to a misc file causes it to report semantic errors.
var textToInsert = $"""Console.WriteLine("Hello World!");{Environment.NewLine}""";
+ var fileChangeTask = WaitForFileChangeAsync(sourceFile.Path, testLspServer);
// Write updated content to disk so the project system can pick up the change.
sourceFile.WriteAllText($"{textToInsert}{initialText}");
await testLspServer.InsertTextAsync(looseFileUriOne, (Line: 0, Column: 0, Text: textToInsert));
- await Task.Delay(100);
+ await fileChangeTask;
await WaitForProjectLoad(looseFileUriOne, testLspServer);
var (workspace, canonicalDocumentTwo) = await GetRequiredLspWorkspaceAndDocumentAsync(looseFileUriOne, testLspServer).ConfigureAwait(false);
Assert.Equal("""
@@ -611,6 +719,23 @@ private async ValueTask WaitForProjectLoad(DocumentUri looseFileUri, TestLspServ
await testLspServer.TestWorkspace.GetService().GetWaiter(FeatureAttribute.Workspace).ExpeditedWaitAsync();
}
+ ///
+ /// Call before triggering the change, then await the result before waiting for the project reload.
+ ///
+ private static async Task WaitForFileChangeAsync(string filePath, TestLspServer testLspServer)
+ {
+ var fileChangeWatcher = testLspServer.GetRequiredLspService();
+ using var fileChangeContext = fileChangeWatcher.CreateContext([new WatchedDirectory(Path.GetDirectoryName(filePath)!, extensionFilters: [])]);
+ var fileChangeTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ fileChangeContext.FileChanged += (_, e) =>
+ {
+ if (PathUtilities.Comparer.Equals(e.FilePath, filePath))
+ fileChangeTcs.TrySetResult();
+ };
+
+ await fileChangeTcs.Task.WaitAsync(TestHelpers.HangMitigatingTimeout);
+ }
+
[Theory, CombinatorialData]
public async Task TestFileWatchesReleasedOnServerShutdown(bool mutatingLspWorkspace)
{
@@ -868,22 +993,12 @@ await testLspServer.DeleteTextAsync(appCsUri,
(workspace, document) = await GetRequiredLspWorkspaceAndDocumentAsync(appCsUri, testLspServer).ConfigureAwait(false);
Assert.Equal(newAppCsText, (await document.GetTextAsync()).ToString());
- // Set up a listener for file change events before writing to disk, so we can wait for
- // the FileSystemWatcher to deliver the event (which triggers the project reload enqueue).
- var fileChangeWatcher = testLspServer.GetRequiredLspService();
- using var fileChangeContext = fileChangeWatcher.CreateContext([new WatchedDirectory(Path.GetDirectoryName(appCsFile.Path)!, extensionFilters: [])]);
- var fileChangeTcs = new TaskCompletionSource();
- fileChangeContext.FileChanged += (_, e) =>
- {
- if (e.FilePath.Equals(appCsFile.Path, StringComparison.OrdinalIgnoreCase))
- fileChangeTcs.TrySetResult();
- };
-
+ var fileChangeTask = WaitForFileChangeAsync(appCsFile.Path, testLspServer);
// Flush the document change to disk to trigger a reload of the FBA project.
appCsFile.WriteAllText(newAppCsText);
// Wait for the file change event to be delivered, ensuring the reload is enqueued.
- await fileChangeTcs.Task.WaitAsync(TestHelpers.HangMitigatingTimeout);
+ await fileChangeTask;
await WaitForProjectLoad(appCsUri, testLspServer);
// Now the document is a miscellaneous file
@@ -1201,23 +1316,13 @@ internal class Util2 { }
);
// Trivial edit+save of the App.cs file will trigger a reload though.
- // Subscribe before writing to disk so the workspace waiter cannot finish before
- // the FileSystemWatcher has delivered the event that enqueues the reload.
- var fileChangeWatcher = testLspServer.GetRequiredLspService();
- using var fileChangeContext = fileChangeWatcher.CreateContext([new WatchedDirectory(Path.GetDirectoryName(appCsFile.Path)!, extensionFilters: [])]);
- var fileChangeTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
- fileChangeContext.FileChanged += (_, e) =>
- {
- if (PathUtilities.Comparer.Equals(e.FilePath, appCsFile.Path))
- fileChangeTcs.TrySetResult();
- };
-
+ var fileChangeTask = WaitForFileChangeAsync(appCsFile.Path, testLspServer);
appCsFile.WriteAllText(appCsText + Environment.NewLine);
var appCsSourceText = SourceText.From(appCsText);
var appCsEndPosition = appCsSourceText.Lines.GetLinePosition(appCsSourceText.Length);
await testLspServer.InsertTextAsync(appCsUri, (Line: appCsEndPosition.Line, Column: appCsEndPosition.Character, Text: Environment.NewLine));
- await fileChangeTcs.Task.WaitAsync(TestHelpers.HangMitigatingTimeout);
+ await fileChangeTask;
await WaitForProjectLoad(appCsUri, testLspServer);
// Get the Util.cs document again
@@ -1282,7 +1387,7 @@ internal class Util { }
var globalOptions = testLspServer.TestWorkspace.ExportProvider.GetExportedValue();
globalOptions.SetGlobalOption(FileBasedAppsOptionsStorage.EnableAutomaticDiscovery, true);
var discovery = testLspServer.GetRequiredLspService();
- await discovery.FindAndLoadEntryPointsAsync();
+ await discovery.FindAndLoadEntryPointsAsync(CancellationToken.None);
await testLspServer.TestWorkspace.GetService().GetWaiter(FeatureAttribute.Workspace).ExpeditedWaitAsync();
// Even though the primary file was never opened in the editor,
diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/Utilities/LanguageServerTestComposition.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/Utilities/LanguageServerTestComposition.cs
index c54c5cbdf74be..5e6065a84df5e 100644
--- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/Utilities/LanguageServerTestComposition.cs
+++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer.UnitTests/Utilities/LanguageServerTestComposition.cs
@@ -22,12 +22,14 @@ public static Task CreateLanguageServerExportProviderAsync(
return LanguageServerExportProviderBuilder.CreateExportProviderAsync(TestPaths.GetLanguageServerDirectory(), extensionManager, assemblyLoader, serverConfiguration, cacheDirectory, loggerFactory, CancellationToken.None);
}
- public static ExportProvider GetSharedExportProvider(ServerConfiguration serverConfiguration, ILoggerFactory loggerFactory)
+ public static ExportProvider GetSharedExportProvider(ServerConfiguration serverConfiguration, ILoggerFactory loggerFactory, params Type[] additionalParts)
{
Contract.ThrowIfTrue(serverConfiguration.ExtensionAssemblyPaths.Any(), "Tests that require extension assemblies should use AbstractLanguageServerMefHost instead");
- var exportProvider = serverConfiguration.DevKitDependencyPath != null
- ? s_devKit.ExportProviderFactory.CreateExportProvider()
- : s_languageServer.ExportProviderFactory.CreateExportProvider();
+ var composition = serverConfiguration.DevKitDependencyPath != null ? s_devKit : s_languageServer;
+ if (additionalParts.Length > 0)
+ composition = composition.AddParts(additionalParts);
+
+ var exportProvider = composition.ExportProviderFactory.CreateExportProvider();
LanguageServerExportProviderBuilder.TestAccessor.InitializeManualExports(exportProvider, new ExtensionAssemblyManager([], [], []), loggerFactory, serverConfiguration);
return exportProvider;
diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/CsprojInConeChecker.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/CsprojInConeChecker.cs
index ca8a7aad7ae3f..de6b9638a268f 100644
--- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/CsprojInConeChecker.cs
+++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/CsprojInConeChecker.cs
@@ -1,8 +1,7 @@
-// Licensed to the .NET Foundation under one or more agreements.
+// 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.Immutable;
using System.Composition;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.CodeAnalysis.LanguageServer.Handler;
@@ -20,35 +19,28 @@ internal sealed class CsprojInConeCheckerFactory() : ILspServiceFactory
{
public ILspService CreateILspService(LspServices lspServices, WellKnownLspServerKinds serverKind)
{
- return new CsprojInConeChecker();
+ return new CsprojInConeChecker(lspServices.GetRequiredService());
}
}
-internal sealed class CsprojInConeChecker : ILspService, IOnInitialized
+internal sealed class CsprojInConeChecker(IWorkspaceFolderTracker workspaceFolderTracker) : ILspService
{
- private ImmutableArray _workspaceFolders;
-
- public Task OnInitializedAsync(ClientCapabilities clientCapabilities, RequestContext context, CancellationToken cancellationToken)
- {
- var initializeManager = context.GetRequiredService();
- _workspaceFolders = initializeManager.GetRequiredWorkspaceFolderPaths();
- return Task.CompletedTask;
- }
-
public bool IsContainedInCsprojCone(string csFilePath)
{
// Note: manual perf testing of this check on Windows, in a reasonably complex case,
// showed an overhead on the order of 100s of microseconds for this check.
// If this overhead becomes problematic, we may want to put a cache in front of it.
- Contract.ThrowIfTrue(_workspaceFolders.IsDefault, $"{nameof(OnInitializedAsync)} must be called before {nameof(IsContainedInCsprojCone)}.");
- if (_workspaceFolders.IsEmpty)
+ // Bound the search to workspace folders so opening an arbitrary file outside the workspace does not
+ // discover and load an unrelated project from one of its ancestor directories.
+ var workspaceFolders = workspaceFolderTracker.GetRequiredWorkspaceFolderPaths();
+ if (workspaceFolders.IsEmpty)
return false;
if (!PathUtilities.IsAbsolute(csFilePath))
return false;
- foreach (var workspaceFolder in _workspaceFolders)
+ foreach (var workspaceFolder in workspaceFolders)
{
var directoryName = PathUtilities.GetDirectoryName(csFilePath);
while (PathUtilities.IsSameDirectoryOrChildOf(child: directoryName, parent: workspaceFolder))
diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsEntryPointDiscovery.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsEntryPointDiscovery.cs
index b23bdc7f74739..72b7f62fba72e 100644
--- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsEntryPointDiscovery.cs
+++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsEntryPointDiscovery.cs
@@ -1,4 +1,4 @@
-// Licensed to the .NET Foundation under one or more agreements.
+// 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.
@@ -20,6 +20,7 @@
using Microsoft.CodeAnalysis.PooledObjects;
using Microsoft.CodeAnalysis.Shared.TestHooks;
using Microsoft.CodeAnalysis.Shared.Utilities;
+using Microsoft.CodeAnalysis.Threading;
using Microsoft.Extensions.Logging;
using Roslyn.LanguageServer.Protocol;
using Roslyn.Utilities;
@@ -38,7 +39,8 @@ public ILspService CreateILspService(LspServices lspServices, WellKnownLspServer
globalOptionService,
listenerProvider.GetListener(FeatureAttribute.Workspace),
lspServices.GetRequiredService().Workspace.Services.GetRequiredService(),
- lspServices.GetRequiredService(),
+ lspServices.GetRequiredService().CreateLogger(),
+ lspServices.GetRequiredService(),
lspServices);
}
}
@@ -47,8 +49,9 @@ internal sealed partial class FileBasedProgramsEntryPointDiscovery(
IGlobalOptionService globalOptionService,
IAsynchronousOperationListener listener,
IFileBasedProgramService fileBasedProgramService,
- ILoggerFactory loggerFactory,
- LspServices lspServices) : ILspService, IOnInitialized
+ ILogger logger,
+ IWorkspaceFolderTracker workspaceFolderTracker,
+ LspServices lspServices) : ILspService, IOnInitialized, IDisposable
{
private static readonly StringComparer s_pathComparer = StringComparer.OrdinalIgnoreCase;
@@ -61,48 +64,56 @@ internal sealed partial class FileBasedProgramsEntryPointDiscovery(
"node_modules"
], StringComparison.OrdinalIgnoreCase);
- private readonly ILogger _logger = loggerFactory.CreateLogger();
- private ImmutableArray _workspaceFolders;
+ private readonly AsyncBatchingWorkQueue _discoveryQueue = new(
+ TimeSpan.Zero,
+ cancellationToken => FindAndLoadEntryPointsAsync(globalOptionService, fileBasedProgramService, workspaceFolderTracker, lspServices, logger, cancellationToken),
+ listener);
public Task OnInitializedAsync(ClientCapabilities clientCapabilities, RequestContext context, CancellationToken cancellationToken)
{
- var initializeManager = context.GetRequiredService();
- _workspaceFolders = initializeManager.GetRequiredWorkspaceFolderPaths();
- Task.Run(async () =>
- {
- try
- {
- using var token = listener.BeginAsyncOperation(nameof(FindAndLoadEntryPointsAsync));
- await FindAndLoadEntryPointsAsync();
- }
- catch (Exception ex) when (FatalError.ReportAndCatch(ex))
- {
- throw ExceptionUtilities.Unreachable();
- }
- }, cancellationToken);
+ workspaceFolderTracker.WorkspaceFoldersChanged += OnWorkspaceFoldersChanged;
+ _discoveryQueue.AddWork();
return Task.CompletedTask;
}
- internal async Task FindAndLoadEntryPointsAsync()
+ private void OnWorkspaceFoldersChanged(object? sender, EventArgs e)
+ => _discoveryQueue.AddWork();
+
+ public void Dispose()
+ {
+ workspaceFolderTracker.WorkspaceFoldersChanged -= OnWorkspaceFoldersChanged;
+ _discoveryQueue.Dispose();
+ }
+
+ internal ValueTask FindAndLoadEntryPointsAsync(CancellationToken cancellationToken)
+ => FindAndLoadEntryPointsAsync(globalOptionService, fileBasedProgramService, workspaceFolderTracker, lspServices, logger, cancellationToken);
+
+ private static async ValueTask FindAndLoadEntryPointsAsync(
+ IGlobalOptionService globalOptionService,
+ IFileBasedProgramService fileBasedProgramService,
+ IWorkspaceFolderTracker workspaceFolderTracker,
+ LspServices lspServices,
+ ILogger logger,
+ CancellationToken cancellationToken)
{
- Contract.ThrowIfTrue(_workspaceFolders.IsDefault, $"{nameof(OnInitializedAsync)} must be called before {nameof(FindAndLoadEntryPointsAsync)}.");
+ var workspaceFolders = workspaceFolderTracker.GetRequiredWorkspaceFolderPaths();
- if (_workspaceFolders.IsEmpty)
+ if (workspaceFolders.IsEmpty)
{
- _logger.LogTrace("No workspace folders to search for file-based apps.");
+ logger.LogTrace("No workspace folders to search for file-based apps.");
return;
}
if (!globalOptionService.GetOption(LanguageServerProjectSystemOptionsStorage.EnableFileBasedPrograms))
{
- _logger.LogTrace(@"""dotnet.projects.enableFileBasedPrograms"" is false. Not discovering entry points.");
+ logger.LogTrace(@"""dotnet.projects.enableFileBasedPrograms"" is false. Not discovering entry points.");
return;
}
if (!globalOptionService.GetOption(FileBasedAppsOptionsStorage.EnableAutomaticDiscovery))
{
- _logger.LogTrace(@"""dotnet.fileBasedApps.enableAutomaticDiscovery"" is false. Not discovering entry points.");
+ logger.LogTrace(@"""dotnet.fileBasedApps.enableAutomaticDiscovery"" is false. Not discovering entry points.");
return;
}
@@ -111,10 +122,13 @@ internal async Task FindAndLoadEntryPointsAsync()
// Note: the overwhelmingly common case is when there is just one workspace folder.
// For simplicity we orient our search around one workspace folder at a time.
- foreach (var workspaceFolder in _workspaceFolders)
+ foreach (var workspaceFolder in workspaceFolders)
{
- foreach (var fileBasedAppPath in FindEntryPoints(workspaceFolder))
+ cancellationToken.ThrowIfCancellationRequested();
+
+ foreach (var fileBasedAppPath in FindEntryPoints(workspaceFolder, fileBasedProgramService, logger))
{
+ cancellationToken.ThrowIfCancellationRequested();
await fileBasedProgramsProjectSystem.TryBeginLoadingFileBasedAppAsync(fileBasedAppPath);
}
}
@@ -146,6 +160,9 @@ protected override bool ShouldIncludeEntry(ref FileSystemEntry entry)
}
internal ImmutableArray FindEntryPoints(string workspaceFolder)
+ => FindEntryPoints(workspaceFolder, fileBasedProgramService, logger);
+
+ private static ImmutableArray FindEntryPoints(string workspaceFolder, IFileBasedProgramService fileBasedProgramService, ILogger logger)
{
var stopwatch = SharedStopwatch.StartNew();
var cacheDirectory = fileBasedProgramService.GetDiscoveryCacheDirectory(workspaceFolder);
@@ -170,7 +187,7 @@ internal ImmutableArray FindEntryPoints(string workspaceFolder)
}
catch (Exception ex)
{
- _logger.LogDebug("Could not read cache file: {ex.Message}", ex.Message);
+ logger.LogDebug("Could not read cache file: {ex.Message}", ex.Message);
}
cache ??= new Cache(workspaceFolder, DateTimeOffset.MinValue, FileBasedAppFullPaths: [], DirectoriesContainingCsproj: []);
@@ -193,10 +210,10 @@ internal ImmutableArray FindEntryPoints(string workspaceFolder)
var newFileBasedAppsBuilder = ArrayBuilder.GetInstance(cache.FileBasedAppFullPaths.Length);
var directoriesContainingCsprojBuilder = ArrayBuilder.GetInstance(cache.DirectoriesContainingCsproj.Length);
- var visitor = new WorkspaceFolderVisitor(cache, newFileBasedAppsBuilder, directoriesContainingCsprojBuilder, _logger);
+ var visitor = new WorkspaceFolderVisitor(cache, newFileBasedAppsBuilder, directoriesContainingCsprojBuilder, logger);
visitor.Visit();
var elapsedMilliseconds = Math.Round(stopwatch.Elapsed.TotalMilliseconds);
- _logger.LogInformation("Finished discovery in '{workspaceFolder}' in {elapsedMilliseconds} milliseconds", workspaceFolder, elapsedMilliseconds);
+ logger.LogInformation("Finished discovery in '{workspaceFolder}' in {elapsedMilliseconds} milliseconds", workspaceFolder, elapsedMilliseconds);
// Ensure items go into the cache file in a stable order.
// This is useful for manual inspection and allows use of 'BinarySearch' to match directories against the cache.
diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs
index dd5417c55147c..a6ce15ba35b49 100644
--- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs
+++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/FileBasedPrograms/FileBasedProgramsProjectSystem.cs
@@ -283,13 +283,14 @@ public async ValueTask TryBeginLoadingFileBasedAppAsync(string documentFilePath)
public async ValueTask> GetOrLoadEntryPointDocumentAsync(string documentFilePath, TextLoader textLoader, LanguageInformation languageInformation, SourceHashAlgorithm checksumAlgorithm, bool doDesignTimeBuild)
{
+ documentFilePath = NormalizeProjectPath(documentFilePath);
var projects = await base.GetOrLoadProjectAsync(documentFilePath, _workspaceFactory.MiscellaneousFilesWorkspaceProjectFactory, CreatePrimordialProjectInfo, doDesignTimeBuild);
return projects.Select(p => LookupExistingDocument(p)).WhereNotNull().ToImmutableArray();
TextDocument? LookupExistingDocument(Project project)
{
- var document = project.Documents.FirstOrDefault(document => document.FilePath == documentFilePath)
- ?? project.AdditionalDocuments.FirstOrDefault(document => document.FilePath == documentFilePath);
+ var document = project.Documents.FirstOrDefault(document => PathUtilities.Comparer.Equals(document.FilePath, documentFilePath))
+ ?? project.AdditionalDocuments.FirstOrDefault(document => PathUtilities.Comparer.Equals(document.FilePath, documentFilePath));
if (document is null)
{
_logger.LogWarning("Could not get a document for '{documentFilePath}' because its project doesn't contain a document for it", documentFilePath);
@@ -298,11 +299,11 @@ public async ValueTask> GetOrLoadEntryPointDocument
return document;
}
- ProjectInfo CreatePrimordialProjectInfo(ProjectSystemProjectFactory projectFactory)
+ ProjectInfo CreatePrimordialProjectInfo(ProjectSystemProjectFactory projectFactory, string normalizedDocumentFilePath)
{
var enableFileBasedPrograms = GlobalOptionService.GetOption(LanguageServerProjectSystemOptionsStorage.EnableFileBasedPrograms);
return MiscellaneousFileUtilities.CreateMiscellaneousProjectInfoForDocument(
- projectFactory.Workspace, documentFilePath, textLoader, languageInformation, checksumAlgorithm, projectFactory.Workspace.Services.SolutionServices, [], enableFileBasedPrograms);
+ projectFactory.Workspace, normalizedDocumentFilePath, textLoader, languageInformation, checksumAlgorithm, projectFactory.Workspace.Services.SolutionServices, [], enableFileBasedPrograms);
}
}
diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.ProjectToLoad.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.ProjectToLoad.cs
deleted file mode 100644
index 43ea6af1ec207..0000000000000
--- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.ProjectToLoad.cs
+++ /dev/null
@@ -1,31 +0,0 @@
-// 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.CodeAnalysis;
-
-namespace Microsoft.CodeAnalysis.LanguageServer.HostWorkspace;
-
-internal abstract partial class LanguageServerProjectLoader
-{
- ///
- /// The project path of the project to load.
- ///
- private sealed record ProjectToLoad(string Path, WorkDoneProgressTracker? ProgressTracker = null)
- {
- public static IEqualityComparer Comparer = new ProjectToLoadComparer();
-
- private sealed class ProjectToLoadComparer : IEqualityComparer
- {
- public bool Equals(ProjectToLoad? x, ProjectToLoad? y)
- {
- return StringComparer.Ordinal.Equals(x?.Path, y?.Path);
- }
-
- public int GetHashCode([DisallowNull] ProjectToLoad obj)
- {
- return StringComparer.Ordinal.GetHashCode(obj.Path);
- }
- }
- }
-}
diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs
index aad7a7c31f8f6..5062d579bb3d2 100644
--- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs
+++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectLoader.cs
@@ -12,6 +12,7 @@
using Microsoft.CodeAnalysis.ProjectSystem;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Shared.TestHooks;
+using Microsoft.CodeAnalysis.Shared.Utilities;
using Microsoft.CodeAnalysis.Threading;
using Microsoft.CodeAnalysis.Workspaces.ProjectSystem;
using Microsoft.CommonLanguageServerProtocol.Framework;
@@ -25,7 +26,7 @@ internal abstract partial class LanguageServerProjectLoader : IAsyncDisposable
{
private static readonly string s_razorDesignTimePath = Path.Combine(AppContext.BaseDirectory, "Targets", "Microsoft.NET.Sdk.Razor.DesignTime.targets");
- private readonly AsyncBatchingWorkQueue _projectsToReload;
+ private readonly AsyncBatchingWorkQueue _projectsToReload;
private bool _isDisposed;
protected readonly LanguageServerWorkspaceFactory _workspaceFactory;
@@ -55,7 +56,7 @@ internal abstract partial class LanguageServerProjectLoader : IAsyncDisposable
/// instance is expected to be a no-op, since it's possible we might have had some scheduled asynchronous work
/// (a design time build, a file change notification) that might have scheduled and could also be in flight.
///
- private readonly Dictionary _loadedProjects = [];
+ private readonly Dictionary _loadedProjects = new(PathUtilities.Comparer);
///
/// Indicates whether loads should report UI progress to the client for this loader.
@@ -103,10 +104,10 @@ protected LanguageServerProjectLoader(
AdditionalProperties = BuildAdditionalProperties(serverConfigurationFactory.ServerConfiguration);
- _projectsToReload = new AsyncBatchingWorkQueue(
+ _projectsToReload = new AsyncBatchingWorkQueue(
TimeSpan.FromMilliseconds(100),
ReloadProjectsAsync,
- ProjectToLoad.Comparer,
+ PathUtilities.Comparer,
Listener);
}
@@ -144,7 +145,7 @@ public async Task ReportErrorAsync(LSP.MessageType errorKind, string message, Ca
}
}
- private async ValueTask ReloadProjectsAsync(ImmutableSegmentedList projectsToLoadOrReload, CancellationToken cancellationToken)
+ private async ValueTask ReloadProjectsAsync(ImmutableSegmentedList projectsToLoadOrReload, CancellationToken cancellationToken)
{
// TODO: support configuration switching
var stopwatch = Stopwatch.StartNew();
@@ -166,21 +167,14 @@ private async ValueTask ReloadProjectsAsync(ImmutableSegmentedList.RunParallelAsync(
source: projectsToLoadOrReload,
- produceItems: static async (projectToLoad, produceItem, args, cancellationToken) =>
+ produceItems: static async (projectPath, produceItem, args, cancellationToken) =>
{
var (@this, toastErrorReporter, buildHostProcessManager) = args;
- try
- {
- var projectRestorePath = await @this.ReloadProjectAsync(
- projectToLoad, toastErrorReporter, buildHostProcessManager, cancellationToken);
-
- if (projectRestorePath is not null)
- produceItem(projectRestorePath);
- }
- finally
- {
- projectToLoad.ProgressTracker?.OnItemProcessed();
- }
+ var projectRestorePath = await @this.ReloadProjectAsync(
+ projectPath, toastErrorReporter, buildHostProcessManager, cancellationToken);
+
+ if (projectRestorePath is not null)
+ produceItem(projectRestorePath);
},
args: (@this: this, toastErrorReporter, buildHostProcessManager),
cancellationToken).ConfigureAwait(false);
@@ -223,10 +217,9 @@ internal sealed record RemoteProjectLoadResult
=> null;
/// The project file path that needs a NuGet restore, if any.
- private async Task ReloadProjectAsync(ProjectToLoad projectToLoad, ToastErrorReporter toastErrorReporter, BuildHostProcessManager buildHostProcessManager, CancellationToken cancellationToken)
+ private async Task ReloadProjectAsync(string projectPath, ToastErrorReporter toastErrorReporter, BuildHostProcessManager buildHostProcessManager, CancellationToken cancellationToken)
{
BuildHostProcessKind? preferredBuildHostKindThatWeDidNotGet = null;
- var projectPath = projectToLoad.Path;
LoadedProject? loadedProject;
// Before doing any work, check if the project has already been unloaded
@@ -246,7 +239,7 @@ internal sealed record RemoteProjectLoadResult
// - Loading VB projects
// - Reloading file-based app projects, where edits were performed to e.g. delete all `#:` directives,
// making the file no longer a file-based app entry point.
- _logger.LogDebug("Reload of '{projectPath}' was canceled.", projectPath);
+ _logger.LogDebug("Reload of '{ProjectPath}' was canceled.", projectPath);
return null;
}
@@ -269,9 +262,7 @@ internal sealed record RemoteProjectLoadResult
// language in-process.
var projectLanguage = loadedProjectInfos.FirstOrDefault()?.Language;
if (projectLanguage != null && projectFactory.Workspace.Services.GetLanguageService(projectLanguage) == null)
- {
return null;
- }
var applied = await loadedProject.TryApplyLoadedProjectInfosAsync(
loadedProjectInfos,
@@ -315,6 +306,10 @@ await loadedProject.ReportTelemetryIfNotPreviouslyReportedAsync(
return null;
}
+ finally
+ {
+ loadedProject.CompleteInitialLoad();
+ }
async Task LogDiagnosticsAsync(ImmutableArray diagnosticLogItems)
{
@@ -339,8 +334,9 @@ async Task LogDiagnosticsAsync(ImmutableArray diagnosticLogIt
}
}
- protected async ValueTask> GetOrLoadProjectAsync(string projectPath, ProjectSystemProjectFactory primordialProjectFactory, Func createPrimordialProjectInfo, bool doDesignTimeBuild)
+ protected async ValueTask> GetOrLoadProjectAsync(string projectPath, ProjectSystemProjectFactory primordialProjectFactory, Func createPrimordialProjectInfo, bool doDesignTimeBuild)
{
+ projectPath = NormalizeProjectPath(projectPath);
using (await _gate.DisposableWaitAsync(CancellationToken.None))
{
Contract.ThrowIfTrue(_isDisposed, "Project loader is already disposed");
@@ -348,7 +344,7 @@ protected async ValueTask> GetOrLoadProjectAsync(string
if (_loadedProjects.TryGetValue(projectPath, out var existingLoadedProject))
return await existingLoadedProject.GetExistingProjectsAsync();
- var primordialProjectInfo = createPrimordialProjectInfo(primordialProjectFactory);
+ var primordialProjectInfo = createPrimordialProjectInfo(primordialProjectFactory, projectPath);
var newLoadedProject = new LoadedProject(projectPath, _fileChangeWatcher);
_loadedProjects.Add(projectPath, newLoadedProject);
@@ -356,8 +352,12 @@ protected async ValueTask> GetOrLoadProjectAsync(string
if (doDesignTimeBuild)
{
- _projectsToReload.AddWork(new ProjectToLoad(projectPath));
newLoadedProject.NeedsReload += LoadedProject_NeedsReload;
+ _projectsToReload.AddWork(newLoadedProject.ProjectFilePath);
+ }
+ else
+ {
+ newLoadedProject.CompleteInitialLoad();
}
return [newProject];
@@ -367,8 +367,9 @@ protected async ValueTask> GetOrLoadProjectAsync(string
///
/// Begins loading a project. If the project has already begun loading, returns without doing any additional work.
///
- protected async Task BeginLoadingProjectAsync(string projectPath, string? projectGuid, WorkDoneProgressTracker? progressTracker = null)
+ internal async Task BeginLoadingProjectAsync(string projectPath)
{
+ projectPath = NormalizeProjectPath(projectPath);
LoadedProject? loadedProject;
using (await _gate.DisposableWaitAsync(CancellationToken.None))
@@ -381,13 +382,9 @@ protected async Task BeginLoadingProjectAsync(string projectPath, string? projec
loadedProject = new LoadedProject(projectPath, _fileChangeWatcher);
_loadedProjects.Add(projectPath, loadedProject);
- _projectsToReload.AddWork(new ProjectToLoad(Path: projectPath, progressTracker));
-
loadedProject.NeedsReload += LoadedProject_NeedsReload;
+ _projectsToReload.AddWork(loadedProject.ProjectFilePath);
}
-
- if (projectGuid is not null)
- await loadedProject.SetProjectGuidForTelemetryAsync(Guid.Parse(projectGuid));
}
// Try to load the contents from the project cache if we have one; we'll do this outside the lock
@@ -414,6 +411,8 @@ await loadedProject.TryApplyLoadedProjectInfosAsync(
{
_logger.LogWarning(e, "Exception encountered while trying to load cached state for {ProjectPath}", projectPath);
}
+
+ return loadedProject;
}
protected void LoadedProject_NeedsReload(object? sender, string triggeringFilePath)
@@ -421,11 +420,40 @@ protected void LoadedProject_NeedsReload(object? sender, string triggeringFilePa
var loadedProject = (LoadedProject)sender!;
_logger.LogTrace("Project {ProjectPath} needs reload due to change in {TriggeringFilePath}", loadedProject.ProjectFilePath, triggeringFilePath);
- _projectsToReload.AddWork(new ProjectToLoad(Path: loadedProject.ProjectFilePath, ProgressTracker: null));
+ _projectsToReload.AddWork(loadedProject.ProjectFilePath);
}
protected Task WaitForProjectsToFinishLoadingAsync() => _projectsToReload.WaitUntilCurrentBatchCompletesAsync();
+ protected static async Task WaitForProjectLoadsAsync(
+ ImmutableArray loadedProjects,
+ WorkDoneProgressTracker? progressTracker = null,
+ CancellationToken cancellationToken = default)
+ {
+ await Task.WhenAll(loadedProjects.SelectAsArray(async loadedProject =>
+ {
+ try
+ {
+ await loadedProject.WaitForLoadAsync(cancellationToken);
+ }
+ finally
+ {
+ progressTracker?.OnItemProcessed();
+ }
+ }));
+ }
+
+ internal async Task WaitForAllProjectLoadsAsync(CancellationToken cancellationToken)
+ {
+ ImmutableArray loadedProjects;
+ using (await _gate.DisposableWaitAsync(cancellationToken))
+ {
+ loadedProjects = [.. _loadedProjects.Values];
+ }
+
+ await WaitForProjectLoadsAsync(loadedProjects, cancellationToken: cancellationToken);
+ }
+
/// Unloads all projects associated with this project loader.
internal async ValueTask UnloadAllProjectsAsync()
{
@@ -457,6 +485,7 @@ public virtual async ValueTask DisposeAsync()
internal async ValueTask TryUnloadProjectAsync(string projectPath, ProjectSystemProjectFactory? fromProjectFactory = null)
{
+ projectPath = NormalizeProjectPath(projectPath);
using (await _gate.DisposableWaitAsync(CancellationToken.None))
{
if (!_loadedProjects.TryGetValue(projectPath, out var loadedProject))
@@ -476,4 +505,7 @@ internal async ValueTask TryUnloadProjectAsync(string projectPath, Project
return true;
}
}
+
+ protected static string NormalizeProjectPath(string projectPath)
+ => PathUtilities.IsAbsolute(projectPath) ? IOUtilities.PerformIO(() => Path.GetFullPath(projectPath), projectPath) : projectPath;
}
diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectSystem.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectSystem.cs
index 4ecf2e35dbeb9..ecb7eb8b0f3c9 100644
--- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectSystem.cs
+++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LanguageServerProjectSystem.cs
@@ -6,9 +6,11 @@
using System.Composition;
using System.Runtime.InteropServices;
using Microsoft.CodeAnalysis.FileBasedPrograms;
+using Microsoft.CodeAnalysis.Host;
using Microsoft.CodeAnalysis.Host.Mef;
using Microsoft.CodeAnalysis.LanguageServer.Handler;
using Microsoft.CodeAnalysis.Options;
+using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Shared.TestHooks;
using Microsoft.CodeAnalysis.Workspaces.ProjectSystem;
using Microsoft.CommonLanguageServerProtocol.Framework;
@@ -159,12 +161,17 @@ public async Task OpenSolutionAsync(string solutionFilePath, IProgress(projects.Length);
foreach (var (path, guid) in projects)
{
- await BeginLoadingProjectAsync(path, guid, progressTracker);
+ var loadedProject = await BeginLoadingProjectAsync(path);
+ if (guid is not null)
+ await loadedProject.SetProjectGuidForTelemetryAsync(Guid.Parse(guid));
+
+ loadedProjects.Add(loadedProject);
}
- await WaitForProjectsToFinishLoadingAsync();
+ await WaitForProjectLoadsAsync(loadedProjects.MoveToImmutable(), progressTracker);
await ProjectInitializationHandler.SendProjectInitializationCompleteNotificationAsync(_clientLanguageServerManager);
}
@@ -177,15 +184,24 @@ public async Task OpenProjectsAsync(ImmutableArray projectFilePaths, IPr
? new WorkDoneProgressTracker(progressReporter, projectFilePaths.Length)
: null;
+ var loadedProjects = ImmutableArray.CreateBuilder(projectFilePaths.Length);
foreach (var path in projectFilePaths)
{
- await BeginLoadingProjectAsync(NormalizeDriveLetter(path), projectGuid: null, progressTracker);
+ var loadedProject = await BeginLoadingProjectAsync(NormalizeDriveLetter(path));
+ loadedProjects.Add(loadedProject);
}
- await WaitForProjectsToFinishLoadingAsync();
+ await WaitForProjectLoadsAsync(loadedProjects.MoveToImmutable(), progressTracker, CancellationToken.None);
await ProjectInitializationHandler.SendProjectInitializationCompleteNotificationAsync(_clientLanguageServerManager);
}
+ internal ImmutableArray GetSupportedProjectFileExtensions()
+ {
+ var supportedLanguages = _hostProjectFactory.Workspace.Services.SolutionServices.GetSupportedLanguages();
+ return _projectFileExtensionRegistry.GetRegisteredProjectFileExtensions().WhereAsArray(
+ extension => _projectFileExtensionRegistry.TryGetLanguageNameFromExtension(extension, out var languageName) && supportedLanguages.Contains(languageName));
+ }
+
protected override async Task TryLoadProjectInMSBuildHostAsync(
BuildHostProcessManager buildHostProcessManager, string projectPath, CancellationToken cancellationToken)
{
diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LoadedProject.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LoadedProject.cs
index a0106aa7911ff..ba6b5cf3aac85 100644
--- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LoadedProject.cs
+++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/HostWorkspace/LoadedProject.cs
@@ -49,6 +49,7 @@ internal sealed partial class LoadedProject : IAsyncDisposable
private readonly List _targets = [];
private (ProjectSystemProjectFactory ProjectFactory, ProjectId Id)? _primordialProjectInfo;
+ private readonly TaskCompletionSource _initialLoadCompletionSource = new(TaskCreationOptions.RunContinuationsAsynchronously);
private bool _reportedTelemetry = false;
private Guid? _projectGuidForTelemetry = null;
@@ -78,6 +79,26 @@ public LoadedProject(string projectFilePath, IFileChangeWatcher fileWatcher)
///
public event EventHandler? NeedsReload;
+ ///
+ /// Waits for the initial project load to settle.
+ ///
+ ///
+ /// if the project remains loaded and contains a primordial project or at least one evaluated
+ /// target; otherwise, .
+ ///
+ public async ValueTask WaitForLoadAsync(CancellationToken cancellationToken)
+ {
+ await _initialLoadCompletionSource.Task.WaitAsync(cancellationToken);
+
+ using (await _gate.DisposableWaitAsync(cancellationToken))
+ {
+ return !_disposed && (_primordialProjectInfo.HasValue || _targets.Count > 0);
+ }
+ }
+
+ public void CompleteInitialLoad()
+ => _initialLoadCompletionSource.TrySetResult();
+
private void ProjectFileChangeContext_FileChanged(object? sender, FileChangedEventArgs e)
{
NeedsReload?.Invoke(this, e.FilePath);
@@ -322,6 +343,8 @@ public async ValueTask DisposeAsync()
if (_disposed)
return;
+ _initialLoadCompletionSource.TrySetResult();
+
_sourceFileCreatedOrDeletedChangeContext?.Dispose();
_projectFileChangeContext.Dispose();
@@ -340,6 +363,13 @@ public async ValueTask DisposeAsync()
}
}
+ internal TestAccessor GetTestAccessor() => new(this);
+
+ internal readonly struct TestAccessor(LoadedProject loadedProject)
+ {
+ public void RaiseNeedsReload() => loadedProject.NeedsReload?.Invoke(loadedProject, loadedProject.ProjectFilePath);
+ }
+
private sealed class DocumentFileInfoComparer : IEqualityComparer
{
public static IEqualityComparer Instance = new DocumentFileInfoComparer();
diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetry.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetry.cs
index ee6b51999b9fa..f4a4ee86e3fe4 100644
--- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetry.cs
+++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/LanguageServerTelemetry.cs
@@ -91,8 +91,8 @@ public void InitializeSession(string telemetryLevel, string? sessionId, bool isD
var metricSink = new VSMetricSink(session);
_registrations =
[
- RoslynTelemetry.AddEventSink(TelemetryEventSink.Create(session, logDelta: true)),
- RoslynTelemetry.AddMetricSink(metricSink),
+ RoslynTelemetry.Current.AddEventSink(TelemetryEventSink.Create(session, logDelta: true)),
+ RoslynTelemetry.Current.AddMetricSink(metricSink),
metricSink,
];
@@ -116,7 +116,7 @@ public void Dispose()
if (_telemetrySession is { } session)
{
FeaturesSessionTelemetry.Report();
- RoslynTelemetry.Flush();
+ RoslynTelemetry.Current.Flush();
foreach (var registration in _registrations)
registration.Dispose();
diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/VSCodeRequestTelemetryLogger.cs b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/VSCodeRequestTelemetryLogger.cs
index 9e82a7bb38fb2..6919497428ff3 100644
--- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/VSCodeRequestTelemetryLogger.cs
+++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/Telemetry/VSCodeRequestTelemetryLogger.cs
@@ -1,4 +1,4 @@
-// Licensed to the .NET Foundation under one or more agreements.
+// 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.
@@ -43,7 +43,7 @@ public static void ReportProjectLoadStarted()
protected override void IncreaseFindDocumentCount(string workspaceCountMetricName)
{
var projectsLoaded = s_initialProjectLoadCompleted;
- RoslynTelemetry.Count(FunctionId.LSP_FindDocumentInWorkspace, workspaceCountMetricName, 1,
+ RoslynTelemetry.Current.Count(FunctionId.LSP_FindDocumentInWorkspace, workspaceCountMetricName, 1,
new("server", ServerTypeName),
new("workspace", workspaceCountMetricName),
new("projectsLoaded", projectsLoaded));
diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.cs.xlf b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.cs.xlf
index a3a104715a398..e6e5f2eed33dc 100644
--- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.cs.xlf
+++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.cs.xlf
@@ -34,7 +34,7 @@
Completed (re)load of {0} project(s) in {1}
- Completed (re)load of {0} project(s) in {1}
+ Dokončeno (opětovné) načtení {0} projektů v {1}
The second placeholder is a time duration like 00:15
diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.de.xlf b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.de.xlf
index a5bf084cf179b..d8c931db8ad5c 100644
--- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.de.xlf
+++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.de.xlf
@@ -34,7 +34,7 @@
Completed (re)load of {0} project(s) in {1}
- Completed (re)load of {0} project(s) in {1}
+ (Neu-)Laden von {0} Projekt(en) in {1} abgeschlossen
The second placeholder is a time duration like 00:15
diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.es.xlf b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.es.xlf
index e6c76252bcb6f..446a928bc9f68 100644
--- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.es.xlf
+++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.es.xlf
@@ -34,7 +34,7 @@
Completed (re)load of {0} project(s) in {1}
- Completed (re)load of {0} project(s) in {1}
+ Carga completada (re)de {0} proyectos en {1}
The second placeholder is a time duration like 00:15
diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.fr.xlf b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.fr.xlf
index e8e6f5665dd7c..75d9417cea16f 100644
--- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.fr.xlf
+++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.fr.xlf
@@ -34,7 +34,7 @@
Completed (re)load of {0} project(s) in {1}
- Completed (re)load of {0} project(s) in {1}
+ (Re)chargement terminé de {0} projet(s) en {1}
The second placeholder is a time duration like 00:15
diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.it.xlf b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.it.xlf
index bdc14fa5d1846..3757d70856a7e 100644
--- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.it.xlf
+++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.it.xlf
@@ -34,7 +34,7 @@
Completed (re)load of {0} project(s) in {1}
- Completed (re)load of {0} project(s) in {1}
+ Completato il (ri)caricamento di {0} progetti in {1}
The second placeholder is a time duration like 00:15
diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.ja.xlf b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.ja.xlf
index 63836da19ba6b..7827439a2125a 100644
--- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.ja.xlf
+++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.ja.xlf
@@ -34,7 +34,7 @@
Completed (re)load of {0} project(s) in {1}
- Completed (re)load of {0} project(s) in {1}
+ {0} プロジェクトの (再) 読み込みを {1} に完了しました
The second placeholder is a time duration like 00:15
diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.ko.xlf b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.ko.xlf
index a2b1d269ec17d..081660751d64e 100644
--- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.ko.xlf
+++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.ko.xlf
@@ -34,7 +34,7 @@
Completed (re)load of {0} project(s) in {1}
- Completed (re)load of {0} project(s) in {1}
+ {1} 만에 {0}개 프로젝트의 다시 로드를 완료함
The second placeholder is a time duration like 00:15
diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.pl.xlf b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.pl.xlf
index fa446ff90881b..ae9bc013649c4 100644
--- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.pl.xlf
+++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.pl.xlf
@@ -34,7 +34,7 @@
Completed (re)load of {0} project(s) in {1}
- Completed (re)load of {0} project(s) in {1}
+ Ukończono (ponowne) ładowanie projektów {0} w czasie {1}
The second placeholder is a time duration like 00:15
diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.pt-BR.xlf b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.pt-BR.xlf
index 0751014a1c3d9..5d77e37f86c34 100644
--- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.pt-BR.xlf
+++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.pt-BR.xlf
@@ -34,7 +34,7 @@
Completed (re)load of {0} project(s) in {1}
- Completed (re)load of {0} project(s) in {1}
+ (Re)carregamento concluído de {0} projetos em {1}
The second placeholder is a time duration like 00:15
diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.ru.xlf b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.ru.xlf
index e0566bc01225e..88ad06e9b301d 100644
--- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.ru.xlf
+++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.ru.xlf
@@ -34,7 +34,7 @@
Completed (re)load of {0} project(s) in {1}
- Completed (re)load of {0} project(s) in {1}
+ Выполнена (повторно) загрузка {0} проектов в {1}
The second placeholder is a time duration like 00:15
diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.tr.xlf b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.tr.xlf
index 7ee191831b66b..d17a81a5ddd96 100644
--- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.tr.xlf
+++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.tr.xlf
@@ -34,7 +34,7 @@
Completed (re)load of {0} project(s) in {1}
- Completed (re)load of {0} project(s) in {1}
+ {0} projenin (yeniden) yüklenmesi {1} içinde tamamlandı
The second placeholder is a time duration like 00:15
diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.zh-Hans.xlf b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.zh-Hans.xlf
index 33bb51e4ceda8..d436014c69730 100644
--- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.zh-Hans.xlf
+++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.zh-Hans.xlf
@@ -34,7 +34,7 @@
Completed (re)load of {0} project(s) in {1}
- Completed (re)load of {0} project(s) in {1}
+ 已在 {0} 内完成 {1} 个项目的(重新)加载
The second placeholder is a time duration like 00:15
diff --git a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.zh-Hant.xlf b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.zh-Hant.xlf
index f11c6a47ac6cc..462da9df44eda 100644
--- a/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.zh-Hant.xlf
+++ b/src/LanguageServer/Microsoft.CodeAnalysis.LanguageServer/xlf/LanguageServerResources.zh-Hant.xlf
@@ -34,7 +34,7 @@
Completed (re)load of {0} project(s) in {1}
- Completed (re)load of {0} project(s) in {1}
+ 已在 {1} 內完成 {0} 個專案的重新載入
The second placeholder is a time duration like 00:15
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