From 9d1322ab56ca0e992300c2583833777de84f1b77 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 3 Sep 2026 14:31:49 +0100 Subject: [PATCH] fix: hardening the roslyn component detection --- README.md | 4 +- package.json | 4 +- .../sdk-configuration-reference/SKILL.md | 2 +- .../SKILL.md | 6 +- src/src/DotNetProjectSdk/Sdk/Sdk.props | 50 ++- src/src/DotNetProjectSdk/Sdk/Sdk.targets | 93 ++++- .../CoreDefaultsTests.cs | 18 + .../Harness/ProjectHarness.cs | 3 + .../PackableProjectDefaultsTests.cs | 4 +- .../RoslynComponentDefaultsTests.cs | 387 +++++++++++++++++- 10 files changed, 553 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 27d8730..89aaf3f 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ Version detection logging is disabled by default. Set `VersionDetectionLogEnable | `NamespacePrefix` | *(required)* | Root namespace prefix, e.g. `Acme`. Results in `Acme.MyProject`. | | `DisableNamespacePrefixCheck` | `false` | Set to `true` to suppress the build error for missing `NamespacePrefix`. | | `TargetFramework` | `net10.0` | Override the default TFM per-project or globally. Defaults to `netstandard2.0` for projects declaring `IsRoslynComponent=true`. | -| `IsRoslynComponent` | `false` | When explicitly `true`, applies source-generator defaults: a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, extended analyzer rules, SourceLink, generated-file output, dependency output, symbol packaging (`IncludeSymbols=false` by default), telemetry exclusion, and package build output. Packable Roslyn components automatically pack the built analyzer assembly (and its PDB) into `analyzers/dotnet/cs/`. Roslyn development dependencies (`Microsoft.CodeAnalysis.*`, `Microsoft.CodeAnalysis.Analyzers`) default to `PrivateAssets="all"`. | +| `IsRoslynComponent` | `false` | When explicitly `true`, applies source-generator defaults: a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, SourceLink with `EmbedUntrackedSources=true`, generated-file output, dependency output, symbol packaging (`IncludeSymbols=false` by default), telemetry exclusion, and package build output. Packable Roslyn components automatically pack the built analyzer assembly and its PDB into `analyzers/dotnet/cs/` (`PurviewPackAnalyzerPdb=true`; set `false` only when symbols are delivered another way — NuGet's `.snupkg` cannot host `analyzers/dotnet/cs` symbols). Pack-time validation (`ValidateRoslynComponentCompilerSettings`) fails the pack if the compiler defaults are missing unless `DisableRoslynCompilerDefaultsValidation=true`. Roslyn development dependencies (`Microsoft.CodeAnalysis.*`, `Microsoft.CodeAnalysis.Analyzers`) default to `PrivateAssets="all"`. | | `PackProjectReferencedSourceGenerators` | `true` | Automatically packs analyzer `ProjectReference` outputs and their runtime dependencies under `analyzers/dotnet/cs/`. Set to `false` to opt out; set `Pack="false"` on an individual reference to exclude only that generator. | | `SourceLinkPackageName` | `Microsoft.SourceLink.GitHub` | SourceLink provider. Set to `Microsoft.SourceLink.AzureDevOps.Git` for ADO repos. | | `DisableSourceLink` | `false` | Set to `true` to stop the SDK from adding the configured SourceLink package automatically. | @@ -243,7 +243,7 @@ For projects where `IsPackable=true`, the SDK provides these defaults **only whe | Property | Default | Description | | -- | -- | -- | | `GenerateDocumentationFile` | `true` | Emits XML documentation. | -| `IncludeSymbols` | `true` | Produces a symbol package (`false` for Roslyn components — their PDB ships in `analyzers/dotnet/cs/` instead). | +| `IncludeSymbols` | `true` | Produces a symbol package (`false` for Roslyn components — their PDB ships inside the `.nupkg` under `analyzers/dotnet/cs/` via `PurviewPackAnalyzerPdb=true`). | | `SymbolPackageFormat` | `snupkg` | Symbol package format. Always the modern `.snupkg`; the legacy `.symbols.nupkg` is never produced by default. | | `PublishRepositoryUrl` | `true` | Publishes the repository URL. | | `EmbedUntrackedSources` | `true` | Embeds untracked sources for SourceLink. | diff --git a/package.json b/package.json index fa5d9d8..dcb9d9d 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,4 @@ { - "name": "changeops", - "version": "1.0.0-prerelease.45" + "name": "pruview-dotnet-project-sdk", + "version": "1.0.0-prerelease.46" } diff --git a/src/src/DotNetProjectSdk/Sdk/.agents/skills/sdk-configuration-reference/SKILL.md b/src/src/DotNetProjectSdk/Sdk/.agents/skills/sdk-configuration-reference/SKILL.md index 7e60ecb..a99099b 100644 --- a/src/src/DotNetProjectSdk/Sdk/.agents/skills/sdk-configuration-reference/SKILL.md +++ b/src/src/DotNetProjectSdk/Sdk/.agents/skills/sdk-configuration-reference/SKILL.md @@ -44,7 +44,7 @@ These are the most important configurable properties exposed by the SDK: - `NamespacePrefix` — required unless `DisableNamespacePrefixCheck=true` - `DisableNamespacePrefixCheck` — default `false` - `TargetFramework` — defaults to `net10.0` when neither `TargetFramework` nor `TargetFrameworks` is set; projects explicitly declaring `IsRoslynComponent=true` default to `netstandard2.0` -- `IsRoslynComponent` — when explicitly `true`, applies source-generator defaults: a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, extended analyzer rules, disabled SourceLink and untracked-source embedding, no dependency file, compiler-generated output under the framework-specific intermediate directory, telemetry exclusion, excluded normal build output, `IncludeSymbols=false` (no `.symbols.nupkg`/`.snupkg` by default — the PDB ships in `analyzers/dotnet/cs/`), and `PrivateAssets=all` applied to `Microsoft.CodeAnalysis.*` / `Microsoft.CodeAnalysis.Analyzers` references. Packable Roslyn components automatically pack the built analyzer assembly and PDB into `analyzers/dotnet/cs/` +- `IsRoslynComponent` — when explicitly `true`, applies source-generator defaults: a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, SourceLink with `EmbedUntrackedSources=true`, no dependency file, compiler-generated output under the framework-specific intermediate directory, telemetry exclusion, excluded normal build output, `IncludeSymbols=false` (no `.symbols.nupkg`/`.snupkg` by default — the PDB ships inside the `.nupkg` under `analyzers/dotnet/cs/` via `PurviewPackAnalyzerPdb=true`), and `PrivateAssets=all` applied to `Microsoft.CodeAnalysis.*` / `Microsoft.CodeAnalysis.Analyzers` references. Packable Roslyn components automatically pack the built analyzer assembly and PDB into `analyzers/dotnet/cs/`; a pack-time validation (`ValidateRoslynComponentCompilerSettings`) fails the pack if the compiler defaults are missing unless `DisableRoslynCompilerDefaultsValidation=true` - `PackProjectReferencedSourceGenerators` — default `true`; packable projects automatically include analyzer `ProjectReference` outputs and runtime dependencies under `analyzers/dotnet/cs/`. Set it to `false` globally or use `Pack="false"` on one analyzer reference to opt out. - `EnableAssemblyNameGeneration` — default `true`; when `true`, `AssemblyName` and default `PackageId` follow the fully evaluated `RootNamespace`. Set `false` before the SDK import to use the standard project-name behaviour - `DisableProjectFileNamingConventionCheck` — default `false`; disables the directory-name/file-name match validation diff --git a/src/src/DotNetProjectSdk/Sdk/.agents/skills/sdk-project-behavior-and-detection/SKILL.md b/src/src/DotNetProjectSdk/Sdk/.agents/skills/sdk-project-behavior-and-detection/SKILL.md index bb27583..7c651b7 100644 --- a/src/src/DotNetProjectSdk/Sdk/.agents/skills/sdk-project-behavior-and-detection/SKILL.md +++ b/src/src/DotNetProjectSdk/Sdk/.agents/skills/sdk-project-behavior-and-detection/SKILL.md @@ -147,10 +147,12 @@ This is why consistent naming and placement matter so much in repos that use the ### For Roslyn component (analyzer/source-generator) projects -- Defaults a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, extended analyzer rules, disabled SourceLink, and excluded normal build output (`IncludeBuildOutput=false`) -- Defaults `IncludeSymbols=false` — no `.symbols.nupkg` or `.snupkg` is produced by default; the analyzer PDB ships inside the main `.nupkg` under `analyzers/dotnet/cs/` beside the analyzer assembly +- Defaults a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, SourceLink with `EmbedUntrackedSources=true`, and excluded normal build output (`IncludeBuildOutput=false`) +- Defaults `IncludeSymbols=false` — no `.symbols.nupkg` or `.snupkg` is produced by default; the analyzer PDB ships inside the main `.nupkg` under `analyzers/dotnet/cs/` beside the analyzer assembly (`PurviewPackAnalyzerPdb=true`; set `false` only when symbols are delivered another way, since NuGet's `.snupkg` cannot host `analyzers/dotnet/cs` symbols) - Packable Roslyn components automatically pack the built analyzer assembly (and PDB) into `analyzers/dotnet/cs/`; `SymbolPackageFormat` defaults to the modern `snupkg` if symbols are explicitly opted into - `Microsoft.CodeAnalysis.*` and `Microsoft.CodeAnalysis.Analyzers` references are defaulted to `PrivateAssets=all` (development-only dependencies) so they never leak into the packed nuspec +- A pack-time validation (`ValidateRoslynComponentCompilerSettings`) fails the pack of a packable Roslyn component if `LangVersion`, `Nullable`, `TreatWarningsAsErrors`, or `EnforceExtendedAnalyzerRules` is missing; opt out with `DisableRoslynCompilerDefaultsValidation=true` +- `ContinuousIntegrationBuild` is set only by real CI environment variables — packability alone never forces SourceLink's CI-mode dirty-repository checks ### For test and shared-testing projects diff --git a/src/src/DotNetProjectSdk/Sdk/Sdk.props b/src/src/DotNetProjectSdk/Sdk/Sdk.props index 9eb9405..d575a25 100644 --- a/src/src/DotNetProjectSdk/Sdk/Sdk.props +++ b/src/src/DotNetProjectSdk/Sdk/Sdk.props @@ -307,6 +307,40 @@ + + + + C# language version used to build the Roslyn component. Defaults to latest. + + + Nullable reference-types context used to build the Roslyn component. Defaults to enable. + + + Treats all compiler/analyzer warnings as errors. Defaults to true for Roslyn components. + + + Enforces Roslyn analyzer best-practice rules. Defaults to true for Roslyn components. + + + Produce deterministic builds. Defaults to true. + + + True when building in a recognized CI environment, producing deterministic SourceLink output. + + + Embeds untracked source files for SourceLink. Defaults to true for Roslyn components. + + + @@ -330,8 +364,13 @@ modern snupkg format rather than the legacy symbols.nupkg. --> false $(NoWarn);NU5128; - true - false + + true + + true false true false @@ -340,9 +379,12 @@ net10.0 - + true + true <_PurviewPackagedEditorConfigFilePath>$(MSBuildThisFileDirectory).editorconfig diff --git a/src/src/DotNetProjectSdk/Sdk/Sdk.targets b/src/src/DotNetProjectSdk/Sdk/Sdk.targets index dd60ab1..a73d334 100644 --- a/src/src/DotNetProjectSdk/Sdk/Sdk.targets +++ b/src/src/DotNetProjectSdk/Sdk/Sdk.targets @@ -29,12 +29,16 @@ Condition="'$(IsCSharpProject)' == 'true' AND '$(IsRoslynComponent)' == 'true' AND '$(IsPackable)' == 'true' AND '$(TargetFrameworks)' == ''" > + @@ -64,6 +68,41 @@ + + + + + + + + + + + + + + + + + + + + + + (StringComparer.OrdinalIgnoreCase); + var results = new List(); + foreach (var file in Files ?? Array.Empty()) + { + // Two analyzer projects can share a runtime dependency (for example a shared + // support assembly). NuGet fails the pack on duplicate target paths, so keep + // only the first file for each resolved analyzers/dotnet/cs package path. + // The calling target re-applies PackagePath metadata afterwards. + var packagePath = "analyzers/dotnet/cs/" + Path.GetFileName(file.ItemSpec); + if (!seen.Add(packagePath)) + continue; + + results.Add(new TaskItem(file.ItemSpec)); + } + DeduplicatedFiles = results.ToArray(); + ]]> + + + + + + + + - + analyzers/dotnet/cs/ @@ -563,6 +647,7 @@ ===================================================================== --> @@ -571,6 +656,10 @@ Include="@(None)" Condition="'%(None.Pack)' == 'true' AND $([System.String]::Copy('%(None.Filename)').ToLowerInvariant()) == 'readme'" /> + <_PurviewPackedReadme + Include="@(None)" + Condition="($([System.String]::Copy('%(None.Link)').ToLowerInvariant()) == 'sdk/readme.md' OR $([System.String]::Copy('%(None.Link)').ToLowerInvariant()) == 'sdk\readme.md')" + /> <_PurviewPackedReadme Include="@(TfmSpecificPackageFile)" Condition="$([System.String]::Copy('%(TfmSpecificPackageFile.Filename)').ToLowerInvariant()) == 'readme'" diff --git a/src/tests/DotNetProjectSdk.IntegrationTests/CoreDefaultsTests.cs b/src/tests/DotNetProjectSdk.IntegrationTests/CoreDefaultsTests.cs index 61675c8..cae7415 100644 --- a/src/tests/DotNetProjectSdk.IntegrationTests/CoreDefaultsTests.cs +++ b/src/tests/DotNetProjectSdk.IntegrationTests/CoreDefaultsTests.cs @@ -152,6 +152,24 @@ public async Task Ci_PropertyNotSet_WhenEnvironmentVariableAbsent(CancellationTo await Assert.That(value).IsNotEqualTo("true"); } + [Test] + public async Task Ci_PropertyNotSet_WhenPackableButNotCi(CancellationToken cancellationToken) + { + // Packability alone must not force ContinuousIntegrationBuild: local packs must not enter + // SourceLink's CI-mode dirty-repository checks (which fail under TreatWarningsAsErrors). + using var h = await ProjectHarness.CreateAsync( + "MyLibrary", + extraEnv: new Dictionary { ["CI"] = "" }, + extraProps: "true", + cancellationToken: cancellationToken + ); + var value = await h.GetPropertyAsync("ContinuousIntegrationBuild", cancellationToken); + await Assert + .That(value) + .IsNotEqualTo("true") + .Because("Only real CI environment variables may set ContinuousIntegrationBuild."); + } + [Test] [Arguments("net9.0", "net9.0")] [Arguments("net10.0", "net10.0")] diff --git a/src/tests/DotNetProjectSdk.IntegrationTests/Harness/ProjectHarness.cs b/src/tests/DotNetProjectSdk.IntegrationTests/Harness/ProjectHarness.cs index 77b13ec..eb3a86f 100644 --- a/src/tests/DotNetProjectSdk.IntegrationTests/Harness/ProjectHarness.cs +++ b/src/tests/DotNetProjectSdk.IntegrationTests/Harness/ProjectHarness.cs @@ -154,6 +154,9 @@ await File.WriteAllTextAsync( true + + + """, cancellationToken diff --git a/src/tests/DotNetProjectSdk.IntegrationTests/PackableProjectDefaultsTests.cs b/src/tests/DotNetProjectSdk.IntegrationTests/PackableProjectDefaultsTests.cs index 36b113b..feab96d 100644 --- a/src/tests/DotNetProjectSdk.IntegrationTests/PackableProjectDefaultsTests.cs +++ b/src/tests/DotNetProjectSdk.IntegrationTests/PackableProjectDefaultsTests.cs @@ -476,10 +476,8 @@ static string ExtractPackVersion(string stdOut, string packageId) stdOut, $"Successfully created package '[^']*{System.Text.RegularExpressions.Regex.Escape(packageId)}\\.([^']+)\\.nupkg'" ); - if (!match.Success) - return string.Empty; - return match.Groups[1].Value; + return match.Success ? match.Groups[1].Value : string.Empty; } static async Task<(int Code, string StdOut, string StdErr)> RunProcessAsync( diff --git a/src/tests/DotNetProjectSdk.IntegrationTests/RoslynComponentDefaultsTests.cs b/src/tests/DotNetProjectSdk.IntegrationTests/RoslynComponentDefaultsTests.cs index ad1dec6..64299f1 100644 --- a/src/tests/DotNetProjectSdk.IntegrationTests/RoslynComponentDefaultsTests.cs +++ b/src/tests/DotNetProjectSdk.IntegrationTests/RoslynComponentDefaultsTests.cs @@ -143,6 +143,7 @@ public async Task IsRoslynComponent_True_AppliesSourceGeneratorDefaults(Cancella "EnforceExtendedAnalyzerRules", "DisableSourceLink", "EmbedUntrackedSources", + "Deterministic", "GenerateDependencyFile", "CompilerGeneratedFilesOutputPath", "SymbolPackageFormat", @@ -152,8 +153,18 @@ public async Task IsRoslynComponent_True_AppliesSourceGeneratorDefaults(Cancella await Assert.That(properties["TargetFramework"]).IsEqualTo("netstandard2.0"); await Assert.That(properties["EnforceExtendedAnalyzerRules"]).IsEqualTo("true"); - await Assert.That(properties["DisableSourceLink"]).IsEqualTo("true"); - await Assert.That(properties["EmbedUntrackedSources"]).IsEqualTo("false"); + await Assert + .That(properties["DisableSourceLink"]) + .IsNotEqualTo("true") + .Because("Roslyn components must ship source-linked PDBs like regular packages."); + await Assert + .That(properties["EmbedUntrackedSources"]) + .IsEqualTo("true") + .Because("Roslyn components must embed untracked sources for full SourceLink parity."); + await Assert + .That(properties["Deterministic"]) + .IsEqualTo("true") + .Because("All builds, including analyzer packages, must be deterministic."); await Assert.That(properties["GenerateDependencyFile"]).IsEqualTo("false"); await Assert .That(TestHelpers.NormalizePath(properties["CompilerGeneratedFilesOutputPath"])) @@ -349,4 +360,376 @@ await Assert .IsTrue() .Because("Microsoft.CodeAnalysis.Analyzers should flow as an analyzer-only development asset."); } + + [Test] + public async Task PackableRoslynComponent_IncludeSymbolsTrue_PdbStillInNupkg(CancellationToken cancellationToken) + { + using var harness = await ProjectHarness + .For("SourceGeneration") + .WithProjectFileContent( + """ + + + true + true + true + true + snupkg + + + """ + ) + .BuildAsync(cancellationToken); + + var packageDirectory = Path.Combine(harness.SolutionDirectory, "packages"); + var packageVersion = $"0.0.0-integration-test-{Guid.NewGuid():N}"; + var (exitCode, stdOut, stdErr) = await harness.RunMSBuildAsync( + $"-restore -t:Pack -p:PackageOutputPath=\"{packageDirectory}\" -p:PackageVersion={packageVersion} -p:Version={packageVersion}", + cancellationToken + ); + + await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); + + var nupkgFiles = Directory.GetFiles(packageDirectory, "Test.SourceGeneration.*.nupkg"); + await Assert.That(nupkgFiles).HasSingleItem().Because("Only the main .nupkg is produced."); + + using var package = await ZipFile.OpenReadAsync(nupkgFiles[0], cancellationToken); + var entries = package.Entries.Select(entry => entry.FullName).ToList(); + await Assert.That(entries).Contains("analyzers/dotnet/cs/Test.SourceGeneration.dll"); + await Assert + .That(entries) + .Contains("analyzers/dotnet/cs/Test.SourceGeneration.pdb") + .Because( + "The analyzer PDB must always ship in the .nupkg by default (PurviewPackAnalyzerPdb=true); the .snupkg cannot host analyzers/dotnet/cs symbols." + ); + await Assert + .That(entries) + .Contains("lib/netstandard2.0/Test.SourceGeneration.dll") + .Because("IncludeBuildOutput=true keeps the library asset for the dual-role layout."); + + var snupkgFiles = Directory.GetFiles(packageDirectory, "Test.SourceGeneration.*.snupkg"); + await Assert.That(snupkgFiles).HasSingleItem().Because("The symbol package must be produced."); + + using var symbolPackage = await ZipFile.OpenReadAsync(snupkgFiles[0], cancellationToken); + var symbolEntries = symbolPackage.Entries.Select(entry => entry.FullName).ToList(); + await Assert + .That(symbolEntries) + .Contains("lib/netstandard2.0/Test.SourceGeneration.pdb") + .Because("The library PDB flows to the .snupkg."); + } + + [Test] + public async Task PackableRoslynComponent_PurviewPackAnalyzerPdbFalse_OmitsPdbFromNupkg( + CancellationToken cancellationToken + ) + { + using var harness = await ProjectHarness + .For("SourceGeneration") + .WithProjectFileContent( + """ + + + true + true + true + true + snupkg + false + + + """ + ) + .BuildAsync(cancellationToken); + + var packageDirectory = Path.Combine(harness.SolutionDirectory, "packages"); + var packageVersion = $"0.0.0-integration-test-{Guid.NewGuid():N}"; + var (exitCode, stdOut, stdErr) = await harness.RunMSBuildAsync( + $"-restore -t:Pack -p:PackageOutputPath=\"{packageDirectory}\" -p:PackageVersion={packageVersion} -p:Version={packageVersion}", + cancellationToken + ); + + await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); + + var nupkgFiles = Directory.GetFiles(packageDirectory, "Test.SourceGeneration.*.nupkg"); + using var package = await ZipFile.OpenReadAsync(nupkgFiles[0], cancellationToken); + var entries = package.Entries.Select(entry => entry.FullName).ToList(); + await Assert.That(entries).Contains("analyzers/dotnet/cs/Test.SourceGeneration.dll"); + await Assert + .That( + entries.Any(entry => + entry.StartsWith("analyzers/dotnet/cs/", StringComparison.OrdinalIgnoreCase) + && entry.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase) + ) + ) + .IsFalse() + .Because("PurviewPackAnalyzerPdb=false opts the analyzer PDB out of the .nupkg."); + await Assert + .That(entries) + .Contains("lib/netstandard2.0/Test.SourceGeneration.dll") + .Because("IncludeBuildOutput=true keeps the library asset for the dual-role layout."); + + var snupkgFiles = Directory.GetFiles(packageDirectory, "Test.SourceGeneration.*.snupkg"); + await Assert.That(snupkgFiles).HasSingleItem().Because("The symbol package must be produced."); + } + + [Test] + public async Task PackableRoslynComponent_MultipleAnalyzerRefs_SharedRuntimeDependencyPackedOnce( + CancellationToken cancellationToken + ) + { + using var generatorOne = await ProjectHarness + .For("GeneratorOne") + .WithProjectFileContent( + """ + + + true + + + + + + + + + """ + ) + .BuildAsync(cancellationToken); + + using var generatorTwo = await ProjectHarness + .For("GeneratorTwo") + .WithSolutionDirectory(generatorOne.SolutionDirectory) + .WithProjectFileContent( + """ + + + true + + + + + + + + + """ + ) + .BuildAsync(cancellationToken); + + using var consumer = await ProjectHarness + .For("Consumer") + .WithSolutionDirectory(generatorOne.SolutionDirectory) + .WithProjectFileContent( + """ + + + net10.0 + true + true + true + + + + + + + """ + ) + .BuildAsync(cancellationToken); + + var packageDirectory = Path.Combine(generatorOne.SolutionDirectory, "packages"); + var packageVersion = $"0.0.0-integration-test-{Guid.NewGuid():N}"; + var (exitCode, stdOut, stdErr) = await consumer.RunMSBuildAsync( + $"-restore -t:Pack -p:PackageOutputPath=\"{packageDirectory}\" -p:PackageVersion={packageVersion} -p:Version={packageVersion}", + cancellationToken + ); + + await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); + var packagePath = Directory.GetFiles(packageDirectory, "Test.Consumer.*.nupkg").Single(); + using var package = await ZipFile.OpenReadAsync(packagePath, cancellationToken); + var entries = package.Entries.Select(entry => entry.FullName).ToList(); + await Assert.That(entries).Contains("analyzers/dotnet/cs/Test.GeneratorOne.dll"); + await Assert.That(entries).Contains("analyzers/dotnet/cs/Test.GeneratorTwo.dll"); + await Assert + .That(entries.Count(entry => entry == "analyzers/dotnet/cs/SharedRuntime.dll")) + .IsEqualTo(1) + .Because("A runtime dependency shared by two analyzer project references must be packed exactly once."); + } + + [Test] + public async Task PackableRoslynComponent_MissingCompilerDefaults_FailsPack(CancellationToken cancellationToken) + { + using var harness = await ProjectHarness + .For("SourceGeneration") + .WithProjectFileContent( + """ + + + true + true + false + + + """ + ) + .BuildAsync(cancellationToken); + + var packageDirectory = Path.Combine(harness.SolutionDirectory, "packages"); + var packageVersion = $"0.0.0-integration-test-{Guid.NewGuid():N}"; + var (exitCode, stdOut, stdErr) = await harness.RunMSBuildAsync( + $"-restore -t:Pack -p:PackageOutputPath=\"{packageDirectory}\" -p:PackageVersion={packageVersion} -p:Version={packageVersion}", + cancellationToken + ); + + await Assert + .That(exitCode) + .IsEqualTo(1) + .Because("A packable Roslyn component that disables TreatWarningsAsErrors must fail the pack.") + .Because(TestHelpers.GenerateError(stdOut, stdErr)); + await Assert + .That(stdOut) + .Contains("PRSGD0003") + .Because("The missing compiler-default diagnostic must be emitted."); + } + + [Test] + public async Task PackableRoslynComponent_MissingCompilerDefaults_CanBeDisabled(CancellationToken cancellationToken) + { + using var harness = await ProjectHarness + .For("SourceGeneration") + .WithProjectFileContent( + """ + + + true + true + false + true + + + """ + ) + .BuildAsync(cancellationToken); + + var packageDirectory = Path.Combine(harness.SolutionDirectory, "packages"); + var packageVersion = $"0.0.0-integration-test-{Guid.NewGuid():N}"; + var (exitCode, stdOut, stdErr) = await harness.RunMSBuildAsync( + $"-restore -t:Pack -p:PackageOutputPath=\"{packageDirectory}\" -p:PackageVersion={packageVersion} -p:Version={packageVersion}", + cancellationToken + ); + + await Assert + .That(exitCode) + .IsEqualTo(0) + .Because("DisableRoslynCompilerDefaultsValidation=true must silence the pack-time validation.") + .Because(TestHelpers.GenerateError(stdOut, stdErr)); + } + + [Test] + public async Task RoslynComponent_ExposesCompilerSettingsToCompiler(CancellationToken cancellationToken) + { + using var harness = await ProjectHarness + .For("SourceGeneration") + .WithProjectFileContent( + """ + + + true + + + """ + ) + .BuildAsync(cancellationToken); + + var compilerVisible = await harness.GetItemIdentitiesAsync("CompilerVisibleProperty", cancellationToken); + await Assert.That(compilerVisible).Contains("LangVersion"); + await Assert.That(compilerVisible).Contains("Nullable"); + await Assert.That(compilerVisible).Contains("TreatWarningsAsErrors"); + await Assert.That(compilerVisible).Contains("EnforceExtendedAnalyzerRules"); + await Assert.That(compilerVisible).Contains("Deterministic"); + await Assert.That(compilerVisible).Contains("ContinuousIntegrationBuild"); + await Assert.That(compilerVisible).Contains("EmbedUntrackedSources"); + } + + [Test] + public async Task RoslynComponent_SourceLinkPackageReference_Added(CancellationToken cancellationToken) + { + using var harness = await ProjectHarness + .For("SourceGeneration") + .WithProjectFileContent( + """ + + + true + + + """ + ) + .BuildAsync(cancellationToken); + + var packageReferences = await harness.GetItemIdentitiesAsync("PackageReference", cancellationToken); + await Assert + .That(packageReferences) + .Contains("Microsoft.SourceLink.GitHub") + .Because("Roslyn components must receive the SourceLink package like regular packages."); + } + + [Test] + public async Task PackableRoslynComponent_LinkedSdkReadme_DoesNotDuplicate(CancellationToken cancellationToken) + { + var repoRoot = Path.Combine(Path.GetTempPath(), "PurviewSdkTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(repoRoot); + await File.WriteAllTextAsync(Path.Combine(repoRoot, "README.md"), "# Test Repo Readme", cancellationToken); + await File.WriteAllTextAsync( + Path.Combine(repoRoot, "package.json"), + /*lang=json,strict*/"""{"name": "test-repo", "version": "0.0.0-test"}""", + cancellationToken + ); + + using var harness = await ProjectHarness + .For("SourceGeneration") + .WithSolutionDirectory(Path.Combine(repoRoot, "src")) + .WithProjectFileContent( + """ + + + true + true + README.md + + + + + + """ + ) + .BuildAsync(cancellationToken); + + var packageDirectory = Path.Combine(harness.SolutionDirectory, "packages"); + var packageVersion = $"0.0.0-integration-test-{Guid.NewGuid():N}"; + var (exitCode, stdOut, stdErr) = await harness.RunMSBuildAsync( + $"-restore -t:Pack -p:PackageOutputPath=\"{packageDirectory}\" -p:PackageVersion={packageVersion} -p:Version={packageVersion} -p:RepoRoot=\"{repoRoot}\"", + cancellationToken + ); + + await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); + + var nupkgFiles = Directory.GetFiles(packageDirectory, "Test.SourceGeneration.*.nupkg"); + using var package = await ZipFile.OpenReadAsync(nupkgFiles[0], cancellationToken); + var entries = package.Entries.Select(entry => entry.FullName).ToList(); + await Assert + .That(entries.Count(entry => entry == "README.md")) + .IsEqualTo(1) + .Because("A linked Sdk/README.md must not be packed twice (no NU5118)."); + } }