diff --git a/.config/purview-build.json b/.config/purview-build.json index 8f2fc36..732f2bd 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -18,9 +18,11 @@ "README.md", "Sdk/.editorconfig", "Sdk/Props/Defaults.props", + "Sdk/Props/PackedVersion.props", "Sdk/Props/VersionDetection.props", "Sdk/Sdk.props", "Sdk/Sdk.targets", + "Sdk/Targets/Blazor.targets", "Sdk/Targets/VersionDetection.targets" ], "purview.skills.testing": [ diff --git a/AGENTS.md b/AGENTS.md index 0c9a710..bacd71a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -279,6 +279,13 @@ Prefer `just` tasks: - `just pack` - `just skills-validate` — the pack-time check over the authored skills - `just skills-add ` — install a skill from another repository into `.agents/skills/` +- `just version-sync` — rewrite the `Purview.BuildSdk` pins in the docs and `templates/global.json` + after bumping `package.json` + +Nothing packed carries a hand-maintained version. The `.editorconfig` marker and +`Props/PackedVersion.props` (the `global.json` fallback when the SDK is not in a versioned package +folder) are stamped at pack time from `$(PackageVersion)`. Only the copy-paste pins in the docs live in +git, and `PackagedVersionFallbackTests` names each stale one as `file:line`. `dotnet` fallback uses `src/BuildSdk.slnx` and `Release`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c7ef75..5f9256e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,8 +11,51 @@ release summarise what the commits recorded rather than what was written at the ## [Unreleased] +## [1.2.0] - 2026-10-28 + ### Added +- **Blazor WebAssembly clients, Blazor hosts and BFF hosts are detected.** `IsBlazorWebAssemblyProject` + (`Microsoft.NET.Sdk.BlazorWebAssembly`), `IsRazorClassLibraryProject` (`Microsoft.NET.Sdk.Razor`), + `IsBffProject` (a web SDK project named `*Bff`, or one setting `IsBffProject=true`) and + `IsBlazorHostProject` (a web SDK project referencing a WebAssembly client) are all compiler-visible. + A host never implies a BFF, because a host that keeps no tokens is not one. A host `` gets an + automatic reference to its `.Client` unless it declares one (`PurviewAutoBlazorClientReference=false` + opts out); any other pairing is resolved during the build by `ResolvePurviewBlazorHostProject`. + +- **WebAssembly client defaults.** A client is `IsPackable=false` unless it says otherwise, gets no + container defaults even beside a `Dockerfile` (`PublishAot` on a client is a publish that fails), and + no automatic `../Shared*` reference, so a server-side library cannot join the browser download because + of a folder name. Globalisation, trimming and the Blazor runtime settings are left to the Blazor SDK. + `DisablePurviewBlazorWebAssemblyDefaults=true` restores the generic defaults. + +- **`PurviewBrowserSafe` makes a library the browser downloads fail the build on what would fail at + runtime.** On a class library it applies `IsAotCompatible=true`, `` + and `WarningsAsErrors` for the trim, single-file, AOT and `CA1416` ids (`PurviewBrowserSafeWarningCodes`) + — each only where the project set nothing, and without `TreatWarningsAsErrors`, so the rest of its + warning policy is untouched. `PurviewBrowserSafeWarningsAsErrors=false` keeps them as warnings. + +- **`PRBLZ0001`: every library a client downloads must have been declared browser-safe.** A client, and + each browser-safe library, asks every `ProjectReference` for its `PurviewBrowserSafe` value and reports + the ones that do not set it, so the guarantee covers the whole download rather than its first level. + `PurviewBrowserSafe="true"` on the `ProjectReference` records the decision for a project that does not + use this SDK; `PurviewBrowserSafeReferenceCheckAsError=true` makes it an error and + `DisablePurviewBrowserSafeReferenceCheck=true` turns it off. + +- **BFF hosts.** No packages are added — YARP, OpenID Connect and any BFF library are the application's + choice — and the OpenAPI interceptor namespace is dropped, because a BFF that only forwards has no + document to generate. Referencing `Microsoft.AspNetCore.OpenApi`, or `PurviewBffExposesApi=true`, + keeps it. + +- **`PDS0008` and `PDS0009` report a BFF authentication cookie that undoes the BFF.** `HttpOnly = false` + lets script in the page read and replay the session; `SameSite = None` makes every BFF endpoint a + cross-site request forgery target. Only a constant assigned to the `CookieAuthenticationOptions` cookie + in a BFF project is reported — never the antiforgery cookie or the OpenID Connect correlation and nonce + cookies, which legitimately differ. + +- **`just version-sync`** rewrites the `Purview.BuildSdk` pins in the docs and `templates/global.json` + from `package.json`. + - **`CA1034` is suppressed on an `extension` block (`PDS0007`).** A C# 14 extension block is compiled into a nested type inside its owning static class, so `CA1034` ("nested types should not be visible") fires on every one, reported at the `extension` keyword and naming nothing because the generated type @@ -36,6 +79,14 @@ release summarise what the commits recorded rather than what was written at the ### Fixed +- **A bootstrapped `global.json` now pins the SDK version that was actually restored.** + `Defaults.props` is imported before `Sdk.props` detects the version from the restored package folder, + and it set a hand-maintained literal, so detection never ran: every bootstrapped `global.json` pinned + whatever was last typed into `Defaults.props`, which went stale on each bump. The literal is gone. The + folder name wins, and an SDK imported from anywhere else falls back to `Props/PackedVersion.props`, + stamped from `$(PackageVersion)` at pack time. The remaining copy-paste pins in the docs are rewritten + by `just version-sync`, and `PackagedVersionFallbackTests` names each stale one as `file:line`. + - **Pack-time skill validation now validates the repository it is packing.** `ValidatePurviewSkills` read the evaluation-time `PurviewSkillsSourceFolder`, which is resolved from `RepoRoot` and an `AGENTS.md` walk-up. On a CI runner `RepoRoot` is taken from `GITHUB_WORKSPACE`, and it can also arrive stale from @@ -210,7 +261,8 @@ release summarise what the commits recorded rather than what was written at the - `.editorconfig` values missing test projects. - Source-generator resolution on pack. -[Unreleased]: https://github.com/purview-dev/build-sdk/compare/v1.1.0...HEAD +[Unreleased]: https://github.com/purview-dev/build-sdk/compare/v1.2.0...HEAD +[1.2.0]: https://github.com/purview-dev/build-sdk/compare/v1.1.0...v1.2.0 [1.1.0]: https://github.com/purview-dev/build-sdk/compare/v1.0.7...v1.1.0 [1.0.7]: https://github.com/purview-dev/build-sdk/compare/v1.0.6...v1.0.7 [1.0.6]: https://github.com/purview-dev/build-sdk/compare/v1.0.5...v1.0.6 diff --git a/Justfile b/Justfile index ba77fb0..a737d6a 100644 --- a/Justfile +++ b/Justfile @@ -88,13 +88,21 @@ restore *args: current_version: echo "==> Current version: {{ GREEN }}{{ current_version }}{{ NORMAL }} (defined in package.json and automatically included in the build output through the Purview.BuildSdk package)" +# Rewrite the Purview.BuildSdk pins in the docs and starter template to the version in package.json +# +# Run after bumping package.json. Packed files are stamped at pack time and need nothing. +[group('Build and Test')] +version-sync: + echo "==> Syncing version pins to {{ GREEN }}{{ current_version }}{{ NORMAL }}" + bun scripts/sync-version.ts + # Run tests for a specific project with a filter (e.g., "/*/*/*/*/", or "/*/*/*/*[Category=Unit]" to run just unit tests) and configuration (e.g., "Release") [group('Build and Test')] test filter="/*/*/*/*/" *args: echo "==> Testing {{ BLUE }}{{ solution_file }}{{ NORMAL }} ({{ GREEN }}{{ build_configuration }}{{ NORMAL }}) with filter {{ YELLOW }}{{ filter }}{{ NORMAL }}" dotnet test --project {{ solution_file }} --configuration {{ build_configuration }} --treenode-filter "{{ filter }}" --ignore-exit-code 8 {{ args }} -# Run agent-pack integration tests in the same Linux SDK environment used by CI +# Run the SDK integration tests in the same Linux SDK environment used by CI [group('Build and Test')] test-linux: pwsh -NoProfile -File ./scripts/test-linux-docker.ps1 diff --git a/README.md b/README.md index 9549048..b09af0b 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ A reusable MSBuild SDK NuGet package that delivers standardised .NET project def | Feature | Detail | | -- | -- | -| **Project type detection** | `IsCSharpProject`, `IsTestProject`, `IsSharedTestingProject`, `IsContainerProject`, `IsWebSdkProject`, `IsAspireHostProject`, … | +| **Project type detection** | `IsCSharpProject`, `IsTestProject`, `IsSharedTestingProject`, `IsContainerProject`, `IsWebSdkProject`, `IsAspireHostProject`, `IsBlazorWebAssemblyProject`, `IsBffProject`, … | +| **Blazor and BFF** | Blazor WebAssembly clients, Blazor Web App hosts and BFF hosts are detected; `PurviewBrowserSafe` makes a library the client downloads fail the build on trim, AOT and browser-unsupported APIs, and the client checks every reference sets it (see [Blazor WebAssembly and BFF](#blazor-webassembly-and-bff)) | | **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`. Style policy: `dotnet_style_require_accessibility_modifiers=omit_if_default`, private instance fields named `_camelCase`, and `IDE0040`/`CA1515`/`CA1852`/`CA1034` at warning or above, enforced by `ValidatePurviewStylePolicy` (opt out with `DisablePurviewStylePolicyValidation=true`) | | **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`) | @@ -41,7 +42,7 @@ A reusable MSBuild SDK NuGet package that delivers standardised .NET project def "runner": "Microsoft.Testing.Platform" }, "msbuild-sdks": { - "Purview.BuildSdk": "1.1.0" + "Purview.BuildSdk": "1.2.0" } } ``` @@ -200,7 +201,7 @@ to a low-importance message) and only a copy that still fails after every retry The bootstrapped `.editorconfig` opens with a version marker: ```editorconfig -# Purview.BuildSdk 1.1.0 - managed file, do not edit +# Purview.BuildSdk 1.2.0 - managed file, do not edit ``` That line is how the SDK recognises a file **it** wrote. When the SDK version changes, a file carrying @@ -392,7 +393,7 @@ Non-packable projects (including web applications) default `WarnOnPackingNonPack | `RepositoryEditorConfigFilePath` | *(auto-detected)* | Override the destination path for the bootstrapped `.editorconfig`. | | `BootstrapGlobalJsonToRepoRoot` | `true` | Creates a `global.json` at the repository root when missing. | | `RepositoryGlobalJsonFilePath` | *(auto-detected)* | Override the destination path for the bootstrapped `global.json`. | -| `PurviewBuildSdkVersionForGlobalJson` | *(auto-detected or `1.0.0` fallback)* | Version written to the `msbuild-sdks.Purview.BuildSdk` entry in a bootstrapped `global.json`. | +| `PurviewBuildSdkVersionForGlobalJson` | *(restored package version, else the version it was packed as)* | Version written to the `msbuild-sdks.Purview.BuildSdk` entry in a bootstrapped `global.json`. | | `PurviewRepoBootstrapMode` | `IfMissing` | `IfMissing` never touches an existing file, `Always` overwrites it, `WarnOnDrift` reports a file that differs from the SDK-provided one, and `Never` skips bootstrapping. | | `PurviewRepoBootstrapCopyRetries` | `3` | Write attempts before a bootstrap write failure is reported. | | `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | `500` | Base delay between bootstrap write attempts. | @@ -496,12 +497,23 @@ The SDK now exports its properties via `CompilerVisibleProperty`, so analyzers a | `IsWebSdkProject` | True when `SdkProjectName` is `Microsoft.NET.Sdk.Web`. | | `IsWorkerSdkProject` | True when `SdkProjectName` is `Microsoft.NET.Sdk.Worker`. | | `IsAspireHostProject` | True when SDK starts with `Aspire.Sdk.Host`. | +| `IsBlazorWebAssemblyProject` | True when `SdkProjectName` is `Microsoft.NET.Sdk.BlazorWebAssembly`. | +| `IsRazorClassLibraryProject` | True when `SdkProjectName` is `Microsoft.NET.Sdk.Razor`. | +| `IsBlazorHostProject` | True for a web SDK project that references a Blazor WebAssembly client. | +| `IsBffProject` | True for a web SDK project named `*Bff`, or that sets `IsBffProject=true`. | +| `PurviewBrowserSafe` | When `true` on a class library, applies the browser-safe defaults. | +| `PurviewBrowserSafeWarningsAsErrors` | When `false`, browser-safe trim and AOT diagnostics stay warnings. | +| `DisablePurviewBrowserSafeReferenceCheck` | Turns off `PRBLZ0001`. | +| `PurviewBrowserSafeReferenceCheckAsError` | Reports `PRBLZ0001` as an error. | +| `DisablePurviewBlazorWebAssemblyDefaults` | Keeps the generic defaults on a WebAssembly client. | +| `PurviewAutoBlazorClientReference` | When `false`, a host does not reference its `.Client` automatically. | +| `PurviewBffExposesApi` | Whether a BFF keeps the OpenAPI interceptor namespace. | | `EditorConfigFilePath` | Path to the SDK-provided `.editorconfig` that is injected into `@(EditorConfigFiles)`. | | `RepositoryEditorConfigFilePath` | Destination path for bootstrapping a physical repo-level `.editorconfig` (defaults to git repo root; falls back to `Directory.Build.props` directory). | | `BootstrapEditorConfigToRepoRoot` | When `true` (default), copies the SDK `.editorconfig` to `RepositoryEditorConfigFilePath` if missing. | | `RepositoryGlobalJsonFilePath` | Destination path for bootstrapping a physical repo-level `global.json` (defaults to git repo root; falls back to `Directory.Build.props` directory). | | `BootstrapGlobalJsonToRepoRoot` | When `true` (default), creates `global.json` at `RepositoryGlobalJsonFilePath` if missing. | -| `PurviewBuildSdkVersionForGlobalJson` | Version used for `msbuild-sdks.Purview.BuildSdk` when bootstrapping `global.json` (auto-detected from SDK package path, fallback `1.0.0`). | +| `PurviewBuildSdkVersionForGlobalJson` | Version used for `msbuild-sdks.Purview.BuildSdk` when bootstrapping `global.json` (auto-detected from SDK package path, falling back to the version stamped in at pack time). | | `DisableAutoCopySdkFiles` | When `true`, disables SDK auto-copy/bootstrap for repo files (`.editorconfig`, `global.json`). | | `PurviewRepoBootstrapMode` | Controls repo file bootstrapping: `IfMissing` (default), `Always`, `WarnOnDrift` or `Never`. | | `PurviewRepoBootstrapCopyRetries` | Number of write attempts before a bootstrap failure is reported (default `3`). | @@ -543,6 +555,83 @@ The SDK now exports its properties via `CompilerVisibleProperty`, so analyzers a --- +## Blazor WebAssembly and BFF + +Every behaviour here applies only to the project type it names. A project that is not a WebAssembly +client, a Blazor host, a BFF or a `PurviewBrowserSafe` library is unchanged. + +### Detection + +| Signal | Result | +| -- | -- | +| `Sdk="Microsoft.NET.Sdk.BlazorWebAssembly"` | `IsBlazorWebAssemblyProject=true` | +| `Sdk="Microsoft.NET.Sdk.Razor"` | `IsRazorClassLibraryProject=true` | +| a web SDK project whose name ends with `Bff` (case-sensitive), or that sets `IsBffProject=true` | `IsBffProject=true`; `IsBffProject=false` opts out | +| a web SDK project that references an `IsBlazorWebAssemblyProject` project | `IsBlazorHostProject=true` | + +`IsBlazorHostProject` never implies `IsBffProject`: a host that keeps no tokens is not a BFF. For the +`` (host) / `.Client` (WebAssembly) pairing it is known at evaluation, and the host gets an +automatic reference to the client unless it declares one (`PurviewAutoBlazorClientReference=false` +opts out). A host referencing a client under another name is resolved during the build: +`dotnet msbuild -t:ResolvePurviewBlazorHostProject -getProperty:IsBlazorHostProject`. + +### WebAssembly client defaults + +| Applied | Failure it prevents | +| -- | -- | +| `IsPackable=false` (the project can still set `true`) | packing an app's `wwwroot` as a package nobody can consume | +| no container defaults, even with a `Dockerfile` | `PublishAot` on a client is a publish that fails | +| no automatic `../Shared*` reference | a server-side library joining the browser download because of a folder name | +| `PRBLZ0001` for each `ProjectReference` that does not set `PurviewBrowserSafe` | a library joining the download without anyone deciding it was safe there | + +Globalisation, trimming mode and the Blazor runtime settings are left to the Blazor SDK. +`DisablePurviewBlazorWebAssemblyDefaults=true` (before the SDK import) restores the generic +defaults. The automatic telemetry packages were checked rather than assumed: the generator is +analyzer-only, and its generated code and `Microsoft.Extensions.Telemetry.Abstractions` publish +trim-clean and run on the WebAssembly runtime (an integration test boots it), so nothing is excluded. +The SDK adds no exporter. + +### Browser-safe libraries + +A library a WebAssembly client downloads runs in the browser, where reflection the trimmer cannot +follow and APIs the browser does not support fail at runtime. `true` +on a class library turns those into build failures, applying only what the project has not set: + +- `IsAotCompatible=true`, which turns on `IsTrimmable` and the trim, single-file and AOT analyzers; +- ``, so `CA1416` reports browser-unsupported APIs such as + `System.Diagnostics.Process`; +- `WarningsAsErrors` for each trim, single-file, AOT and `CA1416` id (`PurviewBrowserSafeWarningCodes`), + rather than `TreatWarningsAsErrors`, so the rest of the project's warning policy is untouched. + `PurviewBrowserSafeWarningsAsErrors=false` opts out; `CA1416` then falls back to the repository's + severity (`suggestion` in the shipped `.editorconfig`). + +File and network I/O are not reported - `File` works against the browser's in-memory file system and +`HttpClient` through `fetch`, so neither is annotated as unsupported - and stay a review obligation. + +The client asks every `ProjectReference`, through an MSBuild call on the referenced project, for its +`PurviewBrowserSafe` value and reports `PRBLZ0001` for each one that does not set it. A browser-safe +library runs the same check over its own references, so the guarantee covers the whole download. +`PurviewBrowserSafe="true"` on the `ProjectReference` records the decision for a project that does not +use this SDK; `PurviewBrowserSafeReferenceCheckAsError=true` makes it an error and +`DisablePurviewBrowserSafeReferenceCheck=true` turns it off. + +### BFF hosts + +- No packages are added: YARP, OpenID Connect and any BFF library (Duende.BFF is commercially + licensed) are the application's choice. +- The OpenAPI interceptor namespace is not added, because a BFF that only forwards has no document to + generate. A BFF referencing `Microsoft.AspNetCore.OpenApi`, or setting `PurviewBffExposesApi=true`, + keeps it. +- `PDS0008` reports `HttpOnly = false` and `PDS0009` reports `SameSite = None` on the + `CookieAuthenticationOptions` cookie - not on the antiforgery cookie or the OpenID Connect + correlation and nonce cookies, which legitimately differ. + +A persisted Data Protection key ring is not checked: it is routinely configured in a shared +ServiceDefaults project the BFF's compilation cannot see, so the rule could not be precise. +Anti-forgery on forwarded routes stays a review obligation. + +--- + ## Test project naming conventions Test projects are automatically detected by their suffix. Supported patterns: @@ -779,6 +868,8 @@ copy of the shipped file, a stale copy is reported rather than obeyed: refresh i | `PDS0005` | (suppressor) | — | Suppresses `IDE0130` for files under a project-root `Extensions/` folder, whose namespace is reset by `PDS0002` | | `PDS0006` | (suppressor) | — | Suppresses `CA1815` on a union declaration | | `PDS0007` | (suppressor) | — | Suppresses `CA1034` on an `extension` block | +| `PDS0008` | Security | Warning | A BFF's authentication cookie must not be set `HttpOnly = false` (see [Blazor WebAssembly and BFF](#blazor-webassembly-and-bff)) | +| `PDS0009` | Security | Warning | A BFF's authentication cookie must not be set `SameSite = None` | `PDS0001`, `PDS0005`, `PDS0006` and `PDS0007` are `DiagnosticSuppressor`s rather than analyzers, so they declare `SuppressionDescriptor`s and do not appear in `AnalyzerReleases.Shipped.md` — that file tracks diff --git a/docs/wiki/Analyzers.md b/docs/wiki/Analyzers.md index fe9e7ae..d98646b 100644 --- a/docs/wiki/Analyzers.md +++ b/docs/wiki/Analyzers.md @@ -13,6 +13,29 @@ command-line builds and Visual Studio. | `PDS0005` | (suppressor) | — | Suppresses `IDE0130` for files rooted under `Extensions/` | | `PDS0006` | (suppressor) | — | Suppresses `CA1815` on a union declaration | | `PDS0007` | (suppressor) | — | Suppresses `CA1034` on an `extension` block | +| `PDS0008` | Security | Warning | A BFF's authentication cookie must not be set `HttpOnly = false` | +| `PDS0009` | Security | Warning | A BFF's authentication cookie must not be set `SameSite = None` | + +## PDS0008 / PDS0009 — BFF authentication cookie + +Active only in a project the SDK classifies as a BFF (`build_property.IsBffProject=true`). A BFF keeps +the tokens on the server and hands the browser a session cookie; these rules report the two settings +that undo that: + +- `PDS0008` — `HttpOnly = false`: script running in the page, injected script included, can read the + session and replay it from anywhere. +- `PDS0009` — `SameSite = None`: the browser attaches the session to cross-site requests, so every BFF + endpoint becomes a cross-site request forgery target. + +Only a **constant** assigned to the cookie of `CookieAuthenticationOptions` is reported +(`options.Cookie.HttpOnly = false`, or `Cookie = { SameSite = SameSiteMode.None }`). The antiforgery +cookie, which an SPA legitimately reads, and the OpenID Connect correlation and nonce cookies, which +legitimately need `SameSite=None`, are never reported. A cookie configured in another assembly, or +through a local variable holding the builder, is missed rather than guessed at. + +A persisted Data Protection key ring is deliberately not checked: the `PersistKeysTo*` call usually +lives in a shared ServiceDefaults project the BFF's compilation cannot see, so a rule would report +correctly configured BFFs. Anti-forgery on forwarded routes stays a review obligation. ## Style policy diff --git a/docs/wiki/Configuration-Reference.md b/docs/wiki/Configuration-Reference.md index a17018b..d8a8f05 100644 --- a/docs/wiki/Configuration-Reference.md +++ b/docs/wiki/Configuration-Reference.md @@ -152,7 +152,7 @@ operations skip them silently. | `RepositoryEditorConfigFilePath` | *(auto-detected)* | Override the destination path for the bootstrapped `.editorconfig`. | | `BootstrapGlobalJsonToRepoRoot` | `true` | Creates a `global.json` at the repository root when missing. | | `RepositoryGlobalJsonFilePath` | *(auto-detected)* | Override the destination path for the bootstrapped `global.json`. | -| `PurviewBuildSdkVersionForGlobalJson` | *(auto-detected or `1.0.0` fallback)* | Version written to the `msbuild-sdks.Purview.BuildSdk` entry in a bootstrapped `global.json`. | +| `PurviewBuildSdkVersionForGlobalJson` | *(restored package version, else the version it was packed as)* | Version written to the `msbuild-sdks.Purview.BuildSdk` entry in a bootstrapped `global.json`. | | `PurviewRepoBootstrapMode` | `IfMissing` | `IfMissing` never touches an existing file, `Always` overwrites it, `WarnOnDrift` reports that an existing file differs from the SDK-provided one, and `Never` skips bootstrapping entirely. | | `PurviewRepoBootstrapCopyRetries` | `3` | Copy attempts before a bootstrap write failure is reported. | | `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | `500` | Base delay between bootstrap write attempts. | @@ -181,6 +181,21 @@ consuming repository; that is gone, because the skills CLI covers it. See | `ExcludePurviewTelemetry` | `false` | Set to `true` to exclude `Purview.Telemetry.SourceGenerator` from all projects. | | `ExcludeMSTelemetryExtension` | `false` | Set to `true` to exclude `Microsoft.Extensions.Telemetry.Abstractions`. Only relevant when `ExcludePurviewTelemetry` is also `false` — when `ExcludePurviewTelemetry=true` the whole telemetry group is skipped anyway. | +## Blazor WebAssembly, browser-safe libraries and BFF hosts + +| Property | Default | Description | +| -- | -- | -- | +| `IsBffProject` | `true` for a web SDK project named `*Bff` | Classifies a BFF host. `false` opts out, `true` opts in. | +| `IsBlazorHostProject` | *(computed)* | `true` for a web SDK project referencing a WebAssembly client. `false` opts out. | +| `PurviewAutoBlazorClientReference` | `true` | A host `` references its `.Client` automatically. | +| `DisablePurviewBlazorWebAssemblyDefaults` | `false` | Keeps the generic defaults on a WebAssembly client. Set before the SDK import. | +| `PurviewBrowserSafe` | *(unset)* | `true` on a class library applies `IsAotCompatible`, `SupportedPlatform browser` and the browser-safe `WarningsAsErrors`. | +| `PurviewBrowserSafeWarningsAsErrors` | `true` | `false` keeps trim/AOT diagnostics as warnings. | +| `PurviewBrowserSafeWarningCodes` | `CA1416`, `IL2001`-`IL2128`, `IL3000`-`IL3005`, `IL3050`-`IL3059` | The ids added to `WarningsAsErrors`. | +| `DisablePurviewBrowserSafeReferenceCheck` | `false` | Turns off `PRBLZ0001`. | +| `PurviewBrowserSafeReferenceCheckAsError` | `false` | Reports `PRBLZ0001` as an error. | +| `PurviewBffExposesApi` | `true` if the BFF references `Microsoft.AspNetCore.OpenApi` | Whether a BFF keeps the OpenAPI interceptor namespace. | + ## Testing | Property | Default | Description | diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index fe97d3d..1e20ee4 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -14,7 +14,7 @@ Add `Purview.BuildSdk` to the `msbuild-sdks` section so MSBuild can resolve the "runner": "Microsoft.Testing.Platform" }, "msbuild-sdks": { - "Purview.BuildSdk": "1.1.0" + "Purview.BuildSdk": "1.2.0" } } ``` diff --git a/docs/wiki/Project-Type-Detection.md b/docs/wiki/Project-Type-Detection.md index defd0c0..c0fadf7 100644 --- a/docs/wiki/Project-Type-Detection.md +++ b/docs/wiki/Project-Type-Detection.md @@ -21,6 +21,10 @@ wiki. | `IsWorkerSdkProject` | `SdkProjectName` is `Microsoft.NET.Sdk.Worker`. | | `IsAspireHostProject` | `SdkProjectName` starts with `Aspire.Sdk.Host` or `Aspire.AppHost.Sdk`. | | `IsCLIProject` | Project name ends with `CLI`, `Console`, `CommandLine`, `QuickStart`, or `QuickStarts`. | +| `IsBlazorWebAssemblyProject` | `SdkProjectName` is `Microsoft.NET.Sdk.BlazorWebAssembly`. | +| `IsRazorClassLibraryProject` | `SdkProjectName` is `Microsoft.NET.Sdk.Razor`. | +| `IsBffProject` | A web SDK project whose name ends with `Bff` (case-sensitive), or that sets `IsBffProject=true`. `IsBffProject=false` opts out. | +| `IsBlazorHostProject` | A web SDK project that references an `IsBlazorWebAssemblyProject` project - at evaluation for the ``/`.Client` pairing, during the build (`ResolvePurviewBlazorHostProject`) otherwise. Never implies `IsBffProject`. | | `IsRoslynComponent` | `true` is declared in the project file. | | `IsPackable` | `true` is declared in the project file. | | `IsWebProject` | Marker used in SDK web-project behaviour. | @@ -103,6 +107,35 @@ The SDK ships the following full `TestType` set, in addition to the standard `Un - `OutputType=Exe` (when not a test/shared-testing project), `CA1515` suppressed. +### Blazor WebAssembly clients (`IsBlazorWebAssemblyProject=true`) + +- `IsPackable=false` unless the project sets it, no container defaults even with a `Dockerfile`, and no + automatic `../Shared*` reference. `DisablePurviewBlazorWebAssemblyDefaults=true` (before the SDK + import) restores the generic defaults. +- `PRBLZ0001` for each `ProjectReference` that does not set `PurviewBrowserSafe`. +- Globalisation, trimming and the Blazor runtime settings are left to the Blazor SDK. + +### Blazor hosts (`IsBlazorHostProject=true`) + +- A host named `` gets an automatic reference to its `.Client` client unless it declares one. + `PurviewAutoBlazorClientReference=false` opts out. + +### BFF hosts (`IsBffProject=true`) + +- No packages added. No OpenAPI interceptor namespace unless the BFF references + `Microsoft.AspNetCore.OpenApi` or sets `PurviewBffExposesApi=true`. +- `PDS0008`/`PDS0009` on the authentication cookie (see [Analyzers](Analyzers.md)). + +### Browser-safe libraries (`PurviewBrowserSafe=true`) + +- `IsAotCompatible=true`, ``, and the trim, single-file, AOT and + `CA1416` ids in `WarningsAsErrors` - each only where the project set nothing. + `PurviewBrowserSafeWarningsAsErrors=false` keeps them as warnings. +- The same `PRBLZ0001` reference check a client runs. + +The README's [Blazor WebAssembly and BFF](../../README.md#blazor-webassembly-and-bff) section has the +failure each behaviour prevents. + ### Roslyn components (`IsRoslynComponent=true`) - Default `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index a324acf..2ff0233 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -69,4 +69,4 @@ The pipeline configuration lives in `purview-build.json`: Windows, Linux, and macOS — never hardcode platform-specific paths in tests or fixtures. - The integration harness (`ProjectHarness`) creates throwaway projects under `Path.GetTempPath()`; see `src/tests/BuildSdk.IntegrationTests/Harness/ProjectHarness.cs`. -- Linux agent-pack integration tests can be run locally via `just test-linux` (Docker-based). \ No newline at end of file +- The SDK integration suite can be run on Linux locally via `just test-linux` (Docker-based). \ No newline at end of file diff --git a/docs/wiki/Repository-Bootstrap.md b/docs/wiki/Repository-Bootstrap.md index e6c6599..8348e36 100644 --- a/docs/wiki/Repository-Bootstrap.md +++ b/docs/wiki/Repository-Bootstrap.md @@ -62,7 +62,7 @@ file, and honours `PurviewRepoBootstrapMode`: | -- | -- | -- | | `BootstrapGlobalJsonToRepoRoot` | `true` | Creates a `global.json` at the repository root when missing. | | `RepositoryGlobalJsonFilePath` | *(auto-detected)* | Override the destination path for the bootstrapped `global.json`. | -| `PurviewBuildSdkVersionForGlobalJson` | *(auto-detected or `1.0.0` fallback)* | Version written to the `msbuild-sdks.Purview.BuildSdk` entry. | +| `PurviewBuildSdkVersionForGlobalJson` | *(restored package version, else the version it was packed as)* | Version written to the `msbuild-sdks.Purview.BuildSdk` entry. | | `DisableAutoCopySdkFiles` | `false` | Master switch that disables repo-level SDK file bootstrapping. | | `PurviewRepoBootstrapMode` | `IfMissing` | `IfMissing`, `Always`, `WarnOnDrift` or `Never`. | diff --git a/package.json b/package.json index 7d495f9..7dc5c6d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "build-sdk", - "version": "1.1.0", + "version": "1.2.0", "homepage": "https://purview.dev/projects/build-sdk/", "bugs": { "url": "https://github.com/purview-dev/build-sdk/issues" diff --git a/scripts/sync-version.ts b/scripts/sync-version.ts new file mode 100644 index 0000000..c7548d5 --- /dev/null +++ b/scripts/sync-version.ts @@ -0,0 +1,40 @@ +// Rewrites the Purview.BuildSdk version pins that an adopter copies by hand - the quick-start snippets +// and the starter template - to the version in package.json, the single source of truth. +// +// Only text that is copied verbatim lives here. Everything that is packed is stamped at pack time +// instead (see StampPurviewPackedVersionTarget and StampPurviewEditorConfigVersionTarget in +// BuildSdk.csproj), so it never needs syncing. PackagedVersionFallbackTests fails the build if a file +// listed here drifts, and names this script as the fix. +// +// just version-sync + +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const root = join(import.meta.dir, ".."); +const { version } = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + +const files = ["README.md", "docs/wiki/Getting-Started.md", "templates/global.json"]; + +const replacements: [RegExp, string][] = [ + // The global.json pin. A '$(...)' property reference documents auto-detection and is not a pin. + [/("Purview\.BuildSdk"\s*:\s*")(?!\$)[^"]+(")/g, `$1${version}$2`], + // The example marker line of a bootstrapped .editorconfig. + [/(# Purview\.BuildSdk )\S+( - managed file)/g, `$1${version}$2`], +]; + +let changed = 0; + +for (const file of files) { + const path = join(root, file); + const before = readFileSync(path, "utf8"); + const after = replacements.reduce((text, [pattern, replacement]) => text.replace(pattern, replacement), before); + + if (after !== before) { + writeFileSync(path, after); + changed++; + console.log(` updated ${file}`); + } +} + +console.log(changed === 0 ? `All pins already at ${version}.` : `Pinned ${changed} file(s) to ${version}.`); diff --git a/scripts/test-linux-docker.ps1 b/scripts/test-linux-docker.ps1 index 30e7c75..b2c5035 100644 --- a/scripts/test-linux-docker.ps1 +++ b/scripts/test-linux-docker.ps1 @@ -1,6 +1,9 @@ [CmdletBinding()] param( - [string]$Image = "mcr.microsoft.com/dotnet/sdk:10.0.400" + [string]$Image = "mcr.microsoft.com/dotnet/sdk:10.0.400", + # The whole integration suite by default, matching CI. Narrow it with a treenode filter, e.g. + # -Filter '/*/*/BlazorProjectDetectionTests/*'. + [string]$Filter = "/*/*/*/*" ) $ErrorActionPreference = "Stop" @@ -12,11 +15,14 @@ if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { $repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path $containerRepositoryRoot = "/repo" +# The working copy is copied rather than mounted, so the build cannot write into it, and .git is left +# behind with the build output. An empty repository is initialised in its place, because the SDK finds +# the repository root by walking up for .git/. docker run --rm ` --mount "type=bind,source=$repositoryRoot,target=$containerRepositoryRoot,readonly" ` --workdir /work ` $Image ` - bash -c "tar --exclude='.git' --exclude='.vs' --exclude='bin' --exclude='obj' --exclude='artifacts' --exclude='TestResults' --exclude='node_modules' -C /repo -cf - . | tar -C /work -xf - && dotnet test src/tests/BuildSdk.IntegrationTests/BuildSdk.IntegrationTests.csproj -c Release -- --treenode-filter '/*/*/AgentPackFolderTests/*'" + bash -c "tar --exclude='.git' --exclude='.vs' --exclude='bin' --exclude='obj' --exclude='artifacts' --exclude='TestResults' --exclude='node_modules' -C /repo -cf - . | tar -C /work -xf - && git init -q && dotnet test --project src/tests/BuildSdk.IntegrationTests/BuildSdk.IntegrationTests.csproj -c Release --ignore-exit-code 8 --treenode-filter '$Filter'" if ($LASTEXITCODE -ne 0) { throw "Linux integration tests failed with exit code $LASTEXITCODE." diff --git a/skills/purview-buildsdk-configuration/SKILL.md b/skills/purview-buildsdk-configuration/SKILL.md index f0ee715..d82768b 100644 --- a/skills/purview-buildsdk-configuration/SKILL.md +++ b/skills/purview-buildsdk-configuration/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-buildsdk-configuration -description: Configure Purview.BuildSdk from Directory.Build.props or a .csproj. Use when setting NamespacePrefix, choosing a testing or mocking framework, turning telemetry or SourceLink off, controlling version detection from package.json, tuning the repository bootstrap, or working out whether a property must be set before the SDK import. Also use when the SDK "did something automatically" and you need the property that controls it. +description: Configure Purview.BuildSdk from Directory.Build.props or a .csproj. Use when setting NamespacePrefix, choosing a testing or mocking framework, turning telemetry or SourceLink off, controlling version detection from package.json, tuning the repository bootstrap, making a library browser-safe for a Blazor WebAssembly client, configuring a BFF host, or working out whether a property must be set before the SDK import. Also use when the SDK "did something automatically" and you need the property that controls it. license: MIT metadata: package: Purview.BuildSdk @@ -27,6 +27,7 @@ Pre-import properties include: - `EnableAssemblyNameGeneration` - `PurviewSharedTestingOutputType`, `PurviewTestContextNoWarn` - `AutoIncludeUsings` +- `DisablePurviewBlazorWebAssemblyDefaults` - repo-wide testing-framework selection you want every project to inherit Everything else is read in `Sdk.targets` and can be set in a project file. Prefer repo-wide defaults @@ -46,6 +47,9 @@ in `Directory.Build.props` unless the setting is deliberately project-specific. | `ExcludePurviewTelemetry` | `false` | Drops the telemetry source generator | | `DisableSourceLink` | `false` | Drops SourceLink from packable projects | | `EnableAssemblyNameGeneration` | `true` | `AssemblyName`/`PackageId` follow the resolved `RootNamespace` | +| `PurviewBrowserSafe` | *(unset)* | On a class library a WebAssembly client downloads: trim, AOT and browser-unsupported APIs fail the build | +| `IsBffProject` | name ends `Bff` | Classifies a BFF host; `false` opts out | +| `PurviewBrowserSafeReferenceCheckAsError` | `false` | Makes `PRBLZ0001` (a client reference that is not browser-safe) an error | The full set, grouped by area and with the behaviour each one changes, is in [references/properties.md](references/properties.md). Read it rather than guessing a property name — @@ -58,7 +62,8 @@ name: `PurviewLogicalProjectName`, `PurviewNamespacePrefix`, `PurviewProjectShortName`, `PurviewTestType`, `RootNamespace`, `AssemblyName`, `PackageVersion`, `TestingType`, `TargetProjectName`, `RepoRoot`, -`RootPackageJson`. +`RootPackageJson`, `IsBlazorHostProject`, `IsBffProject`, `PurviewBffExposesApi`, `IsAotCompatible`, +`WarningsAsErrors`. Ask MSBuild for one directly: diff --git a/skills/purview-buildsdk-configuration/references/properties.md b/skills/purview-buildsdk-configuration/references/properties.md index 93a0d9b..1fb1f81 100644 --- a/skills/purview-buildsdk-configuration/references/properties.md +++ b/skills/purview-buildsdk-configuration/references/properties.md @@ -81,6 +81,24 @@ forces SourceLink's CI-mode dirty-repository checks. | `SourceLinkPackageName` | `Microsoft.SourceLink.GitHub` | | | `DisableSourceLink` | `false` | | +## Blazor WebAssembly, browser-safe libraries and BFF hosts + +| Property | Default | Behaviour | +| -- | -- | -- | +| `IsBffProject` | `true` for a web SDK project named `*Bff`, else `false` | Classifies a BFF host. `false` opts a `*Bff` project out; `true` opts another web project in | +| `IsBlazorHostProject` | *(computed)* | `true` for a web SDK project that references a WebAssembly client. Set `false` to opt out. Never implies `IsBffProject` | +| `PurviewAutoBlazorClientReference` | `true` | A host named `` references its `.Client` WebAssembly client automatically. Skipped when the reference is already declared | +| `DisablePurviewBlazorWebAssemblyDefaults` | `false` | Keeps the generic defaults on a WebAssembly client: packable when asked, Dockerfile container detection, the automatic `Shared` reference. Set before the SDK import | +| `PurviewBrowserSafe` | *(unset)* | `true` on a class library applies `IsAotCompatible`, `SupportedPlatform browser` and the browser-safe `WarningsAsErrors`, each only where the project set nothing | +| `PurviewBrowserSafeWarningsAsErrors` | `true` | `false` keeps the trim and AOT diagnostics as warnings; `CA1416` then falls back to the repository's severity (`suggestion` in the shipped `.editorconfig`) | +| `PurviewBrowserSafeWarningCodes` | `CA1416`, `IL2001`-`IL2128`, `IL3000`-`IL3005`, `IL3050`-`IL3059` | The ids a browser-safe library adds to `WarningsAsErrors` | +| `DisablePurviewBrowserSafeReferenceCheck` | `false` | Turns off `PRBLZ0001`, the check that every project a client (or browser-safe library) references sets `PurviewBrowserSafe` | +| `PurviewBrowserSafeReferenceCheckAsError` | `false` | Reports `PRBLZ0001` as an error | +| `PurviewBffExposesApi` | `true` when the BFF references `Microsoft.AspNetCore.OpenApi`, else `false` | Whether a BFF keeps the `Microsoft.AspNetCore.OpenApi.Generated` interceptor namespace | + +`PurviewBrowserSafe="true"` is also accepted as `ProjectReference` metadata, recording that a +reference was judged browser-safe when it cannot say so itself. + ## Version detection | Property | Default | Behaviour | @@ -101,7 +119,7 @@ forces SourceLink's CI-mode dirty-repository checks. | `RepositoryEditorConfigFilePath` | *(derived)* | Overrides the destination `.editorconfig` | | `BootstrapGlobalJsonToRepoRoot` | `true` | | | `RepositoryGlobalJsonFilePath` | *(derived)* | Overrides the destination `global.json` | -| `PurviewBuildSdkVersionForGlobalJson` | detected SDK version, else `1.0.0` | | +| `PurviewBuildSdkVersionForGlobalJson` | restored package version, else the version it was packed as | | | `PurviewRepoBootstrapCopyRetries` | `3` | | | `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | `500` | | | `PurviewRepoBootstrapCopyFailureAsError` | `true` | `false` downgrades a persistent write failure to a warning | @@ -134,6 +152,10 @@ Exported to analyzers and generators as `build_property.`: - `TestingType`, `TargetProjectName` - `IsContainerProject`, `IsSdkProject`, `SdkProjectName`, `IsWebProject`, `IsWebSdkProject`, `IsWorkerSdkProject`, `IsAspireHostProject`, `IsCLIProject` +- `IsBlazorWebAssemblyProject`, `IsRazorClassLibraryProject`, `IsBlazorHostProject`, `IsBffProject` +- `PurviewBrowserSafe`, `PurviewBrowserSafeWarningsAsErrors`, `DisablePurviewBrowserSafeReferenceCheck`, + `PurviewBrowserSafeReferenceCheckAsError`, `DisablePurviewBlazorWebAssemblyDefaults`, + `PurviewAutoBlazorClientReference`, `PurviewBffExposesApi` - `EditorConfigFilePath`, `RepositoryEditorConfigFilePath`, `BootstrapEditorConfigToRepoRoot` - `RepositoryGlobalJsonFilePath`, `BootstrapGlobalJsonToRepoRoot`, `DisableAutoCopySdkFiles` - `PurviewRepoBootstrapMode`, `PurviewRepoBootstrapCopyRetries`, diff --git a/skills/purview-buildsdk-project-detection/SKILL.md b/skills/purview-buildsdk-project-detection/SKILL.md index 2d0d986..8573ca4 100644 --- a/skills/purview-buildsdk-project-detection/SKILL.md +++ b/skills/purview-buildsdk-project-detection/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-buildsdk-project-detection -description: Explain why Purview.BuildSdk classified a project as test, shared, shared-testing, CLI, web, worker, Aspire host, container or Roslyn component, and why it added a package, a project reference, a namespace or an analyzer automatically. Use when SDK behaviour looks surprising, when a test project gets no runner or no InternalsVisibleTo, or when reasoning about RootNamespace, AssemblyName and TargetProjectName. +description: Explain why Purview.BuildSdk classified a project as test, shared, shared-testing, CLI, web, worker, Aspire host, container, Roslyn component, Blazor WebAssembly client, Razor class library, Blazor host or BFF, and why it added a package, a project reference, a namespace or an analyzer automatically. Use when SDK behaviour looks surprising, when a test project gets no runner or no InternalsVisibleTo, or when reasoning about RootNamespace, AssemblyName and TargetProjectName. license: MIT metadata: package: Purview.BuildSdk @@ -26,7 +26,8 @@ dotnet msbuild -getProperty:PurviewLogicalProjectName -getProperty:Roo The values worth inspecting first: `MSBuildProjectName`, `NamespacePrefix`, `PurviewLogicalProjectName`, `RootNamespace`, `TestingType`, `TargetProjectName`, `SdkProjectName`, `IsTestProject`, `IsSharedProject`, `IsSharedTestingProject`, `IsContainerProject`, `IsCLIProject`, -`IsWebSdkProject`, `IsAspireHostProject`. +`IsWebSdkProject`, `IsAspireHostProject`, `IsBlazorWebAssemblyProject`, `IsRazorClassLibraryProject`, +`IsBlazorHostProject`, `IsBffProject`. ## Detection rules @@ -67,6 +68,24 @@ Recognised by **exact name**, never by substring. The two lists are in | SDK starts with `Aspire.Sdk.Host` or `Aspire.AppHost.Sdk` | `IsAspireHostProject=true` | | `Dockerfile`, `dockerfile` or `Dockerfile.dev` in the project directory | `IsContainerProject=true` | | name ends with `CLI`, `Console`, `CommandLine`, `QuickStart`, `QuickStarts` | `IsCLIProject=true` | +| `Microsoft.NET.Sdk.BlazorWebAssembly` | `IsBlazorWebAssemblyProject=true` | +| `Microsoft.NET.Sdk.Razor` | `IsRazorClassLibraryProject=true` | +| a web SDK project whose name ends with `Bff` (case-sensitive, like `CLI`), or that sets `IsBffProject=true` | `IsBffProject=true` | +| a web SDK project that references an `IsBlazorWebAssemblyProject` project | `IsBlazorHostProject=true` | + +`IsBffProject=false` opts a `*Bff` project out. `IsBlazorHostProject` is **never** inferred into +`IsBffProject`: a host that keeps no tokens is not a BFF, and BFF rules do not describe it. + +`IsBlazorHostProject` is known at evaluation for the ``/`.Client` pairing. A host +referencing a client under any other name is resolved during the build, so ask for it with the +target that resolves it: + +```bash +dotnet msbuild -t:ResolvePurviewBlazorHostProject -getProperty:IsBlazorHostProject +``` + +What each Blazor and BFF classification applies - and the browser-safe library check - is in +[references/blazor.md](references/blazor.md). ## Namespace and identity @@ -94,6 +113,8 @@ Recognised by **exact name**, never by substring. The two lists are in Shared-testing projects skip the runnable test package. - **Packable projects**, **Roslyn components** and **special project types** (CLI, container, web, Aspire host): see [references/detection.md](references/detection.md). +- **Blazor WebAssembly clients, Blazor hosts, BFFs and browser-safe libraries**: see + [references/blazor.md](references/blazor.md). Project-reference probing is layout-driven and covered by `purview-buildsdk-project-layout`. @@ -107,6 +128,8 @@ surface in command-line builds and in Visual Studio. The code-fix assembly ships | `PDS0002` | Extensions classes live under `Extensions//` | | `PDS0003` | Explicit types with target-typed `new()` | | `PDS0004` | Correct acronym capitalisation | +| `PDS0008` | A BFF's authentication cookie is not set `HttpOnly = false` | +| `PDS0009` | A BFF's authentication cookie is not set `SameSite = None` | `PDS0004` follows .NET naming guidance: well-known framework spellings (`Sql`, `Guid`, `Uuid`, `Url`, `Dns`, `Tcp`, `Http`, `Xml`, `Db`, …) are exempt — `Db` among them, so `DbContext`, diff --git a/skills/purview-buildsdk-project-detection/references/blazor.md b/skills/purview-buildsdk-project-detection/references/blazor.md new file mode 100644 index 0000000..c65d153 --- /dev/null +++ b/skills/purview-buildsdk-project-detection/references/blazor.md @@ -0,0 +1,105 @@ +# Blazor WebAssembly, browser-safe libraries and BFF hosts + +Every behaviour below applies only to the project type it names, and each one names the failure it +prevents. Nothing here changes a project that is not a WebAssembly client, a Blazor host, a BFF or a +`PurviewBrowserSafe` library. + +## Blazor WebAssembly client (`IsBlazorWebAssemblyProject`) + +| Applied | Failure it prevents | Opt out | +| -- | -- | -- | +| `IsPackable=false` | packing an app's `wwwroot` as a package nobody can consume | `IsPackable=true` in the project | +| `IsContainerProject=false`, so no `PublishAot`, `InvariantGlobalization` or Docker defaults | `PublishAot` on a client is a publish that fails, and the Blazor SDK owns globalisation | `DisablePurviewBlazorWebAssemblyDefaults=true` before the SDK import | +| no automatic `../Shared*/Shared*.csproj` reference | a server-side library joining the browser download because of a folder name | `DisablePurviewBlazorWebAssemblyDefaults=true` before the SDK import | +| the browser-safe reference check (`PRBLZ0001`) | a library joining the download without anyone deciding it was safe there | `DisablePurviewBrowserSafeReferenceCheck=true` | + +Globalisation, trimming mode and the Blazor runtime settings are deliberately left alone: the Blazor +SDK owns them, and a second default would compete with it. + +### The automatic packages + +The SDK adds `Purview.Telemetry.SourceGenerator` and `Microsoft.Extensions.Telemetry.Abstractions` +to every non-test project, a client included. Both were checked rather than assumed: + +- the generator is analyzer-only (`PrivateAssets=all`), so nothing of it is downloaded; +- its generated `ActivitySource`, `ILogger` and `Meter` code, and `Telemetry.Abstractions` itself, + publish with no trim warnings and run on the WebAssembly runtime + (`BlazorWebAppTests.AutomaticTelemetryPackages_RunInsideTheWebAssemblyRuntime` boots it); +- the SDK adds no exporter. An OTLP exporter cannot work from a browser, which is exactly why the SDK + never adds one - that is the application's choice. + +Nothing is therefore excluded from a client by default. `ExcludePurviewTelemetry` and +`ExcludeMSTelemetryExtension` still remove them. + +## Blazor host (`IsBlazorHostProject`) + +A web SDK project named `` beside a WebAssembly client named `.Client` is the Blazor Web +App pairing. The host gets an automatic `ProjectReference` to the client - probed the same way a test +project's target is, and skipped when the project already references it - so the client's framework +files are served. Opt out with `PurviewAutoBlazorClientReference=false`. + +`IsBlazorHostProject` says nothing about `IsBffProject`. In a Blazor Web App the host is the BFF only +if it keeps the tokens. + +## BFF host (`IsBffProject`) + +- **No packages are added.** YARP, OpenID Connect and any BFF library are the application's choice, + and Duende.BFF carries a commercial licence. +- The `Microsoft.AspNetCore.OpenApi.Generated` interceptor namespace every other web project gets is + removed, because a BFF that only forwards has no API document to generate. A BFF referencing + `Microsoft.AspNetCore.OpenApi` keeps it; `PurviewBffExposesApi` decides explicitly. +- `PDS0008` reports `HttpOnly = false` on the `CookieAuthenticationOptions` cookie (script could read + the session). `PDS0009` reports `SameSite = None` on it (cross-site requests would carry it). Only a + constant assigned to that cookie is reported - never the antiforgery cookie, which script may + legitimately read, nor the OpenID Connect correlation and nonce cookies, which need + `SameSite=None`. + +Deliberately **not** checked, because a build cannot check them precisely: + +- **a persisted Data Protection key ring.** The call that persists it (`PersistKeysTo*`) is routinely + made in a shared ServiceDefaults project, which the BFF's compilation cannot see, so the rule would + fire on correctly configured BFFs - and a noisy rule gets suppressed wholesale. +- **anti-forgery on forwarded routes.** It stays a review obligation. + +## Browser-safe library (`PurviewBrowserSafe=true`) + +Applies to class libraries only, and only what the project has not set: + +- `IsAotCompatible=true` (on a TFM that supports it), which turns on `IsTrimmable` and the trim, + single-file and AOT analyzers; +- ``, so `CA1416` reports an API the browser does not support; +- `WarningsAsErrors` for every trim (`IL2001`-`IL2128`), single-file (`IL3000`-`IL3005`) and AOT + (`IL3050`-`IL3059`) diagnostic and `CA1416`, listed in `PurviewBrowserSafeWarningCodes`. + `TreatWarningsAsErrors` is not touched. + +`CA1416` is `suggestion` in the shipped `.editorconfig`. The compiler's `/warnaserror+:CA1416` +outranks an `.editorconfig` severity, so the error still fires without the repository's setting +changing. The consequence of `PurviewBrowserSafeWarningsAsErrors=false` is that the trim and AOT +diagnostics stay warnings while `CA1416` falls back to the repository's configured severity. + +What it cannot catch: file and network I/O. `File` works against the browser's in-memory file system +and `HttpClient` works through `fetch`, so neither is annotated as unsupported, and no analyzer reports +them. Reviewing for them stays a human obligation. + +### The reference check (`PRBLZ0001`) + +A WebAssembly client asks each `ProjectReference` - through an MSBuild call on the referenced project, +never by guessing from its name - for its `PurviewBrowserSafe` value, and warns for each one that does +not set it. A browser-safe library runs the same check over its own references, so the guarantee +covers the whole download, not only the first level. + +- `PurviewBrowserSafe="true"` metadata on the `ProjectReference` records the decision for a project + that does not use this SDK. +- References with `ReferenceOutputAssembly="false"` or an `OutputItemType` are not downloaded and are + not checked. Package references are not checked either. +- `PurviewBrowserSafeReferenceCheckAsError=true` makes it an error. + +### Purview packages a Contracts library builds on + +Checked against a `PurviewBrowserSafe` library, then run on the WebAssembly runtime: + +| Package | Result | +| -- | -- | +| `Purview.Results` 1.0.4 | clean | +| `Purview.ZodSharp` 2.4.2 | clean, including `[ZodSchema]` | +| `Purview.ValueObjects` 1.2.3 | the runtime assembly is AOT-compatible, but the JSON converter the generator emits for a scalar calls the reflection-based `JsonSerializer.Serialize`/`Deserialize(..., options)` overloads, which is `IL2026` and `IL3050` - errors in a browser-safe library. Set `GenerateJsonConverter = false` on the scalar until the generator writes the value directly. | diff --git a/skills/purview-buildsdk-project-detection/references/detection.md b/skills/purview-buildsdk-project-detection/references/detection.md index bf75823..85fde09 100644 --- a/skills/purview-buildsdk-project-detection/references/detection.md +++ b/skills/purview-buildsdk-project-detection/references/detection.md @@ -64,6 +64,10 @@ dual-role component that wants normal library symbol packaging. | Container | `InvariantGlobalization`, `PublishAot`, Linux Docker defaults, container tooling packages | | Web SDK | `Microsoft.AspNetCore.OpenApi.Generated` added to `InterceptorsNamespaces`, unless marked as a separate web-project mode | | Aspire host | `OutputType=Exe` | +| Blazor WebAssembly client | not packable, no container defaults, no automatic `Shared` reference, the browser-safe reference check - see [blazor.md](blazor.md) | +| Blazor host | an automatic reference to its `.Client` - see [blazor.md](blazor.md) | +| BFF | no packages, no OpenAPI interceptor unless it exposes an API, `PDS0008`/`PDS0009` - see [blazor.md](blazor.md) | +| `PurviewBrowserSafe` library | `IsAotCompatible`, `SupportedPlatform browser`, trim/AOT/`CA1416` as errors - see [blazor.md](blazor.md) | ## The Visual Studio refactorings diff --git a/skills/purview-buildsdk-project-layout/SKILL.md b/skills/purview-buildsdk-project-layout/SKILL.md index 55a019c..6c19c3d 100644 --- a/skills/purview-buildsdk-project-layout/SKILL.md +++ b/skills/purview-buildsdk-project-layout/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-buildsdk-project-layout -description: Decide where a project goes and what to call it in a repository that uses Purview.BuildSdk. Use when creating, moving, splitting or renaming a project or test project, choosing a test suffix, aligning namespaces and assembly identity with NamespacePrefix, or relying on the SDK's automatic project references. Also use when rationalising a repository's shape so the SDK's conventions work without overrides. +description: Decide where a project goes and what to call it in a repository that uses Purview.BuildSdk. Use when creating, moving, splitting or renaming a project or test project, choosing a test suffix, aligning namespaces and assembly identity with NamespacePrefix, laying out a Blazor Web App host, WebAssembly client and browser-safe Contracts library, or relying on the SDK's automatic project references. Also use when rationalising a repository's shape so the SDK's conventions work without overrides. license: MIT metadata: package: Purview.BuildSdk @@ -103,7 +103,32 @@ Test projects probe for their target, in order: - `../../src/$(TargetProjectName)/$(TargetProjectName).csproj` Non-test projects pick up sibling shared projects via `../Shared*/Shared*.csproj`; test projects -pick up `../SharedTesting*/SharedTesting*.csproj`. +pick up `../SharedTesting*/SharedTesting*.csproj`. A Blazor WebAssembly client is the exception: it +does not pick up `Shared*`, because everything it references is downloaded to the browser. + +## Blazor Web App: host and client + +```text +src/src/ + Shop/Shop.csproj Microsoft.NET.Sdk.Web - the host + Shop.Client/Shop.Client.csproj Microsoft.NET.Sdk.BlazorWebAssembly - the client + Shop.ClientShared/Shop.ClientShared.csproj a PurviewBrowserSafe library both use + Shop.Bff/Shop.Bff.csproj a BFF, when tokens are kept server-side in a separate host +src/tests/ + Shop.Client.UnitTests/ TargetProjectName = Shop.Client +``` + +- A web host named `` beside a WebAssembly client named `.Client` is the pairing the SDK + recognises: the host gets `IsBlazorHostProject=true` and a reference to the client automatically + (skipped when declared; `PurviewAutoBlazorClientReference=false` opts out). +- Identities with `NamespacePrefix=Acme`: `Shop.Client` is `Acme.Shop.Client`; `Shop.Bff` is + `Acme.Shop.Bff`; `ClientShared` is a stripped segment, so `Shop.ClientShared` has namespace + `Acme.Shop` and assembly `Acme.Shop.ClientShared`. +- Every library the client references must set `true`, or the + build reports `PRBLZ0001`. Keep server-only code (file, process, database access) out of anything + the client references. +- Name a BFF host `*Bff` (case-sensitive) for `IsBffProject=true`. Being the client's host does not + make a project a BFF. ## Creating a new project diff --git a/src/src/Analyzers/AnalyzerReleases.Shipped.md b/src/src/Analyzers/AnalyzerReleases.Shipped.md index fd7e728..9770203 100644 --- a/src/src/Analyzers/AnalyzerReleases.Shipped.md +++ b/src/src/Analyzers/AnalyzerReleases.Shipped.md @@ -7,3 +7,12 @@ Rule ID | Category | Severity | Notes PDS0002 | Naming | Warning | Extensions root folder resets namespace PDS0003 | Style | Warning | Prefer explicit types with target-typed object creation PDS0004 | Naming | Warning | Use correct acronym capitalization (Api → API) + +## Release 1.2.0 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------ +PDS0008 | Security | Warning | BFF authentication cookie must be HttpOnly +PDS0009 | Security | Warning | BFF authentication cookie must not use SameSite=None diff --git a/src/src/Analyzers/Bff/BffAuthenticationCookieAnalyzer.cs b/src/src/Analyzers/Bff/BffAuthenticationCookieAnalyzer.cs new file mode 100644 index 0000000..6c9c179 --- /dev/null +++ b/src/src/Analyzers/Bff/BffAuthenticationCookieAnalyzer.cs @@ -0,0 +1,161 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Purview.BuildSdk.Analyzers.Bff; + +/// +/// Reports an authentication cookie that a BFF (IsBffProject=true) configures in a way that undoes +/// what a BFF exists to provide: a session the browser holds but script cannot read and other sites cannot +/// send. +/// +/// +/// Only an explicit assignment of a constant is reported, and only on the cookie of +/// CookieAuthenticationOptions - never on a CookieBuilder in general. The antiforgery cookie is +/// legitimately readable by script, and the OpenID Connect correlation and nonce cookies legitimately need +/// SameSite=None; reporting those would make the rule noise, and a noisy rule gets suppressed +/// wholesale. A cookie configured somewhere the compilation cannot see (another assembly, a local variable +/// holding the builder) is not reported: the rule trades those misses for never being wrong. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class BffAuthenticationCookieAnalyzer : DiagnosticAnalyzer +{ + public const string HttpOnlyDiagnosticId = "PDS0008"; + public const string SameSiteNoneDiagnosticId = "PDS0009"; + + const string CookieBuilderMetadataName = "Microsoft.AspNetCore.Http.CookieBuilder"; + const string CookieAuthenticationOptionsMetadataName = + "Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions"; + const string SameSiteModeMetadataName = "Microsoft.AspNetCore.Http.SameSiteMode"; + + // SameSiteMode.None. Unspecified is -1, Lax 1 and Strict 2. + const int SameSiteModeNone = 0; + + static readonly DiagnosticDescriptor HttpOnlyRule = new( + HttpOnlyDiagnosticId, + "BFF authentication cookie must be HttpOnly", + "The authentication cookie is configured with HttpOnly = false, so script running in the page can read the session", + "Security", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "A BFF keeps the tokens on the server and hands the browser only a session cookie. Without HttpOnly, any script that runs in the page - including injected script - can read that cookie and replay the session from anywhere." + ); + + static readonly DiagnosticDescriptor SameSiteNoneRule = new( + SameSiteNoneDiagnosticId, + "BFF authentication cookie must not use SameSite=None", + "The authentication cookie is configured with SameSite = None, so requests from other sites carry the session", + "Security", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "SameSite=None makes the browser attach the BFF's session cookie to cross-site requests, which turns every BFF endpoint into a cross-site request forgery target. Use Strict, or Lax when top-level navigation from another site must arrive signed in." + ); + + public override ImmutableArray SupportedDiagnostics => [HttpOnlyRule, SameSiteNoneRule]; + + public override void Initialize(AnalysisContext context) + { + if (context is null) + throw new ArgumentNullException(nameof(context)); + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterCompilationStartAction(startContext => + { + var globalOptions = startContext.Options.AnalyzerConfigOptionsProvider.GlobalOptions; + if ( + !globalOptions.TryGetBuildProperty(BuildPropertyKeys.IsBffProject, out var isBffProject) + || !string.Equals(isBffProject, "true", StringComparison.OrdinalIgnoreCase) + ) + { + return; + } + + var compilation = startContext.Compilation; + var cookieBuilder = compilation.GetTypeByMetadataName(CookieBuilderMetadataName); + var cookieAuthenticationOptions = compilation.GetTypeByMetadataName( + CookieAuthenticationOptionsMetadataName + ); + var sameSiteMode = compilation.GetTypeByMetadataName(SameSiteModeMetadataName); + + // A BFF that does not reference cookie authentication has no authentication cookie to check. + if (cookieBuilder is null || cookieAuthenticationOptions is null) + return; + + startContext.RegisterOperationAction( + operationContext => + AnalyzeAssignment(operationContext, cookieBuilder, cookieAuthenticationOptions, sameSiteMode), + OperationKind.SimpleAssignment + ); + }); + } + + static void AnalyzeAssignment( + OperationAnalysisContext context, + INamedTypeSymbol cookieBuilder, + INamedTypeSymbol cookieAuthenticationOptions, + INamedTypeSymbol? sameSiteMode + ) + { + var assignment = (ISimpleAssignmentOperation)context.Operation; + if (assignment.Target is not IPropertyReferenceOperation target) + return; + + if (!InheritsFrom(target.Property.ContainingType, cookieBuilder)) + return; + + if (!IsAuthenticationCookie(ResolveBuilder(assignment, target), cookieAuthenticationOptions)) + return; + + var value = assignment.Value.ConstantValue; + if (!value.HasValue) + return; + + if (target.Property.Name == "HttpOnly" && value.Value is false) + { + context.ReportDiagnostic(Diagnostic.Create(HttpOnlyRule, assignment.Syntax.GetLocation())); + } + else if ( + target.Property.Name == "SameSite" + && sameSiteMode is not null + && SymbolEqualityComparer.Default.Equals(target.Property.Type, sameSiteMode) + && value.Value is int mode + && mode == SameSiteModeNone + ) + { + context.ReportDiagnostic(Diagnostic.Create(SameSiteNoneRule, assignment.Syntax.GetLocation())); + } + } + + /// + /// The operation that yields the builder being assigned to. For options.Cookie.HttpOnly = false + /// that is the receiver itself; inside a nested initializer (Cookie = { HttpOnly = false }) the + /// receiver is implicit, and the builder is the member the enclosing initializer initializes. + /// + static IOperation? ResolveBuilder(ISimpleAssignmentOperation assignment, IPropertyReferenceOperation target) => + target.Instance is IInstanceReferenceOperation { ReferenceKind: InstanceReferenceKind.ImplicitReceiver } + && assignment.Parent is IObjectOrCollectionInitializerOperation { Parent: IMemberInitializerOperation member } + ? member.InitializedMember + : target.Instance; + + /// + /// True only for options.Cookie where options is CookieAuthenticationOptions (or a + /// type derived from it). + /// + static bool IsAuthenticationCookie(IOperation? instance, INamedTypeSymbol cookieAuthenticationOptions) => + instance is IPropertyReferenceOperation { Property.Name: "Cookie" } cookieProperty + && InheritsFrom(cookieProperty.Property.ContainingType, cookieAuthenticationOptions); + + static bool InheritsFrom(INamedTypeSymbol? type, INamedTypeSymbol baseType) + { + for (var current = type; current is not null; current = current.BaseType) + { + if (SymbolEqualityComparer.Default.Equals(current, baseType)) + return true; + } + + return false; + } +} diff --git a/src/src/Analyzers/BuildPropertyKeys.cs b/src/src/Analyzers/BuildPropertyKeys.cs index 56afd23..d03690b 100644 --- a/src/src/Analyzers/BuildPropertyKeys.cs +++ b/src/src/Analyzers/BuildPropertyKeys.cs @@ -8,6 +8,8 @@ namespace Purview.BuildSdk.Analyzers; static class BuildPropertyKeys { public const string ProjectDir = "build_property.ProjectDir"; + + public const string IsBffProject = "build_property.IsBffProject"; } /// diff --git a/src/src/BuildSdk/BuildSdk.csproj b/src/src/BuildSdk/BuildSdk.csproj index 5dd3e95..a85845a 100644 --- a/src/src/BuildSdk/BuildSdk.csproj +++ b/src/src/BuildSdk/BuildSdk.csproj @@ -186,6 +186,52 @@ + + + + <_PurviewPackedVersionStampVersion>$([System.String]::Copy('$(PackageVersion)').Trim()) + <_PurviewPackedVersionStampVersion Condition="'$(_PurviewPackedVersionStampVersion)' == ''" + >$(Version) + + <_PurviewStampedPackedVersionProps Condition="'$(_PurviewStampedPackedVersionProps)' == ''" + >$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/$(IntermediateOutputPath)purview-version/PackedVersion.props')) + + + <_PurviewPackedVersionPropsLines Include="<Project>" /> + <_PurviewPackedVersionPropsLines Include="<!-- Generated at pack time by StampPurviewPackedVersionTarget. Do not edit. -->" /> + <_PurviewPackedVersionPropsLines Include="<PropertyGroup>" /> + <_PurviewPackedVersionPropsLines Include="<_PurviewPackedSdkVersion>$(_PurviewPackedVersionStampVersion)</_PurviewPackedSdkVersion>" /> + <_PurviewPackedVersionPropsLines Include="</PropertyGroup>" /> + <_PurviewPackedVersionPropsLines Include="</Project>" /> + + + + + + + diff --git a/src/src/BuildSdk/Sdk/Props/Defaults.props b/src/src/BuildSdk/Sdk/Props/Defaults.props index 0328c53..ccf2d87 100644 --- a/src/src/BuildSdk/Sdk/Props/Defaults.props +++ b/src/src/BuildSdk/Sdk/Props/Defaults.props @@ -130,6 +130,13 @@ false false + false + false + + false false false false @@ -208,11 +215,12 @@ - - 1.1.0 + + diff --git a/src/src/BuildSdk/Sdk/Sdk.props b/src/src/BuildSdk/Sdk/Sdk.props index 55fbbbe..c005468 100644 --- a/src/src/BuildSdk/Sdk/Sdk.props +++ b/src/src/BuildSdk/Sdk/Sdk.props @@ -1,6 +1,11 @@ + + + true + false + + <_PurviewBlazorClientPairProjectPath>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)/../$(MSBuildProjectName).Client/$(MSBuildProjectName).Client.csproj')) + <_PurviewBlazorClientPairProjectText + Condition="'$(IsWebSdkProject)' == 'true' AND Exists('$(_PurviewBlazorClientPairProjectPath)')" + >$([System.IO.File]::ReadAllText(`$(_PurviewBlazorClientPairProjectPath)`)) + <_PurviewHasBlazorClientPair Condition="'$(_PurviewBlazorClientPairProjectText)' != ''" + >$([System.Text.RegularExpressions.Regex]::IsMatch($(_PurviewBlazorClientPairProjectText), `Sdk\s*=\s*"Microsoft\.NET\.Sdk\.BlazorWebAssembly"`)) + true true + + false True when the project SDK starts with Aspire.Sdk.Host. + + True when SdkProjectName is Microsoft.NET.Sdk.BlazorWebAssembly. + + + True when SdkProjectName is Microsoft.NET.Sdk.Razor. + + + True for a web SDK project that references a Blazor WebAssembly client. Says nothing about whether the host is a BFF. + + + True for a web SDK project whose name ends with Bff, or that sets IsBffProject=true. IsBffProject=false opts out. + + + When true on a class library, applies the browser-safe defaults (IsAotCompatible, SupportedPlatform browser, trim/AOT/CA1416 as errors). Unset by default. + + + When false, a browser-safe library keeps its trim, AOT and CA1416 diagnostics as warnings. Defaults to true. + + + When true, a Blazor WebAssembly client (or browser-safe library) does not check that its project references set PurviewBrowserSafe (PRBLZ0001). + + + When true, PRBLZ0001 is reported as an error instead of a warning. Defaults to false. + + + When true, a Blazor WebAssembly client keeps the generic defaults (packable when asked, container detection, the automatic Shared reference). Set before the SDK import. + + + When false, a host named Name does not automatically reference its Name.Client WebAssembly client. Defaults to true. + + + When true, a BFF keeps the OpenAPI interceptor namespace. Defaults to true only when the BFF references Microsoft.AspNetCore.OpenApi. + Path to the SDK-provided .editorconfig used for build-time style enforcement. @@ -738,6 +822,11 @@ $(_PurviewSdkContainerDirectoryName) + + $(_PurviewPackedSdkVersion) 1.0.0 @@ -797,6 +886,23 @@ $(PurviewPolicyExemptNoWarn);CA1515; + + + false + + @@ -1282,9 +1388,13 @@ AUTOMATIC PROJECT REFERENCES FOR TEST PROJECTS Resolves the target project relative to the test project location. ===================================================================== --> + diff --git a/src/src/BuildSdk/Sdk/Sdk.targets b/src/src/BuildSdk/Sdk/Sdk.targets index afd6905..e6da7de 100644 --- a/src/src/BuildSdk/Sdk/Sdk.targets +++ b/src/src/BuildSdk/Sdk/Sdk.targets @@ -8,6 +8,8 @@ >$(TargetFramework) + + + + + + <_PurviewResolveBlazorHostProject Condition="'$(IsBlazorHostProject)' == '' AND '$(IsWebSdkProject)' == 'true'" + >true + false + true + + + + + <_PurviewBlazorClientPairReference Include="$(_PurviewBlazorClientPairProjectPath)" /> + <_PurviewBlazorClientPairReference Remove="@(ProjectReference->'%(FullPath)')" /> + + + + + + <_PurviewBrowserSafeApplies + Condition="'$(PurviewBrowserSafe)' == 'true' AND ('$(OutputType)' == '' OR '$(OutputType)' == 'Library') AND '$(IsWebSdkProject)' != 'true' AND '$(IsBlazorWebAssemblyProject)' != 'true' AND '$(IsTestProject)' != 'true' AND '$(IsSharedTestingProject)' != 'true' AND '$(IsRoslynComponent)' != 'true'" + >true + + + + + true + true + + + CA1416;IL2001;IL2002;IL2003;IL2004;IL2005;IL2006;IL2007;IL2008;IL2009;IL2010;IL2011;IL2012;IL2013;IL2014;IL2015; + IL2016;IL2017;IL2018;IL2019;IL2020;IL2021;IL2022;IL2023;IL2024;IL2025;IL2026;IL2027;IL2028;IL2029;IL2030;IL2031; + IL2032;IL2033;IL2034;IL2035;IL2036;IL2037;IL2038;IL2039;IL2040;IL2041;IL2042;IL2043;IL2044;IL2045;IL2046;IL2047; + IL2048;IL2049;IL2050;IL2051;IL2052;IL2053;IL2054;IL2055;IL2056;IL2057;IL2058;IL2059;IL2060;IL2061;IL2062;IL2063; + IL2064;IL2065;IL2066;IL2067;IL2068;IL2069;IL2070;IL2071;IL2072;IL2073;IL2074;IL2075;IL2076;IL2077;IL2078;IL2079; + IL2080;IL2081;IL2082;IL2083;IL2084;IL2085;IL2086;IL2087;IL2088;IL2089;IL2090;IL2091;IL2092;IL2093;IL2094;IL2095; + IL2096;IL2097;IL2098;IL2099;IL2100;IL2101;IL2102;IL2103;IL2104;IL2105;IL2106;IL2107;IL2108;IL2109;IL2110;IL2111; + IL2112;IL2113;IL2114;IL2115;IL2116;IL2117;IL2118;IL2119;IL2120;IL2121;IL2122;IL2123;IL2124;IL2125;IL2126;IL2127; + IL2128;IL3000;IL3001;IL3002;IL3003;IL3004;IL3005;IL3050;IL3051;IL3052;IL3053;IL3054;IL3055;IL3056;IL3057;IL3058; + IL3059; + + + $([System.Text.RegularExpressions.Regex]::Replace('$(PurviewBrowserSafeWarningCodes)', '\s', '')) + $(WarningsAsErrors);$(PurviewBrowserSafeWarningCodes) + + + + + + + + + + <_PurviewBrowserProjectInfo Include="$(MSBuildProjectFullPath)"> + $(MSBuildProjectName) + $(IsBlazorWebAssemblyProject) + $(PurviewBrowserSafe) + + + + + + + + <_PurviewBrowserProjectReference + Include="@(ProjectReference)" + Condition="'%(ProjectReference.ReferenceOutputAssembly)' != 'false' AND '%(ProjectReference.OutputItemType)' == ''" + /> + + + + + + + + + true + + + + + + + <_PurviewBrowserSafeCandidate + Include="@(_PurviewBrowserProjectReference->'%(FullPath)')" + Condition="'%(_PurviewBrowserProjectReference.PurviewBrowserSafe)' != 'true'" + /> + + + + + + <_PurviewBrowserSafeConfirmed Include="@(_PurviewBrowserSafeReferenceInfo->WithMetadataValue('PurviewBrowserSafe', 'true'))" /> + <_PurviewBrowserUnsafeReference + Include="@(_PurviewBrowserSafeCandidate)" + Exclude="@(_PurviewBrowserSafeConfirmed)" + /> + + + <_PurviewBrowserSafeReferenceRole Condition="'$(IsBlazorWebAssemblyProject)' == 'true'" + >a Blazor WebAssembly client + <_PurviewBrowserSafeReferenceRole Condition="'$(IsBlazorWebAssemblyProject)' != 'true'" + >a browser-safe library + <_PurviewBrowserSafeReferenceText>Set <PurviewBrowserSafe>true</PurviewBrowserSafe> in that project so its build checks it for browser-unsupported APIs, trimming and reflection, or set PurviewBrowserSafe="true" on the ProjectReference to record that it was judged safe. Suppress with DisablePurviewBrowserSafeReferenceCheck=true. + + + + + + + + <_PurviewBffReferencesOpenApi>$([System.Text.RegularExpressions.Regex]::IsMatch($(_PurviewProjectText), `(?is)<PackageReference\s[^>]*Include\s*=\s*["']Microsoft\.AspNetCore\.OpenApi["']`)) + true + false + $([System.String]::Copy(';$(InterceptorsNamespaces);').Replace(';Microsoft.AspNetCore.OpenApi.Generated;', ';').Trim(';')) + + diff --git a/src/tests/Analyzers.IntegrationTests/AnalyzerTestInfrastructure.cs b/src/tests/Analyzers.IntegrationTests/AnalyzerTestInfrastructure.cs index f5f06f3..31fee68 100644 --- a/src/tests/Analyzers.IntegrationTests/AnalyzerTestInfrastructure.cs +++ b/src/tests/Analyzers.IntegrationTests/AnalyzerTestInfrastructure.cs @@ -41,9 +41,16 @@ public static CSharpCompilation CreateCompilation( ); } + /// The fake project directory exposed as build_property.ProjectDir. + /// The value exposed as build_property.RootNamespace. + /// + /// Further build_property.* values, keyed by property name without the prefix - for example + /// IsBffProject. + /// public static AnalyzerOptions CreateAnalyzerOptions( string projectDir = @"C:\FakeProject\", - string rootNamespace = "An.Example.Project" + string rootNamespace = "An.Example.Project", + IReadOnlyDictionary? buildProperties = null ) { var values = new Dictionary(StringComparer.OrdinalIgnoreCase) @@ -52,6 +59,9 @@ public static AnalyzerOptions CreateAnalyzerOptions( ["build_property.RootNamespace"] = rootNamespace, }; + foreach (var (name, value) in buildProperties ?? new Dictionary()) + values["build_property." + name] = value; + InMemoryAnalyzerConfigOptions options = new(values); InMemoryAnalyzerConfigOptionsProvider provider = new(options); return new AnalyzerOptions([], provider); diff --git a/src/tests/Analyzers.IntegrationTests/Bff/BffAuthenticationCookieAnalyzerTests.cs b/src/tests/Analyzers.IntegrationTests/Bff/BffAuthenticationCookieAnalyzerTests.cs new file mode 100644 index 0000000..ef5625f --- /dev/null +++ b/src/tests/Analyzers.IntegrationTests/Bff/BffAuthenticationCookieAnalyzerTests.cs @@ -0,0 +1,262 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Purview.BuildSdk.Analyzers.Bff; + +/// +/// Verifies the BFF authentication-cookie rules (PDS0008, PDS0009) report only an explicit constant on the +/// CookieAuthenticationOptions cookie, and only in a project the SDK classifies as a BFF. +/// +/// +/// The ASP.NET Core types are declared as stand-ins in the test source: the analyzer matches them by +/// metadata name, so a stand-in exercises exactly the lookup a real BFF compilation goes through. +/// +sealed class BffAuthenticationCookieAnalyzerTests +{ + const string AspNetCoreStandIns = """ + namespace Microsoft.AspNetCore.Http + { + public enum SameSiteMode { Unspecified = -1, None = 0, Lax = 1, Strict = 2 } + + public class CookieBuilder + { + public virtual bool HttpOnly { get; set; } + public virtual SameSiteMode SameSite { get; set; } + } + } + + namespace Microsoft.AspNetCore.Antiforgery + { + public class AntiforgeryOptions + { + public Microsoft.AspNetCore.Http.CookieBuilder Cookie { get; set; } = new(); + } + } + + namespace Microsoft.AspNetCore.Authentication.OpenIdConnect + { + public class OpenIdConnectOptions + { + public Microsoft.AspNetCore.Http.CookieBuilder CorrelationCookie { get; set; } = new(); + public Microsoft.AspNetCore.Http.CookieBuilder NonceCookie { get; set; } = new(); + } + } + + namespace Microsoft.AspNetCore.Authentication.Cookies + { + public class CookieAuthenticationOptions + { + public Microsoft.AspNetCore.Http.CookieBuilder Cookie { get; set; } = new(); + } + } + """; + + [Test] + public async Task HttpOnlyFalse_OnAuthenticationCookie_InBff_ReportsDiagnostic(CancellationToken cancellationToken) + { + var diagnostics = await GetDiagnosticsAsync( + """ + static class Setup + { + public static void Configure(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions options) + { + options.Cookie.HttpOnly = false; + } + } + """, + isBffProject: true, + cancellationToken + ); + + await Assert + .That(diagnostics.Select(d => d.Id)) + .IsEquivalentTo([BffAuthenticationCookieAnalyzer.HttpOnlyDiagnosticId]); + } + + [Test] + public async Task SameSiteNone_OnAuthenticationCookie_InBff_ReportsDiagnostic(CancellationToken cancellationToken) + { + var diagnostics = await GetDiagnosticsAsync( + """ + using Microsoft.AspNetCore.Http; + + static class Setup + { + public static void Configure(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions options) + { + options.Cookie.SameSite = SameSiteMode.None; + } + } + """, + isBffProject: true, + cancellationToken + ); + + await Assert + .That(diagnostics.Select(d => d.Id)) + .IsEquivalentTo([BffAuthenticationCookieAnalyzer.SameSiteNoneDiagnosticId]); + } + + [Test] + public async Task NestedCookieInitializer_InBff_ReportsBothDiagnostics(CancellationToken cancellationToken) + { + var diagnostics = await GetDiagnosticsAsync( + """ + using Microsoft.AspNetCore.Http; + using Microsoft.AspNetCore.Authentication.Cookies; + + static class Setup + { + public static CookieAuthenticationOptions Create() => + new() { Cookie = { HttpOnly = false, SameSite = SameSiteMode.None } }; + } + """, + isBffProject: true, + cancellationToken + ); + + await Assert + .That(diagnostics.Select(d => d.Id)) + .IsEquivalentTo([ + BffAuthenticationCookieAnalyzer.HttpOnlyDiagnosticId, + BffAuthenticationCookieAnalyzer.SameSiteNoneDiagnosticId, + ]); + } + + [Test] + public async Task SecureValues_OnAuthenticationCookie_InBff_DoNotReportDiagnostics( + CancellationToken cancellationToken + ) + { + var diagnostics = await GetDiagnosticsAsync( + """ + using Microsoft.AspNetCore.Http; + + static class Setup + { + public static void Configure(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions options) + { + options.Cookie.HttpOnly = true; + options.Cookie.SameSite = SameSiteMode.Strict; + options.Cookie.SameSite = SameSiteMode.Lax; + } + } + """, + isBffProject: true, + cancellationToken + ); + + await Assert.That(diagnostics).IsEmpty(); + } + + [Test] + public async Task OtherCookies_InBff_DoNotReportDiagnostics(CancellationToken cancellationToken) + { + // The antiforgery cookie is legitimately readable by script (an SPA echoes it in a header), and the + // OpenID Connect correlation and nonce cookies need SameSite=None to survive the identity provider's + // cross-site redirect. Reporting any of them would make the rule noise. + var diagnostics = await GetDiagnosticsAsync( + """ + using Microsoft.AspNetCore.Http; + + static class Setup + { + public static void Configure( + Microsoft.AspNetCore.Antiforgery.AntiforgeryOptions antiforgery, + Microsoft.AspNetCore.Authentication.OpenIdConnect.OpenIdConnectOptions oidc, + CookieBuilder standalone + ) + { + antiforgery.Cookie.HttpOnly = false; + antiforgery.Cookie.SameSite = SameSiteMode.None; + oidc.CorrelationCookie.SameSite = SameSiteMode.None; + oidc.NonceCookie.SameSite = SameSiteMode.None; + standalone.HttpOnly = false; + } + } + """, + isBffProject: true, + cancellationToken + ); + + await Assert.That(diagnostics).IsEmpty(); + } + + [Test] + public async Task NonConstantValue_InBff_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + var diagnostics = await GetDiagnosticsAsync( + """ + static class Setup + { + public static void Configure( + Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions options, + bool httpOnly + ) + { + options.Cookie.HttpOnly = httpOnly; + } + } + """, + isBffProject: true, + cancellationToken + ); + + await Assert.That(diagnostics).IsEmpty(); + } + + [Test] + [Arguments(false)] + [Arguments(null)] + public async Task HttpOnlyFalse_OutsideBff_DoesNotReportDiagnostic( + bool? isBffProject, + CancellationToken cancellationToken + ) + { + var diagnostics = await GetDiagnosticsAsync( + """ + static class Setup + { + public static void Configure(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions options) + { + options.Cookie.HttpOnly = false; + } + } + """, + isBffProject, + cancellationToken + ); + + await Assert.That(diagnostics).IsEmpty(); + } + + static async Task> GetDiagnosticsAsync( + string source, + bool? isBffProject, + CancellationToken cancellationToken + ) + { + var compilation = AnalyzerTestInfrastructure + .CreateCompilation(source, @"C:\FakeProject\Setup.cs") + .AddSyntaxTrees( + Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree.ParseText( + AspNetCoreStandIns, + path: AnalyzerTestInfrastructure.NormalizeFakePath(@"C:\FakeProject\AspNetCore.cs"), + cancellationToken: cancellationToken + ) + ); + + var buildProperties = isBffProject is { } value + ? new Dictionary { ["IsBffProject"] = value ? "true" : "false" } + : null; + + var analyzers = ImmutableArray.Create(new BffAuthenticationCookieAnalyzer()); + return await compilation + .WithAnalyzers( + analyzers, + AnalyzerTestInfrastructure.CreateAnalyzerOptions(buildProperties: buildProperties) + ) + .GetAnalyzerDiagnosticsAsync(cancellationToken); + } +} diff --git a/src/tests/BuildSdk.IntegrationTests/BlazorFixtures.cs b/src/tests/BuildSdk.IntegrationTests/BlazorFixtures.cs new file mode 100644 index 0000000..83c2bbe --- /dev/null +++ b/src/tests/BuildSdk.IntegrationTests/BlazorFixtures.cs @@ -0,0 +1,135 @@ +using Purview.BuildSdk.Harness; + +namespace Purview.BuildSdk; + +/// +/// Project files and solution wiring shared by the Blazor WebAssembly, browser-safe and BFF tests. +/// +static class BlazorFixtures +{ + /// The automatic SDK packages, removed where a test is not about them. + public const string RemoveAutomaticPackages = """ + + + """; + + public static string Library(string properties = "", string items = "") => + $""" + + + net10.0 + true + {properties} + + + {items} + + + """; + + public static string Client(string properties = "", string items = "") => + $""" + + + net10.0 + true + {properties} + + + + {items} + + + """; + + public static string Web(string properties = "", string items = "") => + $""" + + + net10.0 + true + {properties} + + + {items} + + + """; + + public const string ClientProgram = """ + using Microsoft.AspNetCore.Components.WebAssembly.Hosting; + + var builder = WebAssemblyHostBuilder.CreateDefault(args); + await builder.Build().RunAsync().ConfigureAwait(false); + """; + + public const string HostProgram = """ + var builder = WebApplication.CreateBuilder(args); + var app = builder.Build(); + app.UseBlazorFrameworkFiles(); + app.UseStaticFiles(); + await app.RunAsync().ConfigureAwait(false); + """; + + /// + /// Creates a project in a new throwaway solution. The returned harness owns the solution directory, so + /// disposing it removes every sibling added with . + /// + public static Task CreateAsync( + string projectName, + string content, + string namespacePrefix = "Acme", + CancellationToken cancellationToken = default + ) => + ProjectHarness + .For(projectName) + .WithNamespacePrefix(namespacePrefix) + .WithProjectFileContent(content) + .BuildAsync(cancellationToken); + + /// Adds a project beside , in the same solution. + public static Task AddSiblingAsync( + ProjectHarness owner, + string projectName, + string content, + string namespacePrefix = "Acme", + CancellationToken cancellationToken = default + ) => + ProjectHarness + .For(projectName) + .WithNamespacePrefix(namespacePrefix) + .WithProjectFileContent(content) + .WithSolutionDirectory(owner.SolutionDirectory) + .BuildAsync(cancellationToken); + + /// + /// Replaces the harness's central package versions with the ones a Blazor Web App restores: the + /// WebAssembly packages for the TFM the fixtures target, and the automatic Purview telemetry packages. + /// + public static Task WritePackageVersionsAsync(ProjectHarness owner, CancellationToken cancellationToken) => + File.WriteAllTextAsync( + Path.Combine(owner.SolutionDirectory, "Directory.Packages.props"), + """ + + + true + + + + + + + + + + """, + cancellationToken + ); + + public static Task WriteSourceAsync( + ProjectHarness project, + string fileName, + string content, + CancellationToken cancellationToken + ) => File.WriteAllTextAsync(Path.Combine(project.ProjectDirectory, fileName), content, cancellationToken); +} diff --git a/src/tests/BuildSdk.IntegrationTests/BlazorProjectDetectionTests.cs b/src/tests/BuildSdk.IntegrationTests/BlazorProjectDetectionTests.cs new file mode 100644 index 0000000..24659fc --- /dev/null +++ b/src/tests/BuildSdk.IntegrationTests/BlazorProjectDetectionTests.cs @@ -0,0 +1,574 @@ +using Purview.BuildSdk.Harness; +using Purview.BuildSdk.Infra; + +namespace Purview.BuildSdk; + +/// +/// Pins the Blazor WebAssembly, Razor class library, Blazor host and BFF detection signals, each opt-out, +/// and that a project which is none of them is left exactly as it was. +/// +sealed class BlazorProjectDetectionTests +{ + [Test] + public async Task BlazorWebAssemblySdk_IsBlazorWebAssemblyProject(CancellationToken cancellationToken) + { + using var h = await BlazorFixtures.CreateAsync( + "Shop.Client", + BlazorFixtures.Client(), + cancellationToken: cancellationToken + ); + + var properties = await h.GetPropertiesAsync( + cancellationToken, + "IsBlazorWebAssemblyProject", + "IsRazorClassLibraryProject", + "IsWebSdkProject", + "IsBffProject", + "IsBlazorHostProject" + ); + + await Assert.That(properties["IsBlazorWebAssemblyProject"]).IsEqualTo("true"); + await Assert.That(properties["IsRazorClassLibraryProject"]).IsEqualTo("false"); + await Assert.That(properties["IsWebSdkProject"]).IsEqualTo("false"); + await Assert.That(properties["IsBffProject"]).IsEqualTo("false"); + await Assert.That(properties["IsBlazorHostProject"]).IsEqualTo("false"); + } + + [Test] + public async Task RazorSdk_IsRazorClassLibraryProject(CancellationToken cancellationToken) + { + using var h = await BlazorFixtures.CreateAsync( + "Shop.Components", + BlazorFixtures + .Library() + .Replace("Microsoft.NET.Sdk\"", "Microsoft.NET.Sdk.Razor\"", StringComparison.Ordinal), + cancellationToken: cancellationToken + ); + + var properties = await h.GetPropertiesAsync( + cancellationToken, + "IsRazorClassLibraryProject", + "IsBlazorWebAssemblyProject" + ); + + await Assert.That(properties["IsRazorClassLibraryProject"]).IsEqualTo("true"); + await Assert.That(properties["IsBlazorWebAssemblyProject"]).IsEqualTo("false"); + } + + [Test] + [Arguments("Microsoft.NET.Sdk", "Shop.Orders")] + [Arguments("Microsoft.NET.Sdk.Web", "Shop.Api")] + [Arguments("Microsoft.NET.Sdk.Worker", "Shop.Worker")] + public async Task OtherProjects_AreNeitherBlazorNorBff( + string sdk, + string projectName, + CancellationToken cancellationToken + ) + { + using var h = await ProjectHarness.For(projectName).WithSdk(sdk).BuildAsync(cancellationToken); + + var properties = await h.GetPropertiesAsync( + cancellationToken, + "IsBlazorWebAssemblyProject", + "IsRazorClassLibraryProject", + "IsBlazorHostProject", + "IsBffProject", + "IsAotCompatible", + "WarningsAsErrors" + ); + + await Assert.That(properties["IsBlazorWebAssemblyProject"]).IsEqualTo("false"); + await Assert.That(properties["IsRazorClassLibraryProject"]).IsEqualTo("false"); + await Assert.That(properties["IsBlazorHostProject"]).IsEqualTo("false"); + await Assert.That(properties["IsBffProject"]).IsEqualTo("false"); + await Assert.That(properties["IsAotCompatible"]).IsEmpty(); + await Assert.That(properties["WarningsAsErrors"]).DoesNotContain("IL2026"); + } + + [Test] + [Arguments("Shop.Bff")] + [Arguments("ShopBff")] + [Arguments("Bff")] + public async Task WebProject_NamedWithBffSuffix_IsBffProject( + string projectName, + CancellationToken cancellationToken + ) + { + using var h = await BlazorFixtures.CreateAsync( + projectName, + BlazorFixtures.Web(), + cancellationToken: cancellationToken + ); + + await Assert.That(await h.GetPropertyAsync("IsBffProject", cancellationToken)).IsEqualTo("true"); + } + + [Test] + [Arguments("Shop.BFF")] + [Arguments("Shop.bff")] + [Arguments("Shop.BffTools")] + public async Task WebProject_WithoutExactBffSuffix_IsNotBffProject( + string projectName, + CancellationToken cancellationToken + ) + { + // Matched the way IsCLIProject matches 'CLI': case-sensitive, and only as a suffix. + using var h = await BlazorFixtures.CreateAsync( + projectName, + BlazorFixtures.Web(), + cancellationToken: cancellationToken + ); + + await Assert.That(await h.GetPropertyAsync("IsBffProject", cancellationToken)).IsEqualTo("false"); + } + + [Test] + public async Task LibraryProject_NamedWithBffSuffix_IsNotBffProject(CancellationToken cancellationToken) + { + // A BFF is a web host; a library that happens to end in Bff is not one. + using var h = await BlazorFixtures.CreateAsync( + "Shop.Bff", + BlazorFixtures.Library(), + cancellationToken: cancellationToken + ); + + await Assert.That(await h.GetPropertyAsync("IsBffProject", cancellationToken)).IsEqualTo("false"); + } + + [Test] + public async Task IsBffProjectFalse_OptsANamedBffOut(CancellationToken cancellationToken) + { + using var h = await BlazorFixtures.CreateAsync( + "Shop.Bff", + BlazorFixtures.Web("false"), + cancellationToken: cancellationToken + ); + + var properties = await h.GetPropertiesAsync(cancellationToken, "IsBffProject", "InterceptorsNamespaces"); + + await Assert.That(properties["IsBffProject"]).IsEqualTo("false"); + // No longer a BFF, so it is an ordinary web project again and keeps the OpenAPI interceptors. + await Assert.That(properties["InterceptorsNamespaces"]).Contains("Microsoft.AspNetCore.OpenApi.Generated"); + } + + [Test] + public async Task IsBffProjectTrue_OptsAWebProjectIn(CancellationToken cancellationToken) + { + using var h = await BlazorFixtures.CreateAsync( + "Shop.Gateway", + BlazorFixtures.Web("true"), + cancellationToken: cancellationToken + ); + + var properties = await h.GetPropertiesAsync(cancellationToken, "IsBffProject", "InterceptorsNamespaces"); + + await Assert.That(properties["IsBffProject"]).IsEqualTo("true"); + await Assert + .That(properties["InterceptorsNamespaces"]) + .DoesNotContain("Microsoft.AspNetCore.OpenApi.Generated"); + } + + [Test] + public async Task IsBffProjectTrue_SetBeforeTheSdkImport_IsHonoured(CancellationToken cancellationToken) + { + using var h = await ProjectHarness + .For("Shop.Gateway") + .WithNamespacePrefix("Acme") + .WithPreImportProperty("IsBffProject", "true") + .WithProjectFileContent(BlazorFixtures.Web()) + .BuildAsync(cancellationToken); + + await Assert.That(await h.GetPropertyAsync("IsBffProject", cancellationToken)).IsEqualTo("true"); + } + + [Test] + public async Task Bff_WithoutAnApiOfItsOwn_HasNoOpenApiInterceptors(CancellationToken cancellationToken) + { + // A BFF that only forwards has no OpenAPI document to generate. + using var h = await BlazorFixtures.CreateAsync( + "Shop.Bff", + BlazorFixtures.Web(), + cancellationToken: cancellationToken + ); + + var properties = await h.GetPropertiesAsync( + cancellationToken, + "InterceptorsNamespaces", + "PurviewBffExposesApi" + ); + + await Assert + .That(properties["InterceptorsNamespaces"]) + .DoesNotContain("Microsoft.AspNetCore.OpenApi.Generated"); + await Assert.That(properties["PurviewBffExposesApi"]).IsEqualTo("false"); + } + + [Test] + public async Task Bff_ReferencingOpenApi_KeepsOpenApiInterceptors(CancellationToken cancellationToken) + { + using var h = await BlazorFixtures.CreateAsync( + "Shop.Bff", + BlazorFixtures.Web(items: """"""), + cancellationToken: cancellationToken + ); + + var properties = await h.GetPropertiesAsync( + cancellationToken, + "InterceptorsNamespaces", + "PurviewBffExposesApi" + ); + + await Assert.That(properties["InterceptorsNamespaces"]).Contains("Microsoft.AspNetCore.OpenApi.Generated"); + await Assert.That(properties["PurviewBffExposesApi"]).IsEqualTo("true"); + } + + [Test] + public async Task Bff_PurviewBffExposesApiTrue_KeepsOpenApiInterceptors(CancellationToken cancellationToken) + { + using var h = await BlazorFixtures.CreateAsync( + "Shop.Bff", + BlazorFixtures.Web("true"), + cancellationToken: cancellationToken + ); + + await Assert + .That(await h.GetPropertyAsync("InterceptorsNamespaces", cancellationToken)) + .Contains("Microsoft.AspNetCore.OpenApi.Generated"); + } + + [Test] + public async Task NonBffWebProject_KeepsOpenApiInterceptors(CancellationToken cancellationToken) + { + using var h = await BlazorFixtures.CreateAsync( + "Shop.Api", + BlazorFixtures.Web(), + cancellationToken: cancellationToken + ); + + await Assert + .That(await h.GetPropertyAsync("InterceptorsNamespaces", cancellationToken)) + .Contains("Microsoft.AspNetCore.OpenApi.Generated"); + } + + [Test] + public async Task Bff_AddsNoPackages(CancellationToken cancellationToken) + { + // YARP, OpenID Connect and any BFF library (Duende.BFF is commercially licensed) are the application's + // choice. A BFF gets exactly the packages any other web project gets. + using var bff = await BlazorFixtures.CreateAsync( + "Shop.Bff", + BlazorFixtures.Web(), + cancellationToken: cancellationToken + ); + using var api = await BlazorFixtures.AddSiblingAsync( + bff, + "Shop.Api", + BlazorFixtures.Web(), + cancellationToken: cancellationToken + ); + + var bffPackages = await bff.GetItemIdentitiesAsync("PackageReference", cancellationToken); + var apiPackages = await api.GetItemIdentitiesAsync("PackageReference", cancellationToken); + + await Assert.That(bffPackages).IsEquivalentTo(apiPackages); + } + + [Test] + public async Task Host_PairedWithClient_IsBlazorHostProject_AndReferencesIt(CancellationToken cancellationToken) + { + using var client = await BlazorFixtures.CreateAsync( + "Shop.Client", + BlazorFixtures.Client(), + cancellationToken: cancellationToken + ); + using var host = await BlazorFixtures.AddSiblingAsync( + client, + "Shop", + BlazorFixtures.Web(), + cancellationToken: cancellationToken + ); + + var evaluation = await host.EvaluateAsync( + ["IsBlazorHostProject", "IsBffProject"], + ["ProjectReference"], + cancellationToken + ); + + await Assert.That(evaluation.Properties["IsBlazorHostProject"]).IsEqualTo("true"); + // A host that keeps no tokens is not a BFF, so being a host must not make it one. + await Assert.That(evaluation.Properties["IsBffProject"]).IsEqualTo("false"); + await Assert + .That(evaluation.Items["ProjectReference"].Select(TestHelpers.NormalizePath)) + .IsEquivalentTo(["../Shop.Client/Shop.Client.csproj"]); + } + + [Test] + [Arguments("../Shop.Client/Shop.Client.csproj")] + [Arguments(@"..\Shop.Client\Shop.Client.csproj")] + public async Task Host_AlreadyReferencingItsClient_GetsNoSecondReference( + string declaredReference, + CancellationToken cancellationToken + ) + { + using var client = await BlazorFixtures.CreateAsync( + "Shop.Client", + BlazorFixtures.Client(), + cancellationToken: cancellationToken + ); + using var host = await BlazorFixtures.AddSiblingAsync( + client, + "Shop", + BlazorFixtures.Web(items: $""""""), + cancellationToken: cancellationToken + ); + + var references = await host.GetProjectReferencesAsync(cancellationToken); + + // Normalised because MSBuild on Linux and macOS rewrites the backslashes in an item spec, so the + // second case reads back with forward slashes there. What matters is that there is only one. + await Assert + .That(references.Select(TestHelpers.NormalizePath)) + .IsEquivalentTo([TestHelpers.NormalizePath(declaredReference)]); + } + + [Test] + public async Task Host_PurviewAutoBlazorClientReferenceFalse_AddsNoReference(CancellationToken cancellationToken) + { + using var client = await BlazorFixtures.CreateAsync( + "Shop.Client", + BlazorFixtures.Client(), + cancellationToken: cancellationToken + ); + using var host = await BlazorFixtures.AddSiblingAsync( + client, + "Shop", + BlazorFixtures.Web("false"), + cancellationToken: cancellationToken + ); + + await Assert.That(await host.GetProjectReferencesAsync(cancellationToken)).IsEmpty(); + } + + [Test] + public async Task Host_IsBlazorHostProjectFalse_IsHonoured(CancellationToken cancellationToken) + { + using var client = await BlazorFixtures.CreateAsync( + "Shop.Client", + BlazorFixtures.Client(), + cancellationToken: cancellationToken + ); + using var host = await BlazorFixtures.AddSiblingAsync( + client, + "Shop", + BlazorFixtures.Web("false"), + cancellationToken: cancellationToken + ); + + await Assert.That(await host.GetPropertyAsync("IsBlazorHostProject", cancellationToken)).IsEqualTo("false"); + } + + [Test] + public async Task Host_SiblingNamedClientThatIsNotWebAssembly_IsNotPaired(CancellationToken cancellationToken) + { + using var library = await BlazorFixtures.CreateAsync( + "Shop.Client", + BlazorFixtures.Library(), + cancellationToken: cancellationToken + ); + using var host = await BlazorFixtures.AddSiblingAsync( + library, + "Shop", + BlazorFixtures.Web(), + cancellationToken: cancellationToken + ); + + var evaluation = await host.EvaluateAsync(["IsBlazorHostProject"], ["ProjectReference"], cancellationToken); + + await Assert.That(evaluation.Properties["IsBlazorHostProject"]).IsEqualTo("false"); + await Assert.That(evaluation.Items["ProjectReference"]).IsEmpty(); + } + + [Test] + public async Task Host_ReferencingAClientUnderAnotherName_IsResolvedByTheBuild(CancellationToken cancellationToken) + { + // Not the /.Client pairing, so evaluation cannot know; the build asks the reference. + using var client = await BlazorFixtures.CreateAsync( + "Storefront", + BlazorFixtures.Client(), + cancellationToken: cancellationToken + ); + using var host = await BlazorFixtures.AddSiblingAsync( + client, + "Shop", + BlazorFixtures.Web(items: """"""), + cancellationToken: cancellationToken + ); + + await Assert.That(await host.GetPropertyAsync("IsBlazorHostProject", cancellationToken)).IsEqualTo("false"); + + var (code, stdOut, stdErr) = await host.RunMSBuildAsync( + "-noconlog -t:ResolvePurviewBlazorHostProject -getProperty:IsBlazorHostProject", + cancellationToken + ); + + await Assert.That(code).IsZero().Because(TestHelpers.GenerateError(stdOut, stdErr)); + await Assert.That(stdOut.Trim()).IsEqualTo("true"); + } + + [Test] + public async Task Client_IsNotPackable_AndHasNoContainerDefaults(CancellationToken cancellationToken) + { + using var h = await ProjectHarness + .For("Shop.Client") + .WithNamespacePrefix("Acme") + .WithProjectFileContent(BlazorFixtures.Client()) + .WithDockerfile() + .BuildAsync(cancellationToken); + + var evaluation = await h.EvaluateAsync( + ["IsPackable", "IsContainerProject", "PublishAot", "DockerDefaultTargetOS"], + ["PackageReference"], + cancellationToken + ); + + await Assert.That(evaluation.Properties["IsPackable"]).IsEqualTo("false"); + await Assert.That(evaluation.Properties["IsContainerProject"]).IsEqualTo("false"); + // PublishAot on a WebAssembly client is a publish that fails; the container group must not apply. + await Assert.That(evaluation.Properties["PublishAot"]).IsNotEqualTo("true"); + await Assert.That(evaluation.Properties["DockerDefaultTargetOS"]).IsNotEqualTo("Linux"); + await Assert + .That(evaluation.Items["PackageReference"]) + .DoesNotContain("Microsoft.VisualStudio.Azure.Containers.Tools.Targets"); + } + + [Test] + public async Task Client_IsPackableTrueInTheProject_StillWins(CancellationToken cancellationToken) + { + using var h = await BlazorFixtures.CreateAsync( + "Shop.Client", + BlazorFixtures.Client("true"), + cancellationToken: cancellationToken + ); + + await Assert.That(await h.GetPropertyAsync("IsPackable", cancellationToken)).IsEqualTo("true"); + } + + [Test] + public async Task Client_LeavesTheBlazorSdkSettingsAlone(CancellationToken cancellationToken) + { + // Globalisation, trimming mode and the runtime settings belong to the Blazor SDK. A second default here + // would compete with it, so every one of them must read exactly what the Blazor SDK alone produces. + string[] blazorOwned = + [ + "InvariantGlobalization", + "BlazorWebAssemblyLoadAllGlobalizationData", + "TrimMode", + "PublishTrimmed", + "RunAOTCompilation", + ]; + + using var withSdk = await BlazorFixtures.CreateAsync( + "Shop.Client", + BlazorFixtures.Client(), + cancellationToken: cancellationToken + ); + var actual = await withSdk.GetPropertiesAsync(cancellationToken, blazorOwned); + + var bareDirectory = Path.Combine(withSdk.SolutionDirectory, "bare"); + Directory.CreateDirectory(bareDirectory); + var bareProject = Path.Combine(bareDirectory, "Bare.csproj"); + await File.WriteAllTextAsync( + Path.Combine(bareDirectory, "Directory.Build.props"), + "", + cancellationToken + ); + await File.WriteAllTextAsync( + Path.Combine(bareDirectory, "Directory.Build.targets"), + "", + cancellationToken + ); + await File.WriteAllTextAsync( + bareProject, + """ + + + net10.0 + + + """, + cancellationToken + ); + + var (code, stdOut, stdErr) = await withSdk.RunProcessAsync( + "dotnet", + $"msbuild \"{bareProject}\" -nologo -noconlog -getProperty:{string.Join(",", blazorOwned)}", + bareDirectory, + cancellationToken + ); + + await Assert.That(code).IsZero().Because(TestHelpers.GenerateError(stdOut, stdErr)); + foreach (var property in blazorOwned) + { + await Assert.That(stdOut).Contains($"\"{property}\": \"{actual[property]}\"").Because(property); + } + } + + [Test] + public async Task Client_DoesNotAutoReferenceShared(CancellationToken cancellationToken) + { + // Every assembly a client references is downloaded to the browser; a server-side Shared library must + // not join that download because of a folder name. + using var shared = await BlazorFixtures.CreateAsync( + "Shared", + BlazorFixtures.Library(), + cancellationToken: cancellationToken + ); + using var client = await BlazorFixtures.AddSiblingAsync( + shared, + "Shop.Client", + BlazorFixtures.Client(), + cancellationToken: cancellationToken + ); + using var library = await BlazorFixtures.AddSiblingAsync( + shared, + "Shop.Orders", + BlazorFixtures.Library(), + cancellationToken: cancellationToken + ); + + await Assert.That(await client.GetProjectReferencesAsync(cancellationToken)).IsEmpty(); + await Assert + .That((await library.GetProjectReferencesAsync(cancellationToken)).Select(TestHelpers.NormalizePath)) + .Contains("../Shared/Shared.csproj"); + } + + [Test] + public async Task Client_DisablePurviewBlazorWebAssemblyDefaults_RestoresTheGenericDefaults( + CancellationToken cancellationToken + ) + { + using var shared = await BlazorFixtures.CreateAsync( + "Shared", + BlazorFixtures.Library(), + cancellationToken: cancellationToken + ); + using var client = await ProjectHarness + .For("Shop.Client") + .WithNamespacePrefix("Acme") + .WithProjectFileContent(BlazorFixtures.Client()) + .WithDockerfile() + .WithSolutionDirectory(shared.SolutionDirectory) + .BuildAsync(cancellationToken); + + // The opt-out is read where the defaults are, before the project body, so it is set as a global + // property here - the same position Directory.Build.props would give it. + var (code, stdOut, stdErr) = await client.RunMSBuildAsync( + "-noconlog -p:DisablePurviewBlazorWebAssemblyDefaults=true -getProperty:IsContainerProject -getItem:ProjectReference", + cancellationToken + ); + + await Assert.That(code).IsZero().Because(TestHelpers.GenerateError(stdOut, stdErr)); + await Assert.That(stdOut).Contains("\"IsContainerProject\": \"true\""); + await Assert.That(stdOut).Contains("Shared.csproj"); + } +} diff --git a/src/tests/BuildSdk.IntegrationTests/BlazorProjectIdentityTests.cs b/src/tests/BuildSdk.IntegrationTests/BlazorProjectIdentityTests.cs new file mode 100644 index 0000000..66c8da7 --- /dev/null +++ b/src/tests/BuildSdk.IntegrationTests/BlazorProjectIdentityTests.cs @@ -0,0 +1,68 @@ +using Purview.BuildSdk.Infra; + +namespace Purview.BuildSdk; + +/// +/// Pins the namespace and assembly identities the naming rules produce for a Blazor Web App's projects, and +/// that a client's test project resolves its target. +/// +sealed class BlazorProjectIdentityTests +{ + [Test] + [Arguments("Shop.Client", "Microsoft.NET.Sdk.BlazorWebAssembly", "Acme.Shop.Client", "Acme.Shop.Client")] + [Arguments("Shop.Bff", "Microsoft.NET.Sdk.Web", "Acme.Shop.Bff", "Acme.Shop.Bff")] + [Arguments("Shop", "Microsoft.NET.Sdk.Web", "Acme.Shop", "Acme.Shop")] + // ClientShared is a stripped segment, so the namespace collapses onto the product while the assembly + // keeps the full logical name and stays distinct from the host's. + [Arguments("Shop.ClientShared", "Microsoft.NET.Sdk", "Acme.Shop", "Acme.Shop.ClientShared")] + [Arguments("ClientShared", "Microsoft.NET.Sdk", "Acme", "Acme.ClientShared")] + public async Task BlazorWebAppProjects_HaveSensibleIdentities( + string projectName, + string sdk, + string expectedRootNamespace, + string expectedAssemblyName, + CancellationToken cancellationToken + ) + { + var content = BlazorFixtures + .Library() + .Replace("Sdk=\"Microsoft.NET.Sdk\"", $"Sdk=\"{sdk}\"", StringComparison.Ordinal); + using var h = await BlazorFixtures.CreateAsync(projectName, content, cancellationToken: cancellationToken); + + var properties = await h.GetPropertiesAsync(cancellationToken, "RootNamespace", "AssemblyName", "PackageId"); + + await Assert.That(properties["RootNamespace"]).IsEqualTo(expectedRootNamespace); + await Assert.That(properties["AssemblyName"]).IsEqualTo(expectedAssemblyName); + await Assert.That(properties["PackageId"]).IsEqualTo(expectedAssemblyName); + } + + [Test] + public async Task ClientTestProject_ResolvesTheClientAsItsTarget(CancellationToken cancellationToken) + { + using var client = await BlazorFixtures.CreateAsync( + "Shop.Client", + BlazorFixtures.Client(), + cancellationToken: cancellationToken + ); + using var tests = await BlazorFixtures.AddSiblingAsync( + client, + "Shop.Client.UnitTests", + BlazorFixtures.Library(), + cancellationToken: cancellationToken + ); + + var evaluation = await tests.EvaluateAsync( + ["TargetProjectName", "RootNamespace", "AssemblyName", "IsTestProject"], + ["ProjectReference"], + cancellationToken + ); + + await Assert.That(evaluation.Properties["IsTestProject"]).IsEqualTo("true"); + await Assert.That(evaluation.Properties["TargetProjectName"]).IsEqualTo("Shop.Client"); + await Assert.That(evaluation.Properties["RootNamespace"]).IsEqualTo("Acme.Shop.Client"); + await Assert.That(evaluation.Properties["AssemblyName"]).IsEqualTo("Acme.Shop.Client.UnitTests"); + await Assert + .That(evaluation.Items["ProjectReference"].Select(TestHelpers.NormalizePath)) + .Contains("../Shop.Client/Shop.Client.csproj"); + } +} diff --git a/src/tests/BuildSdk.IntegrationTests/BlazorWebAppTests.cs b/src/tests/BuildSdk.IntegrationTests/BlazorWebAppTests.cs new file mode 100644 index 0000000..a28f1e7 --- /dev/null +++ b/src/tests/BuildSdk.IntegrationTests/BlazorWebAppTests.cs @@ -0,0 +1,188 @@ +using System.ComponentModel; +using Purview.BuildSdk.Infra; + +namespace Purview.BuildSdk; + +/// +/// End-to-end Blazor Web App scenarios: a host, its WebAssembly client and a browser-safe Contracts library +/// build with no warnings, and the packages the SDK adds automatically actually run inside the WebAssembly +/// runtime rather than merely compiling. +/// +sealed class BlazorWebAppTests +{ + const string ContractsSource = """ + namespace Acme; + + /// An order placed through the storefront. + /// The order identifier. + /// The order total. + public sealed record Order(string Id, decimal Total); + """; + + [Test] + public async Task BlazorWebApp_WithBrowserSafeContracts_BuildsWithNoWarnings(CancellationToken cancellationToken) + { + using var contracts = await BlazorFixtures.CreateAsync( + "Contracts", + BlazorFixtures.Library("true"), + cancellationToken: cancellationToken + ); + await BlazorFixtures.WritePackageVersionsAsync(contracts, cancellationToken); + await BlazorFixtures.WriteSourceAsync(contracts, "Order.cs", ContractsSource, cancellationToken); + + using var client = await BlazorFixtures.AddSiblingAsync( + contracts, + "Shop.Client", + BlazorFixtures.Client(items: """"""), + cancellationToken: cancellationToken + ); + await BlazorFixtures.WriteSourceAsync(client, "Program.cs", BlazorFixtures.ClientProgram, cancellationToken); + + // The host declares no reference to its client: the /.Client pairing supplies it. + using var host = await BlazorFixtures.AddSiblingAsync( + contracts, + "Shop", + BlazorFixtures.Web( + items: """""" + ), + cancellationToken: cancellationToken + ); + await BlazorFixtures.WriteSourceAsync(host, "Program.cs", BlazorFixtures.HostProgram, cancellationToken); + + var (success, output, errors) = await host.BuildAsync(restore: true, cancellationToken: cancellationToken); + + await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors)); + var warnings = output + .Split('\n') + .Where(line => line.Contains(": warning ", StringComparison.Ordinal)) + .Distinct() + .ToArray(); + await Assert.That(warnings).IsEmpty().Because(string.Join('\n', warnings)); + + // The client's framework files reached the host, so the pairing reference really was used. + await Assert + .That( + Directory.Exists( + Path.Combine(client.ProjectDirectory, "bin", "Debug", "net10.0", "wwwroot", "_framework") + ) + ) + .IsTrue(); + } + + [Test] + public async Task AutomaticTelemetryPackages_RunInsideTheWebAssemblyRuntime(CancellationToken cancellationToken) + { + // The SDK adds Purview.Telemetry.SourceGenerator (analyzer-only) and + // Microsoft.Extensions.Telemetry.Abstractions to every non-test project, a WebAssembly client included. + // Compiling is not evidence that they work in the browser, so this publishes a client that exercises + // both - generated activity, log and metric code, and a Telemetry.Abstractions type - and runs it on + // the published WebAssembly runtime, booted under Node. + using var client = await BlazorFixtures.CreateAsync( + "Shop.Client", + BlazorFixtures.Client(), + cancellationToken: cancellationToken + ); + await BlazorFixtures.WritePackageVersionsAsync(client, cancellationToken); + await BlazorFixtures.WriteSourceAsync(client, "Program.cs", TelemetryProbeProgram, cancellationToken); + + var publishDirectory = Path.Combine(client.SolutionDirectory, "publish"); + var (publishCode, publishOutput, publishErrors) = await client.RunProcessAsync( + "dotnet", + $"publish \"{client.ProjectFilePath}\" -nologo -v:q -p:IsPublishable=true -o \"{publishDirectory}\"", + cancellationToken: cancellationToken + ); + + await Assert.That(publishCode).IsZero().Because(TestHelpers.GenerateError(publishOutput, publishErrors)); + // Trimming is where a package that is not browser-safe shows itself first. + await Assert.That(publishOutput).DoesNotContain("warning IL"); + + var framework = Path.Combine(publishDirectory, "wwwroot", "_framework"); + await Assert.That(File.Exists(Path.Combine(framework, "dotnet.js"))).IsTrue(); + + // The runner lives outside the published output, which is only ever passed in as an argument. + var runnerDirectory = Path.Combine(client.SolutionDirectory, "runner"); + Directory.CreateDirectory(runnerDirectory); + var runner = Path.Combine(runnerDirectory, "run-wasm.mjs"); + await File.WriteAllTextAsync(runner, NodeRunner, cancellationToken); + + (int Code, string StdOut, string StdErr) run; + try + { + run = await client.RunProcessAsync( + "node", + $"\"{runner}\" \"{framework}\"", + runnerDirectory, + cancellationToken + ); + } + catch (Win32Exception exception) + { + Skip.Test($"Node is not on PATH, so the WebAssembly runtime cannot be booted: {exception.Message}"); + return; + } + + await Assert.That(run.Code).IsZero().Because(TestHelpers.GenerateError(run.StdOut, run.StdErr)); + await Assert.That(run.StdOut).Contains("PROBE-OK activity=PlacingOrder request=GetOrder"); + } + + const string TelemetryProbeProgram = """ + using System.Diagnostics; + using Microsoft.AspNetCore.Components.WebAssembly.Hosting; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Http.Diagnostics; + using Purview.Telemetry; + + if (args.Contains("--probe")) + { + using var listener = new ActivityListener + { + ShouldListenTo = _ => true, + Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded, + }; + ActivitySource.AddActivityListener(listener); + + ServiceCollection services = new(); + services.AddLogging(); + services.AddMetrics(); + services.AddShopTelemetry(); + using var provider = services.BuildServiceProvider(); + + var telemetry = provider.GetRequiredService(); + using var activity = telemetry.PlacingOrder("order-1"); + telemetry.OrderPlaced("order-1"); + telemetry.RecordOrderValue(42); + + RequestMetadata metadata = new("GET", "/orders/{id}") { RequestName = "GetOrder" }; + Console.WriteLine($"PROBE-OK activity={activity?.OperationName} request={metadata.RequestName}"); + return; + } + + var builder = WebAssemblyHostBuilder.CreateDefault(args); + await builder.Build().RunAsync().ConfigureAwait(false); + + [ActivitySource] + [Logger] + [Meter] + interface IShopTelemetry + { + [Activity] + Activity? PlacingOrder(string orderId); + + [Info] + void OrderPlaced(string orderId); + + [Histogram] + void RecordOrderValue(int value); + } + """; + + const string NodeRunner = """ + // Boots a published Blazor WebAssembly client's runtime under Node and runs its Main with --probe. + import { pathToFileURL } from 'node:url'; + import path from 'node:path'; + + const { dotnet } = await import(pathToFileURL(path.join(process.argv[2], 'dotnet.js')).href); + const runtime = await dotnet.withApplicationArguments('--probe').create(); + process.exit(await runtime.runMain()); + """; +} diff --git a/src/tests/BuildSdk.IntegrationTests/BrowserSafeLibraryTests.cs b/src/tests/BuildSdk.IntegrationTests/BrowserSafeLibraryTests.cs new file mode 100644 index 0000000..71ae119 --- /dev/null +++ b/src/tests/BuildSdk.IntegrationTests/BrowserSafeLibraryTests.cs @@ -0,0 +1,251 @@ +using Purview.BuildSdk.Harness; +using Purview.BuildSdk.Infra; + +namespace Purview.BuildSdk; + +/// +/// Pins what PurviewBrowserSafe=true applies to a class library, that it never overrides what the +/// project set, and that it turns a browser runtime failure - reflection the trimmer cannot follow, an API +/// the browser does not support - into a build failure. +/// +sealed class BrowserSafeLibraryTests +{ + const string ReflectionSource = """ + namespace Acme; + + /// Creates a type by name. + public static class Factory + { + /// Creates an instance of the named type. + /// The assembly-qualified type name. + /// The instance. + public static object? Create(string typeName) => Activator.CreateInstance(Type.GetType(typeName)!); + } + """; + + const string ProcessSource = """ + using System.Diagnostics; + + namespace Acme; + + /// Starts a process. + public static class Launcher + { + /// Starts the tool. + public static void Launch() => Process.Start("tool")?.Dispose(); + } + """; + + [Test] + public async Task BrowserSafe_AppliesTheBrowserSafeDefaults(CancellationToken cancellationToken) + { + using var h = await BlazorFixtures.CreateAsync( + "Contracts", + BlazorFixtures.Library("true"), + cancellationToken: cancellationToken + ); + + var evaluation = await h.EvaluateAsync( + [ + "IsAotCompatible", + "IsTrimmable", + "EnableTrimAnalyzer", + "EnableAotAnalyzer", + "WarningsAsErrors", + "TreatWarningsAsErrors", + ], + ["SupportedPlatform"], + cancellationToken + ); + + await Assert.That(evaluation.Properties["IsAotCompatible"]).IsEqualTo("true"); + await Assert.That(evaluation.Properties["IsTrimmable"]).IsEqualTo("true"); + await Assert.That(evaluation.Properties["EnableTrimAnalyzer"]).IsEqualTo("true"); + await Assert.That(evaluation.Properties["EnableAotAnalyzer"]).IsEqualTo("true"); + await Assert.That(evaluation.Items["SupportedPlatform"]).Contains("browser"); + + var warningsAsErrors = evaluation + .Properties["WarningsAsErrors"] + .Split(';', StringSplitOptions.RemoveEmptyEntries); + await Assert.That(warningsAsErrors).Contains("CA1416"); + await Assert.That(warningsAsErrors).Contains("IL2026"); + await Assert.That(warningsAsErrors).Contains("IL2057"); + await Assert.That(warningsAsErrors).Contains("IL3050"); + // The aim is to make *these* failures fatal, not to change the project's whole warning policy. + await Assert.That(evaluation.Properties["TreatWarningsAsErrors"]).IsNotEqualTo("true"); + } + + [Test] + public async Task BrowserSafe_KeepsWhatTheProjectSet(CancellationToken cancellationToken) + { + using var h = await BlazorFixtures.CreateAsync( + "Contracts", + BlazorFixtures.Library( + """ + true + false + CS0168 + """, + """""" + ), + cancellationToken: cancellationToken + ); + + var evaluation = await h.EvaluateAsync( + ["IsAotCompatible", "WarningsAsErrors"], + ["SupportedPlatform"], + cancellationToken + ); + + await Assert.That(evaluation.Properties["IsAotCompatible"]).IsEqualTo("false"); + await Assert.That(evaluation.Properties["WarningsAsErrors"]).StartsWith("CS0168;"); + await Assert.That(evaluation.Items["SupportedPlatform"].Count(p => p == "browser")).IsEqualTo(1); + } + + [Test] + public async Task BrowserSafeWarningsAsErrorsFalse_LeavesWarningsAsErrorsAlone(CancellationToken cancellationToken) + { + using var h = await BlazorFixtures.CreateAsync( + "Contracts", + BlazorFixtures.Library( + """ + true + false + """ + ), + cancellationToken: cancellationToken + ); + + var properties = await h.GetPropertiesAsync(cancellationToken, "IsAotCompatible", "WarningsAsErrors"); + + await Assert.That(properties["IsAotCompatible"]).IsEqualTo("true"); + await Assert.That(properties["WarningsAsErrors"]).DoesNotContain("IL2026"); + await Assert.That(properties["WarningsAsErrors"]).DoesNotContain("CA1416"); + } + + [Test] + public async Task BrowserSafe_OnAnExecutable_IsNotApplied(CancellationToken cancellationToken) + { + // Only a library is downloaded as a dependency of a client. + using var h = await BlazorFixtures.CreateAsync( + "Shop.Tool", + BlazorFixtures.Library("trueExe"), + cancellationToken: cancellationToken + ); + + var properties = await h.GetPropertiesAsync(cancellationToken, "IsAotCompatible", "WarningsAsErrors"); + + await Assert.That(properties["IsAotCompatible"]).IsEmpty(); + await Assert.That(properties["WarningsAsErrors"]).DoesNotContain("IL2026"); + } + + [Test] + public async Task BrowserSafe_MultiTargeted_SetsIsAotCompatibleOnlyWhereItIsSupported( + CancellationToken cancellationToken + ) + { + using var h = await BlazorFixtures.CreateAsync( + "Contracts", + BlazorFixtures + .Library("true") + .Replace( + "net10.0", + "net10.0;netstandard2.0", + StringComparison.Ordinal + ), + cancellationToken: cancellationToken + ); + + var modern = await h.GetPropertiesAsync( + new Dictionary { ["TargetFramework"] = "net10.0" }, + cancellationToken, + "IsAotCompatible" + ); + var legacy = await h.GetPropertiesAsync( + new Dictionary { ["TargetFramework"] = "netstandard2.0" }, + cancellationToken, + "IsAotCompatible" + ); + + await Assert.That(modern["IsAotCompatible"]).IsEqualTo("true"); + // The .NET SDK warns (NETSDK1210) for IsAotCompatible on a TFM that cannot honour it. + await Assert.That(legacy["IsAotCompatible"]).IsEmpty(); + } + + [Test] + public async Task BrowserSafe_ReflectionTheTrimmerCannotFollow_FailsTheBuild(CancellationToken cancellationToken) + { + using var h = await CreateLibraryAsync( + "true", + ReflectionSource, + cancellationToken + ); + + var (success, output, errors) = await h.BuildAsync(restore: true, cancellationToken: cancellationToken); + + await Assert.That(success).IsFalse().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(output).Contains("error IL2057"); + } + + [Test] + public async Task BrowserSafe_ProcessStart_FailsTheBuild(CancellationToken cancellationToken) + { + // The shipped .editorconfig sets CA1416 to suggestion; /warnaserror+:CA1416 still has to make a + // browser-unsupported call fatal. If the compiler ever stops letting WarningsAsErrors outrank an + // .editorconfig severity, this is the test that says so. + using var h = await CreateLibraryAsync( + "true", + ProcessSource, + cancellationToken + ); + + var (success, output, errors) = await h.BuildAsync(restore: true, cancellationToken: cancellationToken); + + await Assert.That(success).IsFalse().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(output).Contains("error CA1416"); + await Assert.That(output).Contains("'browser'"); + } + + [Test] + public async Task BrowserSafeWarningsAsErrorsFalse_ReflectionIsAWarning(CancellationToken cancellationToken) + { + using var h = await CreateLibraryAsync( + "truefalse", + ReflectionSource, + cancellationToken + ); + + var (success, output, errors) = await h.BuildAsync(restore: true, cancellationToken: cancellationToken); + + await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(output).Contains("warning IL2057"); + } + + [Test] + public async Task NotBrowserSafe_TheSameCalls_BuildAsBefore(CancellationToken cancellationToken) + { + using var h = await CreateLibraryAsync("", ReflectionSource, cancellationToken); + await BlazorFixtures.WriteSourceAsync(h, "Launcher.cs", ProcessSource, cancellationToken); + + var (success, output, errors) = await h.BuildAsync(restore: true, cancellationToken: cancellationToken); + + await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(output).DoesNotContain("IL2057"); + await Assert.That(output).DoesNotContain("CA1416"); + } + + static async Task CreateLibraryAsync( + string properties, + string source, + CancellationToken cancellationToken + ) + { + var h = await BlazorFixtures.CreateAsync( + "Contracts", + BlazorFixtures.Library(properties, BlazorFixtures.RemoveAutomaticPackages), + cancellationToken: cancellationToken + ); + await BlazorFixtures.WriteSourceAsync(h, "Source.cs", source, cancellationToken); + return h; + } +} diff --git a/src/tests/BuildSdk.IntegrationTests/BrowserSafeReferenceCheckTests.cs b/src/tests/BuildSdk.IntegrationTests/BrowserSafeReferenceCheckTests.cs new file mode 100644 index 0000000..1dcfdf2 --- /dev/null +++ b/src/tests/BuildSdk.IntegrationTests/BrowserSafeReferenceCheckTests.cs @@ -0,0 +1,209 @@ +using Purview.BuildSdk.Harness; +using Purview.BuildSdk.Infra; + +namespace Purview.BuildSdk; + +/// +/// Pins PRBLZ0001: a WebAssembly client (or a browser-safe library) asks each of its project references +/// whether it sets PurviewBrowserSafe, so no library joins the browser download without anyone +/// deciding it was safe there. +/// +/// +/// One test runs a full build to prove the check is wired into it; the rest invoke the target by name, which +/// asks the same question without restoring the WebAssembly packages. +/// +sealed class BrowserSafeReferenceCheckTests +{ + const string CheckTarget = "-t:PurviewValidateBrowserSafeReferences"; + + [Test] + public async Task Client_ReferencingAnUndeclaredLibrary_WarnsDuringTheBuild(CancellationToken cancellationToken) + { + using var contracts = await BlazorFixtures.CreateAsync( + "Contracts", + BlazorFixtures.Library(items: BlazorFixtures.RemoveAutomaticPackages), + cancellationToken: cancellationToken + ); + await BlazorFixtures.WritePackageVersionsAsync(contracts, cancellationToken); + using var client = await BlazorFixtures.AddSiblingAsync( + contracts, + "Shop.Client", + BlazorFixtures.Client( + items: """""" + + BlazorFixtures.RemoveAutomaticPackages + ), + cancellationToken: cancellationToken + ); + await BlazorFixtures.WriteSourceAsync(client, "Program.cs", BlazorFixtures.ClientProgram, cancellationToken); + + var (success, output, errors) = await client.BuildAsync(restore: true, cancellationToken: cancellationToken); + + await Assert.That(success).IsTrue().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(output).Contains("warning PRBLZ0001"); + await Assert.That(output).Contains("'Contracts'"); + await Assert.That(output).Contains("true"); + } + + [Test] + public async Task Client_ReferencingABrowserSafeLibrary_DoesNotWarn(CancellationToken cancellationToken) + { + using var library = await BlazorFixtures.CreateAsync( + "Contracts", + BlazorFixtures.Library("true"), + cancellationToken: cancellationToken + ); + using var client = await AddClientAsync(library, cancellationToken: cancellationToken); + + var (code, output, errors) = await client.RunMSBuildAsync(CheckTarget, cancellationToken); + + await Assert.That(code).IsZero().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(output).DoesNotContain("PRBLZ0001"); + } + + [Test] + public async Task Client_ReferenceMetadataDeclaresItSafe_DoesNotWarn(CancellationToken cancellationToken) + { + // The route for a project that does not use this SDK, and so cannot answer the question itself. + using var library = await BlazorFixtures.CreateAsync( + "Contracts", + BlazorFixtures.Library(), + cancellationToken: cancellationToken + ); + using var client = await AddClientAsync( + library, + referenceMetadata: """ PurviewBrowserSafe="true" """, + cancellationToken: cancellationToken + ); + + var (code, output, errors) = await client.RunMSBuildAsync(CheckTarget, cancellationToken); + + await Assert.That(code).IsZero().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(output).DoesNotContain("PRBLZ0001"); + } + + [Test] + public async Task Client_ReferenceThatIsNotAnAssembly_IsNotChecked(CancellationToken cancellationToken) + { + using var library = await BlazorFixtures.CreateAsync( + "Contracts", + BlazorFixtures.Library(), + cancellationToken: cancellationToken + ); + using var client = await AddClientAsync( + library, + referenceMetadata: """ ReferenceOutputAssembly="false" """, + cancellationToken: cancellationToken + ); + + var (code, output, errors) = await client.RunMSBuildAsync(CheckTarget, cancellationToken); + + await Assert.That(code).IsZero().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(output).DoesNotContain("PRBLZ0001"); + } + + [Test] + public async Task Client_CheckAsError_FailsOnAnUndeclaredLibrary(CancellationToken cancellationToken) + { + using var library = await BlazorFixtures.CreateAsync( + "Contracts", + BlazorFixtures.Library(), + cancellationToken: cancellationToken + ); + using var client = await AddClientAsync( + library, + clientProperties: "true", + cancellationToken: cancellationToken + ); + + var (code, output, errors) = await client.RunMSBuildAsync(CheckTarget, cancellationToken); + + await Assert.That(code).IsNotZero().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(output).Contains("error PRBLZ0001"); + } + + [Test] + public async Task Client_CheckDisabled_DoesNotWarn(CancellationToken cancellationToken) + { + using var library = await BlazorFixtures.CreateAsync( + "Contracts", + BlazorFixtures.Library(), + cancellationToken: cancellationToken + ); + using var client = await AddClientAsync( + library, + clientProperties: "true", + cancellationToken: cancellationToken + ); + + var (code, output, errors) = await client.RunMSBuildAsync(CheckTarget, cancellationToken); + + await Assert.That(code).IsZero().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(output).DoesNotContain("PRBLZ0001"); + } + + [Test] + public async Task BrowserSafeLibrary_ReferencingAnUndeclaredLibrary_Warns(CancellationToken cancellationToken) + { + // A browser-safe library is downloaded with the client, and so is everything it references: checking + // each browser-safe library's own references is what makes the guarantee hold for the whole download. + using var core = await BlazorFixtures.CreateAsync( + "Core", + BlazorFixtures.Library(), + cancellationToken: cancellationToken + ); + using var contracts = await BlazorFixtures.AddSiblingAsync( + core, + "Contracts", + BlazorFixtures.Library( + "true", + """""" + ), + cancellationToken: cancellationToken + ); + + var (code, output, errors) = await contracts.RunMSBuildAsync(CheckTarget, cancellationToken); + + await Assert.That(code).IsZero().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(output).Contains("warning PRBLZ0001"); + await Assert.That(output).Contains("'Core'"); + await Assert.That(output).Contains("browser-safe library"); + } + + [Test] + public async Task OrdinaryLibrary_ReferencingAnUndeclaredLibrary_IsNotChecked(CancellationToken cancellationToken) + { + using var core = await BlazorFixtures.CreateAsync( + "Core", + BlazorFixtures.Library(), + cancellationToken: cancellationToken + ); + using var orders = await BlazorFixtures.AddSiblingAsync( + core, + "Orders", + BlazorFixtures.Library(items: """"""), + cancellationToken: cancellationToken + ); + + var (code, output, errors) = await orders.RunMSBuildAsync(CheckTarget, cancellationToken); + + await Assert.That(code).IsZero().Because(TestHelpers.GenerateError(output, errors)); + await Assert.That(output).DoesNotContain("PRBLZ0001"); + } + + /// Adds a WebAssembly client that references . + static Task AddClientAsync( + ProjectHarness library, + string clientProperties = "", + string referenceMetadata = "", + CancellationToken cancellationToken = default + ) => + BlazorFixtures.AddSiblingAsync( + library, + "Shop.Client", + BlazorFixtures.Client( + clientProperties, + $"""""" + ), + cancellationToken: cancellationToken + ); +} diff --git a/src/tests/BuildSdk.IntegrationTests/CompilerVisiblePropertyTests.cs b/src/tests/BuildSdk.IntegrationTests/CompilerVisiblePropertyTests.cs index b19f293..7f306b6 100644 --- a/src/tests/BuildSdk.IntegrationTests/CompilerVisiblePropertyTests.cs +++ b/src/tests/BuildSdk.IntegrationTests/CompilerVisiblePropertyTests.cs @@ -51,6 +51,17 @@ public async Task CompilerVisibleProperties_Include_AllSdkProperties(Cancellatio "IsWebSdkProject", "IsWorkerSdkProject", "IsAspireHostProject", + "IsBlazorWebAssemblyProject", + "IsRazorClassLibraryProject", + "IsBlazorHostProject", + "IsBffProject", + "PurviewBrowserSafe", + "PurviewBrowserSafeWarningsAsErrors", + "DisablePurviewBrowserSafeReferenceCheck", + "PurviewBrowserSafeReferenceCheckAsError", + "DisablePurviewBlazorWebAssemblyDefaults", + "PurviewAutoBlazorClientReference", + "PurviewBffExposesApi", "EditorConfigFilePath", "CurrentYear", "AutoGeneratedAssemblyInfoFile", diff --git a/src/tests/BuildSdk.IntegrationTests/PackagedVersionFallbackTests.cs b/src/tests/BuildSdk.IntegrationTests/PackagedVersionFallbackTests.cs index ba12819..11f36e5 100644 --- a/src/tests/BuildSdk.IntegrationTests/PackagedVersionFallbackTests.cs +++ b/src/tests/BuildSdk.IntegrationTests/PackagedVersionFallbackTests.cs @@ -10,21 +10,26 @@ namespace Purview.BuildSdk; /// /// /// These drift silently and are only noticed by a consumer. PurviewBuildSdkVersionForGlobalJson is -/// the one that bites: it is the version written into a bootstrapped global.json when the SDK -/// cannot detect its own version from the restored package path, so a stale fallback pins a brand-new -/// repository to a release that predates every fix in the SDK doing the bootstrapping. +/// the one that bites: it is the version written into a bootstrapped global.json, so a stale value +/// pins a brand-new repository to a release that predates every fix in the SDK doing the bootstrapping. +/// It is therefore never maintained by hand — it is detected from the restored package folder, or read +/// from the version stamped in at pack time — and only the copy-paste pins in the docs remain, which +/// just version-sync rewrites. /// public sealed partial class PackagedVersionFallbackTests { [GeneratedRegex( - @"]*>\s*(?[^<\s]+)\s*", + @"]*>(?[^<]*)", RegexOptions.Singleline )] - private static partial Regex GlobalJsonVersionFallback(); + private static partial Regex GlobalJsonVersionDefault(); [GeneratedRegex(@"""Purview\.BuildSdk""\s*:\s*""(?[^""]+)""")] private static partial Regex BuildSdkPin(); + [GeneratedRegex(@"# Purview\.BuildSdk (?\S+) - managed file")] + private static partial Regex EditorConfigExampleMarker(); + [GeneratedRegex(@"^#\s*Purview\.BuildSdk\s+(?\S+)")] private static partial Regex EditorConfigVersionMarker(); @@ -61,18 +66,155 @@ static string PackageVersion() ?? throw new InvalidOperationException("package.json has no 'version'."); } + /// + /// Defaults.props is imported before Sdk.props detects the version, so any literal here + /// silently wins over detection — which is how a hand-maintained fallback once pinned every bootstrapped + /// global.json to whatever was last typed into it. + /// [Test] - public async Task GlobalJsonVersionFallback_MatchesPackageJson(CancellationToken cancellationToken) + public async Task GlobalJsonVersionDefault_IsEmpty(CancellationToken cancellationToken) { var defaults = await File.ReadAllTextAsync( Path.Combine(RepositoryRoot, "src", "src", "BuildSdk", "Sdk", "Props", "Defaults.props"), cancellationToken ); - var match = GlobalJsonVersionFallback().Match(defaults); + var match = GlobalJsonVersionDefault().Match(defaults); await Assert.That(match.Success).IsTrue(); - await Assert.That(match.Groups["version"].Value).IsEqualTo(PackageVersion()); + await Assert + .That(match.Groups["version"].Value.Trim()) + .IsEmpty() + .Because( + "Defaults.props must not set PurviewBuildSdkVersionForGlobalJson: it is imported first, so a literal there wins over detecting the version from the package folder and over the pack-time stamp." + ); + } + + /// + /// A restored SDK lives at <id>/<version>/Sdk/, so the folder name is the version a + /// consumer actually restored, and has to be what a bootstrapped global.json pins. + /// + [Test] + public async Task GlobalJsonVersion_IsDetectedFromTheRestoredPackageFolder(CancellationToken cancellationToken) + { + var scratchDirectory = CreateScratchDirectory(); + + try + { + var sdkDirectory = CopySdkTo(Path.Combine(scratchDirectory, "purview.buildsdk", "9.8.7")); + + await Assert + .That(await EvaluateGlobalJsonVersionAsync(sdkDirectory, scratchDirectory, cancellationToken)) + .IsEqualTo("9.8.7") + .Because("a restored SDK's folder name is its version, and must outrank every fallback"); + } + finally + { + Directory.Delete(scratchDirectory, recursive: true); + } + } + + /// + /// Drives the real pack-time stamp into an SDK that does not sit in a versioned package folder, and asserts + /// the version it was packed with is the one a bootstrapped global.json pins. + /// + /// + /// The version asked for is deliberately not the repository's: a stamp that agrees with + /// package.json by coincidence proves nothing. + /// + [Test] + public async Task GlobalJsonVersion_FallsBackToTheStampedPackedVersion(CancellationToken cancellationToken) + { + const string stampedVersion = "4.5.6-test"; + + var projectPath = Path.Combine(RepositoryRoot, "src", "src", "BuildSdk", "BuildSdk.csproj"); + var scratchDirectory = CreateScratchDirectory(); + + try + { + var sdkDirectory = CopySdkTo(Path.Combine(scratchDirectory, "unversioned")); + var stampPath = Path.Combine(sdkDirectory, "Props", "PackedVersion.props"); + + var (exitCode, stdOut, stdErr) = await ProcessRunner.RunDotNetAsync( + $"msbuild \"{projectPath}\" -t:StampPurviewPackedVersionTarget -p:PackageVersion={stampedVersion} \"-p:_PurviewStampedPackedVersionProps={stampPath}\" -v:m", + RepositoryRoot, + cancellationToken + ); + + await Assert.That(exitCode).IsEqualTo(0).Because(stdOut + stdErr); + await Assert.That(File.Exists(stampPath)).IsTrue().Because(stdOut + stdErr); + + await Assert + .That(await EvaluateGlobalJsonVersionAsync(sdkDirectory, scratchDirectory, cancellationToken)) + .IsEqualTo(stampedVersion) + .Because("an SDK outside a versioned package folder must fall back to Props/PackedVersion.props"); + } + finally + { + Directory.Delete(scratchDirectory, recursive: true); + } + } + + static string CreateScratchDirectory() + { + var path = Path.Combine(Path.GetTempPath(), "purview-version-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + + return path; + } + + /// + /// Copies the source SDK to <container>/Sdk, the shape it has inside a package. + /// + static string CopySdkTo(string containerDirectory) + { + var source = SdkPaths.SdkDirectory.Replace('/', Path.DirectorySeparatorChar); + var destination = Path.Combine(containerDirectory, "Sdk"); + + foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)) + { + // A stamp left in the source tree by a local pack would mask the case under test. + if (Path.GetFileName(file) == "PackedVersion.props") + continue; + + var target = Path.Combine(destination, Path.GetRelativePath(source, file)); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.Copy(file, target); + } + + return destination; + } + + static async Task EvaluateGlobalJsonVersionAsync( + string sdkDirectory, + string workingDirectory, + CancellationToken cancellationToken + ) + { + // A .csproj, because Sdk.props only resolves the version for C# projects. + var projectPath = Path.Combine(workingDirectory, "Probe.csproj"); + await File.WriteAllTextAsync( + projectPath, + $""" + + + Test + + + + """, + cancellationToken + ); + + var (exitCode, stdOut, stdErr) = await ProcessRunner.RunDotNetAsync( + $"msbuild \"{projectPath}\" -nologo -getProperty:PurviewBuildSdkVersionForGlobalJson", + workingDirectory, + cancellationToken + ); + + await Assert.That(exitCode).IsEqualTo(0).Because(stdOut + stdErr); + + return stdOut.Trim(); } /// @@ -187,6 +329,11 @@ CancellationToken cancellationToken /// The quick-start snippets and the starter template are what somebody adopting the SDK copies, so a /// stale pin there is adopted verbatim. /// + /// + /// These are the only version literals left that are maintained in git rather than stamped at pack + /// time, and just version-sync (scripts/sync-version.ts) rewrites exactly this list. The + /// failure names every stale line, so the fix does not need a debugger. + /// [Test] [Arguments("README.md")] [Arguments("docs/wiki/Getting-Started.md")] @@ -194,18 +341,30 @@ CancellationToken cancellationToken public async Task AdoptionPin_MatchesPackageJson(string relativePath, CancellationToken cancellationToken) { var path = Path.Combine(RepositoryRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)); - var content = await File.ReadAllTextAsync(path, cancellationToken); - - var pins = BuildSdkPin() - .Matches(content) - .Select(match => match.Groups["version"].Value) + var lines = await File.ReadAllLinesAsync(path, cancellationToken); + var expected = PackageVersion(); + + var pins = lines + .SelectMany( + (line, index) => + BuildSdkPin() + .Matches(line) + .Concat(EditorConfigExampleMarker().Matches(line)) + .Select(match => (Line: index + 1, Version: match.Groups["version"].Value)) + ) // The property reference in the auto-detection documentation is not a pin. - .Where(version => !version.StartsWith('$')) + .Where(pin => !pin.Version.StartsWith('$')) .ToArray(); - await Assert.That(pins).IsNotEmpty(); + await Assert.That(pins).IsNotEmpty().Because($"{relativePath} is expected to pin Purview.BuildSdk."); + + var stale = pins.Where(pin => pin.Version != expected) + .Select(pin => $"{relativePath}:{pin.Line} pins {pin.Version}") + .ToArray(); - foreach (var pin in pins) - await Assert.That(pin).IsEqualTo(PackageVersion()); + await Assert + .That(stale) + .IsEmpty() + .Because($"package.json is {expected}; run `just version-sync` to update: {string.Join(", ", stale)}"); } } diff --git a/templates/global.json b/templates/global.json index 838474b..1614832 100644 --- a/templates/global.json +++ b/templates/global.json @@ -3,6 +3,6 @@ "runner": "Microsoft.Testing.Platform" }, "msbuild-sdks": { - "Purview.BuildSdk": "1.1.0" + "Purview.BuildSdk": "1.2.0" } }