diff --git a/README.md b/README.md index b279d16..d1e8a50 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A reusable MSBuild SDK NuGet package that delivers standardised .NET project def | **Project type detection** | `IsCSharpProject`, `IsTestProject`, `IsSharedTestingProject`, `IsContainerProject`, `IsWebSdkProject`, `IsAspireHostProject`, … | | **C# defaults** | `net10.0` TFM (overridable), `LangVersion=preview`, `Nullable=enable`, `ImplicitUsings=enable`, deterministic builds | | **Code style** | `.editorconfig` baked into the package, applied via `EditorConfigFilePath`, and auto-bootstrapped to repo root if missing; `EnforceCodeStyleInBuild=true`, `EnableNETAnalyzers=true`, `AnalysisLevel=latest`, `AnalysisMode=All` | -| **NuGet packaging** | `PublishRepositoryUrl=true`, `IncludeSymbols=true`, `SymbolPackageFormat=snupkg`, `EmbedUntrackedSources=true` for packable projects | +| **NuGet packaging** | `AssemblyName`/`PackageId` default to the fully evaluated `RootNamespace`; packable projects get `GenerateDocumentationFile=true`, `PublishRepositoryUrl=true`, `IncludeSymbols=true`, `SymbolPackageFormat=snupkg`, `EmbedUntrackedSources=true`, and portable PDBs delivered via `.snupkg` (not the `.nupkg`) | | **Repo bootstrap** | Missing repo-root `.editorconfig` and `global.json` are auto-copied/created by default (disable via `DisableAutoCopySdkFiles=true`) | | **CI detection** | `ContinuousIntegrationBuild` set automatically when `CI`, `GITHUB_ACTIONS`, or `TF_BUILD` env vars are present | | **SourceLink** | `Microsoft.SourceLink.GitHub` added to all packable projects (configurable via `SourceLinkPackageName`) | @@ -78,28 +78,18 @@ The SDK applies several conventions automatically based on the `.csproj` filenam ### Defaults (no extra configuration) -By default (`EnableAssemblyNameGeneration=false`), `AssemblyName` follows standard .NET behaviour — it's just the `.csproj` filename. `RootNamespace` is always derived from `$(NamespacePrefix).$(ProjectName)`: +`RootNamespace` is always derived from `$(NamespacePrefix).$(ProjectName)` and is the canonical default public name. By default (`EnableAssemblyNameGeneration=true`), `AssemblyName` and `PackageId` both follow the fully evaluated `RootNamespace`. Test projects retain their detected suffix so test assemblies stay distinct from the source assembly. Set `EnableAssemblyNameGeneration=false` (before the SDK import) to opt out and use standard .NET behaviour (the `.csproj` filename): -| `.csproj` filename | `AssemblyName` | `RootNamespace` | Detected as | +| `.csproj` filename | `AssemblyName` / `PackageId` | `RootNamespace` | Detected as | | -- | -- | -- | -- | -| `Api.csproj` | `Api` | `Acme.Api` | Source project | -| `Api.UnitTests.csproj` | `Api.UnitTests` | `Acme.Api` | `IsTestProject=true`, `TestingType=Unit` | -| `Api.IntegrationTests.csproj` | `Api.IntegrationTests` | `Acme.Api` | `IsTestProject=true`, `TestingType=Integration` | -| `SharedTestingFramework.csproj` | `SharedTestingFramework` | `Acme.SharedTestingFramework` | `IsSharedTestingProject=true` | +| `Api.csproj` | `Acme.Api` | `Acme.Api` | Source project | +| `Api.UnitTests.csproj` | `Acme.Api.UnitTests` | `Acme.Api` | `IsTestProject=true`, `TestingType=Unit` | +| `Api.IntegrationTests.csproj` | `Acme.Api.IntegrationTests` | `Acme.Api` | `IsTestProject=true`, `TestingType=Integration` | +| `SharedTestingFramework.csproj` | `Acme.SharedTestingFramework` | `Acme` | `IsSharedTestingProject=true` | -> **Note:** `InternalsVisibleTo` follows `$(AssemblyName)` — so for `Api.csproj` the SDK generates `Api.UnitTests`, `Api.IntegrationTests`, etc. +> **Note:** `InternalsVisibleTo` follows `$(AssemblyName)` — so for `Api.csproj` the SDK generates `Acme.Api.UnitTests`, `Acme.Api.IntegrationTests`, etc. -### With `EnableAssemblyNameGeneration=true` - -When enabled, the SDK derives `AssemblyName` and `PackageId` from `$(PurviewLogicalProjectName)` — the full `$(NamespacePrefix).$(ProjectName)` with deduplication: - -| `.csproj` filename | `AssemblyName` | `RootNamespace` | -| -- | -- | -- | -| `Api.csproj` | `Acme.Api` | `Acme.Api` | -| `Api.UnitTests.csproj` | `Acme.Api.UnitTests` | `Acme.Api` | -| `Core.Infrastructure.csproj` | `Acme.Core.Infrastructure` | `Acme.Core.Infrastructure` | - -Use short `.csproj` names in both modes — the SDK handles the prefixing: +Use short `.csproj` names — the SDK handles the prefixing: ```text ✅ Api.csproj → short name, SDK resolves the rest @@ -241,11 +231,33 @@ Version detection logging is disabled by default. Set `VersionDetectionLogEnable | `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. | -| `EnableAssemblyNameGeneration` | `false` | When `true`, the SDK derives `AssemblyName` (and `PackageId`) from `$(PurviewLogicalProjectName)` — i.e. `$(NamespacePrefix).$(ProjectName)` with deduplication logic. When `false` (default), standard .NET behaviour applies (`$(MSBuildProjectName)`). Explicit `` in a `.csproj` always takes precedence. | +| `EnableAssemblyNameGeneration` | `true` | When `true` (default), `AssemblyName` and `PackageId` derive from the fully evaluated `RootNamespace`. When explicitly `false`, standard .NET behaviour applies (`$(MSBuildProjectName)`). Explicit ``/`` in a `.csproj` always take precedence. | | `DisableProjectFileNamingConventionCheck` | `false` | Set to `true` to disable the validation that requires `MyProject\MyProject.csproj` naming alignment. | | `DisableGenerateAssemblyInfoClass` | `false` | Set to `true` to disable the generated `AssemblyInfo` helper source. | | `AutoIncludeUsings` | `true` | Controls SDK-added global usings for `NamespacePrefix` and `RootNamespace`. | +### Packable project defaults + +For projects where `IsPackable=true`, the SDK provides these defaults **only when the consuming project has not supplied a value** — explicit values are always preserved: + +| Property | Default | Description | +| -- | -- | -- | +| `GenerateDocumentationFile` | `true` | Emits XML documentation. | +| `IncludeSymbols` | `true` | Produces a symbol package. | +| `SymbolPackageFormat` | `snupkg` | Symbol package format (`symbols.nupkg` for Roslyn components). | +| `PublishRepositoryUrl` | `true` | Publishes the repository URL. | +| `EmbedUntrackedSources` | `true` | Embeds untracked sources for SourceLink. | +| `DebugType` | `portable` | Ensures portable PDBs for symbol-package delivery. | +| `IncludeSource` | `true` | Includes source files in the package. | + +Portable PDBs are delivered through the `.snupkg`; the normal `.nupkg` does **not** receive PDB files unless the project explicitly opts in (for example by adding `.pdb` to `AllowedOutputExtensionsInPackageBuildOutputFolder`). + +**Repository README auto-inclusion:** when the repo root is discoverable (`.git` marker or CI workspace variable), the repository-root `README.md` is packed automatically for packable projects and registered via `PackageReadmeFile` — but only when the file exists and `PackageReadmeFile` has not been configured explicitly. The SDK skips the auto-inclusion if a README-named file is already being packed, so no duplicate readme items are produced. No README is required; if the file is absent the pack succeeds without readme metadata. + +The SDK never forces organization/package-specific metadata — `Authors`, `Company`, `PackageLicenseExpression`, `PackageLicenseFile`, `Description`, `PackageTags`, `PackageProjectUrl`, and repository URLs are left to the repository or individual package. `IsPackable` is not set blindly: it defaults to `false` and only becomes `true` when a project explicitly opts in. + +Non-packable projects (including web applications) default `WarnOnPackingNonPackableProject=false`, so solution-wide pack operations skip them silently. Set `true` explicitly to re-enable the "cannot be packed" warning. + ### Repo bootstrap | Property | Default | Description | @@ -323,7 +335,7 @@ The SDK now exports its properties via `CompilerVisibleProperty`, so analyzers a | `ExcludePurviewTelemetry` | Opt-out for `Purview.Telemetry.SourceGenerator`. | | `ExcludeMSTelemetryExtension` | Opt-out for `Microsoft.Extensions.Telemetry.Abstractions`. | | `DisableGenerateAssemblyInfoClass` | Disables generated `AssemblyInfo` helper source. | -| `EnableAssemblyNameGeneration` | When `true`, the SDK derives `AssemblyName` from the logical project name. | +| `EnableAssemblyNameGeneration` | When `true` (default), `AssemblyName` derives from `RootNamespace`. | | `DisableAutoInternalsVisibleTo` | Disables automatic `InternalsVisibleTo` generation. | | `AutoIncludeUsings` | Controls SDK-added global usings. | | `IsCSharpProject` | True when the project is a `.csproj`. | @@ -389,13 +401,13 @@ Projects named `SharedTestingFramework`, `SharedTestingInfrastructure`, `SharedT The SDK automatically generates `[assembly: InternalsVisibleTo("…")]` attributes for every non-test C# project. The friend assembly name is derived from the source project's resolved `$(AssemblyName)`, so all naming modes are handled correctly: - **Explicit ``** — if a project sets `Custom.Assembly`, the generated attributes use `Custom.Assembly.UnitTests`, `Custom.Assembly.IntegrationTests`, etc. -- **`EnableAssemblyNameGeneration=true`** — the SDK-derived fully-qualified name is used (e.g. `Acme.MyProject.UnitTests`). -- **Default** — standard .NET behaviour: `$(MSBuildProjectName)` (e.g. `MyProject.UnitTests`). +- **Default** — `AssemblyName` is `RootNamespace`-derived, so fully-qualified names are used (e.g. `Acme.MyProject.UnitTests`). +- **`EnableAssemblyNameGeneration=false`** — standard .NET behaviour: `$(MSBuildProjectName)` (e.g. `MyProject.UnitTests`). Two categories of friend assemblies are generated: 1. **TestType variants** — one `InternalsVisibleTo` per defined `TestType` (`Unit`, `Integration`, `Architecture`, `Contract`, `Functional`, …), formatted as `$(AssemblyName).{TestType}Tests`. -2. **SharedTesting projects** — one per known shared testing project name (`SharedTestingFramework`, `SharedTestingInfrastructure`, etc.). When `EnableAssemblyNameGeneration=true` and a `NamespacePrefix` is set, these are prefixed (e.g. `Acme.SharedTestingFramework`); otherwise the raw name is used. +2. **SharedTesting projects** — one per known shared testing project name (`SharedTestingFramework`, `SharedTestingInfrastructure`, etc.). By default (`EnableAssemblyNameGeneration=true`) with a `NamespacePrefix` set, these are prefixed (e.g. `Acme.SharedTestingFramework`); with `EnableAssemblyNameGeneration=false` the raw name is used. ### Disabling automatic InternalsVisibleTo @@ -462,27 +474,22 @@ Outside this scope, normal `IDE0130` behaviour remains unchanged. ## Assembly name generation -By default (`EnableAssemblyNameGeneration=false`), the SDK follows standard .NET behaviour: `AssemblyName` is `$(MSBuildProjectName)`. Set `EnableAssemblyNameGeneration=true` (in `Directory.Build.props` or individual `.csproj`) to have the SDK derive `AssemblyName` from `$(PurviewLogicalProjectName)`: +By default (`EnableAssemblyNameGeneration=true`), the SDK treats `RootNamespace` as the canonical public name: `AssemblyName` and `PackageId` both default to the fully evaluated `RootNamespace`. The defaults are applied during `Sdk.props` evaluation — before the Microsoft SDK computes `TargetName` and before the project body — so compilation, output paths, project references, restore, and packing all agree on the same identities. Set `EnableAssemblyNameGeneration=false` **before the SDK import** to opt out and fall back to standard .NET behaviour (`$(MSBuildProjectName)`). -```xml - - Acme - true - -``` +With the default enabled: -With this enabled: +| Project name | `NamespacePrefix` | `RootNamespace` | Resolved `AssemblyName` / `PackageId` | +| -- | -- | -- | -- | +| `Api` | `Acme` | `Acme.Api` | `Acme.Api` | +| `Acme.Api` | `Acme` | `Acme.Api` | `Acme.Api` (no double-prefix) | +| `Core.Infrastructure` | `Acme` | `Acme.Infrastructure` | `Acme.Infrastructure` (`.Core` suffix stripped) | +| `Acme` | `Acme` | `Acme` | `Acme` | -| Project name | `NamespacePrefix` | Resolved `AssemblyName` | -| -- | -- | -- | -| `Api` | `Acme` | `Acme.Api` | -| `Acme.Api` | `Acme` | `Acme.Api` (no double-prefix) | -| `Core.Infrastructure` | `Acme` | `Acme.Core.Infrastructure` | -| `Acme` | `Acme` | `Acme` | +Test projects keep their detected suffix: `Api.UnitTests` → `AssemblyName`/`PackageId` = `Acme.Api.UnitTests`, while `RootNamespace` remains `Acme.Api`. -`PackageId` follows `AssemblyName` (with namespace-remove patterns applied). An explicit `` in a `.csproj` always takes precedence over generation. +Explicit `` or `` in a `.csproj` (or `Directory.Build.props`) always takes precedence. Because the defaults run before the project body, project-authored values set in the body are evaluated later and win. -> **Note:** `RootNamespace` is derived from `$(PurviewLogicalProjectName)` regardless of this setting — it always reflects `$(NamespacePrefix).$(ProjectName)` with suffix stripping applied. `EnableAssemblyNameGeneration` only controls whether `AssemblyName`/`PackageId` follow suit. +> **Note:** set `EnableAssemblyNameGeneration=false` **before** the SDK import (for example in `Directory.Build.props`) — it is consumed during `Sdk.props` evaluation. --- diff --git a/package.json b/package.json index 1ed4921..d61f5b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "changeops", - "version": "1.0.0-prerelease.43", + "version": "1.0.0-prerelease.44", "devDependencies": { "@changesets/cli": "^2.31.0" } diff --git a/src/src/DotNetProjectSdk/Sdk/.agents/skills/project-placement-defaults/SKILL.md b/src/src/DotNetProjectSdk/Sdk/.agents/skills/project-placement-defaults/SKILL.md index 52e0ab4..1a2d3e5 100644 --- a/src/src/DotNetProjectSdk/Sdk/.agents/skills/project-placement-defaults/SKILL.md +++ b/src/src/DotNetProjectSdk/Sdk/.agents/skills/project-placement-defaults/SKILL.md @@ -58,6 +58,7 @@ Align identities with existing repository conventions: - Test project names should clearly indicate scope/type with recognized test suffixes. - `NamespacePrefix` should remain the root identity source for the repo. - `RootNamespace` usually flows from the logical project identity generated by the SDK; avoid custom namespace overrides unless required. +- `AssemblyName` and `PackageId` default to the fully evaluated `RootNamespace`, so a project's package/assembly identity follows its namespace unless the repo explicitly overrides `AssemblyName`/`PackageId` or opts out via `EnableAssemblyNameGeneration=false`. - When moving files between projects, update namespaces so they match the destination project's conventions. Do not invent a new naming scheme when an existing one is already in use. 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 693eea6..25e0657 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 @@ -46,7 +46,7 @@ These are the most important configurable properties exposed by the SDK: - `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, extended analyzer rules, disabled SourceLink and untracked-source embedding, no dependency file, compiler-generated output under the framework-specific intermediate directory, `symbols.nupkg`, `PackSourceGeneratorSymbols`, telemetry exclusion, and excluded normal build output - `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 `false`; when `true`, `AssemblyName` and default `PackageId` follow the logical project name +- `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 - `DisableGenerateAssemblyInfoClass` — default `false`; disables generated `AssemblyInfo` - `DisableAutoInternalsVisibleTo` — default `false`; disables automatic friend assembly generation @@ -60,6 +60,8 @@ These are the most important configurable properties exposed by the SDK: - `ExcludeMSTelemetryExtension` — default `false`; removes `Microsoft.Extensions.Telemetry.Abstractions`, only relevant if `ExcludePurviewTelemetry` is also `true` - `IsPackable` — defaults to `false` if not set elsewhere - `PackageTags`, `IncludeSource`, `IncludeSymbols`, `PublishRepositoryUrl`, `SymbolPackageFormat` — standard pack-related settings the SDK participates in for packable projects +- Packable-project defaults (only applied when the consuming project has not supplied a value): `GenerateDocumentationFile=true`, `IncludeSymbols=true`, `SymbolPackageFormat=snupkg`, `PublishRepositoryUrl=true`, `EmbedUntrackedSources=true`, `DebugType=portable`. Portable PDBs are delivered through the `.snupkg`; the normal `.nupkg` does not receive PDB files unless the project opts in explicitly +- If the repo root is discoverable, the repository-root `README.md` is packed automatically (and registered via `PackageReadmeFile`) when the file exists and `PackageReadmeFile` was not configured explicitly ## Test framework settings 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 8da51c3..23a0001 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 @@ -97,6 +97,8 @@ Key behavior: 2. `RootNamespace` defaults to `PurviewLogicalProjectName`. 3. Known suffixes are stripped from `RootNamespace`, including shared/shared-testing names and common segments like `Core`, `EF`, `Shared`, `ClientShared`, and `ServiceDefaults`. 4. Test suffixes are removed from `RootNamespace`, so `Acme.Api.UnitTests` still maps back to `Acme.Api`. +5. `AssemblyName` and `PackageId` default to the fully evaluated `RootNamespace` (the canonical default public name). Test/shared-testing projects keep their detected suffix in `AssemblyName`/`PackageId` so test assemblies stay distinct. Explicit `AssemblyName`/`PackageId` values always win. +6. The naming defaults are applied during `Sdk.props` evaluation (before the Microsoft SDK computes `TargetName`), so the compiled output name always matches `AssemblyName`. Do not hand-author alternate namespace conventions unless the repository explicitly opts out of the SDK defaults. @@ -133,9 +135,16 @@ This is why consistent naming and placement matter so much in repos that use the - Adds SourceLink unless `DisableSourceLink=true` - Adds Purview telemetry packages unless `ExcludePurviewTelemetry=true` -- Generates documentation files for non-test, non-shared-testing library projects +- Generates documentation files (`GenerateDocumentationFile=true`) unless explicitly disabled - Generates `InternalsVisibleTo` attributes unless `DisableAutoInternalsVisibleTo=true` +### For packable projects + +- Defaults `GenerateDocumentationFile`, `IncludeSymbols`, `SymbolPackageFormat=snupkg`, `PublishRepositoryUrl`, `EmbedUntrackedSources`, `IncludeSource`, and `DebugType=portable` — only when the consuming project has not supplied a value +- Delivers portable PDBs via the `.snupkg`; the normal `.nupkg` does not receive PDBs unless the project opts in explicitly +- Packs the repository-root `README.md` (registered via `PackageReadmeFile`) when the file exists and `PackageReadmeFile` is unset; skips when a README is already being packed +- Non-packable projects (including web apps) default `WarnOnPackingNonPackableProject=false` so solution-wide pack operations skip them silently + ### For test and shared-testing projects - Applies test-friendly `NoWarn` defaults diff --git a/src/src/DotNetProjectSdk/Sdk/Props/Defaults.props b/src/src/DotNetProjectSdk/Sdk/Props/Defaults.props index bdf85de..b96657d 100644 --- a/src/src/DotNetProjectSdk/Sdk/Props/Defaults.props +++ b/src/src/DotNetProjectSdk/Sdk/Props/Defaults.props @@ -46,7 +46,7 @@ >false true false + >true .agents @@ -58,7 +58,7 @@ >false false - false + true enable false true diff --git a/src/src/DotNetProjectSdk/Sdk/Sdk.props b/src/src/DotNetProjectSdk/Sdk/Sdk.props index 9ca5df1..72e4f7d 100644 --- a/src/src/DotNetProjectSdk/Sdk/Sdk.props +++ b/src/src/DotNetProjectSdk/Sdk/Sdk.props @@ -75,7 +75,7 @@ false false + >true false true @@ -228,7 +228,7 @@ Disables generation of the AssemblyInfo helper class in intermediate output. - When true, enables SDK generation of AssemblyName from the logical project name. When false (default), AssemblyName uses the standard .NET behaviour (project name). + When true (default), the SDK derives AssemblyName from RootNamespace and PackageId from RootNamespace. Set to false to fall back to the standard .NET behaviour (project name). Disables automatic InternalsVisibleTo generation for test and shared testing projects. @@ -442,6 +442,40 @@ >$([System.Text.RegularExpressions.Regex]::Replace($(RootNamespace), `[.]$(TestingType)Tests?$$`, ``)) + + + + $(RootNamespace) + + $(PurviewLogicalProjectName) + $(RootNamespace) + $(PurviewLogicalProjectName) + + $([System.Text.RegularExpressions.Regex]::Replace($(MSBuildProjectName), `[.]$(TestingType)Tests?$$`, ``)) @@ -492,7 +526,7 @@ ===================================================================== --> true - $(TargetsForTfmSpecificContentInPackage);IncludeLinkedSdkFiles;IncludeSdkDotAgentsGitIgnoreFiles + $(TargetsForTfmSpecificContentInPackage);IncludeLinkedSdkFiles;IncludeSdkDotAgentsGitIgnoreFiles;IncludeRepoReadmeInPackage - - true - $(AllowedOutputExtensionsInPackageBuildOutputFolder);.xml - - - $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb - $(PackageTags); - true - true - true - snupkg + true + true + true + true + true + snupkg + portable + + + + + README.md - - $(PurviewLogicalProjectName) - <_PurviewDefaultPackageId>$(AssemblyName) - <_PurviewDefaultPackageId Condition="'$(_PurviewNamespaceRemovePattern)' != ''" - >$([System.Text.RegularExpressions.Regex]::Replace('$(_PurviewDefaultPackageId)', '\.(?:$(_PurviewNamespaceRemovePattern))(?=\.|$)', '')) - $(_PurviewDefaultPackageId) - - + + + <_PurviewPackedReadme + Include="@(None)" + Condition="'%(None.Pack)' == 'true' AND $([System.String]::Copy('%(None.Filename)').ToLowerInvariant()) == 'readme'" + /> + <_PurviewPackedReadme + Include="@(TfmSpecificPackageFile)" + Condition="$([System.String]::Copy('%(TfmSpecificPackageFile.Filename)').ToLowerInvariant()) == 'readme'" + /> + + + + / + + + + diff --git a/src/tests/DotNetProjectSdk.IntegrationTests/AgentPackFolderTests.cs b/src/tests/DotNetProjectSdk.IntegrationTests/AgentPackFolderTests.cs index 53858ba..92e1b23 100644 --- a/src/tests/DotNetProjectSdk.IntegrationTests/AgentPackFolderTests.cs +++ b/src/tests/DotNetProjectSdk.IntegrationTests/AgentPackFolderTests.cs @@ -56,7 +56,7 @@ await File.WriteAllBytesAsync( await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); await Assert.That(stdOut + stdErr).DoesNotContain("NU5118"); - var packagePath = Directory.GetFiles(feedDirectory, "PackableProject.*.nupkg").Single(); + var packagePath = Directory.GetFiles(feedDirectory, "Test.PackableProject.*.nupkg").Single(); using var package = await ZipFile.OpenReadAsync(packagePath, cancellationToken); var entries = package.Entries.Select(entry => entry.FullName).ToList(); await Assert.That(entries).Contains("README.md"); @@ -162,7 +162,7 @@ await File.WriteAllTextAsync( await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); var packagePath = Directory - .GetFiles(feedDirectory, $"PackableProject.{packageVersion}.nupkg", SearchOption.TopDirectoryOnly) + .GetFiles(feedDirectory, $"Test.PackableProject.{packageVersion}.nupkg", SearchOption.TopDirectoryOnly) .SingleOrDefault(); await Assert.That(packagePath).IsNotNull().Because("The packed project package was not created."); @@ -246,7 +246,7 @@ await File.WriteAllTextAsync( await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); var packagePath = Directory - .GetFiles(feedDirectory, $"PackableProject.{packageVersion}.nupkg", SearchOption.TopDirectoryOnly) + .GetFiles(feedDirectory, $"Test.PackableProject.{packageVersion}.nupkg", SearchOption.TopDirectoryOnly) .SingleOrDefault(); await Assert.That(packagePath).IsNotNull().Because("The packed project package was not created."); @@ -374,7 +374,7 @@ await File.WriteAllTextAsync( await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); var packagePath = Directory - .GetFiles(feedDirectory, $"PackableProject.{packageVersion}.nupkg", SearchOption.TopDirectoryOnly) + .GetFiles(feedDirectory, $"Test.PackableProject.{packageVersion}.nupkg", SearchOption.TopDirectoryOnly) .SingleOrDefault(); await Assert.That(packagePath).IsNotNull().Because("The packed project package was not created."); diff --git a/src/tests/DotNetProjectSdk.IntegrationTests/PackableProjectDefaultsTests.cs b/src/tests/DotNetProjectSdk.IntegrationTests/PackableProjectDefaultsTests.cs new file mode 100644 index 0000000..36b113b --- /dev/null +++ b/src/tests/DotNetProjectSdk.IntegrationTests/PackableProjectDefaultsTests.cs @@ -0,0 +1,518 @@ +using System.Diagnostics; +using System.IO.Compression; +using System.Xml.Linq; +using Purview.DotNetProjectSdk.Harness; +using Purview.DotNetProjectSdk.Infra; + +namespace Purview.DotNetProjectSdk; + +/// +/// Verifies packable-project defaults and final package artifacts: DLL output matches the +/// evaluated AssemblyName, the NuGet package filename and nuspec id match PackageId, portable +/// PDBs are delivered via the .snupkg (not the normal .nupkg), XML docs are produced, README +/// auto-inclusion works when present, and solution-wide packs skip non-packable projects without +/// warnings unless explicitly opted in. +/// +public sealed class PackableProjectDefaultsTests +{ + const string OfflinePackProps = + "true" + + "true" + + "true" + + "true"; + + [Test] + public async Task PackableProject_Defaults_Applied_WhenNotSupplied(CancellationToken cancellationToken) + { + using var h = await ProjectHarness.CreateAsync( + "ZodSharp.SystemTextJson", + namespacePrefix: "ZodSharp", + extraProps: OfflinePackProps, + cancellationToken: cancellationToken + ); + + var props = await h.GetPropertiesAsync( + cancellationToken, + "GenerateDocumentationFile", + "IncludeSymbols", + "SymbolPackageFormat", + "PublishRepositoryUrl", + "EmbedUntrackedSources", + "DebugType", + "AllowedOutputExtensionsInPackageBuildOutputFolder" + ); + + await Assert.That(props["GenerateDocumentationFile"]).IsEqualTo("true"); + await Assert.That(props["IncludeSymbols"]).IsEqualTo("true"); + await Assert.That(props["SymbolPackageFormat"]).IsEqualTo("snupkg"); + await Assert.That(props["PublishRepositoryUrl"]).IsEqualTo("true"); + await Assert.That(props["EmbedUntrackedSources"]).IsEqualTo("true"); + await Assert.That(props["DebugType"]).IsEqualTo("portable"); + await Assert + .That(props["AllowedOutputExtensionsInPackageBuildOutputFolder"]) + .DoesNotContain(".pdb") + .Because("Portable PDBs must be delivered via the .snupkg, not the normal .nupkg."); + } + + [Test] + public async Task PackableProject_ExplicitValues_ArePreserved(CancellationToken cancellationToken) + { + using var h = await ProjectHarness.CreateAsync( + "ZodSharp.SystemTextJson", + namespacePrefix: "ZodSharp", + extraProps: "true" + + "false" + + "false" + + "symbols.nupkg" + + "false" + + "false" + + "embedded" + + "false", + cancellationToken: cancellationToken + ); + + var props = await h.GetPropertiesAsync( + cancellationToken, + "GenerateDocumentationFile", + "IncludeSymbols", + "SymbolPackageFormat", + "PublishRepositoryUrl", + "EmbedUntrackedSources", + "DebugType", + "IncludeSource" + ); + + await Assert.That(props["GenerateDocumentationFile"]).IsEqualTo("false"); + await Assert.That(props["IncludeSymbols"]).IsEqualTo("false"); + await Assert.That(props["SymbolPackageFormat"]).IsEqualTo("symbols.nupkg"); + await Assert.That(props["PublishRepositoryUrl"]).IsEqualTo("false"); + await Assert.That(props["EmbedUntrackedSources"]).IsEqualTo("false"); + await Assert.That(props["DebugType"]).IsEqualTo("embedded"); + await Assert.That(props["IncludeSource"]).IsEqualTo("false"); + } + + [Test] + public async Task PackableLibrary_NupkgAndSnupkg_Contents(CancellationToken cancellationToken) + { + using var h = await ProjectHarness.CreateAsync( + "ZodSharp.SystemTextJson", + namespacePrefix: "ZodSharp", + extraProps: OfflinePackProps, + cancellationToken: cancellationToken + ); + + var (exitCode, stdOut, stdErr) = await PackAsync(h, cancellationToken); + await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); + + // Compiled DLL filename matches the evaluated AssemblyName. + var dllPath = Path.Combine(h.ProjectDirectory, "bin", "Release", "net10.0", "ZodSharp.SystemTextJson.dll"); + await Assert.That(File.Exists(dllPath)).IsTrue().Because($"Expected DLL not found: {dllPath}"); + + var packageVersion = ExtractPackVersion(stdOut, "ZodSharp.SystemTextJson"); + var feedDirectory = Path.Combine(h.SolutionDirectory, "feed"); + var nupkgPath = Path.Combine(feedDirectory, $"ZodSharp.SystemTextJson.{packageVersion}.nupkg"); + var snupkgPath = Path.Combine(feedDirectory, $"ZodSharp.SystemTextJson.{packageVersion}.snupkg"); + + await Assert.That(File.Exists(nupkgPath)).IsTrue(); + await Assert.That(File.Exists(snupkgPath)).IsTrue(); + + using (var nupkg = await ZipFile.OpenReadAsync(nupkgPath, cancellationToken)) + { + var entries = nupkg.Entries.Select(entry => entry.FullName).ToList(); + await Assert.That(entries).Contains("lib/net10.0/ZodSharp.SystemTextJson.dll"); + await Assert.That(entries).Contains("lib/net10.0/ZodSharp.SystemTextJson.xml"); + await Assert + .That(entries.Any(entry => entry.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase))) + .IsFalse() + .Because("The normal package must not contain PDB files by default."); + + var nuspecEntry = nupkg.Entries.Single(entry => + entry.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase) + ); + await using var nuspecStream = await nuspecEntry.OpenAsync(cancellationToken); + var nuspec = XDocument.Load(nuspecStream); + await Assert.That(nuspec.Root!.Name.LocalName).IsEqualTo("package"); + await Assert + .That(nuspec.Descendants().First(e => e.Name.LocalName == "id").Value) + .IsEqualTo("ZodSharp.SystemTextJson"); + } + + using (var snupkg = await ZipFile.OpenReadAsync(snupkgPath, cancellationToken)) + { + var entries = snupkg.Entries.Select(entry => entry.FullName).ToList(); + await Assert + .That(entries) + .Contains("lib/net10.0/ZodSharp.SystemTextJson.pdb") + .Because( + $"The symbol package must contain the portable PDB.{Environment.NewLine}Entries: {string.Join(", ", entries)}" + ); + await Assert + .That(entries.Any(entry => entry.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))) + .IsFalse() + .Because("The symbol package must not contain compiled assemblies."); + } + } + + [Test] + public async Task PackableLibrary_FilenameDiffersFromRootNamespace_PackageUsesRootNamespace( + CancellationToken cancellationToken + ) + { + using var h = await ProjectHarness.CreateAsync( + "JsonLib", + namespacePrefix: "ZodSharp", + extraProps: OfflinePackProps, + cancellationToken: cancellationToken + ); + + var (exitCode, stdOut, stdErr) = await PackAsync(h, cancellationToken); + await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); + + var feedDirectory = Path.Combine(h.SolutionDirectory, "feed"); + await Assert + .That(Directory.GetFiles(feedDirectory, "ZodSharp.JsonLib.*.nupkg")) + .IsNotEmpty() + .Because("The package must be named after RootNamespace, not the project filename."); + + var dllPath = Path.Combine(h.ProjectDirectory, "bin", "Release", "net10.0", "ZodSharp.JsonLib.dll"); + await Assert.That(File.Exists(dllPath)).IsTrue(); + } + + [Test] + public async Task MultiTargeted_PackableLibrary_ProducesBothTfmFolders(CancellationToken cancellationToken) + { + using var h = await ProjectHarness.CreateAsync( + "ZodSharp.SystemTextJson", + namespacePrefix: "ZodSharp", + extraProps: OfflinePackProps + + "net10.0;netstandard2.0", + cancellationToken: cancellationToken + ); + + var (exitCode, stdOut, stdErr) = await PackAsync(h, cancellationToken); + await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); + + var packageVersion = ExtractPackVersion(stdOut, "ZodSharp.SystemTextJson"); + var nupkgPath = Path.Combine(h.SolutionDirectory, "feed", $"ZodSharp.SystemTextJson.{packageVersion}.nupkg"); + using var nupkg = await ZipFile.OpenReadAsync(nupkgPath, cancellationToken); + var entries = nupkg.Entries.Select(entry => entry.FullName).ToList(); + + await Assert.That(entries).Contains("lib/net10.0/ZodSharp.SystemTextJson.dll"); + await Assert.That(entries).Contains("lib/netstandard2.0/ZodSharp.SystemTextJson.dll"); + } + + [Test] + public async Task ExplicitAssemblyName_ProducesMatchingDll_AndPackageNamedByRootNamespace( + CancellationToken cancellationToken + ) + { + using var h = await ProjectHarness.CreateAsync( + "ZodSharp.SystemTextJson", + namespacePrefix: "ZodSharp", + extraProps: OfflinePackProps + "Custom.Binary", + cancellationToken: cancellationToken + ); + + var (exitCode, stdOut, stdErr) = await PackAsync(h, cancellationToken); + await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); + + var dllPath = Path.Combine(h.ProjectDirectory, "bin", "Release", "net10.0", "Custom.Binary.dll"); + await Assert.That(File.Exists(dllPath)).IsTrue(); + + var feedDirectory = Path.Combine(h.SolutionDirectory, "feed"); + await Assert + .That(Directory.GetFiles(feedDirectory, "ZodSharp.SystemTextJson.*.nupkg")) + .IsNotEmpty() + .Because("PackageId still defaults to RootNamespace when only AssemblyName is overridden."); + } + + [Test] + public async Task ExplicitPackageId_ProducesPackageNamedByOverride(CancellationToken cancellationToken) + { + using var h = await ProjectHarness.CreateAsync( + "ZodSharp.SystemTextJson", + namespacePrefix: "ZodSharp", + extraProps: OfflinePackProps + "Custom.Package", + cancellationToken: cancellationToken + ); + + var (exitCode, stdOut, stdErr) = await PackAsync(h, cancellationToken); + await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); + + var dllPath = Path.Combine(h.ProjectDirectory, "bin", "Release", "net10.0", "ZodSharp.SystemTextJson.dll"); + await Assert.That(File.Exists(dllPath)).IsTrue(); + + var feedDirectory = Path.Combine(h.SolutionDirectory, "feed"); + await Assert.That(Directory.GetFiles(feedDirectory, "Custom.Package.*.nupkg")).IsNotEmpty(); + } + + [Test] + public async Task Readme_Included_WhenPresent_AndPackageReadmeFileUnset(CancellationToken cancellationToken) + { + using var h = await ProjectHarness.CreateAsync( + "ZodSharp.SystemTextJson", + namespacePrefix: "ZodSharp", + extraProps: OfflinePackProps + + "false", + cancellationToken: cancellationToken + ); + + // Make the repo root discoverable and place a README there. A package.json is required + // alongside the .git marker so version detection succeeds. + await File.WriteAllTextAsync(Path.Combine(h.SolutionDirectory, ".git"), string.Empty, cancellationToken); + await File.WriteAllTextAsync( + Path.Combine(h.SolutionDirectory, "package.json"), + /*lang=json,strict*/ + """{"name": "zodsharp-systemtextjson", "version": "1.0.0"}""", + cancellationToken + ); + await File.WriteAllTextAsync( + Path.Combine(h.SolutionDirectory, "README.md"), + "# ZodSharp.SystemTextJson\n", + cancellationToken + ); + + var (exitCode, stdOut, stdErr) = await PackAsync(h, cancellationToken); + await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); + + var packageVersion = ExtractPackVersion(stdOut, "ZodSharp.SystemTextJson"); + var nupkgPath = Path.Combine(h.SolutionDirectory, "feed", $"ZodSharp.SystemTextJson.{packageVersion}.nupkg"); + using var nupkg = await ZipFile.OpenReadAsync(nupkgPath, cancellationToken); + var entries = nupkg.Entries.Select(entry => entry.FullName).ToList(); + + await Assert.That(entries).Contains("README.md"); + + var nuspecEntry = nupkg.Entries.Single(entry => + entry.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase) + ); + await using var nuspecStream = await nuspecEntry.OpenAsync(cancellationToken); + var nuspec = XDocument.Load(nuspecStream); + var readmeElement = nuspec.Descendants().FirstOrDefault(e => e.Name.LocalName == "readme"); + await Assert + .That(readmeElement?.Value) + .IsEqualTo("README.md") + .Because("PackageReadmeFile must be registered automatically when unset."); + } + + [Test] + public async Task Readme_Absent_NoFailure_AndNoReadmeInPackage(CancellationToken cancellationToken) + { + using var h = await ProjectHarness.CreateAsync( + "ZodSharp.SystemTextJson", + namespacePrefix: "ZodSharp", + extraProps: OfflinePackProps + + "false", + cancellationToken: cancellationToken + ); + + // Repo root discoverable, but no README present. + await File.WriteAllTextAsync(Path.Combine(h.SolutionDirectory, ".git"), string.Empty, cancellationToken); + await File.WriteAllTextAsync( + Path.Combine(h.SolutionDirectory, "package.json"), + /*lang=json,strict*/ + """{"name": "zodsharp-systemtextjson", "version": "1.0.0"}""", + cancellationToken + ); + + var (exitCode, stdOut, stdErr) = await PackAsync(h, cancellationToken); + await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); + + var packageVersion = ExtractPackVersion(stdOut, "ZodSharp.SystemTextJson"); + var nupkgPath = Path.Combine(h.SolutionDirectory, "feed", $"ZodSharp.SystemTextJson.{packageVersion}.nupkg"); + using var nupkg = await ZipFile.OpenReadAsync(nupkgPath, cancellationToken); + await Assert.That(nupkg.Entries.Select(entry => entry.FullName)).DoesNotContain("README.md"); + } + + [Test] + public async Task NonPackable_WebProject_SolutionWidePack_NoWarning_ByDefault(CancellationToken cancellationToken) + { + var sharedDir = Path.Combine(Path.GetTempPath(), "PurviewSdkTests", Guid.NewGuid().ToString("N")); + + try + { + using var lib = await ProjectHarness + .For("ZodSharp.SystemTextJson") + .WithSolutionDirectory(sharedDir) + .WithNamespacePrefix("ZodSharp") + .AddPropertyRaw(OfflinePackProps) + .BuildAsync(cancellationToken); + + using var web = await ProjectHarness + .For("ZodSharp.Web") + .WithSolutionDirectory(sharedDir) + .WithNamespacePrefix("ZodSharp") + .WithSdk("Microsoft.NET.Sdk.Web") + .AddPropertyRaw( + "truetruetrue" + ) + .BuildAsync(cancellationToken); + + await File.WriteAllTextAsync( + Path.Combine(web.ProjectDirectory, "Program.cs"), + "public class Program { public static void Main() { } }\n", + cancellationToken + ); + await WriteValidSolutionAsync(sharedDir, cancellationToken); + + var feedDirectory = Path.Combine(sharedDir, "feed"); + var solutionPath = Path.Combine(sharedDir, "TestingSolution.slnx"); + var (exitCode, stdOut, stdErr) = await RunProcessAsync( + "dotnet", + $"pack \"{solutionPath}\" -c Release -o \"{feedDirectory}\"", + sharedDir, + cancellationToken + ); + + await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); + await Assert + .That(stdOut + stdErr) + .DoesNotContain("cannot be packaged because packaging has been disabled") + .Because("Non-packable projects must not warn by default during solution-wide pack."); + await Assert.That(Directory.GetFiles(feedDirectory, "ZodSharp.SystemTextJson.*.nupkg")).IsNotEmpty(); + await Assert + .That(Directory.GetFiles(feedDirectory, "ZodSharp.Web.*")) + .IsEmpty() + .Because("The non-packable web project must not produce a package."); + } + finally + { + if (Directory.Exists(sharedDir)) + Directory.Delete(sharedDir, recursive: true); + } + } + + [Test] + public async Task NonPackable_WebProject_ExplicitWarnOnPackingNonPackableProject_EmitsWarning( + CancellationToken cancellationToken + ) + { + var sharedDir = Path.Combine(Path.GetTempPath(), "PurviewSdkTests", Guid.NewGuid().ToString("N")); + + try + { + using var lib = await ProjectHarness + .For("ZodSharp.SystemTextJson") + .WithSolutionDirectory(sharedDir) + .WithNamespacePrefix("ZodSharp") + .AddPropertyRaw(OfflinePackProps) + .BuildAsync(cancellationToken); + + using var web = await ProjectHarness + .For("ZodSharp.Web") + .WithSolutionDirectory(sharedDir) + .WithNamespacePrefix("ZodSharp") + .WithSdk("Microsoft.NET.Sdk.Web") + .AddPropertyRaw( + "truetruetruetrue" + ) + .BuildAsync(cancellationToken); + + await File.WriteAllTextAsync( + Path.Combine(web.ProjectDirectory, "Program.cs"), + "public class Program { public static void Main() { } }\n", + cancellationToken + ); + await WriteValidSolutionAsync(sharedDir, cancellationToken); + + var feedDirectory = Path.Combine(sharedDir, "feed"); + var solutionPath = Path.Combine(sharedDir, "TestingSolution.slnx"); + var (exitCode, stdOut, stdErr) = await RunProcessAsync( + "dotnet", + $"pack \"{solutionPath}\" -c Release -o \"{feedDirectory}\"", + sharedDir, + cancellationToken + ); + + await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); + await Assert + .That(stdOut + stdErr) + .Contains("cannot be packaged because packaging has been disabled") + .Because("Explicit opt-in to WarnOnPackingNonPackableProject must surface the warning."); + } + finally + { + if (Directory.Exists(sharedDir)) + Directory.Delete(sharedDir, recursive: true); + } + } + + /// + /// Writes a valid .slnx containing the two projects. The harness's own solution file uses a + /// bare <File> element that solution tooling does not recognise for build/pack operations. + /// + static async Task WriteValidSolutionAsync(string solutionDirectory, CancellationToken cancellationToken) + { + await File.WriteAllTextAsync( + Path.Combine(solutionDirectory, "TestingSolution.slnx"), + """ + + + + + + + """, + cancellationToken + ); + } + + static async Task<(int Code, string StdOut, string StdErr)> PackAsync( + ProjectHarness harness, + CancellationToken cancellationToken + ) + { + var feedDirectory = Path.Combine(harness.SolutionDirectory, "feed"); + return await RunProcessAsync( + "dotnet", + $"pack \"{harness.ProjectFilePath}\" -c Release -o \"{feedDirectory}\"", + harness.SolutionDirectory, + cancellationToken + ); + } + + static string ExtractPackVersion(string stdOut, string packageId) + { + var match = System.Text.RegularExpressions.Regex.Match( + stdOut, + $"Successfully created package '[^']*{System.Text.RegularExpressions.Regex.Escape(packageId)}\\.([^']+)\\.nupkg'" + ); + if (!match.Success) + return string.Empty; + + return match.Groups[1].Value; + } + + static async Task<(int Code, string StdOut, string StdErr)> RunProcessAsync( + string fileName, + string arguments, + string workingDirectory, + CancellationToken cancellationToken + ) + { + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }, + }; + + ProjectHarness.IsolateFromHostEnvironment( + process.StartInfo.Environment, + new Dictionary(StringComparer.OrdinalIgnoreCase) + ); + + process.Start(); + var stdoutTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var stderrTask = process.StandardError.ReadToEndAsync(cancellationToken); + await process.WaitForExitAsync(cancellationToken); + + return (process.ExitCode, await stdoutTask, await stderrTask); + } +} diff --git a/src/tests/DotNetProjectSdk.IntegrationTests/ProjectIdentityRulesTests.cs b/src/tests/DotNetProjectSdk.IntegrationTests/ProjectIdentityRulesTests.cs index b9db51a..cb89d32 100644 --- a/src/tests/DotNetProjectSdk.IntegrationTests/ProjectIdentityRulesTests.cs +++ b/src/tests/DotNetProjectSdk.IntegrationTests/ProjectIdentityRulesTests.cs @@ -121,7 +121,7 @@ CancellationToken cancellationToken var props = await h.GetPropertiesAsync(cancellationToken, "AssemblyName", "RootNamespace", "PackageId"); - await Assert.That(props["AssemblyName"]).IsEqualTo("Purview.Aspire.ResourceIsolation.ServiceDefaults"); + await Assert.That(props["AssemblyName"]).IsEqualTo("Purview.Aspire.ResourceIsolation"); await Assert.That(props["RootNamespace"]).IsEqualTo("Purview.Aspire.ResourceIsolation"); await Assert.That(props["PackageId"]).IsEqualTo("Purview.Aspire.ResourceIsolation"); } @@ -276,7 +276,9 @@ public async Task InternalsVisibleTo_UsesFullyQualifiedTestAssemblyNames(Cancell } [Test] - public async Task InternalsVisibleTo_UsesShortTestAssemblyNames_ByDefault(CancellationToken cancellationToken) + public async Task InternalsVisibleTo_UsesFullyQualifiedTestAssemblyNames_ByDefault( + CancellationToken cancellationToken + ) { using var h = await ProjectHarness.CreateAsync( "SourceGenerator", @@ -291,13 +293,13 @@ public async Task InternalsVisibleTo_UsesShortTestAssemblyNames_ByDefault(Cancel await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); var friendAssemblies = ExtractItemMetadataValues(stdOut, "AssemblyAttribute", "_Parameter1"); - await Assert.That(friendAssemblies).Contains("SourceGenerator.UnitTests"); - await Assert.That(friendAssemblies).Contains("SourceGenerator.IntegrationTests"); - await Assert.That(friendAssemblies).Contains("SourceGenerator.ArchitectureTests"); - await Assert.That(friendAssemblies).Contains("SourceGenerator.ContractTests"); - await Assert.That(friendAssemblies).Contains("SourceGenerator.FunctionalTests"); - // SharedTesting projects should use short names when EnableAssemblyNameGeneration is not set - await Assert.That(friendAssemblies).Contains("SharedTestingFramework"); + await Assert.That(friendAssemblies).Contains("ExampleProject.SourceGenerator.UnitTests"); + await Assert.That(friendAssemblies).Contains("ExampleProject.SourceGenerator.IntegrationTests"); + await Assert.That(friendAssemblies).Contains("ExampleProject.SourceGenerator.ArchitectureTests"); + await Assert.That(friendAssemblies).Contains("ExampleProject.SourceGenerator.ContractTests"); + await Assert.That(friendAssemblies).Contains("ExampleProject.SourceGenerator.FunctionalTests"); + // SharedTesting projects are prefixed by default (EnableAssemblyNameGeneration defaults to true) + await Assert.That(friendAssemblies).Contains("ExampleProject.SharedTestingFramework"); } [Test] @@ -322,34 +324,36 @@ public async Task InternalsVisibleTo_UsesExplicitAssemblyName(CancellationToken await Assert.That(friendAssemblies).Contains("Custom.Assembly.ArchitectureTests"); await Assert.That(friendAssemblies).Contains("Custom.Assembly.ContractTests"); await Assert.That(friendAssemblies).Contains("Custom.Assembly.FunctionalTests"); - // SharedTesting projects use raw names (no AssemblyName prefix) since they're standalone projects - await Assert.That(friendAssemblies).Contains("SharedTestingFramework"); + // SharedTesting projects use the prefixed name by default (EnableAssemblyNameGeneration defaults to true) + await Assert.That(friendAssemblies).Contains("ExampleProject.SharedTestingFramework"); } [Test] - public async Task AssemblyName_DefaultsToProjectName_WhenGenerationDisabled(CancellationToken cancellationToken) + public async Task AssemblyName_DefaultsToRootNamespace(CancellationToken cancellationToken) { using var h = await ProjectHarness.CreateAsync( "Api", namespacePrefix: "ExampleProject", cancellationToken: cancellationToken ); - await Assert.That(await h.GetPropertyAsync("AssemblyName", cancellationToken)).IsEqualTo("Api"); + await Assert.That(await h.GetPropertyAsync("AssemblyName", cancellationToken)).IsEqualTo("ExampleProject.Api"); } [Test] - public async Task AssemblyName_DefaultsToProjectName_ForShortChildProject(CancellationToken cancellationToken) + public async Task AssemblyName_DefaultsToRootNamespace_ForShortChildProject(CancellationToken cancellationToken) { using var h = await ProjectHarness.CreateAsync( "SourceGenerator", namespacePrefix: "ExampleProject", cancellationToken: cancellationToken ); - await Assert.That(await h.GetPropertyAsync("AssemblyName", cancellationToken)).IsEqualTo("SourceGenerator"); + await Assert + .That(await h.GetPropertyAsync("AssemblyName", cancellationToken)) + .IsEqualTo("ExampleProject.SourceGenerator"); } [Test] - public async Task PackageId_DefaultsToProjectName_WhenGenerationDisabled(CancellationToken cancellationToken) + public async Task PackageId_DefaultsToRootNamespace(CancellationToken cancellationToken) { using var h = await ProjectHarness.CreateAsync( "Api", @@ -359,17 +363,33 @@ public async Task PackageId_DefaultsToProjectName_WhenGenerationDisabled(Cancell var props = await h.GetPropertiesAsync(cancellationToken, "AssemblyName", "PackageId"); - await Assert.That(props["AssemblyName"]).IsEqualTo("Api"); - await Assert.That(props["PackageId"]).IsEqualTo("Api"); + await Assert.That(props["AssemblyName"]).IsEqualTo("ExampleProject.Api"); + await Assert.That(props["PackageId"]).IsEqualTo("ExampleProject.Api"); } [Test] - public async Task EnableAssemblyNameGeneration_DefaultsToFalse(CancellationToken cancellationToken) + public async Task EnableAssemblyNameGeneration_DefaultsToTrue(CancellationToken cancellationToken) { using var h = await ProjectHarness.CreateAsync("MyLibrary", cancellationToken: cancellationToken); + await Assert + .That(await h.GetPropertyAsync("EnableAssemblyNameGeneration", cancellationToken)) + .IsEqualTo("true"); + } + + [Test] + public async Task EnableAssemblyNameGeneration_CanBeSetToFalse_ToOptOut(CancellationToken cancellationToken) + { + using var h = await ProjectHarness.CreateAsync( + "MyLibrary", + namespacePrefix: "ExampleProject", + preImportProps: "false", + cancellationToken: cancellationToken + ); await Assert .That(await h.GetPropertyAsync("EnableAssemblyNameGeneration", cancellationToken)) .IsEqualTo("false"); + // Opting out restores the standard .NET behaviour (project name). + await Assert.That(await h.GetPropertyAsync("AssemblyName", cancellationToken)).IsEqualTo("MyLibrary"); } [Test] diff --git a/src/tests/DotNetProjectSdk.IntegrationTests/RootNamespaceNamingTests.cs b/src/tests/DotNetProjectSdk.IntegrationTests/RootNamespaceNamingTests.cs new file mode 100644 index 0000000..76e9fbe --- /dev/null +++ b/src/tests/DotNetProjectSdk.IntegrationTests/RootNamespaceNamingTests.cs @@ -0,0 +1,182 @@ +using Purview.DotNetProjectSdk.Harness; + +namespace Purview.DotNetProjectSdk; + +/// +/// Verifies RootNamespace-derived naming: AssemblyName and PackageId default to the fully +/// evaluated RootNamespace, explicit overrides always win, and no literal $(...) expression +/// survives into either name. +/// +public sealed class RootNamespaceNamingTests +{ + [Test] + public async Task PackableLibrary_AssemblyNameAndPackageId_DefaultToRootNamespace( + CancellationToken cancellationToken + ) + { + // Project filename (ZodSharp.SystemTextJson) equals its root namespace. + using var h = await ProjectHarness.CreateAsync( + "ZodSharp.SystemTextJson", + namespacePrefix: "ZodSharp", + extraProps: "true", + cancellationToken: cancellationToken + ); + + var props = await h.GetPropertiesAsync(cancellationToken, "AssemblyName", "PackageId", "RootNamespace"); + + await Assert.That(props["RootNamespace"]).IsEqualTo("ZodSharp.SystemTextJson"); + await Assert.That(props["AssemblyName"]).IsEqualTo("ZodSharp.SystemTextJson"); + await Assert.That(props["PackageId"]).IsEqualTo("ZodSharp.SystemTextJson"); + await Assert.That(props["AssemblyName"]).DoesNotContain("$("); + await Assert.That(props["PackageId"]).DoesNotContain("$("); + } + + [Test] + public async Task PackableLibrary_AssemblyNameAndPackageId_DefaultToRootNamespace_WhenFilenameDiffers( + CancellationToken cancellationToken + ) + { + // Project filename (JsonLib) differs from RootNamespace (ZodSharp.JsonLib). + using var h = await ProjectHarness.CreateAsync( + "JsonLib", + namespacePrefix: "ZodSharp", + extraProps: "true", + cancellationToken: cancellationToken + ); + + var props = await h.GetPropertiesAsync(cancellationToken, "AssemblyName", "PackageId", "RootNamespace"); + + await Assert.That(props["RootNamespace"]).IsEqualTo("ZodSharp.JsonLib"); + await Assert.That(props["AssemblyName"]).IsEqualTo("ZodSharp.JsonLib"); + await Assert.That(props["PackageId"]).IsEqualTo("ZodSharp.JsonLib"); + } + + [Test] + public async Task ComposedRootNamespace_FromPropertyExpression_IsFullyEvaluated(CancellationToken cancellationToken) + { + // RootNamespace is composed from $(ProductPrefix) before the SDK import; AssemblyName and + // PackageId must follow the composed value, not the NamespacePrefix-derived default. + using var h = await ProjectHarness + .For("SystemTextJson") + .WithNamespacePrefix("Contoso") + .WithPreImportPropertiesRaw( + "ZodSharp$(ProductPrefix).SystemTextJson" + ) + .AddPropertyRaw("true") + .BuildAsync(cancellationToken); + + var props = await h.GetPropertiesAsync(cancellationToken, "AssemblyName", "PackageId", "RootNamespace"); + + await Assert.That(props["RootNamespace"]).IsEqualTo("ZodSharp.SystemTextJson"); + await Assert.That(props["AssemblyName"]).IsEqualTo("ZodSharp.SystemTextJson"); + await Assert.That(props["PackageId"]).IsEqualTo("ZodSharp.SystemTextJson"); + await Assert.That(props["AssemblyName"]).DoesNotContain("$("); + await Assert.That(props["PackageId"]).DoesNotContain("$("); + } + + [Test] + public async Task ExplicitAssemblyName_Override_Wins_AndPackageIdFollowsRootNamespace( + CancellationToken cancellationToken + ) + { + using var h = await ProjectHarness.CreateAsync( + "SystemTextJson", + namespacePrefix: "ZodSharp", + extraProps: "trueCustom.Binary", + cancellationToken: cancellationToken + ); + + var props = await h.GetPropertiesAsync(cancellationToken, "AssemblyName", "PackageId", "RootNamespace"); + + await Assert.That(props["AssemblyName"]).IsEqualTo("Custom.Binary"); + await Assert.That(props["PackageId"]).IsEqualTo("ZodSharp.SystemTextJson"); + await Assert.That(props["RootNamespace"]).IsEqualTo("ZodSharp.SystemTextJson"); + } + + [Test] + public async Task ExplicitPackageId_Override_Wins_AndAssemblyNameFollowsRootNamespace( + CancellationToken cancellationToken + ) + { + using var h = await ProjectHarness.CreateAsync( + "SystemTextJson", + namespacePrefix: "ZodSharp", + extraProps: "trueCustom.Package", + cancellationToken: cancellationToken + ); + + var props = await h.GetPropertiesAsync(cancellationToken, "AssemblyName", "PackageId", "RootNamespace"); + + await Assert.That(props["AssemblyName"]).IsEqualTo("ZodSharp.SystemTextJson"); + await Assert.That(props["PackageId"]).IsEqualTo("Custom.Package"); + await Assert.That(props["RootNamespace"]).IsEqualTo("ZodSharp.SystemTextJson"); + } + + [Test] + public async Task ExplicitAssemblyName_And_PackageId_Overrides_AllWin(CancellationToken cancellationToken) + { + using var h = await ProjectHarness.CreateAsync( + "SystemTextJson", + namespacePrefix: "ZodSharp", + extraProps: "trueCustom.BinaryCustom.Package", + cancellationToken: cancellationToken + ); + + var props = await h.GetPropertiesAsync(cancellationToken, "AssemblyName", "PackageId", "RootNamespace"); + + await Assert.That(props["AssemblyName"]).IsEqualTo("Custom.Binary"); + await Assert.That(props["PackageId"]).IsEqualTo("Custom.Package"); + await Assert.That(props["RootNamespace"]).IsEqualTo("ZodSharp.SystemTextJson"); + } + + [Test] + public async Task TestProject_AssemblyName_RetainsTestSuffix(CancellationToken cancellationToken) + { + // RootNamespace strips the test suffix; the test assembly must stay distinct from the source. + using var h = await ProjectHarness.CreateAsync( + "Api.UnitTests", + namespacePrefix: "ExampleProject", + cancellationToken: cancellationToken + ); + + var props = await h.GetPropertiesAsync(cancellationToken, "AssemblyName", "PackageId", "RootNamespace"); + + await Assert.That(props["RootNamespace"]).IsEqualTo("ExampleProject.Api"); + await Assert.That(props["AssemblyName"]).IsEqualTo("ExampleProject.Api.UnitTests"); + await Assert.That(props["PackageId"]).IsEqualTo("ExampleProject.Api.UnitTests"); + } + + [Test] + public async Task MultiTargetedProject_AssemblyNameAndPackageId_AreConsistent(CancellationToken cancellationToken) + { + using var h = await ProjectHarness.CreateAsync( + "ZodSharp.SystemTextJson", + namespacePrefix: "ZodSharp", + extraProps: "truenet10.0;netstandard2.0", + cancellationToken: cancellationToken + ); + + var props = await h.GetPropertiesAsync(cancellationToken, "AssemblyName", "PackageId", "RootNamespace"); + + await Assert.That(props["RootNamespace"]).IsEqualTo("ZodSharp.SystemTextJson"); + await Assert.That(props["AssemblyName"]).IsEqualTo("ZodSharp.SystemTextJson"); + await Assert.That(props["PackageId"]).IsEqualTo("ZodSharp.SystemTextJson"); + } + + [Test] + public async Task EnableAssemblyNameGeneration_False_OptsOut_ToProjectName(CancellationToken cancellationToken) + { + using var h = await ProjectHarness.CreateAsync( + "SystemTextJson", + namespacePrefix: "ZodSharp", + preImportProps: "false", + cancellationToken: cancellationToken + ); + + var props = await h.GetPropertiesAsync(cancellationToken, "AssemblyName", "PackageId"); + + // Opting out restores standard .NET behaviour (project name). + await Assert.That(props["AssemblyName"]).IsEqualTo("SystemTextJson"); + await Assert.That(props["PackageId"]).IsEqualTo("SystemTextJson"); + } +} diff --git a/src/tests/DotNetProjectSdk.IntegrationTests/RoslynComponentDefaultsTests.cs b/src/tests/DotNetProjectSdk.IntegrationTests/RoslynComponentDefaultsTests.cs index f76b847..7d714d1 100644 --- a/src/tests/DotNetProjectSdk.IntegrationTests/RoslynComponentDefaultsTests.cs +++ b/src/tests/DotNetProjectSdk.IntegrationTests/RoslynComponentDefaultsTests.cs @@ -87,11 +87,11 @@ public async Task PackableProject_AutomaticallyPacksAnalyzerProjectReference(Can ); await Assert.That(exitCode).IsEqualTo(0).Because(TestHelpers.GenerateError(stdOut, stdErr)); - var packagePath = Directory.GetFiles(packageDirectory, "Consumer.*.nupkg").Single(); + var packagePath = Directory.GetFiles(packageDirectory, "Test.Consumer.*.nupkg").Single(); using var package = await ZipFile.OpenReadAsync(packagePath, cancellationToken); await Assert .That(package.Entries.Select(entry => entry.FullName)) - .Contains("analyzers/dotnet/cs/SourceGeneration.dll"); + .Contains("analyzers/dotnet/cs/Test.SourceGeneration.dll"); await Assert .That(package.Entries.Select(entry => entry.FullName)) .Contains("analyzers/dotnet/cs/RuntimeDependency.dll");