From 60d0a9459c9986ab8b47cf74a121e55b2b30a0ba Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 10 Nov 2026 23:06:53 +0000 Subject: [PATCH 1/6] feat: unions! v1-ish --- Directory.Packages.props | 18 ++++++------------ global.json | 7 +++---- 2 files changed, 9 insertions(+), 16 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 0664bb7..73eb2c7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,26 +1,20 @@ - - 11.0.100-rc.3.26522.148 + 11.0.100 10.0.14 - 3.0.0-prerelease.2 - 1.3.0-prerelease.2 + 3.0.0-prerelease.5 + 2.0.0-prerelease.2 - 1.2.2 + 2.0.0 5.9.0 - 1.76.0 - 1.2.0 + 1.81.16 + 2.0.0 diff --git a/global.json b/global.json index c5ef705..a81043e 100644 --- a/global.json +++ b/global.json @@ -1,11 +1,10 @@ { "sdk": { - "version": "11.0.100-rc.3.26522.148", - "rollForward": "latestMajor", - "allowPrerelease": true + "version": "11.0.100", + "rollForward": "latestMajor" }, "msbuild-sdks": { - "Purview.BuildSdk": "1.3.0" + "Purview.BuildSdk": "2.0.0" }, "test": { "runner": "Microsoft.Testing.Platform" From 137d41798030d3d6f814d58fd49587a96e2ab253 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 11 Nov 2026 09:55:11 +0000 Subject: [PATCH 2/6] feat: net11 alignment with zodsharp --- .csharpierignore | 9 ++- .editorconfig | 2 +- .github/workflows/pr.yml | 6 +- .github/workflows/release.yml | 6 +- AGENTS.md | 16 +++- CHANGELOG.md | 34 ++++++++ Directory.Packages.props | 12 ++- README.md | 9 ++- docs/wiki/Blazor-and-Native-AOT.md | 2 +- docs/wiki/Contributing.md | 3 +- docs/wiki/Getting-Started.md | 11 +-- docs/wiki/Source-Generator.md | 3 +- docs/wiki/Testing.md | 7 +- docs/wiki/Union-Errors.md | 75 +++++++++++++++++ package.json | 8 +- .../AotConsole/AotConsole.csproj | 1 - .../AotWebApi/AotWebApi.csproj | 1 - .../BlazorServer/BlazorServer.csproj | 1 - .../BlazorWasm/BlazorWasm.csproj | 4 +- scripts/restore-dotnet-tools.ps1 | 5 +- skills/purview-results-union-errors/SKILL.md | 7 +- .../references/migrating-error-returns.md | 6 +- src/Directory.Build.props | 28 ++++--- src/Directory.Build.targets | 8 +- .../UnionCaseResultCodeFixProvider.cs | 3 +- .../Diagnostics/ResultUnionDiagnostics.cs | 4 +- .../Discovery/ResultUnionDiscovery.cs | 4 +- src/src/SourceGenerator/README.md | 38 ++++++++- .../UnionEqualityDiagnosticSuppressor.cs | 4 +- .../ResultsHttpTelemetryTests.cs | 9 ++- .../ClosedHierarchyTests.cs | 52 ++++++++++++ .../GeneratedTestUnions.cs | 81 +++++++++++++++++++ .../NonBoxingUnionTests.cs | 76 +++++++++++++++++ .../ResultsSourceGeneratorTestOptions.cs | 6 +- .../UnionCompilerBehaviourTests.cs | 3 +- 35 files changed, 471 insertions(+), 73 deletions(-) create mode 100644 src/tests/SourceGenerator.UnitTests/ClosedHierarchyTests.cs create mode 100644 src/tests/SourceGenerator.UnitTests/NonBoxingUnionTests.cs diff --git a/.csharpierignore b/.csharpierignore index 7c12f5d..d48054f 100644 --- a/.csharpierignore +++ b/.csharpierignore @@ -1,10 +1,13 @@ -# C# 15 union declarations are a preview language feature that is not yet parseable by CSharpier, so files +# C# 15 syntax is not yet parseable by CSharpier 1.3.0, so files that use it are excluded rather than -# that declare unions are excluded rather than reported as formatting failures. CSharpier reports +# reported as formatting failures. CSharpier reports "was not formatted due to syntax errors" for these -# "was not formatted due to syntax errors" for these files; every other file is formatted normally +# files; every other file is formatted normally. Two features are affected: union declarations and the + +# 'closed' hierarchy modifier. src/tests/AspNetCore.UnitTests/ResultsHttpTestUnions.cs +src/tests/SourceGenerator.UnitTests/ClosedHierarchyTests.cs src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs src/tests/ZodSharp.AspNetCore.UnitTests/ValidationTestUnions.cs src/tests/ZodSharp.UnitTests/ValidationTestUnions.cs diff --git a/.editorconfig b/.editorconfig index f8b3050..a9b39cb 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,4 @@ -# Purview.BuildSdk 1.3.0 - managed file, do not edit +# Purview.BuildSdk 2.0.0 - managed file, do not edit # # This file is written by Purview.BuildSdk and is replaced whenever the SDK version changes. The version # on the first line is how the SDK recognises a file it owns: a .editorconfig without that marker is diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 97b1c9f..2bfc905 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -16,11 +16,11 @@ jobs: name: Build and test uses: purview-dev/build/.github/workflows/purview-build.yml@main with: - # The shared workflow defaults to the 10.0.x SDK, but this repository targets net11.0 and pins - # the .NET 11 SDK in global.json. Without the matching SDK the runner cannot resolve global.json + # This repository targets net11.0 and pins the GA .NET 11 SDK in global.json. The shared workflow + # defaults to the 10.0.x SDK, so without the matching SDK the runner cannot resolve global.json # ("A compatible .NET SDK was not found") and every step fails. # Keep in sync with `sdk.version` in global.json. - dotnet-version: "11.0.100-rc.3.26522.148" + dotnet-version: "11.0.100" # Mirrors Build:TestFilter in purview-build.json: every test project is a *.UnitTests project # whose tests the SDK categorises as Unit. test-filter: "/*/*/*/*[Category=Unit]" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7502462..0805c84 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,10 +29,10 @@ jobs: - uses: purview-dev/build/actions/purview-release@main with: release-mode: NuGet - # The shared action defaults to the 10.0.x SDK, but this repository targets net11.0 and pins - # the .NET 11 SDK in global.json; without it the release job cannot resolve global.json. + # This repository targets net11.0 and pins the GA .NET 11 SDK in global.json; without it the + # release job cannot resolve global.json. # Keep in sync with `sdk.version` in global.json (and with .github/workflows/pr.yml). - dotnet-version: "11.0.100-rc.3.26522.148" + dotnet-version: "11.0.100" trusted-publishing: true # The nuget.org profile name, not the sign-in email. nuget-user: kieronlanning diff --git a/AGENTS.md b/AGENTS.md index e276e95..c82a600 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,17 @@ generator that makes C# 15 union error cases ergonomic), and the ZodSharp and AS ## Runtime invariants +- The runtime packages target `net10.0` and `net11.0` only (Purview.BuildSdk's `Supported` set). .NET 8 and .NET 9 + are out of support and must not be reintroduced. Runtime-adjacent packages are pinned to the lowest shared + servicing band (`10.0.15`), never the newer `net11.0` band. +- Compile with `LangVersion=latest`, never `preview`. C# 15 is the default for `net11.0`, and nothing here needs a + preview language feature. Union declarations and `closed` hierarchies need the .NET 11 runtime types + (`IUnion`/`UnionAttribute`, `IsClosedTypeAttribute`), so they belong in net11-only projects (examples, tests, + packed consumers), never in shared or multi-targeted shipped code. +- A compiler `union` declaration boxes a value-type case. For a hot error union, a custom `[Union]` type with typed + storage and the non-boxing access pattern (`HasValue` plus `TryGetValue` per case) is supported by + `[GenerateResult]` unchanged. `Result.TryGetValue`/`TryGetError` are the result-side equivalents: + keep them non-boxing and keep generated helpers as pure `Result<...>.Failure(...)` calls. - `Result` is a `readonly record struct` with three states: `Uninitialized` (the `default` value), `Success` and `Failure`. Keep all three observable through `IsInitialized`, `IsSuccess` and `IsFailure`. @@ -158,8 +169,9 @@ including the diagnostics table, build properties and activation rules. code fix needs `Microsoft.CodeAnalysis.*.Workspaces`, which must not enter the generator's analyzer closure. The code fix never references the generator and there is no `InternalsVisibleTo`; the generator references the code fix analyzer-style only (`ReferenceOutputAssembly="false"`), so no reference cycle can form, and the shared well-known names are pinned by the code-fix tests. -- Union-declaring test files are excluded from CSharpier through `.csharpierignore`, because the preview syntax - is not parseable. Add any new file that declares a union to that list. +- Union-declaring test files (and files using the C# 15 `closed` modifier) are excluded from CSharpier through + `.csharpierignore`, because CSharpier 1.3.0 cannot parse that syntax. Add any new file that declares a union or a + closed hierarchy to that list. ## ASP.NET Core integration invariants diff --git a/CHANGELOG.md b/CHANGELOG.md index 21b5cc1..e954f4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,40 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and Released versions correspond to `v` GitHub releases. +## Unreleased + +### Changed + +- **.NET 11 GA.** The repository now builds against the .NET 11 GA SDK (`11.0.100`) rather than a release + candidate. CI pins match `global.json`. +- **C# 15 is no longer preview.** `LangVersion` moved from `preview` to `latest`; a `net11.0` project gets C# 15 by + default, so declaring a union needs no language-version switch. The packed consumers no longer set + `LangVersion=preview`, which proves a consumer needs nothing special. +- **.NET 8 and .NET 9 are out of support** and remain deliberately untargeted; only `net10.0` and `net11.0` ship. +- **Lowest common runtime bits.** `Microsoft.AspNetCore.TestHost` and the `net10.0` runtime-adjacent version are + pinned to `10.0.15`, the oldest servicing band the packages claim to support, rather than the newer `net11.0` + band. + +### Added + +- **Non-boxing union coverage.** A custom `[Union]` type with typed storage and the non-boxing access pattern + (`HasValue` + `TryGetValue`) is generated for and executed by the test suite, including an assertion that a + value-type case does not allocate when matched. A compiler `union` declaration always boxes its cases; the + custom shape is the supported way to avoid that on a hot error path. +- **Closed-hierarchy coverage.** A C# 15 `closed` hierarchy used as `TError`, switched exhaustively without a + discard arm, is compiled and executed by the test suite. + +### Fixed + +- The unmapped-failure telemetry test no longer reads the first measurement from the process-static meter; it + filters by error-type tag like its sibling, so concurrent tests cannot make it flaky. + +### Documentation + +- README, wiki, package READMEs, `AGENTS.md` and the union skill now state that C# 15 unions are GA, need a + `net11.0` target rather than `LangVersion=preview`, and explain the non-boxing custom-union option and closed + hierarchies. + ## 1.1.0-prerelease.2 A minor prerelease built against Purview.ZodSharp 3.0.0-prerelease.2 and Purview.ValueObjects 1.3.0-prerelease.2. It diff --git a/Directory.Packages.props b/Directory.Packages.props index 73eb2c7..c47ebcc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,7 +2,12 @@ 11.0.100 - 10.0.14 + + 10.0.15 3.0.0-prerelease.5 @@ -36,10 +41,11 @@ /> - + + - + diff --git a/README.md b/README.md index 55adb0f..d7fdaa3 100644 --- a/README.md +++ b/README.md @@ -295,7 +295,7 @@ The full documentation suite lives in [`docs/wiki`](docs/wiki/Getting-Started.md - **.NET 10 or .NET 11** — the runtime packages multi-target `net10.0` and `net11.0`, so adopting them does not force a runtime upgrade. The source generator bundled in `Purview.Results` targets `netstandard2.0` - so any compiler host can load it. .NET 8 and 9 are not targeted; both are close to end of support. + so any compiler host can load it. .NET 8 and .NET 9 are not targeted; both are out of support. - **Unions require .NET 11.** The compiler needs `System.Runtime.CompilerServices.IUnion` and `UnionAttribute`, which do not exist earlier, so a `[GenerateResult]` union declaration cannot compile for `net10.0`. Everything else works on both: the result types, the combinators, the ASP.NET Core mapping and @@ -303,11 +303,12 @@ The full documentation suite lives in [`docs/wiki`](docs/wiki/Getting-Started.md mapper already handles — register a mapping for the error type itself. - **Purview.ZodSharp 3** for the ZodSharp integrations, on the same `net10.0`/`net11.0` set. Trimmed, Native AOT and Blazor applications: see [Blazor and Native AOT](docs/wiki/Blazor-and-Native-AOT.md). -- **C# 15 preview — only to *declare* a union.** The requirement is per-project and scoped: +- **C# 15 is GA — only a union *declaration* needs .NET 11.** The requirement is per-project and scoped: - Using `Result`, `Result` and the combinators needs **nothing special**. No `LangVersion`, no `EnablePreviewFeatures`. - - Declaring your own `[GenerateResult]` union needs `LangVersion=preview` in **that** project, because the - union syntax is yours to compile. + - Declaring your own `[GenerateResult]` union needs a `net11.0` target. C# 15 is the default language + version for a `net11.0` project in the .NET 11 SDK, so no `LangVersion` is needed there either; set it + explicitly only if a project overrides the default. These packages deliberately do **not** set `EnablePreviewFeatures`. Doing so emits `[assembly: RequiresPreviewFeatures]`, which makes CA2252 (an error by default) fire on every member a diff --git a/docs/wiki/Blazor-and-Native-AOT.md b/docs/wiki/Blazor-and-Native-AOT.md index 7254483..ad59464 100644 --- a/docs/wiki/Blazor-and-Native-AOT.md +++ b/docs/wiki/Blazor-and-Native-AOT.md @@ -27,7 +27,7 @@ warnings as errors, runs each one, and scans every output for compiler-host asse | `Purview.ZodSharp.Blazor` 3.x (optional) | `net11.0` | | `Purview.ZodSharp.Http` 3.x (optional) | `net10.0`, `net11.0` | -A `[GenerateResult]` union needs `net11.0` and `LangVersion=preview` in the project that declares it, in a Blazor +A `[GenerateResult]` union needs `net11.0` in the project that declares it, in a Blazor client as anywhere else. ## A Blazor form whose input flows through a result diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md index ba1d0a6..2d94234 100644 --- a/docs/wiki/Contributing.md +++ b/docs/wiki/Contributing.md @@ -102,7 +102,8 @@ Conventions: ## Packaging traps - A project is packable **only** when its own `.csproj` declares `true`. -- A new union-declaring file must be added to `.csharpierignore`, because the preview syntax is not parseable. +- A new union-declaring file (or a file that uses the C# 15 `closed` modifier) must be added to `.csharpierignore`, + because CSharpier 1.3.0 cannot parse that syntax yet. - A new diagnostic must be added to `AnalyzerReleases.Unshipped.md`. - A property the generator reads must be declared as a `CompilerVisibleProperty` and shipped in `Sdk/buildTransitive/*.props`. diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index b7c8e1d..b62de89 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -5,15 +5,16 @@ This guide installs the packages, models an error union, returns a result, and m ## Requirements - **.NET 10 or .NET 11** — the runtime packages multi-target `net10.0` and `net11.0`; the source generator - targets `netstandard2.0` so any compiler host can load it. .NET 8 and 9 are not targeted. + targets `netstandard2.0` so any compiler host can load it. .NET 8 and .NET 9 are not targeted; both are out + of support. - **Unions require .NET 11**, because the compiler needs `System.Runtime.CompilerServices.IUnion` and `UnionAttribute`. On `net10.0` the result types, combinators, ASP.NET Core mapping and ZodSharp bridge all work; only the union declaration itself is unavailable, so map the error type directly instead of a case. -- **C# 15 preview — only to *declare* a union.** Using the result types and combinators needs nothing +- **C# 15 — only a union *declaration* needs .NET 11.** Using the result types and combinators needs nothing special: no `LangVersion`, no `EnablePreviewFeatures`. A project that declares its own - `[GenerateResult]` union needs `LangVersion=preview` in that project, because the union syntax is yours to - compile. Adoption is therefore incremental — you can take `Result` into an existing - solution first and introduce unions per project later. + `[GenerateResult]` union needs a `net11.0` target, where C# 15 is the SDK's default language version, so it + needs no `LangVersion` either. Adoption is therefore incremental — you can take `Result` into + an existing solution first and introduce unions per project later. These packages do not set `EnablePreviewFeatures`, so they do not force preview features on your solution. See [Guarantees and Limitations](Guarantees-and-Limitations.md). diff --git a/docs/wiki/Source-Generator.md b/docs/wiki/Source-Generator.md index f10e2ed..b17811f 100644 --- a/docs/wiki/Source-Generator.md +++ b/docs/wiki/Source-Generator.md @@ -15,7 +15,8 @@ dotnet add package Purview.Results The component targets `netstandard2.0` (with the SDK's Roslyn defaults) so any compiler host can load it, and it is applied to the compilation automatically from the package's `analyzers/dotnet/cs` folder. Requirements: a -C# 15 compiler with union declaration support (the .NET 11 SDK or later) and `LangVersion=preview`. +C# 15 compiler with union declaration support (the .NET 11 SDK or later) and a `net11.0` target. C# 15 is the +default language version for `net11.0` in the .NET 11 SDK, so no `LangVersion` is needed. ## Activation and design diff --git a/docs/wiki/Testing.md b/docs/wiki/Testing.md index ad479f8..6005cb3 100644 --- a/docs/wiki/Testing.md +++ b/docs/wiki/Testing.md @@ -19,12 +19,17 @@ dotnet test src/Results.slnx --no-build --treenode-filter "/*/*/*/*[Category=Uni The `*.UnitTests` project naming is what the SDK uses to derive `TestingType=Unit` and apply the matching test category, which is what the filter matches. Use `--treenode-filter` for filtering — not `dotnet test --filter`. +The non-packable `net11.0` projects (tests and examples) compile with runtime async +(`Features=runtime-async=on`), which is still a preview feature in .NET 11 but gives cleaner live call stacks +and lower async overhead while debugging. The shipped libraries deliberately do not: Blazor WebAssembly's Mono +runtime does not implement it. The tests therefore call the same stable lowering a consumer runs. + ## Test projects | Project | Covers | | --- | --- | | `src/tests/Results.UnitTests` | `Result` and `Result` states, the throw-on-misuse contract, and the extension operations (`ResultsTests`, `ResultTErrorTests`, `ResultExtensionsTests`) | -| `src/tests/SourceGenerator.UnitTests` | Generation, diagnostics, incremental caching, the compiler experiments, the code fix, and the suppressor | +| `src/tests/SourceGenerator.UnitTests` | Generation, diagnostics, incremental caching, the compiler experiments (including the C# 15 `closed` hierarchy and the non-boxing custom union), the code fix, and the suppressor | | `src/tests/AspNetCore.UnitTests` | `DefaultResultsHttpMapper`, the endpoint filter, failure mappers and unmapped-failure behaviour | | `src/tests/ZodSharp.UnitTests` | `ToResult` and the validation-result integration | | `src/tests/ZodSharp.AspNetCore.UnitTests` | `ZodResultsFailureMapper`, registration order and the validation problem mapper | diff --git a/docs/wiki/Union-Errors.md b/docs/wiki/Union-Errors.md index 2619069..f30804d 100644 --- a/docs/wiki/Union-Errors.md +++ b/docs/wiki/Union-Errors.md @@ -166,12 +166,87 @@ Nesting also works with the HTTP layer: the ASP.NET Core mapper resolves a union mapping for the leaf (`Map`) wins, and a mapping for the enclosing union (`Map` or `Map`) still applies when the leaf has none. +## Non-boxing custom unions + +A `union` declaration always stores its contents in an `object?`, so a value-type case boxes. When the error +union is on a hot path, declare a custom `[Union]` type with typed storage and the **non-boxing access +pattern** — a `HasValue` property plus one `TryGetValue` overload per case. The compiler calls `HasValue` for a +`null` pattern and `TryGetValue` for a type pattern, so matching a value-type case keeps it unboxed: + +```csharp +using System.Runtime.CompilerServices; + +public readonly record struct Count(int Value); +public readonly record struct Label(string Value); + +[Union] +public readonly struct CountOrLabel : IUnion +{ + private readonly Count _count; + private readonly Label? _label; + private readonly byte _tag; + + public CountOrLabel(Count value) { _count = value; _label = null; _tag = 1; } + public CountOrLabel(Label value) { _count = default; _label = value; _tag = 2; } + + public object? Value => _tag switch { 1 => _count, 2 => _label, _ => null }; + public bool HasValue => _tag != 0; + + public bool TryGetValue(out Count value) { value = _count; return _tag == 1; } + public bool TryGetValue(out Label value) { value = _label.GetValueOrDefault(); return _tag == 2; } +} + +// The Count arm calls TryGetValue(out Count): no box. +static int Describe(CountOrLabel value) => value switch +{ + Count count => count.Value, + Label label => label.Value.Length, + null => -1, +}; +``` + +The generator does not change or re-emit the union, so `[GenerateResult]` works on a custom union exactly as +on a `union` declaration: the per-case `AsFailure` helpers and the union-receiver factory are emitted. They add +no boxing of their own — each is a plain `Result<...>.Failure(...)` call, and `Result` stores its +error in a typed field. + +## Closed hierarchies as an error type + +C# 15's `closed` modifier is the alternative when the cases are classes with shared behaviour or inheritance. It +fixes the direct descendants at compile time, so a switch over them is exhaustive without a discard arm, and the +hierarchy works as `TError`: + +```csharp +public closed record class SyncOutcome; +public record class Synced(int Count) : SyncOutcome; +public record class Skipped(string Reason) : SyncOutcome; + +Result result = new Skipped("offline"); + +var text = result.Match( + value => $"synced {value}", + error => error switch + { + Synced synced => $"synced {synced.Count}", + Skipped skipped => $"skipped: {skipped.Reason}", + } +); +``` + +`closed` needs the .NET 11 runtime (`IsClosedTypeAttribute`), just as a union needs `IUnion`/`UnionAttribute`. +Prefer a union for data-only cases, where every alternative is declared in one place; prefer a closed hierarchy +when the cases need members or inheritance. The HTTP mapper keys a non-union error by its runtime type, so register +a mapping for each descendant (or handle the base type with a fallback/`IResultsFailureMapper`); a mapping for the +base type alone is not consulted for a derived instance. + ## Union shapes that are supported | Shape | Supported | | --- | --- | | A union declaration whose cases are public single-parameter constructors | Yes | | A type marked `[Union]` with public single-parameter constructors | Yes | +| A custom `[Union]` type with `HasValue`/`TryGetValue` (non-boxing access pattern) | Yes — the generator is unchanged, and value-type cases stay unboxed | +| A `closed` hierarchy used as `TError` (not a union) | Yes — no `[GenerateResult]` helpers, but the result and HTTP mapper accept it | | A **leaf** case type shared by two unions | Yes — `RSG1006` warning; the per-case `AsFailure` helper is generated once, and the union-receiver `Failure(...)` factory is generated for both | | A **union-typed** case shared by two unions (composition) | Yes — no warning; the including union's factory covers the included union's cases, and no ambiguous per-case helper is generated | | A case reachable through two different included unions | Yes — `RSG1008` warning; that case's inclusion factory is skipped | diff --git a/package.json b/package.json index f8457f6..c994fae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "results", - "version": "1.1.0-prerelease.3", + "version": "2.0.0-prerelease.1", "license": "MIT", "private": true, "keywords": [], @@ -25,7 +25,7 @@ "typescript": "^7.0.2" }, "engines": { - "bun": ">=1.4.2" + "bun": ">=1.4.4" }, - "packageManager": "bun@1.4.2" -} \ No newline at end of file + "packageManager": "bun@1.4.4" +} diff --git a/scripts/packed-consumers/AotConsole/AotConsole.csproj b/scripts/packed-consumers/AotConsole/AotConsole.csproj index 913afdc..e576a74 100644 --- a/scripts/packed-consumers/AotConsole/AotConsole.csproj +++ b/scripts/packed-consumers/AotConsole/AotConsole.csproj @@ -10,7 +10,6 @@ Exe net11.0 - preview PackedResultsAot PackedResultsAot enable diff --git a/scripts/packed-consumers/AotWebApi/AotWebApi.csproj b/scripts/packed-consumers/AotWebApi/AotWebApi.csproj index 36faaee..a37b399 100644 --- a/scripts/packed-consumers/AotWebApi/AotWebApi.csproj +++ b/scripts/packed-consumers/AotWebApi/AotWebApi.csproj @@ -16,7 +16,6 @@ --> net11.0 - preview PackedResultsAotWeb PackedResultsAotWeb enable diff --git a/scripts/packed-consumers/BlazorServer/BlazorServer.csproj b/scripts/packed-consumers/BlazorServer/BlazorServer.csproj index e4f8e37..a9c6ef0 100644 --- a/scripts/packed-consumers/BlazorServer/BlazorServer.csproj +++ b/scripts/packed-consumers/BlazorServer/BlazorServer.csproj @@ -12,7 +12,6 @@ --> net11.0 - preview PackedResultsServer PackedResultsServer enable diff --git a/scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj b/scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj index 9fb7fa6..0415a48 100644 --- a/scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj +++ b/scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj @@ -6,13 +6,13 @@ beside guard Directory.Build.props/.targets and Directory.Packages.props files that stop any inheritance, and supplies the versions as $(ResultsPackageVersion), $(ZodSharpPackageVersion) and $(AspNetCorePackageVersion). - It declares a [GenerateResult] union, so the packed generator runs here (C# 15 unions need LangVersion preview). + It declares a [GenerateResult] union, so the packed generator runs here. C# 15 is the default language + version for net11.0, so no LangVersion is needed either. It is published trimmed with every warning an error, then again with RunAOTCompilation=true, and each publish is served and loaded in a headless browser when one is available. --> net11.0 - preview PackedResultsWasm PackedResultsWasm enable diff --git a/scripts/restore-dotnet-tools.ps1 b/scripts/restore-dotnet-tools.ps1 index 5bbc78e..8dec26d 100644 --- a/scripts/restore-dotnet-tools.ps1 +++ b/scripts/restore-dotnet-tools.ps1 @@ -4,8 +4,9 @@ Restores the repository's .NET local tools (.config/dotnet-tools.json), retrying a failed attempt. .DESCRIPTION - `dotnet tool restore` can fail on a cold package cache, exit 1, and still leave every tool installed. The - .NET 11 SDK (rc.3) mis-attributes the command of a RID-specific tool to the entry beside it in the manifest: + `dotnet tool restore` can fail on a cold package cache, exit 1, and still leave every tool installed. A + .NET 11 SDK release candidate (observed on rc.3) mis-attributes the command of a RID-specific tool to the + entry beside it in the manifest: dotnet-inspect 0.26.0 declares DotNetCliTool version 2 with RuntimeIdentifierPackages, and the purview.build entry after it is then rejected with diff --git a/skills/purview-results-union-errors/SKILL.md b/skills/purview-results-union-errors/SKILL.md index 4dff228..47396f3 100644 --- a/skills/purview-results-union-errors/SKILL.md +++ b/skills/purview-results-union-errors/SKILL.md @@ -171,14 +171,17 @@ compared by value. ## Requirements and switches -- **.NET 11 SDK or later**, a `net11.0` target and `LangVersion=preview` in the project that declares the union — - union declarations are a preview language feature, and `IUnion`/`UnionAttribute` do not exist before .NET 11. +- **.NET 11 SDK or later and a `net11.0` target** in the project that declares the union: `IUnion`/`UnionAttribute` + do not exist before .NET 11. C# 15 is the default language version for `net11.0`, so no `LangVersion` is needed. Projects that only *use* the result types can target `net10.0`. - A reference to `Purview.Results`, which provides both result shapes (`Result` and `Result`) and this generator. - `-p:DisableResultsSourceGenerator=true` (or a `Directory.Build.props` setting) disables the helpers while the opt-in attribute is still emitted, which is useful when the generated code has to be inspected in isolation. +- **Performance:** a compiler `union` declaration boxes a value-type case. If the error union is on a hot path, + declare a custom `[Union]` type with typed storage and the non-boxing access pattern (`HasValue` plus + `TryGetValue` per case); `[GenerateResult]` works on it unchanged, and pattern matching then stays unboxed. ## Checklist diff --git a/skills/purview-results-union-errors/references/migrating-error-returns.md b/skills/purview-results-union-errors/references/migrating-error-returns.md index d502ec1..fa003df 100644 --- a/skills/purview-results-union-errors/references/migrating-error-returns.md +++ b/skills/purview-results-union-errors/references/migrating-error-returns.md @@ -11,9 +11,9 @@ error-code enums with a C# 15 `union` of case types returned as `Resulthttps://github.com/purview-dev/results universal - preview + + latest diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index 09e4ff7..aabdfe2 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -2,8 +2,10 @@ ()"; + ? $"Convert the union case to a failed unit result with {unionDisplay}.{FailureFactoryName}()" + : $"Convert the union case to a failed result with {unionDisplay}.{FailureFactoryName}<{valueType!.Name}>()"; context.RegisterCodeFix( CodeAction.Create( @@ -173,27 +174,54 @@ static bool IsCaseOf(INamedTypeSymbol union, ITypeSymbol caseType, HashSet + /// Gets a union's case types from the same sources the generator uses: a nested IUnionMembers + /// provider's public static single-parameter Create methods when one exists, otherwise its public + /// single-parameter constructors. + /// + static IEnumerable GetCaseTypes(INamedTypeSymbol union) + { + var provider = union + .GetTypeMembers("IUnionMembers") + .FirstOrDefault(static member => !member.IsImplicitlyDeclared && member.TypeKind == TypeKind.Interface); + + if (provider is not null) + { + foreach (var current in provider.AllInterfaces.Prepend(provider)) + { + foreach (var member in current.GetMembers("Create")) + { + if (member is IMethodSymbol { IsStatic: true, Parameters.Length: 1 } method) + yield return method.Parameters[0].Type; + } + } + + yield break; + } + + foreach (var constructor in union.InstanceConstructors) + { + if ( + constructor.DeclaredAccessibility == Accessibility.Public + && constructor.Parameters.Length == 1 + && constructor.Parameters[0].RefKind is RefKind.None or RefKind.In + ) + yield return constructor.Parameters[0].Type; + } + } + static bool IsUnionVisibleBySimpleName(SemanticModel model, INamedTypeSymbol union, int position) { if (union.ContainingNamespace.IsGlobalNamespace) @@ -201,7 +229,13 @@ static bool IsUnionVisibleBySimpleName(SemanticModel model, INamedTypeSymbol uni foreach (var symbol in model.LookupSymbols(position, name: union.Name)) { - if (SymbolEqualityComparer.Default.Equals(symbol, union)) + if ( + SymbolEqualityComparer.Default.Equals(symbol, union) + || ( + symbol is INamedTypeSymbol named + && SymbolEqualityComparer.Default.Equals(named.OriginalDefinition, union.OriginalDefinition) + ) + ) return true; } @@ -224,7 +258,9 @@ CancellationToken cancellationToken return document; // The union-receiver factory is named on the union type: Union.Failure(case) for a unit result and - // Union.Failure(case) for a value result. + // Union.Failure(case) for a value result. A static extension member's explicit type arguments + // are the extension block's parameters first, so a generic union repeats its type arguments before the + // value type: Union.Failure(case). var unionName = SyntaxFactory .ParseTypeName(unionType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)) .WithAdditionalAnnotations(Simplifier.Annotation); @@ -236,13 +272,28 @@ CancellationToken cancellationToken } else { - var typeArgument = SyntaxFactory - .ParseTypeName(valueType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)) - .WithAdditionalAnnotations(Simplifier.Annotation); + List typeArguments = []; + + if (unionType.IsGenericType) + { + typeArguments.AddRange( + unionType.TypeArguments.Select(static argument => + SyntaxFactory + .ParseTypeName(argument.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)) + .WithAdditionalAnnotations(Simplifier.Annotation) + ) + ); + } + + typeArguments.Add( + SyntaxFactory + .ParseTypeName(valueType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)) + .WithAdditionalAnnotations(Simplifier.Annotation) + ); factoryName = SyntaxFactory.GenericName( SyntaxFactory.Identifier(FailureFactoryName), - SyntaxFactory.TypeArgumentList(SyntaxFactory.SingletonSeparatedList(typeArgument)) + SyntaxFactory.TypeArgumentList(SyntaxFactory.SeparatedList(typeArguments)) ); } diff --git a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md index afde190..aadad75 100644 --- a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md @@ -4,5 +4,9 @@ ; Every rule is recorded in AnalyzerReleases.Shipped.md under Release 1.0.0. Add a new or changed ; diagnostic here, and move it across when the next version ships. ; +; RSG1002 and RSG1007 keep the same category, severity and enabled-by-default state; only their message and +; meaning changed (generic unions and IUnionMembers providers are now supported), so no release-tracking +; entry is required. +; ; Note RSG2000 is the suppression id a consumer adds to NoWarn to turn the CA1815 suppression off. ; Suppression descriptors are not release-tracked, so it must not be listed in either file. diff --git a/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs b/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs index d63ba83..fbfbe74 100644 --- a/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs +++ b/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs @@ -72,16 +72,17 @@ static class DiagnosticLibrary ); /// - /// The union or one of its case types is generic, which the first implementation does not support. + /// A union case type contains a type parameter the union does not declare, which a generated top-level + /// helper cannot repeat. /// public static readonly DiagnosticDescriptor UnsupportedGenericConfiguration = new( id: "RSG1002", title: "Unsupported generic union configuration", - messageFormat: "'{0}' is a generic union or has a case type that contains type parameters, which is not supported", + messageFormat: "'{0}' has a case type that contains a type parameter the union does not declare, or the union is nested in a generic type, which the generated helper cannot repeat", category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true, - description: "Generic unions cannot be referenced from the generated extension helpers, and neither can a case type that still contains type parameters." + description: "A case may use the union's own type parameters. A type parameter introduced by an enclosing type cannot be declared by the generated top-level extension helper, and a union nested inside a generic type cannot be named without that type's parameters; the case or union is skipped." ); /// @@ -137,16 +138,17 @@ static class DiagnosticLibrary ); /// - /// The union declares an IUnionMembers member provider, which is not supported. + /// The union declares an IUnionMembers member provider that does not declare the required + /// creation members. /// public static readonly DiagnosticDescriptor UnsupportedMemberProvider = new( id: "RSG1007", - title: "Unsupported union member provider", - messageFormat: "'{0}' declares an IUnionMembers member provider, which is not supported; declare the union cases as the union's own parameters or constructors", + title: "Invalid union member provider", + messageFormat: "'{0}' declares an IUnionMembers provider with no public static single-parameter Create methods; declare one Create method per case", category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true, - description: "The first implementation resolves union cases from the union type's own public single-parameter constructors." + description: "When a union contains a nested IUnionMembers interface, the language finds the union's creation members only on that provider. Each case must be declared by a public static single-parameter Create method." ); /// @@ -238,12 +240,12 @@ static class DiagnosticLibrary /// /// RSG1000 (not a union): nothing can be generated. /// RSG1001 (no cases): nothing can be generated. - /// RSG1002: blocking for a generic union, non-blocking for a single case type that still contains type parameters — the remaining cases are generated. + /// RSG1002: non-blocking; a case type that names a type parameter the union does not declare is skipped, and the remaining cases are generated. /// RSG1003: blocking when the union itself cannot be referenced, non-blocking for a single inaccessible case type. /// RSG1004 (duplicate configuration): non-blocking; the union is valid and its helpers are generated once. /// RSG1005 (generated class-name collision): blocking for the colliding union, which is skipped. /// RSG1006 (shared case type): non-blocking; only the shared case's helper is skipped. - /// RSG1007 (member provider): nothing can be generated. + /// RSG1007 (invalid member provider): nothing can be generated from the provider. /// RSG1008 (ambiguous included case): non-blocking; only the ambiguous case's inclusion factory is skipped. /// /// diff --git a/src/src/SourceGenerator/Diagnostics/ResultUnionDiagnostics.cs b/src/src/SourceGenerator/Diagnostics/ResultUnionDiagnostics.cs index 258fa07..13e9ec7 100644 --- a/src/src/SourceGenerator/Diagnostics/ResultUnionDiagnostics.cs +++ b/src/src/SourceGenerator/Diagnostics/ResultUnionDiagnostics.cs @@ -30,10 +30,14 @@ namespace Purview.Results.SourceGenerator.Diagnostics; /// Whether the union itself can be referenced from generated code. /// Whether the union is public. /// Whether every usable case type is public. -/// The usable case types, in constructor order. +/// The usable case types, in declaration order. /// /// The case types reachable through the union's included (union-typed) cases, in discovery order. /// +/// +/// Whether the union's creation members live on a nested IUnionMembers provider. +/// +/// The union's nested IUnionMembers provider, when it has one. /// Every finding, in discovery order. readonly record struct ResultUnionTargetAnalysis( bool HasGenerateResultAttribute, @@ -41,8 +45,10 @@ readonly record struct ResultUnionTargetAnalysis( bool UnionIsReferenceable, bool UnionIsPublic, bool EveryCaseIsPublic, - ImmutableArray CaseTypes, + ImmutableArray CaseTypes, ImmutableArray IncludedCases, + bool UsesMemberProvider, + INamedTypeSymbol? MemberProvider, ImmutableArray Diagnostics ) { @@ -151,6 +157,8 @@ public static ResultUnionTargetAnalysis Analyze(INamedTypeSymbol symbol, Cancell EveryCaseIsPublic: false, CaseTypes: [], IncludedCases: [], + UsesMemberProvider: false, + MemberProvider: null, Diagnostics: [] ); } @@ -169,6 +177,8 @@ public static ResultUnionTargetAnalysis Analyze(INamedTypeSymbol symbol, Cancell EveryCaseIsPublic: false, CaseTypes: [], IncludedCases: [], + UsesMemberProvider: false, + MemberProvider: null, Diagnostics: [ ResultDiagnostic.Create( @@ -195,7 +205,8 @@ public static ResultUnionTargetAnalysis Analyze(INamedTypeSymbol symbol, Cancell ); } - if (HasNonImplicitMemberProvider(symbol)) + var memberProvider = GetMemberProvider(symbol); + if (memberProvider is not null && GetProviderCaseTypes(memberProvider).IsDefaultOrEmpty) { diagnostics.Add( ResultDiagnostic.Create( @@ -207,7 +218,7 @@ public static ResultUnionTargetAnalysis Analyze(INamedTypeSymbol symbol, Cancell ); } - if (symbol.IsGenericType || symbol.Arity > 0) + if (HasGenericContainingType(symbol)) { diagnostics.Add( ResultDiagnostic.Create( @@ -232,6 +243,7 @@ public static ResultUnionTargetAnalysis Analyze(INamedTypeSymbol symbol, Cancell ); } + var typeParameters = symbol.TypeParameters; if (diagnostics.Any(static diagnostic => diagnostic.IsBlocking)) { return new ResultUnionTargetAnalysis( @@ -242,12 +254,21 @@ public static ResultUnionTargetAnalysis Analyze(INamedTypeSymbol symbol, Cancell EveryCaseIsPublic: false, CaseTypes: [], IncludedCases: [], + UsesMemberProvider: memberProvider is not null, + MemberProvider: memberProvider, Diagnostics: diagnostics.ToImmutable() ); } var everyCaseIsPublic = true; - var caseTypes = AnalyzeCases(symbol, diagnostics, ref everyCaseIsPublic, cancellationToken); + var caseTypes = AnalyzeCases( + symbol, + memberProvider, + typeParameters, + diagnostics, + ref everyCaseIsPublic, + cancellationToken + ); var includedCases = AnalyzeIncludedCases( symbol, caseTypes, @@ -277,37 +298,74 @@ public static ResultUnionTargetAnalysis Analyze(INamedTypeSymbol symbol, Cancell EveryCaseIsPublic: everyCaseIsPublic, CaseTypes: caseTypes, IncludedCases: includedCases, + UsesMemberProvider: memberProvider is not null, + MemberProvider: memberProvider, Diagnostics: diagnostics.ToImmutable() ); } /// - /// Resolves the union's usable case types from the language's case-type rule, reporting case findings - /// (which never block the union) for the cases that cannot be referenced from generated code. + /// Resolves the union's usable case types from the language's case-type rule — a nested + /// IUnionMembers provider's Create methods when one exists, otherwise the union's public + /// single-parameter constructors — and reports case findings (which never block the union) for the + /// cases that cannot be referenced from generated code. /// - static ImmutableArray AnalyzeCases( + static ImmutableArray AnalyzeCases( INamedTypeSymbol union, + INamedTypeSymbol? memberProvider, + ImmutableArray typeParameters, ImmutableArray.Builder diagnostics, ref bool everyCaseIsPublic, CancellationToken cancellationToken ) { - var caseTypes = ImmutableArray.CreateBuilder(); + var caseTypes = ImmutableArray.CreateBuilder(); + List<(ITypeSymbol CaseType, Location Location)> candidates = []; - foreach (var constructor in union.InstanceConstructors) + if (memberProvider is not null) { - cancellationToken.ThrowIfCancellationRequested(); + foreach (var method in GetProviderCreateMethods(memberProvider)) + candidates.Add((method.Parameters[0].Type, GetParameterLocation(method.Parameters[0]))); + } + else + { + foreach (var constructor in union.InstanceConstructors) + { + if (IsCaseConstructor(constructor)) + candidates.Add((constructor.Parameters[0].Type, GetParameterLocation(constructor.Parameters[0]))); + } + } - if (!IsCaseConstructor(constructor)) - continue; + foreach (var (caseType, caseLocation) in candidates) + { + cancellationToken.ThrowIfCancellationRequested(); - var caseType = constructor.Parameters[0].Type; - var caseLocation = constructor.Parameters[0].Locations.IsDefaultOrEmpty - ? Location.None - : constructor.Parameters[0].Locations[0]; var displayName = caseType.ToDisplayString(DisplayFormat); - if (ContainsTypeParameters(caseType)) + // A bare type parameter is a valid union case only when it is one of the union's own type + // parameters. It gets a union-receiver factory but no per-case AsFailure helper. + if (caseType is ITypeParameterSymbol parameter) + { + if (!typeParameters.Contains(parameter, SymbolEqualityComparer.Default)) + { + diagnostics.Add( + ResultDiagnostic.Create( + DiagnosticLibrary.UnsupportedGenericConfiguration, + DiagnosticScope.Case, + caseLocation, + displayName + ) + ); + continue; + } + + caseTypes.Add(parameter); + continue; + } + + // A case may mention the union's own type parameters, but a type parameter supplied by an + // enclosing type cannot be declared by the generated top-level helper. + if (ContainsExternalTypeParameters(caseType, typeParameters)) { diagnostics.Add( ResultDiagnostic.Create( @@ -375,25 +433,26 @@ CancellationToken cancellationToken /// static ImmutableArray AnalyzeIncludedCases( INamedTypeSymbol union, - ImmutableArray caseTypes, + ImmutableArray caseTypes, Location reportLocation, ImmutableArray.Builder diagnostics, ref bool everyCaseIsPublic, CancellationToken cancellationToken ) { - HashSet directCases = [with(SymbolEqualityComparer.Default), .. caseTypes]; + HashSet directCases = [with(SymbolEqualityComparer.Default), .. caseTypes]; Dictionary> paths = [with(SymbolEqualityComparer.Default)]; HashSet ambiguous = [with(SymbolEqualityComparer.Default)]; HashSet stack = [with(SymbolEqualityComparer.Default)]; + var typeParameters = union.TypeParameters; foreach (var caseType in caseTypes) { cancellationToken.ThrowIfCancellationRequested(); - if (caseType.IsUnion) - Walk(caseType, []); + if (caseType is INamedTypeSymbol { IsUnion: true } unionCase) + Walk(unionCase, []); } if (paths.Count == 0) @@ -463,7 +522,7 @@ void Walk(INamedTypeSymbol currentUnion, ImmutableArray parent if (!stack.Add(currentUnion)) return; - foreach (var caseType in GetUsableCaseTypes(currentUnion)) + foreach (var caseType in GetUsableCaseTypes(currentUnion, typeParameters)) { if ( SymbolEqualityComparer.Default.Equals(caseType, union) @@ -496,17 +555,16 @@ void Walk(INamedTypeSymbol currentUnion, ImmutableArray parent /// Resolves an included union's usable case types without reporting findings, which are reported when /// that union is itself analyzed as an opted-in target. /// - static ImmutableArray GetUsableCaseTypes(INamedTypeSymbol union) + static ImmutableArray GetUsableCaseTypes( + INamedTypeSymbol union, + ImmutableArray allowedTypeParameters + ) { var caseTypes = ImmutableArray.CreateBuilder(); - foreach (var constructor in union.InstanceConstructors) + foreach (var caseType in GetCaseTypeCandidates(union)) { - if (!IsCaseConstructor(constructor)) - continue; - - var caseType = constructor.Parameters[0].Type; - if (ContainsTypeParameters(caseType)) + if (caseType is ITypeParameterSymbol || ContainsExternalTypeParameters(caseType, allowedTypeParameters)) continue; if (!TryGetReferenceableAccessibility(caseType, out _)) @@ -524,6 +582,89 @@ static ImmutableArray GetUsableCaseTypes(INamedTypeSymbol unio return caseTypes.ToImmutable(); } + /// + /// Gets the union's case type candidates: the IUnionMembers provider's Create parameters + /// when a provider exists, otherwise its public single-parameter constructors. + /// + static IEnumerable GetCaseTypeCandidates(INamedTypeSymbol union) + { + if (GetMemberProvider(union) is { } provider) + { + foreach (var method in GetProviderCreateMethods(provider)) + yield return method.Parameters[0].Type; + + yield break; + } + + foreach (var constructor in union.InstanceConstructors) + { + if (IsCaseConstructor(constructor)) + yield return constructor.Parameters[0].Type; + } + } + + /// + /// Gets the union's nested IUnionMembers provider, when it declares one. + /// + /// The union to inspect. + /// The provider interface, or . + /// + /// The language finds a union's members only on a public nested interface named IUnionMembers. + /// A type that declares one is a member-provider union even when it also has constructors. + /// + public static INamedTypeSymbol? GetMemberProvider(INamedTypeSymbol union) + { + foreach (var member in union.GetTypeMembers("IUnionMembers")) + { + if (!member.IsImplicitlyDeclared && member.TypeKind == TypeKind.Interface) + return member; + } + + return null; + } + + /// + /// Determines whether the union is nested inside a generic type. The generated helper is a top-level type, + /// so it cannot name the containing type's type parameters; such a union is reported rather than emitted + /// with an unbound type argument. + /// + static bool HasGenericContainingType(INamedTypeSymbol union) + { + for (var current = union.ContainingType; current is not null; current = current.ContainingType) + { + if (current.Arity > 0) + return true; + } + + return false; + } + + /// + /// Gets every static single-parameter Create method declared by a provider or an interface it + /// inherits. The parameter type of each is a case type. + /// + static IEnumerable GetProviderCreateMethods(INamedTypeSymbol provider) + { + foreach (var current in provider.AllInterfaces.Prepend(provider)) + { + foreach (var member in current.GetMembers("Create")) + { + if (member is IMethodSymbol { IsStatic: true, Parameters.Length: 1 } method) + yield return method; + } + } + } + + /// + /// Gets a provider's case types, used to decide whether the provider declares the required creation + /// members. + /// + static ImmutableArray GetProviderCaseTypes(INamedTypeSymbol provider) => + [.. GetProviderCreateMethods(provider).Select(static method => method.Parameters[0].Type)]; + + static Location GetParameterLocation(IParameterSymbol parameter) => + parameter.Locations.IsDefaultOrEmpty ? Location.None : parameter.Locations[0]; + static bool PathsEqual(ImmutableArray left, ImmutableArray right) { if (left.Length != right.Length) @@ -548,33 +689,22 @@ static bool IsCaseConstructor(IMethodSymbol constructor) => && constructor.Parameters[0].RefKind is RefKind.None or RefKind.In; /// - /// Determines whether the union directly declares a user-authored IUnionMembers member provider - /// interface, which the first implementation does not support. + /// Determines whether a type contains a type parameter that the generated helper cannot declare: one + /// that is not supplied by the union itself (directly, through an array element, or through a generic + /// type argument). /// - static bool HasNonImplicitMemberProvider(INamedTypeSymbol union) - { - foreach (var member in union.GetTypeMembers("IUnionMembers")) - { - if (!member.IsImplicitlyDeclared) - return true; - } - - return false; - } - - /// - /// Determines whether a type contains a type parameter (directly, through an array element, or through - /// a generic type argument), which cannot be referenced from a generated top-level extension method. - /// - static bool ContainsTypeParameters(ITypeSymbol type) + static bool ContainsExternalTypeParameters( + ITypeSymbol type, + ImmutableArray allowedTypeParameters + ) { switch (type) { - case ITypeParameterSymbol: - return true; + case ITypeParameterSymbol parameter: + return !allowedTypeParameters.Contains(parameter, SymbolEqualityComparer.Default); case IArrayTypeSymbol array: - return ContainsTypeParameters(array.ElementType); + return ContainsExternalTypeParameters(array.ElementType, allowedTypeParameters); case INamedTypeSymbol named: { @@ -583,11 +713,12 @@ static bool ContainsTypeParameters(ITypeSymbol type) foreach (var argument in named.TypeArguments) { - if (ContainsTypeParameters(argument)) + if (ContainsExternalTypeParameters(argument, allowedTypeParameters)) return true; } - return named.ContainingType is not null && ContainsTypeParameters(named.ContainingType); + return named.ContainingType is not null + && ContainsExternalTypeParameters(named.ContainingType, allowedTypeParameters); } default: @@ -610,6 +741,11 @@ static bool TryGetReferenceableAccessibility(ITypeSymbol type, out bool isPublic case IArrayTypeSymbol array: return TryGetReferenceableAccessibility(array.ElementType, out isPublic); + // The caller has already rejected a type parameter the generated helper cannot declare; a type + // parameter that reaches here is the union's own, and is referenceable by name. + case ITypeParameterSymbol: + return true; + case INamedTypeSymbol named: { if (named.IsFileLocal || named.IsUnboundGenericType) diff --git a/src/src/SourceGenerator/Discovery/ResultUnionDiscovery.cs b/src/src/SourceGenerator/Discovery/ResultUnionDiscovery.cs index 4af2ed7..8d0bfee 100644 --- a/src/src/SourceGenerator/Discovery/ResultUnionDiscovery.cs +++ b/src/src/SourceGenerator/Discovery/ResultUnionDiscovery.cs @@ -57,12 +57,18 @@ CancellationToken cancellationToken var extensionClassName = CreateExtensionClassName(union); var cases = CreateCaseModels(analysis.CaseTypes); var includedCases = CreateIncludedCaseModels(analysis.IncludedCases); + var typeParameters = CreateTypeParameterModels(union); return GeneratorResult.Create( new ResultUnionModel( Namespace: namespaceName, IsGlobalNamespace: namespaceName.Length == 0, UnionType: new TypeIdentity(union), + UnionTypeLiteral: union.ToDisplayString(LiteralFormat), + IsGeneric: typeParameters.Count > 0, + TypeParameters: typeParameters, + UsesMemberProvider: analysis.UsesMemberProvider, + MemberProviderLiteral: analysis.MemberProvider?.ToDisplayString(LiteralFormat), FullyQualifiedName: union.ToDisplayString(ResultUnionDiagnostics.DisplayFormat), Accessibility: analysis.UnionIsPublic && analysis.EveryCaseIsPublic ? TypeDeclarationAccessibility.Public @@ -80,6 +86,66 @@ CancellationToken cancellationToken ); } + /// + /// The display format used for emitted type names: fully qualified, with C# keywords and nullable + /// annotations. A type parameter renders as its bare name, so a generic union's own parameters can appear + /// in a generated top-level helper. + /// + static readonly SymbolDisplayFormat LiteralFormat = + SymbolDisplayFormat.FullyQualifiedFormat.WithMiscellaneousOptions( + SymbolDisplayMiscellaneousOptions.UseSpecialTypes + | SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers + | SymbolDisplayMiscellaneousOptions.IncludeNullableReferenceTypeModifier + ); + + /// + /// Renders a type as a literal, render-only reference. + /// + /// + /// A TypeReference built from a symbol carries a TypeIdentity whose name is empty for a + /// type parameter, which CodeWriter rejects in a declaration. The generator emits fully qualified + /// names, so a literal reference over the rendered name is both exact and safe for arrays, nullable + /// types, constructed generics and type parameters. + /// + static TypeReference ToLiteral(ITypeSymbol type) => + new(new TypeIdentity(type.ToDisplayString(LiteralFormat), null)); + + static EquatableArray CreateTypeParameterModels(INamedTypeSymbol union) + { + if (union.TypeParameters.IsDefaultOrEmpty) + return EquatableArray.Empty; + + var parameters = ImmutableArray.CreateBuilder(union.TypeParameters.Length); + + foreach (var parameter in union.TypeParameters) + { + var constraints = ImmutableArray.CreateBuilder(); + + if (parameter.HasUnmanagedTypeConstraint) + constraints.Add("unmanaged"); + else if (parameter.HasValueTypeConstraint) + constraints.Add("struct"); + else if (parameter.HasReferenceTypeConstraint) + constraints.Add( + parameter.ReferenceTypeConstraintNullableAnnotation == NullableAnnotation.Annotated + ? "class?" + : "class" + ); + else if (parameter.HasNotNullConstraint) + constraints.Add("notnull"); + + foreach (var constraintType in parameter.ConstraintTypes) + constraints.Add(constraintType.ToDisplayString(LiteralFormat)); + + if (parameter.HasConstructorConstraint) + constraints.Add("new()"); + + parameters.Add(new ResultUnionTypeParameterModel(parameter.Name, constraints.ToImmutable())); + } + + return parameters.ToImmutable(); + } + static EquatableArray ToReportableDiagnostics(ImmutableArray findings) { if (findings.IsDefaultOrEmpty) @@ -92,7 +158,7 @@ static EquatableArray ToReportableDiagnostics(ImmutableArr return diagnostics.ToImmutable(); } - static EquatableArray CreateCaseModels(ImmutableArray caseTypes) + static EquatableArray CreateCaseModels(ImmutableArray caseTypes) { var cases = ImmutableArray.CreateBuilder(caseTypes.Length); @@ -100,9 +166,10 @@ static EquatableArray CreateCaseModels(ImmutableArray includedCases { cases.Add( new IncludedUnionCaseModel( - TypeReference.Create(includedCase.CaseType), + ToLiteral(includedCase.CaseType), includedCase.CaseType.ToDisplayString(ResultUnionDiagnostics.DisplayFormat), - includedCase.UnionPath.Select(static unionType => new TypeIdentity(unionType)).ToImmutableArray() + includedCase + .UnionPath.Select(static unionType => new IncludedUnionStepModel( + ToLiteral(unionType), + ResultUnionDiagnostics.GetMemberProvider(unionType)?.ToDisplayString(LiteralFormat) + )) + .ToImmutableArray() ) ); } diff --git a/src/src/SourceGenerator/Emit/ResultUnionEmitter.cs b/src/src/SourceGenerator/Emit/ResultUnionEmitter.cs index 90f7ff7..a653a5b 100644 --- a/src/src/SourceGenerator/Emit/ResultUnionEmitter.cs +++ b/src/src/SourceGenerator/Emit/ResultUnionEmitter.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using System.Text; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Text; @@ -67,6 +68,11 @@ static void EmitUnion(SourceProductionContext context, GeneratorContext generati static void EmitHelper(CodeWriter writer, ResultUnionModel union, ResultUnionCaseModel unionCase) { + // A bare type-parameter case has no safe extension-method receiver; its union-receiver factory is the + // supported form. + if (unionCase.IsTypeParameterCase) + return; + EmitValueHelper(writer, union, unionCase); EmitUnitHelper(writer, union, unionCase); } @@ -75,14 +81,14 @@ static void EmitValueHelper(CodeWriter writer, ResultUnionModel union, ResultUni { var resultType = CreateResultTypeReference(union); var resultTypeName = resultType.RenderFullName; + var valueTypeParameterName = GetValueTypeParameterName(union); writer.XmlSummary( $"Creates a failed result containing the {XmlInlineCode(DocText.Escape(unionCase.FullyQualifiedName))} union case of {XmlInlineCode(DocText.Escape(union.FullyQualifiedName))}." ); - writer.XmlTypeParam( - PropertyLibrary.ValueTypeParameterName, - "The type of the successful value of the created result." - ); + foreach (var parameter in union.TypeParameters) + writer.XmlTypeParam(parameter.Name, $"The union's {XmlInlineCode(parameter.Name)} type argument."); + writer.XmlTypeParam(valueTypeParameterName, "The type of the successful value of the created result."); writer.XmlParam( PropertyLibrary.ErrorParameterName, $"The {XmlInlineCode(DocText.Escape(unionCase.FullyQualifiedName))} union case to convert." @@ -99,7 +105,7 @@ static void EmitValueHelper(CodeWriter writer, ResultUnionModel union, ResultUni options with { IsStatic = true, - GenericTypes = [new GenericTypeParameterOptions(PropertyLibrary.ValueTypeParameterName)], + GenericTypes = MethodTypeParameters(union, valueTypeParameterName), Parameters = [ new ParameterDeclarationOptions(PropertyLibrary.ErrorParameterName, unionCase.CaseType) @@ -120,6 +126,8 @@ static void EmitUnitHelper(CodeWriter writer, ResultUnionModel union, ResultUnio writer.XmlSummary( $"Creates a failed unit result containing the {XmlInlineCode(DocText.Escape(unionCase.FullyQualifiedName))} union case of {XmlInlineCode(DocText.Escape(union.FullyQualifiedName))}." ); + foreach (var parameter in union.TypeParameters) + writer.XmlTypeParam(parameter.Name, $"The union's {XmlInlineCode(parameter.Name)} type argument."); writer.XmlParam( PropertyLibrary.ErrorParameterName, $"The {XmlInlineCode(DocText.Escape(unionCase.FullyQualifiedName))} union case to convert." @@ -136,6 +144,7 @@ static void EmitUnitHelper(CodeWriter writer, ResultUnionModel union, ResultUnio options with { IsStatic = true, + GenericTypes = MethodTypeParameters(union, valueTypeParameterName: null), Parameters = [ new ParameterDeclarationOptions(PropertyLibrary.ErrorParameterName, unionCase.CaseType) @@ -156,29 +165,50 @@ options with /// (case.AsFailure<TValue>()) keeps the call site unambiguous when a case type is shared with /// another union, because each union owns its own factory set. The factory is therefore the shared-case /// safe form of the per-case helpers. + /// + /// A generic union needs a generic extension block (extension<T>(Option<T>)), which the + /// framework's structured ExtensionBlock API does not express: it accepts only a plain named + /// receiver. The header is therefore written as text and the body through the normal writer API, with + /// the union's constraints repeated on the block. + /// /// static void EmitUnionFactory(CodeWriter writer, ResultUnionModel union) { - writer.ExtensionBlock( - new TypeReference(union.UnionType), - body => - { - foreach (var unionCase in union.Cases) - { - EmitUnitFactory(body, union, unionCase); - EmitValueFactory(body, union, unionCase); - } + if (!union.IsGeneric) + { + writer.ExtensionBlock(Literal(union.UnionTypeLiteral), body => EmitFactoryMembers(body, union)); + return; + } - foreach (var includedCase in union.IncludedCases) - { - EmitIncludedUnitFactory(body, union, includedCase); - EmitIncludedValueFactory(body, union, includedCase); - } + var typeParameterList = string.Join(", ", union.TypeParameters.Select(static parameter => parameter.Name)); - EmitUnitSuccessFactory(body, union); - EmitValueSuccessFactory(body, union); - } - ); + writer.Line($"extension<{typeParameterList}>({union.UnionTypeLiteral})"); + + foreach (var parameter in union.TypeParameters) + { + if (parameter.Constraints.Count > 0) + writer.Line($"\twhere {parameter.Name} : {string.Join(", ", parameter.Constraints)}"); + } + + writer.OpenBlock(null, body => EmitFactoryMembers(body, union)); + } + + static void EmitFactoryMembers(CodeWriter writer, ResultUnionModel union) + { + foreach (var unionCase in union.Cases) + { + EmitUnitFactory(writer, union, unionCase); + EmitValueFactory(writer, union, unionCase); + } + + foreach (var includedCase in union.IncludedCases) + { + EmitIncludedUnitFactory(writer, union, includedCase); + EmitIncludedValueFactory(writer, union, includedCase); + } + + EmitUnitSuccessFactory(writer, union); + EmitValueSuccessFactory(writer, union); } static void EmitUnitFactory(CodeWriter writer, ResultUnionModel union, ResultUnionCaseModel unionCase) @@ -218,14 +248,12 @@ static void EmitValueFactory(CodeWriter writer, ResultUnionModel union, ResultUn { var resultType = CreateResultTypeReference(union); var resultTypeName = resultType.RenderFullName; + var valueTypeParameterName = GetValueTypeParameterName(union); writer.XmlSummary( $"Creates a failed result containing the {XmlInlineCode(DocText.Escape(unionCase.FullyQualifiedName))} union case of {XmlInlineCode(DocText.Escape(union.FullyQualifiedName))}." ); - writer.XmlTypeParam( - PropertyLibrary.ValueTypeParameterName, - "The type of the successful value of the created result." - ); + writer.XmlTypeParam(valueTypeParameterName, "The type of the successful value of the created result."); writer.XmlParam( PropertyLibrary.ErrorParameterName, $"The {XmlInlineCode(DocText.Escape(unionCase.FullyQualifiedName))} union case to convert." @@ -242,7 +270,7 @@ static void EmitValueFactory(CodeWriter writer, ResultUnionModel union, ResultUn options with { IsStatic = true, - GenericTypes = [new GenericTypeParameterOptions(PropertyLibrary.ValueTypeParameterName)], + GenericTypes = [new GenericTypeParameterOptions(valueTypeParameterName)], Parameters = [ new ParameterDeclarationOptions(PropertyLibrary.ErrorParameterName, unionCase.CaseType), @@ -297,14 +325,12 @@ static void EmitIncludedValueFactory(CodeWriter writer, ResultUnionModel union, { var resultType = CreateResultTypeReference(union); var resultTypeName = resultType.RenderFullName; + var valueTypeParameterName = GetValueTypeParameterName(union); writer.XmlSummary( $"Creates a failed result containing the {XmlInlineCode(DocText.Escape(includedCase.FullyQualifiedName))} case of {XmlInlineCode(DocText.Escape(union.FullyQualifiedName))}, reached through the union(s) that include it." ); - writer.XmlTypeParam( - PropertyLibrary.ValueTypeParameterName, - "The type of the successful value of the created result." - ); + writer.XmlTypeParam(valueTypeParameterName, "The type of the successful value of the created result."); writer.XmlParam( PropertyLibrary.ErrorParameterName, $"The {XmlInlineCode(DocText.Escape(includedCase.FullyQualifiedName))} case to convert." @@ -321,7 +347,7 @@ static void EmitIncludedValueFactory(CodeWriter writer, ResultUnionModel union, options with { IsStatic = true, - GenericTypes = [new GenericTypeParameterOptions(PropertyLibrary.ValueTypeParameterName)], + GenericTypes = [new GenericTypeParameterOptions(valueTypeParameterName)], Parameters = [ new ParameterDeclarationOptions(PropertyLibrary.ErrorParameterName, includedCase.CaseType), @@ -333,18 +359,22 @@ options with /// /// Renders the nested union value for a case reached through an included union: the case wrapped in each - /// path union innermost first, and finally in the declaring union. + /// path union innermost first, and finally in the declaring union. A union whose creation members live on + /// an IUnionMembers provider is constructed through that provider rather than a constructor. /// static string RenderIncludedValue(ResultUnionModel union, IncludedUnionCaseModel includedCase) { var current = PropertyLibrary.ErrorParameterName; - foreach (var unionType in includedCase.UnionPath) - current = $"new {unionType.RenderFullName}({current})"; + foreach (var step in includedCase.UnionPath) + current = CreateUnionValue(step.UnionType.RenderFullName, step.MemberProviderLiteral, current); - return $"new {union.UnionType.RenderFullName}({current})"; + return CreateUnionValue(union.UnionTypeLiteral, union.MemberProviderLiteral, current); } + static string CreateUnionValue(string unionType, string? memberProvider, string argument) => + memberProvider is null ? $"new {unionType}({argument})" : $"{memberProvider}.Create({argument})"; + static void EmitUnitSuccessFactory(CodeWriter writer, ResultUnionModel union) { var resultType = CreateUnitResultTypeReference(union); @@ -368,14 +398,12 @@ static void EmitValueSuccessFactory(CodeWriter writer, ResultUnionModel union) { var resultType = CreateResultTypeReference(union); var resultTypeName = resultType.RenderFullName; + var valueTypeParameterName = GetValueTypeParameterName(union); writer.XmlSummary( $"Creates a successful result carrying a value for {XmlInlineCode(DocText.Escape(union.FullyQualifiedName))}." ); - writer.XmlTypeParam( - PropertyLibrary.ValueTypeParameterName, - "The type of the successful value of the created result." - ); + writer.XmlTypeParam(valueTypeParameterName, "The type of the successful value of the created result."); writer.XmlParam(PropertyLibrary.ValueParameterName, "The successful value."); writer.XmlReturn( $"A successful {XmlInlineCode(DocText.Escape(PropertyLibrary.ResultTypeName))} carrying {XmlParamRef(PropertyLibrary.ValueParameterName)}." @@ -389,16 +417,12 @@ static void EmitValueSuccessFactory(CodeWriter writer, ResultUnionModel union) options with { IsStatic = true, - GenericTypes = [new GenericTypeParameterOptions(PropertyLibrary.ValueTypeParameterName)], + GenericTypes = [new GenericTypeParameterOptions(valueTypeParameterName)], Parameters = [ - // The method's own type parameter is referenced as a literal named argument, the same - // representation the generic result type reference uses: the framework's - // TypeReference.ForTypeParameter carries no identity, and the writer's parameter - // validation rejects a parameter whose reference has no identity name. new ParameterDeclarationOptions( PropertyLibrary.ValueParameterName, - new TypeReference(new TypeIdentity(PropertyLibrary.ValueTypeParameterName, null)) + Literal(valueTypeParameterName) ), ], }, @@ -407,23 +431,12 @@ options with } /// - /// Creates the Result<TValue, TUnion> reference for a union, using the framework's - /// structured type-reference abstraction so generic arguments, nesting and namespace qualification are - /// rendered consistently. + /// Creates the Result<TValue, TUnion> reference for a union, rendered as a fully-qualified + /// literal so a generic union's own type parameters can appear in the generated signature. /// - /// - /// The method's own type parameter is referenced as a literal named argument - /// (new TypeIdentity("TValue", null)), which renders as a bare TValue. This is the - /// framework's supported representation for a literal type-argument name: CodeWriter validates - /// every generic argument through its TypeIdentity, so TypeReference.ForTypeParameter — - /// which carries no identity — cannot be used inside a constructed generic type reference. - /// static TypeReference CreateResultTypeReference(ResultUnionModel union) => - new( - ResultsTypeLibrary.Result.MakeGeneric( - new TypeIdentity(PropertyLibrary.ValueTypeParameterName, null), - union.UnionType - ) + Literal( + $"global::{PropertyLibrary.ResultsNamespace}.Result<{GetValueTypeParameterName(union)}, {union.UnionTypeLiteral}>" ); /// @@ -432,5 +445,44 @@ static TypeReference CreateResultTypeReference(ResultUnionModel union) => /// The union whose cases the helper converts. /// The structured reference to the unit result type. static TypeReference CreateUnitResultTypeReference(ResultUnionModel union) => - new(ResultsTypeLibrary.ResultUnit.MakeGeneric(union.UnionType)); + Literal($"global::{PropertyLibrary.ResultsNamespace}.Result<{union.UnionTypeLiteral}>"); + + /// + /// Creates a literal, fully-qualified type reference. + /// + static TypeReference Literal(string typeName) => new(new TypeIdentity(typeName, null)); + + /// + /// Creates the generic parameters a per-case helper declares: the union's own type parameters first, then + /// the successful value type. A caller of a generic union's helper therefore supplies the union arguments + /// first and the value type last. + /// + static EquatableArray MethodTypeParameters( + ResultUnionModel union, + string? valueTypeParameterName + ) + { + var parameters = ImmutableArray.CreateBuilder( + union.TypeParameters.Count + (valueTypeParameterName is null ? 0 : 1) + ); + + foreach (var parameter in union.TypeParameters) + parameters.Add(new GenericTypeParameterOptions(parameter.Name) { Constraints = parameter.Constraints }); + + if (valueTypeParameterName is not null) + parameters.Add(new GenericTypeParameterOptions(valueTypeParameterName)); + + return parameters.ToImmutable(); + } + + /// + /// Gets the successful-value type parameter name, avoiding a collision when the union declares a type + /// parameter with the same name. + /// + static string GetValueTypeParameterName(ResultUnionModel union) => + union.TypeParameters.Any(static parameter => + string.Equals(parameter.Name, PropertyLibrary.ValueTypeParameterName, StringComparison.Ordinal) + ) + ? "TResultValue" + : PropertyLibrary.ValueTypeParameterName; } diff --git a/src/src/SourceGenerator/Models/ResultUnionModel.cs b/src/src/SourceGenerator/Models/ResultUnionModel.cs index 5d50aad..ae79931 100644 --- a/src/src/SourceGenerator/Models/ResultUnionModel.cs +++ b/src/src/SourceGenerator/Models/ResultUnionModel.cs @@ -38,12 +38,47 @@ public Location ToLocation() => : Location.Create(FilePath ?? string.Empty, TextSpan, LinePositionSpan); } +/// +/// Describes one type parameter of a generic union, including the constraint clauses the generated +/// extension block and helpers must repeat. +/// +/// The type parameter name. +/// +/// The constraint expressions written after where {Name} :, in source order (for example +/// class, notnull, a base type, an interface or new()). +/// +readonly record struct ResultUnionTypeParameterModel(string Name, EquatableArray Constraints) +{ + /// + /// Gets an empty type-parameter model. + /// + public static readonly ResultUnionTypeParameterModel Empty; +} + +/// +/// Describes one step in an included union's construction path: the union type to construct and whether +/// its creation members live on an IUnionMembers provider. +/// +/// The literal reference to the union type, including any generic arguments. +/// +/// The fully-qualified literal reference to the union's IUnionMembers provider when its creation +/// members live there; otherwise , meaning a public constructor is used. +/// +readonly record struct IncludedUnionStepModel(TypeReference UnionType, string? MemberProviderLiteral) +{ + /// + /// Gets an empty path step. + /// + public static readonly IncludedUnionStepModel Empty; +} + /// /// Describes one discovered union case: the type a union value may hold, plus its display name. /// /// /// The reference to the case type as it is spelled at the generated use site, preserving any nullable -/// annotation and generic construction. +/// annotation and generic construction. A case that names one of the union's own type parameters keeps that +/// parameter in the reference. /// /// /// The fully-qualified display name of the case type, used for diagnostics and deterministic ordering. @@ -52,7 +87,17 @@ public Location ToLocation() => /// Whether the case type is itself a union. A union-typed case is an included union: its own /// cases are reachable through it, and sharing it across unions is the intended composition pattern. /// -readonly record struct ResultUnionCaseModel(TypeReference CaseType, string FullyQualifiedName, bool IsUnionCase) +/// +/// Whether the case is one of the union's own bare type parameters. Such a case has a union-receiver factory +/// but no per-case AsFailure helper, because an extension method on a bare T would apply to +/// every value. +/// +readonly record struct ResultUnionCaseModel( + TypeReference CaseType, + string FullyQualifiedName, + bool IsUnionCase, + bool IsTypeParameterCase +) { /// /// Gets an empty case model. @@ -76,7 +121,7 @@ readonly record struct ResultUnionCaseModel(TypeReference CaseType, string Fully readonly record struct IncludedUnionCaseModel( TypeReference CaseType, string FullyQualifiedName, - EquatableArray UnionPath + EquatableArray UnionPath ) { /// @@ -91,7 +136,25 @@ EquatableArray UnionPath /// /// The union's containing namespace, or an empty string for the global namespace. /// Whether the union is declared in the global namespace. -/// The identity of the union type itself. +/// The identity of the union type itself, used for equality and ordering. +/// +/// The fully-qualified literal reference to the union, including any generic arguments (for example +/// global::Test.Option<T>). The generated signatures use it verbatim, which is what lets a +/// generic union's own type parameters appear in a top-level extension class. +/// +/// Whether the union declares type parameters. +/// +/// The union's type parameters and constraints, in declaration order. The generated extension block and +/// per-case helpers repeat them. +/// +/// +/// Whether the union's creation members live on a nested IUnionMembers provider instead of public +/// constructors. +/// +/// +/// The fully-qualified literal reference to the IUnionMembers provider when +/// is true; otherwise . +/// /// The union's display name, used for diagnostics. /// /// The accessibility of the generated helper class: never more accessible than the union and every case @@ -121,6 +184,11 @@ readonly record struct ResultUnionModel( string Namespace, bool IsGlobalNamespace, TypeIdentity UnionType, + string UnionTypeLiteral, + bool IsGeneric, + EquatableArray TypeParameters, + bool UsesMemberProvider, + string? MemberProviderLiteral, string FullyQualifiedName, TypeDeclarationAccessibility Accessibility, string ExtensionClassName, diff --git a/src/src/SourceGenerator/README.md b/src/src/SourceGenerator/README.md index b94b062..20715c3 100644 --- a/src/src/SourceGenerator/README.md +++ b/src/src/SourceGenerator/README.md @@ -156,6 +156,51 @@ stores its error in a typed field, so nothing in the generated path boxes the ca `union` declaration always stores its contents as `object?`; reach for a custom union when an error type is on a hot path. `NonBoxingUnionTests` proves the match allocates nothing. +## Generic and member-provider unions + +A generic union's own type parameters and constraints are repeated on the generated helpers and on a generic +extension block that carries the union-receiver factory: + +```csharp +public readonly record struct None; +public readonly record struct Some(T Value); + +[GenerateResult] +public readonly union Maybe(None, Some); + +// Static extension-member type arguments are the extension block's parameters first, then the method's. +Result> failure = Maybe.Failure(new Some("present")); +Result> helper = new Some("present").AsFailure(); +``` + +A custom union may place its creation members on a nested `IUnionMembers` provider. The generator reads the case +list from the provider's public static single-parameter `Create` methods and constructs values through +`IUnionMembers.Create`, so a union with a private constructor works: + +```csharp +[Union] +[GenerateResult] +public readonly struct ProvidedError : IUnion, ProvidedError.IUnionMembers +{ + private readonly object? _value; + private ProvidedError(object? value) => _value = value; + + public interface IUnionMembers + { + static ProvidedError Create(ProvidedCase value) => new(value); + object? Value { get; } + } + + object? IUnionMembers.Value => _value; + public object? Value => _value; +} +``` + +A provider that declares no usable `Create` method is reported as `RSG1007` and blocks generation. A case that +names a type parameter the union does not declare is reported as `RSG1002` and skipped. A bare union type +parameter case is supported by the union-receiver factory (but not by a per-case `AsFailure` helper); a non-named +case such as an array is reported as `RSG1009`. + ## Why the generator exists C# composes a union *case* into its union and `Result` composes the *union* into a result, @@ -189,7 +234,8 @@ experiment in `src/tests/SourceGenerator.UnitTests/UnionCompilerBehaviourTests.c | **Only one user-defined conversion** may participate in a conversion sequence, so `case → union → Result<…>` can never compose | `Result_GivenCaseValue_DirectAssignmentDoesNotCompile` | `CS0029` | The only shape the language accepts is a *generic case type* carrying the value type parameter -(`ConversionOperator_OnGenericCaseType_Compiles`) — precisely the shape the generator rejects as `RSG1002`. +(`ConversionOperator_OnGenericCaseType_Compiles`) — the shape a generic union's helper declares instead of a +generated conversion. One helper per case type is therefore the best ergonomics the language allows, and the package's code fix offers the rewrite in the IDE when a case value is returned where a result is expected. @@ -247,12 +293,12 @@ The diagnostics live in one shared library (`Diagnostics/DiagnosticLibrary.cs` + | --- | --- | --- | --- | --- | | `RSG1000` | Error | Analyzer | Yes | `[GenerateResult]` was applied to a type that is not a union. | | `RSG1001` | Error | Analyzer | Yes | The union declares no union cases. | -| `RSG1002` | Error | Analyzer | Union: yes / case: no | Generic union, or a case type that contains type parameters. | +| `RSG1002` | Error | Analyzer | No (only the offending case is skipped) | A case type names a type parameter the union does not declare, or the union is nested in a generic type. A generic union itself is supported. | | `RSG1003` | Error | Analyzer | Union: yes / case: no | A type that generated code must reference is not accessible (for example a `file` type). | | `RSG1004` | Error | Analyzer | No | The same union is configured more than once (for example on two partial declarations); the helpers are generated once. | | `RSG1005` | Error | Generator | Yes (the colliding union is skipped) | Two unions produce the same generated class name in one namespace. | | `RSG1006` | Warning | Generator | No (only the shared leaf case's `AsFailure` helper is skipped) | A **leaf** case type is shared with another union, so its per-case helper is generated once; the union-receiver `Failure(...)` factory is generated for every union and is the shared-case safe form. A union-typed case shared across unions is composition, not a shared leaf, and is not reported. | -| `RSG1007` | Error | Analyzer | Yes | The union uses an `IUnionMembers` member provider, which the first implementation does not support. | +| `RSG1007` | Error | Analyzer | Yes | The union's `IUnionMembers` provider declares no public static single-parameter `Create` method, so it does not declare any cases. | | `RSG1008` | Warning | Analyzer | No (only the ambiguous case's inclusion factory is skipped) | A case is reachable through two different included unions, so no inclusion factory can name its construction path. List the case through the union that directly declares it instead. | | `RSG1009` | Warning | Analyzer | No (only the unsupported case is skipped) | A case type is not a named type (for example an array), so no helper or factory can be declared against it. Wrap it in a record or struct case instead. | | `RSG9000` | Error | Generator | — | The generator failed unexpectedly. The failure is reported instead of escaping, because an escaping exception surfaces as `CS8785` and discards *all* generated output for the compilation. Please report it. | @@ -325,14 +371,15 @@ companion `SourceGenerator.CodeFixes` component (`src/src/SourceGenerator.CodeFi It answers the compiler's `CS0029` ("cannot implicitly convert") and rewrites a returned case value into the union-receiver factory, so `return new TenantNotFound(id);` becomes `return TenantError.Failure(new TenantNotFound(id));` from the lightbulb, or -`TenantError.Failure(new TenantNotFound(id))` when the method returns a unit `Result`. Naming the union -keeps the rewrite correct even when the case type is shared with another union (`RSG1006`). +`TenantError.Failure(new TenantNotFound(id))` when the method returns a unit `Result`. A generic +union repeats its type arguments first: `Maybe.Failure(new Some("x"))`. Naming the +union keeps the rewrite correct even when the case type is shared with another union (`RSG1006`). A fix is only offered when the rewrite will bind: | Guard | Why | | --- | --- | -| The converted type is `Purview.Results.Result` or the unit `Purview.Results.Result` | Only results have generated factories; a value result gets `Failure(case)` and a unit result `Failure(case)` | +| The converted type is `Purview.Results.Result` or the unit `Purview.Results.Result` | Only results have generated factories; a value result gets `Failure(case)` (or `Failure(case)` for a generic union) and a unit result `Failure(case)` | | `TError` is a union **and** opted in with `[GenerateResult]` | The factory is only generated for opted-in unions | | The expression's type is one of `TError`'s case types | The factory exists once per case | | The union is reachable by its simple name at the call site | The factory extension block is generated into the union's own namespace | diff --git a/src/src/SourceGenerator/Validation/ResultUnionValidator.cs b/src/src/SourceGenerator/Validation/ResultUnionValidator.cs index 643e951..510535c 100644 --- a/src/src/SourceGenerator/Validation/ResultUnionValidator.cs +++ b/src/src/SourceGenerator/Validation/ResultUnionValidator.cs @@ -91,6 +91,11 @@ GeneratorContext context { foreach (var unionCase in union.Cases) { + // A bare type-parameter case has only the union-receiver factory; it must not claim the + // per-case helper from another union. + if (unionCase.IsTypeParameterCase) + continue; + if (!caseOwners.TryGetValue(unionCase.CaseType, out var owners)) { owners = []; @@ -109,6 +114,9 @@ GeneratorContext context foreach (var unionCase in union.Cases) { + if (unionCase.IsTypeParameterCase) + continue; + var owners = caseOwners[unionCase.CaseType]; if (owners.Count == 1) diff --git a/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs b/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs index ea8ccc0..56e9f03 100644 --- a/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs +++ b/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs @@ -61,8 +61,7 @@ namespace Purview.Results.SourceGenerator; [GenerateResult] public readonly union RegisterTenantError(TenantError, BillingError); -/// A count, stored unboxed by . -/// The count value. +/// A count, stored unboxed by ./// The count value. public readonly record struct Count(int Value); /// A label, stored unboxed by . @@ -139,3 +138,160 @@ public bool TryGetValue(out Label value) return _tag == 2; } } + +/// The empty case of a generic union. +public readonly record struct None; + +/// A present value of a generic union. +/// The value type. +/// The value. +public readonly record struct Some(T Value); + +/// +/// A generic C# 15 union. The generator emits the union-receiver factory in a generic extension block and +/// declares the union's own type parameter on every per-case helper. +/// +[GenerateResult] +public readonly union Maybe(None, Some); + +/// The empty case of a constrained generic union. +public readonly record struct ConstrainedNone; + +/// A present value of a constrained generic union. +/// The reference-type value. +/// The value. +public readonly record struct ConstrainedSome(T Value); + +/// A generic union with a bare type-parameter case, which is factory-only. +/// The value or text. +[GenerateResult] +public readonly union TypedOrText(T, string); + +/// A union with a nullable value-type case and a nullable reference-type case. +[GenerateResult] +public readonly union NullableValue(int?, string?); + +/// The empty case of an included generic union. +public readonly record struct InnerNone; + +/// A present value of an included generic union. +/// The value type. +/// The value. +public readonly record struct InnerSome(T Value); + +/// A generic union used as a case of another generic union. +/// The value type. +[GenerateResult] +public readonly union InnerError(InnerNone, InnerSome); + +/// A text case distinct to the outer union. +/// The text. +public readonly record struct OuterText(string Value); + +/// A generic union that includes another generic union as one case. +/// The value type. +[GenerateResult] +public readonly union OuterError(InnerError, OuterText); + +/// A case of a class-based union. +/// The identifier. +public sealed record ClassCase(int Id); + +/// A class-based custom union, which uses reference semantics and a public constructor. +[Union] +[GenerateResult] +public class ClassUnion : IUnion +{ + /// Creates the union from its case. + /// The case value. + public ClassUnion(ClassCase value) => Value = value; + + /// + public object? Value { get; } +} + +/// A generic union with a constraint that the generated extension block must repeat. +/// The reference-type value. +[GenerateResult] +public readonly union ConstrainedMaybe(ConstrainedNone, ConstrainedSome) + where T : class; + +/// A named case of a member-provider union. +/// The identifier. +public readonly record struct ProvidedCase(int Id); + +/// +/// A custom union whose creation members live on a nested IUnionMembers provider, which is the shape +/// the compiler uses when a union needs a private constructor. +/// +/// +/// The provider is public and declares a single-parameter static Create method per case, so the +/// generator reads the case list from the provider rather than from constructors and constructs values +/// through IUnionMembers.Create. +/// +[Union] +[GenerateResult] +public readonly struct ProvidedError : IUnion, ProvidedError.IUnionMembers +{ + readonly object? _value; + + ProvidedError(object? value) => _value = value; + + /// Provides the union's creation and access members. + public interface IUnionMembers + { + /// Creates the union from its case. + /// The case value. + /// The union holding the case. + static ProvidedError Create(ProvidedCase value) => new(value); + + /// Gets the boxed case value. + object? Value { get; } + } + + object? IUnionMembers.Value => _value; + + /// + public object? Value => _value; +} + +/// A named case of a generic member-provider union. +/// The value type. +/// The value. +public readonly record struct ProvidedSome(T Value); + +/// The empty case of a generic member-provider union. +public readonly record struct ProvidedNone; + +/// A generic custom union whose creation members live on an IUnionMembers provider. +/// The value type. +[Union] +[GenerateResult] +public readonly struct ProvidedOption : IUnion, ProvidedOption.IUnionMembers +{ + readonly object? _value; + + ProvidedOption(object? value) => _value = value; + + /// Provides the union's creation and access members. + public interface IUnionMembers + { + /// Creates the union from a present value. + /// The present value. + /// The union holding the value. + static ProvidedOption Create(ProvidedSome value) => new(value); + + /// Creates the union from its empty case. + /// The empty case. + /// The empty union. + static ProvidedOption Create(ProvidedNone value) => new(value); + + /// Gets the boxed case value. + object? Value { get; } + } + + object? IUnionMembers.Value => _value; + + /// + public object? Value => _value; +} diff --git a/src/tests/SourceGenerator.UnitTests/GenericUnionTests.cs b/src/tests/SourceGenerator.UnitTests/GenericUnionTests.cs new file mode 100644 index 0000000..0d74b0b --- /dev/null +++ b/src/tests/SourceGenerator.UnitTests/GenericUnionTests.cs @@ -0,0 +1,101 @@ +namespace Purview.Results.SourceGenerator; + +/// +/// Executes the helpers generated for a generic C# 15 union. The union's own type parameter is declared by +/// the generated extension block and by every per-case helper, so a call supplies the union arguments first +/// and the successful value type last. +/// +public class GenericUnionTests +{ + [Test] + public async Task UnionFactory_GivenGenericUnionCase_ProducesValueFailure() + { + // Arrange + Some value = new("present"); + + // Act +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result> result = Maybe.Failure(value); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error is Some { Value: "present" }).IsTrue(); + } + + [Test] + public async Task UnionFactory_GivenGenericUnionUnitCase_ProducesUnitFailure() + { + // Arrange + None value = default; + + // Act +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result> result = Maybe.Failure(value); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error is None).IsTrue(); + } + + [Test] + public async Task AsFailure_GivenGenericUnionCase_DeclaresTheUnionParameterAndValueType() + { + // Arrange + Some value = new("present"); + + // Act +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result> result = value.AsFailure(); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error is Some { Value: "present" }).IsTrue(); + } + + [Test] + public async Task UnionFactory_GivenGenericUnion_ProducesSuccess() + { + // Act +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result> unit = Maybe.Success(); + Result> value = Maybe.Success(42); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // Assert + await Assert.That(unit.IsSuccess).IsTrue(); + await Assert.That(value.IsSuccess).IsTrue(); + await Assert.That(value.Value).IsEqualTo(42); + } + + [Test] + public async Task UnionFactory_GivenBareTypeParameterCase_ProducesValueFailure() + { + // Act +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result> result = TypedOrText.Failure(42); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error is int { } value && value == 42).IsTrue(); + } + + [Test] + public async Task UnionFactory_GivenConstrainedGenericUnion_RepeatsTheConstraint() + { + // Arrange + ConstrainedSome value = new("present"); + + // Act +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result> result = ConstrainedMaybe.Failure(value); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error is ConstrainedSome { Value: "present" }).IsTrue(); + } +} diff --git a/src/tests/SourceGenerator.UnitTests/MemberProviderUnionTests.cs b/src/tests/SourceGenerator.UnitTests/MemberProviderUnionTests.cs new file mode 100644 index 0000000..3ab1c59 --- /dev/null +++ b/src/tests/SourceGenerator.UnitTests/MemberProviderUnionTests.cs @@ -0,0 +1,57 @@ +namespace Purview.Results.SourceGenerator; + +/// +/// Executes the helpers generated for a custom union whose creation members live on a nested +/// IUnionMembers provider. The generator resolves the case list from the provider's Create +/// methods and constructs values through them. +/// +public class MemberProviderUnionTests +{ + [Test] + public async Task UnionFactory_GivenMemberProviderUnion_ProducesValueFailure() + { + // Arrange + ProvidedCase value = new(7); + + // Act +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result result = ProvidedError.Failure(value); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error is ProvidedCase { Id: 7 }).IsTrue(); + } + + [Test] + public async Task AsFailure_GivenMemberProviderUnionCase_ProducesUnitFailure() + { + // Arrange + ProvidedCase value = new(7); + + // Act +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result result = value.AsFailure(); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error is ProvidedCase { Id: 7 }).IsTrue(); + } + + [Test] + public async Task UnionFactory_GivenGenericMemberProviderUnion_ProducesValueFailure() + { + // Arrange + ProvidedSome value = new(7); + + // Act +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result> result = ProvidedOption.Failure(value); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error is ProvidedSome { Value: 7 }).IsTrue(); + } +} diff --git a/src/tests/SourceGenerator.UnitTests/ResultsDiagnosticAnalyzerTests.cs b/src/tests/SourceGenerator.UnitTests/ResultsDiagnosticAnalyzerTests.cs index f5ee3ef..7da051f 100644 --- a/src/tests/SourceGenerator.UnitTests/ResultsDiagnosticAnalyzerTests.cs +++ b/src/tests/SourceGenerator.UnitTests/ResultsDiagnosticAnalyzerTests.cs @@ -91,7 +91,7 @@ namespace Test } [Test] - public async Task AnalyzeAsync_GivenGenericUnion_ReportsUnsupportedGenericConfiguration( + public async Task AnalyzeAsync_GivenGenericUnion_SupportsTheNamedCasesAndReportsTheBareTypeParameterCase( CancellationToken cancellationToken ) { @@ -107,8 +107,52 @@ namespace Test // Act var result = await AnalyzeAsync([TestSources.GenerateResultAttributeDeclaration, source], cancellationToken); - // Assert - await Assert.That(result).HasDiagnostic("RSG1002"); + // Assert — a generic union is supported, including its bare type-parameter case. + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task AnalyzeAsync_GivenCaseWithAnExternalTypeParameter_ReportsUnsupportedGenericConfiguration( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + namespace Test + { + public readonly record struct External(T Value); + + [GenerateResult] + public readonly union InnerError(string, External); + } + """; + + // Act + var result = await AnalyzeAsync([TestSources.GenerateResultAttributeDeclaration, source], cancellationToken); + + // Assert — External names the union's own T, so it is supported. + await Assert.That(result).DoesNotHaveDiagnostic("RSG1002"); + + const string nestedSource = """ + namespace Test + { + public readonly record struct External(T Value); + + public class Outer + { + [GenerateResult] + public readonly union InnerError(External, string); + } + } + """; + + var nested = await AnalyzeAsync( + [TestSources.GenerateResultAttributeDeclaration, nestedSource], + cancellationToken + ); + + // Assert — T belongs to Outer, not to the union, so the helper cannot declare it. + await Assert.That(nested).HasDiagnostic("RSG1002"); } [Test] @@ -173,7 +217,7 @@ namespace Test } [Test] - public async Task AnalyzeAsync_GivenUnionMemberProvider_ReportsUnsupportedMemberProvider( + public async Task AnalyzeAsync_GivenMemberProviderWithoutCreateMethods_ReportsInvalidMemberProvider( CancellationToken cancellationToken ) { diff --git a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorDiagnosticsTests.cs b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorDiagnosticsTests.cs index a90ce05..22ac022 100644 --- a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorDiagnosticsTests.cs +++ b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorDiagnosticsTests.cs @@ -42,25 +42,27 @@ CancellationToken cancellationToken } [Test] - public async Task GenerateAsync_GivenGenericUnion_EmitsNoHelpersAndReportsNothing( - CancellationToken cancellationToken - ) + public async Task GenerateAsync_GivenGenericUnion_EmitsHelpersForItsNamedCases(CancellationToken cancellationToken) { // Arrange const string source = """ namespace Test { + public readonly record struct None; + + public readonly record struct Some(T Value); + [GenerateResult] - public readonly union BoxError(T, string); + public readonly union BoxError(None, Some); } """; // Act using var result = await GenerateAsync(source, cancellationToken); - // Assert + // Assert — the bare type-parameter case is absent, so every remaining case is named and usable. await Assert.That(result).HasNoDiagnostics(); - await Assert.That(result.PrimarySyntaxTrees).IsEmpty(); + await Assert.That(result).HasGeneratedClass("BoxErrorResultExtensions"); } [Test] diff --git a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs index 62e53c3..6756476 100644 --- a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs +++ b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs @@ -452,9 +452,9 @@ CancellationToken cancellationToken var generated = result.AssertSingleGeneratedSource(); await Assert.That(generated).DoesNotContain("namespace "); - // A type in the global namespace is rendered without a global:: qualifier, which is what resolves - // in the namespace-less generated file. - await Assert.That(generated).Contains("this GlobalNotFound error"); + // A literal reference keeps the global:: qualifier, which resolves in the namespace-less generated + // file and stays unambiguous. + await Assert.That(generated).Contains("this global::GlobalNotFound error"); } [Test] diff --git a/src/tests/SourceGenerator.UnitTests/UnionCaseResultCodeFixProviderTests.cs b/src/tests/SourceGenerator.UnitTests/UnionCaseResultCodeFixProviderTests.cs index 3bc1f92..88e2579 100644 --- a/src/tests/SourceGenerator.UnitTests/UnionCaseResultCodeFixProviderTests.cs +++ b/src/tests/SourceGenerator.UnitTests/UnionCaseResultCodeFixProviderTests.cs @@ -308,4 +308,86 @@ public static Result Register(AccountId accountId) await Assert.That(result.FixedCode).Contains("RegisterTenantError.Failure("); await Assert.That(result.RewriteCompiles).IsTrue(); } + + [Test] + public async Task UnionCaseResultCodeFixProvider_GivenGenericUnionCase_ShouldRepeatTheUnionTypeArguments( + CancellationToken cancellationToken + ) + { + const string source = """ + using Purview.Results; + + namespace Test + { + public readonly record struct Tenant(int Id); + + public readonly record struct None; + + public readonly record struct Some(T Value); + + [GenerateResult] + public readonly union Maybe(None, Some); + + public static class Usage + { + public static Result> Get() => new Some("present"); + } + } + """; + + var result = await UnionCodeFixTestHarness.ApplyAsync(source, cancellationToken); + + await Assert.That(result.FixOffered).IsTrue(); + await Assert.That(result.FixedCode).Contains("Maybe.Failure("); + await Assert.That(result.RewriteCompiles).IsTrue(); + } + + [Test] + public async Task UnionCaseResultCodeFixProvider_GivenMemberProviderUnionCase_ShouldOfferTheFix( + CancellationToken cancellationToken + ) + { + const string source = """ + using System.Runtime.CompilerServices; + using Purview.Results; + + namespace Test + { + public readonly record struct Tenant(int Id); + + public readonly record struct ProvidedCase(int Id); + + [Union] + [GenerateResult] + public readonly struct ProvidedError : IUnion, ProvidedError.IUnionMembers + { + private readonly object? _value; + + private ProvidedError(object? value) => _value = value; + + public interface IUnionMembers + { + static ProvidedError Create(ProvidedCase value) => new(value); + + object? Value { get; } + } + + object? IUnionMembers.Value => _value; + + public object? Value => _value; + } + + public static class Usage + { + public static Result Get() => new ProvidedCase(1); + } + } + """; + + var result = await UnionCodeFixTestHarness.ApplyAsync(source, cancellationToken); + + await Assert.That(result.FixOffered).IsTrue(); + await Assert.That(result.FixedCode).Contains("ProvidedError.Failure("); + await Assert.That(result.RewriteCompiles).IsTrue(); + } } diff --git a/src/tests/SourceGenerator.UnitTests/UnionShapeTests.cs b/src/tests/SourceGenerator.UnitTests/UnionShapeTests.cs new file mode 100644 index 0000000..aaad909 --- /dev/null +++ b/src/tests/SourceGenerator.UnitTests/UnionShapeTests.cs @@ -0,0 +1,73 @@ +namespace Purview.Results.SourceGenerator; + +/// +/// Executes the helpers generated for the union shapes beyond a plain non-generic struct declaration: a +/// nullable value-type case, a nullable reference-type case, and a class-based union. +/// +public class UnionShapeTests +{ + [Test] + public async Task UnionFactory_GivenNullableValueCase_ProducesValueFailure() + { + // Act +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result result = NullableValue.Failure(42); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error is int { } value && value == 42).IsTrue(); + } + + [Test] + public async Task UnionFactory_GivenNullableReferenceCase_ProducesValueFailure() + { + // Act +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result result = NullableValue.Failure("present"); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error is string { } value && value == "present").IsTrue(); + } + + [Test] + public async Task UnionFactory_GivenClassUnionCase_ProducesValueFailure() + { + // Act +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result result = ClassUnion.Failure(new ClassCase(7)); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error is ClassCase { Id: 7 }).IsTrue(); + } + + [Test] + public async Task UnionFactory_GivenClassUnion_ProducesSuccess() + { + // Act +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result result = ClassUnion.Success(42); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo(42); + } + + [Test] + public async Task UnionFactory_GivenIncludedGenericUnionCase_ConstructsTheNestedUnion() + { + // Act +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result> result = OuterError.Failure(new InnerSome(7)); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // Assert — the outer union holds the inner union, which holds the leaf case. + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error is InnerError inner && inner is InnerSome { Value: 7 }).IsTrue(); + } +} From 4110a8b65e30f9d399da920fece1729576a36bb0 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 11 Nov 2026 13:22:30 +0000 Subject: [PATCH 4/6] feat: union support --- .config/dotnet-tools.json | 8 +- .csharpierignore | 22 -- .editorconfig | 5 + AGENTS.md | 13 +- CHANGELOG.md | 8 + Directory.Packages.props | 2 +- README.md | 13 + docs/wiki/Diagnostics.md | 4 +- docs/wiki/Guarantees-and-Limitations.md | 10 +- docs/wiki/Source-Generator.md | 14 +- docs/wiki/Testing.md | 7 + docs/wiki/Union-Errors.md | 27 +- scripts/packed-consumers/AotWebApi/Program.cs | 6 +- .../packed-consumers/BlazorServer/Program.cs | 5 +- skills/purview-results-union-errors/SKILL.md | 42 +- src/src/AspNetCore/ResultHttpResultT.cs | 3 +- src/src/Examples.AspNetCore.Zod/Program.cs | 5 +- .../Examples.AspNetCore.Zod/TenantError.cs | 2 +- .../Examples.ValueObjects.Zod/TenantError.cs | 2 +- src/src/Examples.Zod/TenantError.cs | 2 +- src/src/Results/Sdk/README.md | 16 + .../AnalyzerReleases.Unshipped.md | 6 +- .../Diagnostics/DiagnosticLibrary.cs | 6 +- .../Diagnostics/ResultUnionDiagnostics.cs | 290 ++++++++++++-- .../Discovery/ResultUnionDiscovery.cs | 6 +- .../Helpers/ResultsTypeLibrary.cs | 13 + src/src/SourceGenerator/README.md | 45 ++- .../SourceGenerator/ResultsSourceGenerator.cs | 5 +- .../UnionEqualityDiagnosticSuppressor.cs | 18 +- .../ValidationProblemResultExtensions.cs | 9 +- .../ZodResultsFailureMapper.cs | 4 +- .../ClosedHierarchyTests.cs | 1 + .../GenerateUnionDiscoveryRuntimeTests.cs | 76 ++++ .../GenerateUnionDiscoveryTests.cs | 367 ++++++++++++++++++ .../GeneratedTestUnions.cs | 26 +- .../GeneratedUnionDiscoveryTestTypes.cs | 92 +++++ .../Purview/Unions/GenerateUnionAttribute.cs | 21 + .../ResultsDiagnosticAnalyzerTests.cs | 59 +++ .../SourceGenerator.UnitTests/TestSources.cs | 19 + .../ZodResultsFailureMapperTests.cs | 10 +- .../ZodValidationProblemsTests.cs | 12 +- .../ValidationTestUnions.cs | 5 +- .../ZodValidationResultExtensionsTests.cs | 2 +- 43 files changed, 1136 insertions(+), 172 deletions(-) delete mode 100644 .csharpierignore create mode 100644 src/tests/SourceGenerator.UnitTests/GenerateUnionDiscoveryRuntimeTests.cs create mode 100644 src/tests/SourceGenerator.UnitTests/GenerateUnionDiscoveryTests.cs create mode 100644 src/tests/SourceGenerator.UnitTests/GeneratedUnionDiscoveryTestTypes.cs create mode 100644 src/tests/SourceGenerator.UnitTests/Purview/Unions/GenerateUnionAttribute.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 3fc1c2c..bc9bc27 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,25 +3,25 @@ "isRoot": true, "tools": { "csharpier": { - "version": "1.3.0", + "version": "1.4.0", "commands": [ "csharpier" ], "rollForward": false }, "dotnet-inspect": { - "version": "0.26.0", + "version": "0.27.0", "commands": [ "dotnet-inspect" ], "rollForward": false }, "purview.build": { - "version": "0.8.0", + "version": "0.9.0", "commands": [ "purview-build" ], "rollForward": false } } -} +} \ No newline at end of file diff --git a/.csharpierignore b/.csharpierignore deleted file mode 100644 index d48054f..0000000 --- a/.csharpierignore +++ /dev/null @@ -1,22 +0,0 @@ -# C# 15 syntax is not yet parseable by CSharpier 1.3.0, so files that use it are excluded rather than - -# reported as formatting failures. CSharpier reports "was not formatted due to syntax errors" for these - -# files; every other file is formatted normally. Two features are affected: union declarations and the - -# 'closed' hierarchy modifier. - -src/tests/AspNetCore.UnitTests/ResultsHttpTestUnions.cs -src/tests/SourceGenerator.UnitTests/ClosedHierarchyTests.cs -src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs -src/tests/ZodSharp.AspNetCore.UnitTests/ValidationTestUnions.cs -src/tests/ZodSharp.UnitTests/ValidationTestUnions.cs -src/src/Examples.Basic/TenantError.cs -src/src/Examples.Basic/BillingError.cs -src/src/Examples.Zod/TenantError.cs -src/src/Examples.ValueObjects.Zod/TenantError.cs -src/src/Examples.AspNetCore/TenantError.cs -src/src/Examples.AspNetCore/BillingError.cs -src/src/Examples.AspNetCore.Zod/TenantError.cs -scripts/packed-consumers/Shared/TenantError.cs -scripts/packed-consumers/AotConsole/Model.cs diff --git a/.editorconfig b/.editorconfig index a9b39cb..2abb49f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2690,6 +2690,11 @@ dotnet_diagnostic.RCS1205.severity = none # PDS0004: Use correct acronym capitalization dotnet_diagnostic.PDS0004.severity = none +[**/SourceGenerator.UnitTests/Purview/Unions/**.{cs,vb}] +# The test-only stand-in for Purview.Unions' GenerateUnionAttribute must declare that package's namespace so +# the results generator matches it by metadata identity, exactly as it matches the packaged attribute. +dotnet_diagnostic.IDE0130.severity = none + [**/Extensions/**.{cs,vb}] # Files under the Extensions/ namespace-reset convention must not force developers to add pragmas; # suppress only the namespace-conflict diagnostics that arise from declaring framework-style diff --git a/AGENTS.md b/AGENTS.md index ace8cae..564cac5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,12 +112,21 @@ including the diagnostics table, build properties and activation rules. hosts, and keep `DiagnosticLibrary.IsBlocking` as the single blocking policy. - New or changed rules require an `AnalyzerReleases.Unshipped.md` entry (the compiler's RS2008 rule catalogue). - **One suppressor.** `Suppressors/UnionEqualityDiagnosticSuppressor.cs` is the only programmatic suppression in - this repository: it suppresses `CA1815` and only `CA1815`, and only on a declaration that is both a union - (`ITypeSymbol.IsUnion`) and opted in with `[GenerateResult]`. Keep it that narrow — a union without the + this repository: it suppresses `CA1815` and only `CA1815`, and only on a declaration that is both a union as + `ResultUnionDiagnostics.IsUnionType` recognises one (a language union or a source-visible + `Purview.Unions.GenerateUnionAttribute` target) and opted in with `[GenerateResult]`. Keep it that narrow — a union without the attribute, a union's case types and every other value type must keep the warning — and never let it report diagnostics, because a suppressor is not a rule host. Suppression descriptors are not release-tracked, so `RSG2000` (the suppression id a consumer adds to `NoWarn` to turn the suppression off) must not appear in `AnalyzerReleases.Unshipped.md`, which lists reported rules only. +- **Union recognition never depends on another generator's output.** A target is a union when + `ITypeSymbol.IsUnion` is true or when it carries `Purview.Unions.GenerateUnionAttribute`, matched by metadata + identity only — never add a project or package reference from the shipped generator to `Purview.Unions`. A + `[GenerateUnion]` target resolves the first non-empty source-visible case list in the union generator's order: + the attribute's `typeof` list, partial static factory signatures, a nested `IUnionMembers` provider, then + source-visible public single-parameter constructors. Normalise a case exactly as the union generator exposes it + (nullable value type → underlying type, nullable reference annotation stripped, constructed generics and arrays + preserved). A `[GenerateUnion]` target with no source-visible case list is `RSG1001`, not `RSG1000`. - **Keep the pipeline value-equatable.** Do not let `ISymbol`, `Compilation`, `SemanticModel`, `IOperation`, `SyntaxNode`, `SyntaxTree` or `Location` reach cached models; convert them during discovery (`ResultUnionModel`, `ResultUnionCaseModel`, `ResultSourceLocation`) and use `EquatableArray`. diff --git a/CHANGELOG.md b/CHANGELOG.md index b4645b4..d020ca9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,14 @@ Released versions correspond to `v` GitHub releases. ### Fixed +- **`[GenerateResult]` discovery for `Purview.Unions` unions.** A union opted into `Purview.Unions` generation + carries its `[Union]` attribute and constructors in *that* generator's output, which this generator never sees. + `[GenerateResult]` now recognises a source-visible `Purview.Unions.GenerateUnionAttribute` by metadata identity + (the shipped generator still has no dependency on `Purview.Unions`) and reads the first non-empty source-visible + case list in the union generator's order: the `typeof` list, partial static factory signatures, a nested + `IUnionMembers` provider, then any source-visible constructors. A `[GenerateUnion]` union with no source-visible + case list is reported as `RSG1001`; `RSG1000` now describes the full set of recognised union shapes. Case types + are normalised as the union generator exposes them (`int?` → `int`, nullable reference annotations stripped). - **`NU5118` on `dotnet pack`.** The analyzer pack target ran in every inner build and added the same TFM-independent generator and code-fix files once per target framework. It now runs once, for the default target framework, matching the SDK's own TFM-specific pack targets; a Release pack completes with no warnings. diff --git a/Directory.Packages.props b/Directory.Packages.props index c47ebcc..71e53d4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,7 +18,7 @@ 5.9.0 - 1.81.16 + 1.83.0 2.0.0 diff --git a/README.md b/README.md index d7fdaa3..7d758c7 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,19 @@ a diagnostic suppressor that answers `CA1815` for opted-in unions, so a `[Genera (`(TenantError)new TenantNotFound(id)`) is the only helper-free form; see the [source generator README](src/src/SourceGenerator/README.md) for the compiler evidence. +[`Purview.Unions`](https://github.com/purview-dev/unions) can generate the union type itself, and +`[GenerateResult]` works on a `[GenerateUnion]` declaration. Roslyn generators never see each other's output, so +the results generator reads the union's **source-visible** case list — the `[GenerateUnion]` `typeof` list, +partial static factory signatures, or a nested `IUnionMembers` provider — rather than the generated constructors: + +```csharp +using Purview.Unions; + +[GenerateUnion(typeof(TenantNotFound), typeof(TenantDisabled))] +[GenerateResult] +public partial struct TenantError; +``` + Expose the result over HTTP with the ASP.NET Core package, which maps each error case to a response: ```csharp diff --git a/docs/wiki/Diagnostics.md b/docs/wiki/Diagnostics.md index b2abcc3..fa73db3 100644 --- a/docs/wiki/Diagnostics.md +++ b/docs/wiki/Diagnostics.md @@ -14,8 +14,8 @@ The union rules live in one shared library (`Diagnostics/DiagnosticLibrary.cs`, | ID | Severity | Reported by | Blocking | Description | | --- | --- | --- | --- | --- | -| `RSG1000` | Error | Analyzer | Yes | `[GenerateResult]` was applied to a type that is not a union. | -| `RSG1001` | Error | Analyzer | Yes | The union declares no union cases. | +| `RSG1000` | Error | Analyzer | Yes | `[GenerateResult]` was applied to a type that is not a union. A union is a C# 15 `union` declaration, a `[Union]` type, or a source-visible `Purview.Unions` `[GenerateUnion]` type. | +| `RSG1001` | Error | Analyzer | Yes | The union declares no source-visible cases: no `[GenerateUnion]` `typeof` list, partial static factory signatures, `IUnionMembers` provider or public single-parameter constructors. | | `RSG1002` | Error | Analyzer | No (only the offending case is skipped) | A case type names a type parameter the union does not declare, or the union is nested in a generic type. A generic union itself is supported. | | `RSG1003` | Error | Analyzer | Union: yes / case: no | A type that generated code must reference is not accessible (for example a `file` type). | | `RSG1004` | Error | Analyzer | No | The same union is configured more than once (for example on two partial declarations); the helpers are generated once. | diff --git a/docs/wiki/Guarantees-and-Limitations.md b/docs/wiki/Guarantees-and-Limitations.md index 3a5e62e..2a37a9b 100644 --- a/docs/wiki/Guarantees-and-Limitations.md +++ b/docs/wiki/Guarantees-and-Limitations.md @@ -98,9 +98,13 @@ reference, so no telemetry package dependency is added. ## Union support limits -- `[GenerateResult]` is supported on `union` declarations and custom `[Union]` types, including class-based - unions, nullable cases and generic unions. Generic unions are supported, as are `IUnionMembers` member providers - that declare a public static single-parameter `Create` per case. A bare union type parameter case gets the +- `[GenerateResult]` is supported on `union` declarations, custom `[Union]` types and source-visible + `Purview.Unions` `[GenerateUnion]` targets, including class-based unions, nullable cases and generic unions. + A `[GenerateUnion]` target's cases are read from its `typeof` list, partial static factory signatures, + `IUnionMembers` provider or source-visible constructors — never from another generator's output — and a + target with no source-visible case list is `RSG1001`. Generic unions are supported, as are `IUnionMembers` + member providers that declare a public static single-parameter `Create` per case. A bare union type parameter + case gets the union-receiver factory only. A case that names a type parameter the union does not declare is `RSG1002`; an array case is `RSG1009`; a provider with no usable `Create` is `RSG1007`. - Generated implicit conversions are not possible — see [Union Errors](Union-Errors.md) for the five compiler diff --git a/docs/wiki/Source-Generator.md b/docs/wiki/Source-Generator.md index b17811f..598c49b 100644 --- a/docs/wiki/Source-Generator.md +++ b/docs/wiki/Source-Generator.md @@ -28,10 +28,16 @@ default language version for `net11.0` in the .NET 11 SDK, so no `LangVersion` i generator shares the same analysis to decide whether generation can continue. No rule is reported twice. - **One suppressor, with one narrow job.** `UnionEqualityDiagnosticSuppressor` answers `CA1815` for opted-in unions only and reports no diagnostics of its own. -- **Union membership is decided by the language** (`ITypeSymbol.IsUnion`), never by type names, source text, - reflection or the union's runtime value. -- **Case types come from the language's own rule**: a union's public single-parameter constructors define its - case types, and a `union` declaration synthesises one per declared case. +- **Union membership is decided by the language or by a metadata identity**, never by type names, source text, + reflection or the union's runtime value. A target is a union when `ITypeSymbol.IsUnion` is true, or when it + carries `Purview.Unions.GenerateUnionAttribute` (matched by metadata name only; this package does not depend + on `Purview.Unions`). +- **Case types come from the source-visible list.** A language union uses its public single-parameter + constructors (with an `IUnionMembers` provider taking precedence when one exists); a `union` declaration + synthesises one per declared case. A `[GenerateUnion]` union uses the first non-empty source list in the union + generator's order — the attribute's `typeof` list, partial static factory signatures, a nested + `IUnionMembers` provider, then source-visible constructors — because the `[Union]` attribute and constructors + another generator emits are never visible here. ## The incremental pipeline diff --git a/docs/wiki/Testing.md b/docs/wiki/Testing.md index 6005cb3..648ef3f 100644 --- a/docs/wiki/Testing.md +++ b/docs/wiki/Testing.md @@ -55,6 +55,13 @@ code with the framework's `CodeQuery` and terminal assertion extensions (`result it drives `GenerateIncrementalAsync`, asserts `New` on the first run, `Cached`/`Unchanged` on an identical rerun, and `Modified` only for the stages whose inputs changed. +**Cross-generator discovery is covered by a source-visible stand-in.** `GenerateUnionDiscoveryTests` declares a +stand-in `Purview.Unions.GenerateUnionAttribute` (and source-visible conversions where the generated helpers need +them), because `Purview.Unions` is not on a CI-safe package feed yet. It exercises the `typeof` list, partial +factory signatures, an included `[GenerateUnion]` case and the `RSG1001` gap; `GenerateUnionDiscoveryRuntimeTests` +executes the helpers against the real result types. Referencing `Purview.Unions` so both generators run in one +compilation is recorded as a follow-up for when that package is on the feed the CI can restore from. + ## Compiler experiments Behaviour that motivates a design decision is recorded as a compiler experiment rather than an assumption. diff --git a/docs/wiki/Union-Errors.md b/docs/wiki/Union-Errors.md index 4fda264..6643aa2 100644 --- a/docs/wiki/Union-Errors.md +++ b/docs/wiki/Union-Errors.md @@ -212,11 +212,24 @@ no boxing of their own — each is a plain `Result<...>.Failure(...)` call, and error in a typed field. [`Purview.Unions`](https://github.com/purview-dev/unions) can generate this custom-union shape from a -`[GenerateUnion]` declaration. A source generator cannot see another generator's output during analysis, so -`[GenerateResult]` must be applied to a union whose shape is declared in source (a `union` declaration or a -hand-written `[Union]` type). If you let `Purview.Unions` generate the type, call -`Result.Failure(case)` directly; the case-to-union conversion applies as a single argument -conversion, so the result is still non-boxing. +`[GenerateUnion]` declaration, and `[GenerateResult]` works on it directly: + +```csharp +using Purview.Unions; + +[GenerateUnion(typeof(TenantNotFound), typeof(TenantDisabled))] +[GenerateResult] +public partial struct TenantError; +``` + +A source generator never sees another generator's output, so the `[Union]` attribute and the constructors +`Purview.Unions` emits are invisible while the results generator runs. `[GenerateResult]` therefore reads the +union's **source-visible** case list instead, in the union generator's order: the `[GenerateUnion]` `typeof` +list, the union's partial static factory signatures, a nested `IUnionMembers` provider, then any source-visible +public single-parameter constructors. It uses the first non-empty list. A generic `[GenerateUnion]` union must +use one of the last three surfaces, because `typeof(T)` is not a legal attribute argument; a `[GenerateUnion]` +union with no source-visible case list is reported as `RSG1001`. + ## Generic unions @@ -308,8 +321,10 @@ base type alone is not consulted for a derived instance. | --- | --- | | A union declaration whose cases are public single-parameter constructors | Yes | | A type marked `[Union]` with public single-parameter constructors | Yes | +| A `[GenerateUnion]` union with a source-visible case list (`typeof` list, partial static factory signatures, or an `IUnionMembers` provider) | Yes — cases are read from the source list, not from another generator's output | +| A `[GenerateUnion]` union with no source-visible case list | No — `RSG1001` blocks the union | | A class-based `[Union]` type with reference semantics | Yes — construction and helpers are identical; the null union is a null reference | -| Nullable reference-type and nullable value-type cases | Yes — the nullable annotation is preserved on the factory and helper parameter | +| Nullable value-type and nullable reference-type cases | Yes — a nullable value type is exposed as its underlying type (`int?` → `int`), and a nullable reference annotation is stripped because it is not part of the case's identity | | A custom `[Union]` type with `HasValue`/`TryGetValue` (non-boxing access pattern) | Yes — the generator is unchanged, and value-type cases stay unboxed | | A `closed` hierarchy used as `TError` (not a union) | Yes — no `[GenerateResult]` helpers, but the result and HTTP mapper accept it | | A **leaf** case type shared by two unions | Yes — `RSG1006` warning; the per-case `AsFailure` helper is generated once, and the union-receiver `Failure(...)` factory is generated for both | diff --git a/scripts/packed-consumers/AotWebApi/Program.cs b/scripts/packed-consumers/AotWebApi/Program.cs index 4957cb0..b35c4fe 100644 --- a/scripts/packed-consumers/AotWebApi/Program.cs +++ b/scripts/packed-consumers/AotWebApi/Program.cs @@ -47,10 +47,8 @@ var tenants = app.MapGroup("/api/tenants"); // A value result: the validation problem, the code rule's 422, the case mapping's 409, or the tenant. -tenants.MapPost( - "/", - (TenantRegistration registration, TenantRegistrationService service) => - service.Register(registration).ToHttpResult() +tenants.MapPost("/", (TenantRegistration registration, TenantRegistrationService service) => + service.Register(registration).ToHttpResult() ); // A unit result: a success carries no payload, so it answers 204 No Content. diff --git a/scripts/packed-consumers/BlazorServer/Program.cs b/scripts/packed-consumers/BlazorServer/Program.cs index d757692..cc4ca7f 100644 --- a/scripts/packed-consumers/BlazorServer/Program.cs +++ b/scripts/packed-consumers/BlazorServer/Program.cs @@ -29,9 +29,8 @@ // The service validates and returns a result, which WithResultsHttp maps. AddZodSharpBlazor validates form models only // and does not turn on minimal-API parameter validation, so the handler always runs and the result rules above answer: // the refinement's 422, the existing tenant's 409, and the standard validation problem for everything else. -app.MapPost( - "/api/tenants", - (TenantRegistration registration, TenantRegistrationService service) => service.Register(registration) +app.MapPost("/api/tenants", (TenantRegistration registration, TenantRegistrationService service) => + service.Register(registration) ) .WithResultsHttp() .DisableAntiforgery(); diff --git a/skills/purview-results-union-errors/SKILL.md b/skills/purview-results-union-errors/SKILL.md index f0ff0d3..ae7efc7 100644 --- a/skills/purview-results-union-errors/SKILL.md +++ b/skills/purview-results-union-errors/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-results-union-errors -description: "Use when modelling expected failures as a C# 15 union and converting its cases into Purview.Results values — declaring [GenerateResult] unions, using the generated per-case AsFailure()/AsFailure() helpers and the union-receiver Union.Failure(...)/Union.Success(...) factories, reacting to the RSG1000-RSG1009 and RSG9000 diagnostics, or explaining why a union case cannot convert implicitly." +description: "Use when modelling expected failures as a C# 15 union (including a Purview.Unions [GenerateUnion] type) and converting its cases into Purview.Results values — declaring [GenerateResult] unions, using the generated per-case AsFailure()/AsFailure() helpers and the union-receiver Union.Failure(...)/Union.Success(...) factories, reacting to the RSG1000-RSG1009 and RSG9000 diagnostics, or explaining why a union case cannot convert implicitly." --- # Modelling error unions for Purview.Results @@ -89,6 +89,36 @@ is correct even when the case type is shared with another union. It is only offe converted type is a result, the error type is a `[GenerateResult]` union, the value is one of its cases or a case reachable through an included union, and the union is in scope by simple name). +## Unions generated by Purview.Unions + +A [`Purview.Unions`](https://github.com/purview-dev/unions) `[GenerateUnion]` type can be a result error union +too: + +```csharp +using Purview.Unions; + +[GenerateUnion(typeof(TenantNotFound), typeof(TenantDisabled))] +[GenerateResult] +public partial struct TenantError; +``` + +Roslyn generators never see another generator's output, so the `[Union]` attribute and constructors +`Purview.Unions` emits are invisible while the results generator runs. `[GenerateResult]` reads the union's +**source-visible** case list instead, using the first non-empty one in the union generator's order: + +1. the `[GenerateUnion]` `typeof` list; +2. the union's partial static factory signatures; +3. a nested `IUnionMembers` provider's static `Create` methods; +4. any source-visible public single-parameter constructors. + +A generic `[GenerateUnion]` union must use surfaces 2–4, because `typeof(T)` is not a legal attribute argument. +No source `[Union]` attribute or constructor is needed: the `[GenerateUnion]` attribute is matched by metadata +identity, so the target is a union for this generator and `RSG1000` is not reported. A `[GenerateUnion]` union +with no source-visible case list is `RSG1001`, and the `IUnionMembers` provider remains the portable alternative +when another generator (such as telemetry) also needs a source-visible list. Case types are normalised as the +union generator exposes them: nullable value type → underlying type, nullable reference annotation stripped, +constructed generics and arrays preserved. + ## Chaining across services When one service calls another, the caller's operation-family union takes the callee's **error union** as a case, @@ -132,8 +162,8 @@ The package ships an analyzer and a generator that share one diagnostics library | ID | Severity | Meaning | | --- | --- | --- | -| `RSG1000` | Error | `[GenerateResult]` was applied to a type that is not a union | -| `RSG1001` | Error | The union declares no union cases | +| `RSG1000` | Error | `[GenerateResult]` was applied to a type that is not a union (a `union` declaration, a `[Union]` type, or a source-visible `[GenerateUnion]` type) | +| `RSG1001` | Error | The union declares no source-visible cases (`[GenerateUnion]` `typeof` list, partial factories, `IUnionMembers` provider or public single-parameter constructors) | | `RSG1002` | Error | A case type names a type parameter the union does not declare, or the union is nested in a generic type (a generic union itself is supported) | | `RSG1003` | Error | A type generated code must reference is not accessible (for example a `file` type) | | `RSG1004` | Error | The same union is configured more than once; the helpers are generated once | @@ -151,7 +181,8 @@ rather than the union itself. The other rules skip the union. A generic union is supported: the generator repeats its type parameters and constraints on the helpers and on the generic extension block. A custom union may place its creation members on a nested `IUnionMembers` provider with a public static single-parameter `Create` per case; the generator reads the case list from it and constructs through -`IUnionMembers.Create`. A bare union type parameter case gets the union-receiver factory only. The remaining +`IUnionMembers.Create`. A `[GenerateUnion]` union uses the first non-empty source-visible list (see above); a +generic one cannot use its `typeof` list. A bare union type parameter case gets the union-receiver factory only. The remaining unsupported shape is a case that names a type parameter the union does not declare (`RSG1002`) and a non-named case such as an array (`RSG1009`). @@ -192,7 +223,8 @@ compared by value. ## Checklist 1. Cases are small records with meaningful payloads. -2. The union is a `union` declaration annotated with `[GenerateResult]`. +2. The union is a `union` declaration, a `[Union]` type or a source-visible `[GenerateUnion]` type, annotated + with `[GenerateResult]`. 3. Every method that fails for an expected reason returns `Result`, or `Result` when the success carries no value. 4. Failures are produced with `new Case(...).AsFailure()` (or `AsFailure()` for a unit result, the diff --git a/src/src/AspNetCore/ResultHttpResultT.cs b/src/src/AspNetCore/ResultHttpResultT.cs index 04cdcb4..fa57464 100644 --- a/src/src/AspNetCore/ResultHttpResultT.cs +++ b/src/src/AspNetCore/ResultHttpResultT.cs @@ -93,8 +93,7 @@ static class ResultHttpResultExecution { internal static Task Execute(IResultValue result, HttpContext httpContext) { - var mapper = - httpContext.RequestServices.GetService() + var mapper = httpContext.RequestServices.GetService() ?? throw new InvalidOperationException( $"No {nameof(IResultsHttpMapper)} is registered. Call services.AddResultsHttp() before mapping a " + "result with ToHttpResult()." diff --git a/src/src/Examples.AspNetCore.Zod/Program.cs b/src/src/Examples.AspNetCore.Zod/Program.cs index 9fcd2be..e8347c5 100644 --- a/src/src/Examples.AspNetCore.Zod/Program.cs +++ b/src/src/Examples.AspNetCore.Zod/Program.cs @@ -46,9 +46,8 @@ TenantRegistrationService service = new(); -app.MapPost( - "/tenants", - (TenantRegistrationRequest request) => service.Register(TenantInput.Create(request.TenantId, request.Name)) +app.MapPost("/tenants", (TenantRegistrationRequest request) => + service.Register(TenantInput.Create(request.TenantId, request.Name)) ) .WithResultsHttp(); diff --git a/src/src/Examples.AspNetCore.Zod/TenantError.cs b/src/src/Examples.AspNetCore.Zod/TenantError.cs index e058198..401f5af 100644 --- a/src/src/Examples.AspNetCore.Zod/TenantError.cs +++ b/src/src/Examples.AspNetCore.Zod/TenantError.cs @@ -1,5 +1,5 @@ -using Purview.Results.ZodSharp; using System.Collections.Immutable; +using Purview.Results.ZodSharp; using ZodSharp.Core; namespace Purview.Results.Examples.AspNetCore.Zod; diff --git a/src/src/Examples.ValueObjects.Zod/TenantError.cs b/src/src/Examples.ValueObjects.Zod/TenantError.cs index cfd20ad..6dc5fd8 100644 --- a/src/src/Examples.ValueObjects.Zod/TenantError.cs +++ b/src/src/Examples.ValueObjects.Zod/TenantError.cs @@ -33,4 +33,4 @@ readonly record struct TenantInputInvalid(Guid TenantId, ImmutableArray /// The identifier of the tenant. -readonly record struct TenantAlreadyExists(TenantId TenantId); \ No newline at end of file +readonly record struct TenantAlreadyExists(TenantId TenantId); diff --git a/src/src/Examples.Zod/TenantError.cs b/src/src/Examples.Zod/TenantError.cs index 13a07bb..31b2ebc 100644 --- a/src/src/Examples.Zod/TenantError.cs +++ b/src/src/Examples.Zod/TenantError.cs @@ -1,5 +1,5 @@ -using Purview.Results.ZodSharp; using System.Collections.Immutable; +using Purview.Results.ZodSharp; using ZodSharp.Core; namespace Purview.Results.Examples.Zod; diff --git a/src/src/Results/Sdk/README.md b/src/src/Results/Sdk/README.md index 33aa576..7f86673 100644 --- a/src/src/Results/Sdk/README.md +++ b/src/src/Results/Sdk/README.md @@ -153,6 +153,22 @@ union, while `Union.Failure(...)` always names the union you want. A case type t union's cases by building the nested value (`RegisterTenantError.Failure(new BillingAccountMissing(id))`). +A union generated by [`Purview.Unions`](https://github.com/purview-dev/unions) is supported as well: + +```csharp +using Purview.Unions; + +[GenerateUnion(typeof(TenantNotFound), typeof(TenantDisabled))] +[GenerateResult] +public partial struct TenantError; +``` + +Another generator's output is never visible to this generator, so the `[Union]` attribute and constructors +`Purview.Unions` emits are not read. `[GenerateResult]` instead uses the union's source-visible case list — the +`[GenerateUnion]` `typeof` list, partial static factory signatures, a nested `IUnionMembers` provider, then any +source-visible public single-parameter constructors, first non-empty list wins. A `[GenerateUnion]` union with +no source-visible case list is reported as `RSG1001`. + The generator cannot declare that conversion for you: C# forbids user-defined operators in a static class, forbids conversion operators in extension members, and permits only one user-defined conversion per conversion sequence. The same package ships a code fix that offers the rewrite in the IDE when a case value is returned diff --git a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md index aadad75..818a342 100644 --- a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md @@ -4,9 +4,9 @@ ; Every rule is recorded in AnalyzerReleases.Shipped.md under Release 1.0.0. Add a new or changed ; diagnostic here, and move it across when the next version ships. ; -; RSG1002 and RSG1007 keep the same category, severity and enabled-by-default state; only their message and -; meaning changed (generic unions and IUnionMembers providers are now supported), so no release-tracking -; entry is required. +; RSG1000, RSG1001, RSG1002 and RSG1007 keep the same category, severity and enabled-by-default state; only +; their message and meaning changed (generic unions, IUnionMembers providers and source-visible +; Purview.Unions [GenerateUnion] targets are now supported), so no release-tracking entry is required. ; ; Note RSG2000 is the suppression id a consumer adds to NoWarn to turn the CA1815 suppression off. ; Suppression descriptors are not release-tracked, so it must not be listed in either file. diff --git a/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs b/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs index fbfbe74..fdb56f9 100644 --- a/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs +++ b/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs @@ -51,11 +51,11 @@ static class DiagnosticLibrary public static readonly DiagnosticDescriptor TargetIsNotUnion = new( id: "RSG1000", title: "GenerateResult target is not a union", - messageFormat: "'{0}' is not a union type; [GenerateResult] can only be applied to a union declaration or a type marked with [Union]", + messageFormat: "'{0}' is not a union type; [GenerateResult] can only be applied to a union declaration, a type marked with [Union], or a type opted into union generation with [GenerateUnion]", category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true, - description: "The results source generator only generates helpers for C# 15 union types." + description: "The results source generator only generates helpers for union types: a C# 15 union declaration, a type marked with [Union], or a source-visible Purview.Unions [GenerateUnion] target." ); /// @@ -68,7 +68,7 @@ static class DiagnosticLibrary category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true, - description: "A union declares its case types through public single-parameter constructors, or through an IUnionMembers member provider." + description: "A union declares its case types through its source-visible case list: the [GenerateUnion] typeof list, partial static factory signatures, an IUnionMembers member provider, or public single-parameter constructors." ); /// diff --git a/src/src/SourceGenerator/Diagnostics/ResultUnionDiagnostics.cs b/src/src/SourceGenerator/Diagnostics/ResultUnionDiagnostics.cs index 13e9ec7..2e7ff20 100644 --- a/src/src/SourceGenerator/Diagnostics/ResultUnionDiagnostics.cs +++ b/src/src/SourceGenerator/Diagnostics/ResultUnionDiagnostics.cs @@ -1,5 +1,7 @@ using System.Collections.Immutable; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; using Purview.Results.SourceGenerator.Helpers; // ITypeSymbol.IsUnion is still marked [Experimental] in Roslyn 5.9, and this analysis deliberately targets @@ -26,7 +28,10 @@ namespace Purview.Results.SourceGenerator.Diagnostics; /// generated and which case types it should reference). /// /// Whether the target opts in with the generated attribute. -/// Whether the target is a C# union. +/// +/// Whether the target is a union this generator recognises: a language union or a source-visible +/// Purview.Unions.GenerateUnionAttribute target. +/// /// Whether the union itself can be referenced from generated code. /// Whether the union is public. /// Whether every usable case type is public. @@ -71,10 +76,20 @@ ImmutableArray Diagnostics /// the analyzer and the source generator so the two hosts cannot drift apart. /// /// -/// Union membership is decided by the language through — never by type -/// name conventions, source-text parsing, reflection or runtime inspection of the union's value. Case types -/// are resolved using the language's own rule: for each public constructor with exactly one by-value (or -/// in) parameter, the type of that parameter is a case type of the union. +/// +/// A target is a union when the language says so () or when it opts into +/// another generator with Purview.Unions.GenerateUnionAttribute. The latter is matched by metadata +/// identity only, because this generator never sees another generator's output: a [GenerateUnion] +/// type has no visible [Union] attribute and no visible constructors in this compilation. Never +/// infer union membership from type names, source-text parsing, reflection or the union's runtime value. +/// +/// +/// A language union resolves its cases from the language's own rule — a nested IUnionMembers +/// provider when one exists, otherwise each public constructor with exactly one by-value (or in) +/// parameter. A [GenerateUnion] target resolves the first non-empty source-visible list +/// instead: the attribute's typeof list, its partial static factory signatures, a nested +/// IUnionMembers provider, then any source-visible public single-parameter constructors. +/// /// static class ResultUnionDiagnostics { @@ -96,6 +111,30 @@ static class ResultUnionDiagnostics /// when the opt-in attribute is applied. public static bool HasGenerateResultAttribute(INamedTypeSymbol symbol) => CountGenerateResultAttributes(symbol) > 0; + /// + /// Determines whether the target opts into union generation with + /// Purview.Unions.GenerateUnionAttribute. + /// + /// The target symbol. + /// when the attribute is applied. + /// + /// The attribute is matched by metadata identity only. Its application is source-visible, so a target + /// is recognisable as a union even though the [Union] attribute and the constructors the union + /// generator emits are not visible to this generator. + /// + public static bool HasGenerateUnionAttribute(INamedTypeSymbol symbol) => + HasAttribute(symbol, ResultsTypeLibrary.GenerateUnionAttribute); + + /// + /// Determines whether the type is a union as far as this generator is concerned: a language union + /// (a union declaration or a source-visible [Union] type) or a type opted into union + /// generation with Purview.Unions.GenerateUnionAttribute. + /// + /// The type to test. + /// when the type is a union this generator can read cases from. + public static bool IsUnionType(INamedTypeSymbol type) => + type.IsUnion || HasAttribute(type, ResultsTypeLibrary.UnionAttribute) || HasGenerateUnionAttribute(type); + /// /// Counts the opt-in attribute applications on the target. Attribute applications on separate partial /// declarations are counted separately, which is what makes duplicate configuration detectable. @@ -167,7 +206,7 @@ public static ResultUnionTargetAnalysis Analyze(INamedTypeSymbol symbol, Cancell var declarationLocation = symbol.Locations.IsDefaultOrEmpty ? Location.None : symbol.Locations[0]; var attributeLocation = GetAttributeLocation(symbol, cancellationToken) ?? declarationLocation; - if (!symbol.IsUnion) + if (!IsUnionType(symbol)) { return new ResultUnionTargetAnalysis( HasGenerateResultAttribute: true, @@ -264,6 +303,7 @@ public static ResultUnionTargetAnalysis Analyze(INamedTypeSymbol symbol, Cancell var caseTypes = AnalyzeCases( symbol, memberProvider, + attributeLocation, typeParameters, diagnostics, ref everyCaseIsPublic, @@ -305,14 +345,21 @@ public static ResultUnionTargetAnalysis Analyze(INamedTypeSymbol symbol, Cancell } /// - /// Resolves the union's usable case types from the language's case-type rule — a nested - /// IUnionMembers provider's Create methods when one exists, otherwise the union's public - /// single-parameter constructors — and reports case findings (which never block the union) for the - /// cases that cannot be referenced from generated code. + /// Resolves the union's usable case types from its source-visible case list, and reports case findings + /// (which never block the union) for the cases that cannot be referenced from generated code. /// + /// + /// A language union keeps the language's rule: a nested IUnionMembers provider's Create + /// methods when one exists, otherwise the union's public single-parameter constructors. A + /// [GenerateUnion] target reads the first non-empty source-visible list the union generator + /// supports — the attribute's typeof list, its partial static factory signatures, a nested + /// IUnionMembers provider, or any source-visible public single-parameter constructors — because + /// the union's own constructors live in another generator's output. + /// static ImmutableArray AnalyzeCases( INamedTypeSymbol union, INamedTypeSymbol? memberProvider, + Location fallbackLocation, ImmutableArray typeParameters, ImmutableArray.Builder diagnostics, ref bool everyCaseIsPublic, @@ -320,26 +367,13 @@ CancellationToken cancellationToken ) { var caseTypes = ImmutableArray.CreateBuilder(); - List<(ITypeSymbol CaseType, Location Location)> candidates = []; + var candidates = GetCaseCandidates(union, memberProvider, fallbackLocation, cancellationToken); - if (memberProvider is not null) - { - foreach (var method in GetProviderCreateMethods(memberProvider)) - candidates.Add((method.Parameters[0].Type, GetParameterLocation(method.Parameters[0]))); - } - else - { - foreach (var constructor in union.InstanceConstructors) - { - if (IsCaseConstructor(constructor)) - candidates.Add((constructor.Parameters[0].Type, GetParameterLocation(constructor.Parameters[0]))); - } - } - - foreach (var (caseType, caseLocation) in candidates) + foreach (var (candidateType, caseLocation) in candidates) { cancellationToken.ThrowIfCancellationRequested(); + var caseType = NormalizeCaseType(candidateType); var displayName = caseType.ToDisplayString(DisplayFormat); // A bare type parameter is a valid union case only when it is one of the union's own type @@ -451,7 +485,7 @@ CancellationToken cancellationToken { cancellationToken.ThrowIfCancellationRequested(); - if (caseType is INamedTypeSymbol { IsUnion: true } unionCase) + if (caseType is INamedTypeSymbol unionCase && IsUnionType(unionCase)) Walk(unionCase, []); } @@ -522,7 +556,7 @@ void Walk(INamedTypeSymbol currentUnion, ImmutableArray parent if (!stack.Add(currentUnion)) return; - foreach (var caseType in GetUsableCaseTypes(currentUnion, typeParameters)) + foreach (var caseType in GetUsableCaseTypes(currentUnion, typeParameters, cancellationToken)) { if ( SymbolEqualityComparer.Default.Equals(caseType, union) @@ -543,7 +577,7 @@ void Walk(INamedTypeSymbol currentUnion, ImmutableArray parent paths.Add(caseType, path); - if (caseType.IsUnion) + if (IsUnionType(caseType)) Walk(caseType, path); } @@ -557,13 +591,18 @@ void Walk(INamedTypeSymbol currentUnion, ImmutableArray parent /// static ImmutableArray GetUsableCaseTypes( INamedTypeSymbol union, - ImmutableArray allowedTypeParameters + ImmutableArray allowedTypeParameters, + CancellationToken cancellationToken ) { var caseTypes = ImmutableArray.CreateBuilder(); - foreach (var caseType in GetCaseTypeCandidates(union)) + foreach (var candidateType in GetCaseTypeCandidates(union, cancellationToken)) { + cancellationToken.ThrowIfCancellationRequested(); + + var caseType = NormalizeCaseType(candidateType); + if (caseType is ITypeParameterSymbol || ContainsExternalTypeParameters(caseType, allowedTypeParameters)) continue; @@ -583,24 +622,197 @@ ImmutableArray allowedTypeParameters } /// - /// Gets the union's case type candidates: the IUnionMembers provider's Create parameters - /// when a provider exists, otherwise its public single-parameter constructors. + /// Gets the union's case type candidates from the first non-empty source-visible case list. /// - static IEnumerable GetCaseTypeCandidates(INamedTypeSymbol union) + /// + /// A [GenerateUnion] target mirrors the union generator's order: the attribute's typeof + /// list, its partial static factory signatures, a nested IUnionMembers provider, then any + /// source-visible public single-parameter constructors. A language union keeps the language's rule: the + /// provider when one exists, otherwise its constructors. + /// + static List<(ITypeSymbol CaseType, Location Location)> GetCaseCandidates( + INamedTypeSymbol union, + INamedTypeSymbol? memberProvider, + Location fallbackLocation, + CancellationToken cancellationToken + ) { - if (GetMemberProvider(union) is { } provider) + if (HasGenerateUnionAttribute(union)) { - foreach (var method in GetProviderCreateMethods(provider)) - yield return method.Parameters[0].Type; + var attributeCases = GetAttributeCaseTypes(union); + if (!attributeCases.IsDefaultOrEmpty) + return [.. attributeCases.Select(caseType => (caseType, fallbackLocation))]; - yield break; + var factoryCases = GetFactoryCaseCandidates(union, cancellationToken); + if (factoryCases.Count > 0) + return factoryCases; } + if (memberProvider is not null) + { + List<(ITypeSymbol CaseType, Location Location)> providerCases = []; + + foreach (var method in GetProviderCreateMethods(memberProvider)) + { + cancellationToken.ThrowIfCancellationRequested(); + providerCases.Add((method.Parameters[0].Type, GetParameterLocation(method.Parameters[0]))); + } + + if (providerCases.Count > 0) + return providerCases; + } + + List<(ITypeSymbol CaseType, Location Location)> constructorCases = []; + foreach (var constructor in union.InstanceConstructors) { + cancellationToken.ThrowIfCancellationRequested(); + if (IsCaseConstructor(constructor)) - yield return constructor.Parameters[0].Type; + constructorCases.Add((constructor.Parameters[0].Type, GetParameterLocation(constructor.Parameters[0]))); } + + return constructorCases; + } + + /// + /// Gets an included union's case type candidates without reporting findings. + /// + static IEnumerable GetCaseTypeCandidates(INamedTypeSymbol union, CancellationToken cancellationToken) + { + foreach (var candidate in GetCaseCandidates(union, GetMemberProvider(union), Location.None, cancellationToken)) + yield return candidate.CaseType; + } + + /// + /// Reads the [GenerateUnion] typeof case list, in declaration order. + /// + static ImmutableArray GetAttributeCaseTypes(INamedTypeSymbol union) + { + foreach (var attribute in union.GetAttributes()) + { + if (!ResultsTypeLibrary.GenerateUnionAttribute.Matches(attribute.AttributeClass)) + continue; + + if (attribute.ConstructorArguments.Length != 1) + return []; + + var argument = attribute.ConstructorArguments[0]; + if (argument.Kind != TypedConstantKind.Array || argument.IsNull) + return []; + + var builder = ImmutableArray.CreateBuilder(argument.Values.Length); + + foreach (var value in argument.Values) + { + if (value.Kind == TypedConstantKind.Type && value.Value is ITypeSymbol type) + builder.Add(type); + } + + return builder.ToImmutable(); + } + + return []; + } + + /// + /// Reads the case types from the union's own partial static factory signatures. + /// + static List<(ITypeSymbol CaseType, Location Location)> GetFactoryCaseCandidates( + INamedTypeSymbol union, + CancellationToken cancellationToken + ) + { + List<(ITypeSymbol CaseType, Location Location)> candidates = []; + + foreach (var method in OrderedFactoryMethods(union, cancellationToken)) + candidates.Add((method.Parameters[0].Type, GetParameterLocation(method.Parameters[0]))); + + return candidates; + } + + /// + /// Gets the partial static factory signatures to read cases from, in source order. + /// + /// + /// A factory is a static method with exactly one unrestricted parameter whose return type is the union, + /// declared as a partial signature without a body. This mirrors the union generator, so a + /// generated union's source-visible factories are the same list that generator implements. + /// + static IEnumerable OrderedFactoryMethods(INamedTypeSymbol union, CancellationToken cancellationToken) + { + foreach (var member in OrderedMembers(union)) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (member is not IMethodSymbol { IsStatic: true, Parameters.Length: 1 } method) + continue; + + if (method.Parameters[0].RefKind is not (RefKind.None or RefKind.In)) + continue; + + if (!SymbolEqualityComparer.Default.Equals(method.ReturnType, union)) + continue; + + if (!IsPartialMethodDefinition(method, cancellationToken)) + continue; + + yield return method; + } + } + + static IEnumerable OrderedMembers(INamedTypeSymbol type) => + type.GetMembers() + .OrderBy(static member => + member.Locations.IsDefaultOrEmpty ? int.MaxValue : member.Locations[0].SourceSpan.Start + ); + + /// + /// Determines whether a method is a partial signature without a body, which is what makes a + /// factory declaration a case list rather than an implementation. + /// + static bool IsPartialMethodDefinition(IMethodSymbol method, CancellationToken cancellationToken) + { + foreach (var reference in method.DeclaringSyntaxReferences) + { + if ( + reference.GetSyntax(cancellationToken) is MethodDeclarationSyntax syntax + && syntax.Modifiers.Any(SyntaxKind.PartialKeyword) + && syntax.Body is null + && syntax.ExpressionBody is null + ) + return true; + } + + return false; + } + + /// + /// Normalises a case type exactly as the union generator exposes it: a nullable value type becomes its + /// underlying type, a nullable reference annotation is stripped, and constructed generic types and + /// arrays are preserved as they are. + /// + static ITypeSymbol NormalizeCaseType(ITypeSymbol caseType) => + caseType switch + { + INamedTypeSymbol named + when named.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T + && named.TypeArguments.Length == 1 => NormalizeCaseType(named.TypeArguments[0]), + _ when caseType.NullableAnnotation == NullableAnnotation.Annotated => caseType.WithNullableAnnotation( + NullableAnnotation.NotAnnotated + ), + _ => caseType, + }; + + static bool HasAttribute(INamedTypeSymbol type, TypeIdentity identity) + { + foreach (var attribute in type.GetAttributes()) + { + if (identity.Matches(attribute.AttributeClass)) + return true; + } + + return false; } /// diff --git a/src/src/SourceGenerator/Discovery/ResultUnionDiscovery.cs b/src/src/SourceGenerator/Discovery/ResultUnionDiscovery.cs index 8d0bfee..8e6aa45 100644 --- a/src/src/SourceGenerator/Discovery/ResultUnionDiscovery.cs +++ b/src/src/SourceGenerator/Discovery/ResultUnionDiscovery.cs @@ -5,10 +5,6 @@ using Purview.Results.SourceGenerator.Helpers; using Purview.Results.SourceGenerator.Models; -// ITypeSymbol.IsUnion is still marked [Experimental] in Roslyn 5.9, and this discovery deliberately targets -// it. -#pragma warning disable RSEXPERIMENTAL006 - namespace Purview.Results.SourceGenerator.Discovery; /// @@ -168,7 +164,7 @@ static EquatableArray CreateCaseModels(ImmutableArray + /// The Purview.Unions.GenerateUnionAttribute opt-in attribute. A type carrying it is a union + /// even though the attribute and its constructors are emitted by another generator, which Roslyn never + /// exposes to this one. + /// + /// + /// Matched by metadata identity only: the shipped generator has no reference to Purview.Unions. + /// + public static readonly TypeIdentity GenerateUnionAttribute = new( + typeName: "GenerateUnionAttribute", + @namespace: "Purview.Unions" + ); + /// /// The runtime Result<TValue, TError> result type. Callers apply the generic arguments /// for a specific union. diff --git a/src/src/SourceGenerator/README.md b/src/src/SourceGenerator/README.md index 20715c3..1a7c3ba 100644 --- a/src/src/SourceGenerator/README.md +++ b/src/src/SourceGenerator/README.md @@ -156,6 +156,34 @@ stores its error in a typed field, so nothing in the generated path boxes the ca `union` declaration always stores its contents as `object?`; reach for a custom union when an error type is on a hot path. `NonBoxingUnionTests` proves the match allocates nothing. +## Unions generated by `Purview.Unions` + +A [`Purview.Unions`](https://github.com/purview-dev/unions) `[GenerateUnion]` type is supported too: + +```csharp +using Purview.Unions; + +[GenerateUnion(typeof(TenantNotFound), typeof(TenantDisabled))] +[GenerateResult] +public partial struct TenantError; +``` + +Roslyn generators never see each other's output, so the `[Union]` attribute and the constructors +`Purview.Unions` emits are invisible while this generator runs. It reads the union's **source-visible** case +list instead, in the union generator's order, and uses the first non-empty one: + +1. the `[GenerateUnion]` `typeof` list; +2. the union's partial static factory signatures; +3. a nested `IUnionMembers` provider's `Create` methods; +4. any source-visible public single-parameter constructors. + +A generic `[GenerateUnion]` union must use one of surfaces 2–4, because `typeof(T)` is not a legal attribute +argument. A `[GenerateUnion]` union with no source-visible case list is reported as `RSG1001`. The target is a +union for this generator because of the attribute's metadata identity, so `RSG1000` is not reported and no +source `[Union]` attribute or constructor is required. Case types are normalised the same way the union +generator exposes them: a nullable value type becomes its underlying type, a nullable reference annotation is +stripped, and constructed generic types and arrays keep their shape. + ## Generic and member-provider unions A generic union's own type parameters and constraints are repeated on the generated helpers and on a generic @@ -261,10 +289,15 @@ needs no generated code. - **One suppressor, with one narrow job.** `UnionEqualityDiagnosticSuppressor` answers `CA1815` for opted-in unions (see [Diagnostic suppressions](#diagnostic-suppressions)). It reports no diagnostics of its own, so it cannot hide a rule. -- **Union membership is decided by the language** (`ITypeSymbol.IsUnion`), never by type names, source text, - reflection or the union's runtime value. -- **Case types come from the language's own rule**: a union's public single-parameter constructors define its - case types, and a `union` declaration synthesises one per declared case. +- **Union membership is decided by the language or a metadata identity** — `ITypeSymbol.IsUnion`, a + source-visible `[Union]`, or `Purview.Unions.GenerateUnionAttribute` — never by type names, source text, + reflection or the union's runtime value. The `GenerateUnionAttribute` identity is matched by metadata name + only, so the shipped generator has no dependency on `Purview.Unions`. +- **Case types come from the source-visible list**: a language union's public single-parameter constructors + (or its `IUnionMembers` provider), and a `union` declaration's synthesised constructors. A `[GenerateUnion]` + union uses the first non-empty source list in the union generator's order — the attribute's `typeof` list, + partial static factory signatures, a nested `IUnionMembers` provider, then source-visible constructors — + because another generator's output (its `[Union]` attribute and constructors) is never visible here. - **Immutable pipeline models.** The pipeline resolves Roslyn symbols during discovery and converts them into value-equatable models (`TypeIdentity`, `TypeReference`, `EquatableArray`, spans/paths as values). No syntax nodes, symbols, semantic models or compilations are retained in cached state, so unrelated edits do @@ -291,8 +324,8 @@ The diagnostics live in one shared library (`Diagnostics/DiagnosticLibrary.cs` + | ID | Severity | Reported by | Blocking | Description | | --- | --- | --- | --- | --- | -| `RSG1000` | Error | Analyzer | Yes | `[GenerateResult]` was applied to a type that is not a union. | -| `RSG1001` | Error | Analyzer | Yes | The union declares no union cases. | +| `RSG1000` | Error | Analyzer | Yes | `[GenerateResult]` was applied to a type that is not a union. A union is a C# 15 `union` declaration, a `[Union]` type, or a source-visible `Purview.Unions` `[GenerateUnion]` type. | +| `RSG1001` | Error | Analyzer | Yes | The union declares no source-visible cases: no `[GenerateUnion]` `typeof` list, partial static factory signatures, `IUnionMembers` provider or public single-parameter constructors. | | `RSG1002` | Error | Analyzer | No (only the offending case is skipped) | A case type names a type parameter the union does not declare, or the union is nested in a generic type. A generic union itself is supported. | | `RSG1003` | Error | Analyzer | Union: yes / case: no | A type that generated code must reference is not accessible (for example a `file` type). | | `RSG1004` | Error | Analyzer | No | The same union is configured more than once (for example on two partial declarations); the helpers are generated once. | diff --git a/src/src/SourceGenerator/ResultsSourceGenerator.cs b/src/src/SourceGenerator/ResultsSourceGenerator.cs index 97e13c4..c7cd83d 100644 --- a/src/src/SourceGenerator/ResultsSourceGenerator.cs +++ b/src/src/SourceGenerator/ResultsSourceGenerator.cs @@ -56,9 +56,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) "GetResultUnionGenerationModel" ); - context.RegisterSourceOutput( - generationModel, - static (sourceProductionContext, model) => ExecuteSafely(sourceProductionContext, model) + context.RegisterSourceOutput(generationModel, static (sourceProductionContext, model) => + ExecuteSafely(sourceProductionContext, model) ); } diff --git a/src/src/SourceGenerator/Suppressors/UnionEqualityDiagnosticSuppressor.cs b/src/src/SourceGenerator/Suppressors/UnionEqualityDiagnosticSuppressor.cs index e37cc7a..d327d4a 100644 --- a/src/src/SourceGenerator/Suppressors/UnionEqualityDiagnosticSuppressor.cs +++ b/src/src/SourceGenerator/Suppressors/UnionEqualityDiagnosticSuppressor.cs @@ -4,10 +4,6 @@ using Microsoft.CodeAnalysis.Diagnostics; using Purview.Results.SourceGenerator.Diagnostics; -// ITypeSymbol.IsUnion is still marked [Experimental] in Roslyn 5.9, and this suppression deliberately -// targets it. -#pragma warning disable RSEXPERIMENTAL006 - namespace Purview.Results.SourceGenerator.Suppressors; /// @@ -23,11 +19,12 @@ namespace Purview.Results.SourceGenerator.Suppressors; /// [SuppressMessage]) beside the declaration to keep the build clean. /// /// -/// The suppression is deliberately narrow: it applies only where the reported declaration is a union -/// (, the language's own answer, exactly as the shared diagnostics library -/// uses) and is opted in with [GenerateResult]. A union without the attribute, and every -/// non-union value type, keeps the warning. A consumer who wants union equality declared adds the suppression -/// id to the build's warning suppressions (NoWarn), which brings CA1815 back for every union. +/// The suppression is deliberately narrow: it applies only where the reported declaration is a union as the +/// shared diagnostics library recognises one (a language union or a source-visible +/// Purview.Unions.GenerateUnionAttribute target) and is opted in with +/// [GenerateResult]. A union without the attribute, and every non-union value type, keeps the +/// warning. A consumer who wants union equality declared adds the suppression id to the build's warning +/// suppressions (NoWarn), which brings CA1815 back for every union. /// /// /// A suppressor can only suppress non-error, configurable diagnostics, and it never reports a diagnostic of @@ -95,7 +92,8 @@ static bool IsOptedInUnionDeclaration(SuppressionAnalysisContext context, Diagno continue; return context.GetSemanticModel(tree).GetDeclaredSymbol(declaration, context.CancellationToken) - is INamedTypeSymbol { IsUnion: true } union + is INamedTypeSymbol union + && ResultUnionDiagnostics.IsUnionType(union) && ResultUnionDiagnostics.HasGenerateResultAttribute(union); } diff --git a/src/src/ZodSharp.AspNetCore/Extensions/Purview/Results/ZodSharp/ValidationProblemResultExtensions.cs b/src/src/ZodSharp.AspNetCore/Extensions/Purview/Results/ZodSharp/ValidationProblemResultExtensions.cs index b8f4c8e..5bfd27b 100644 --- a/src/src/ZodSharp.AspNetCore/Extensions/Purview/Results/ZodSharp/ValidationProblemResultExtensions.cs +++ b/src/src/ZodSharp.AspNetCore/Extensions/Purview/Results/ZodSharp/ValidationProblemResultExtensions.cs @@ -50,12 +50,13 @@ public IResult ToValidationProblem(HttpContext context, int statusCode = StatusC { ArgumentNullException.ThrowIfNull(context); - var options = - context.RequestServices?.GetService>()?.Value + var options = context.RequestServices?.GetService>()?.Value ?? new ZodProblemDetailsOptions(); - var includeTraceId = - context.RequestServices?.GetService>()?.Value.IncludeTraceId ?? true; + var includeTraceId = context + .RequestServices?.GetService>() + ?.Value.IncludeTraceId + ?? true; return ZodValidationProblems.ToProblem( errors, diff --git a/src/src/ZodSharp.AspNetCore/ZodResultsFailureMapper.cs b/src/src/ZodSharp.AspNetCore/ZodResultsFailureMapper.cs index 4f44169..2765203 100644 --- a/src/src/ZodSharp.AspNetCore/ZodResultsFailureMapper.cs +++ b/src/src/ZodSharp.AspNetCore/ZodResultsFailureMapper.cs @@ -35,8 +35,8 @@ public sealed partial class ZodResultsFailureMapper( ILogger logger ) : IResultsFailureMapper { - readonly ZodProblemDetailsOptions _problemOptions = - problemOptions?.Value ?? throw new ArgumentNullException(nameof(problemOptions)); + readonly ZodProblemDetailsOptions _problemOptions = problemOptions?.Value + ?? throw new ArgumentNullException(nameof(problemOptions)); readonly ZodResultsHttpOptions _rules = rules?.Value ?? throw new ArgumentNullException(nameof(rules)); readonly ResultsHttpOptions _results = results?.Value ?? throw new ArgumentNullException(nameof(results)); diff --git a/src/tests/SourceGenerator.UnitTests/ClosedHierarchyTests.cs b/src/tests/SourceGenerator.UnitTests/ClosedHierarchyTests.cs index ea0c570..f3e5e78 100644 --- a/src/tests/SourceGenerator.UnitTests/ClosedHierarchyTests.cs +++ b/src/tests/SourceGenerator.UnitTests/ClosedHierarchyTests.cs @@ -43,6 +43,7 @@ public async Task Match_GivenClosedErrorHierarchy_ExhaustivelySwitchesWithoutDef // No discard arm: adding a new direct descendant of SyncOutcome breaks this switch at compile time, which // is the exhaustiveness guarantee 'closed' provides. + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0072:Add missing cases")] static string Describe(SyncOutcome outcome) => outcome switch { diff --git a/src/tests/SourceGenerator.UnitTests/GenerateUnionDiscoveryRuntimeTests.cs b/src/tests/SourceGenerator.UnitTests/GenerateUnionDiscoveryRuntimeTests.cs new file mode 100644 index 0000000..61e3cc1 --- /dev/null +++ b/src/tests/SourceGenerator.UnitTests/GenerateUnionDiscoveryRuntimeTests.cs @@ -0,0 +1,76 @@ +namespace Purview.Results.SourceGenerator; + +/// +/// Executes the helpers generated for union-shaped types whose cases come from a source-visible +/// [GenerateUnion] list rather than source constructors. +/// +public class GenerateUnionDiscoveryRuntimeTests +{ + [Test] + public async Task AsFailure_GivenGenerateUnionTypeofListCase_ProducesValueFailure() + { + // Arrange + DiscoveredSourceCase value = new(7); + + // Act + // The explicit type proves the helper produces the result for the discovered union. +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result result = value.AsFailure(); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // Assert + // The test stand-in has no [Union] attribute (the real generator would emit one), so the active case + // is read through its Value property rather than by union pattern matching. + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error.Value is DiscoveredSourceCase { Id: 7 }).IsTrue(); + } + + [Test] + public async Task UnionFactory_GivenGenerateUnionTypeofListCase_ProducesValueFailure() + { + // Arrange + DiscoveredSourceCase value = new(7); + + // Act + // The union-receiver factory names the union discovered from the typeof list. +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result result = DiscoveredError.Failure(value); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error.Value is DiscoveredSourceCase { Id: 7 }).IsTrue(); + } + + [Test] + public async Task AsFailure_GivenGenerateUnionPartialFactoryCase_ProducesValueFailure() + { + // Arrange + FactoryDiscoveredCase value = new(7); + + // Act +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result result = value.AsFailure(); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error.Value is FactoryDiscoveredCase { Id: 7 }).IsTrue(); + } + + [Test] + public async Task UnionFactory_GivenGenerateUnionPartialFactoryCase_ProducesUnitFailure() + { + // Arrange + FactoryDiscoveredOtherCase value = new("rejected"); + + // Act +#pragma warning disable IDE0007 // Use 'var' instead of explicit type + Result result = FactoryDiscoveredError.Failure(value); +#pragma warning restore IDE0007 // Use 'var' instead of explicit type + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error.Value is FactoryDiscoveredOtherCase { Reason: "rejected" }).IsTrue(); + } +} diff --git a/src/tests/SourceGenerator.UnitTests/GenerateUnionDiscoveryTests.cs b/src/tests/SourceGenerator.UnitTests/GenerateUnionDiscoveryTests.cs new file mode 100644 index 0000000..c4e028e --- /dev/null +++ b/src/tests/SourceGenerator.UnitTests/GenerateUnionDiscoveryTests.cs @@ -0,0 +1,367 @@ +using Purview.Results.SourceGenerator.Analyzers; + +namespace Purview.Results.SourceGenerator; + +/// +/// Discovery tests for unions generated by Purview.Unions. A Roslyn generator never sees another +/// generator's output, so a [GenerateUnion] target has no visible [Union] attribute and no +/// visible constructors in this compilation; its cases must come from the source-visible list instead. +/// +/// +/// The tests declare a stand-in Purview.Unions.GenerateUnionAttribute (and, where the generated +/// helpers need a case-to-union conversion that the real generator would emit, source-visible conversion +/// operators). The two-generator integration test is recorded as an interop follow-up, because +/// Purview.Unions is not on a CI-safe package feed yet. +/// +public class GenerateUnionDiscoveryTests + : TUnitSourceGeneratorTestBase +{ + [Test] + public async Task GenerateAsync_GivenGenerateUnionTypeofListAndNoConstructors_EmitsTheCaseHelpers( + CancellationToken cancellationToken + ) + { + // Arrange + // The type has no source constructor and no [Union] attribute: exactly what this generator sees for + // a union another generator emits. The implicit operators stand in for the conversion the union + // generator's output provides at the final compilation. + const string source = """ + using Purview.Unions; + + namespace Test + { + public readonly record struct SourceCase(int Id); + + public readonly record struct OtherCase(string Reason); + + [GenerateUnion(typeof(SourceCase), typeof(OtherCase))] + [GenerateResult] + public readonly struct GeneratedError + { + readonly object? _value; + + GeneratedError(object? value) => _value = value; + + public static implicit operator GeneratedError(SourceCase value) => new(value); + + public static implicit operator GeneratedError(OtherCase value) => new(value); + + public object? Value => _value; + } + } + """; + + // Act + using var result = await GenerateAsync( + [TestSources.GenerateUnionAttributeDeclaration, source], + cancellationToken + ); + + // Assert + result.AssertNoCompilationErrors(); + await Assert.That(result).HasGeneratedClass("GeneratedErrorResultExtensions"); + + var generated = result.AssertSingleGeneratedSource(); + await Assert.That(generated).Contains("this global::Test.SourceCase error"); + await Assert.That(generated).Contains("this global::Test.OtherCase error"); + await Assert.That(generated).Contains("extension(global::Test.GeneratedError)"); + await Assert.That(generated).Contains("> Failure("); + await Assert.That(generated).Contains("> Failure("); + await Assert.That(generated).Contains("> Success()"); + } + + [Test] + public async Task GenerateAsync_GivenGenerateUnionAttributeAndConstructors_UsesTheTypeofList( + CancellationToken cancellationToken + ) + { + // Arrange + // ConstructorOnlyCase is a public single-parameter constructor but is deliberately absent from the + // typeof list, so it proves the attribute list wins over the constructor list. + const string source = """ + using Purview.Unions; + + namespace Test + { + public readonly record struct SourceCase(int Id); + + public readonly record struct OtherCase(string Reason); + + public readonly record struct ConstructorOnlyCase(bool Flag); + + [GenerateUnion(typeof(SourceCase), typeof(OtherCase))] + [GenerateResult] + public readonly struct GeneratedError + { + readonly object? _value; + + public GeneratedError(SourceCase value) => _value = value; + + public GeneratedError(OtherCase value) => _value = value; + + public GeneratedError(ConstructorOnlyCase value) => _value = value; + + public static implicit operator GeneratedError(SourceCase value) => new(value); + + public static implicit operator GeneratedError(OtherCase value) => new(value); + + public object? Value => _value; + } + } + """; + + // Act + using var result = await GenerateAsync( + [TestSources.GenerateUnionAttributeDeclaration, source], + cancellationToken + ); + + // Assert + result.AssertNoCompilationErrors(); + + var generated = result.AssertSingleGeneratedSource(); + await Assert.That(generated).Contains("this global::Test.SourceCase error"); + await Assert.That(generated).Contains("this global::Test.OtherCase error"); + await Assert.That(generated).DoesNotContain("ConstructorOnlyCase"); + } + + [Test] + public async Task GenerateAsync_GivenGenerateUnionPartialFactories_EmitsTheCaseHelpers( + CancellationToken cancellationToken + ) + { + // Arrange + // The case list is the pair of partial static factory signatures; the implementations exist only so + // the test compilation is valid. + const string source = """ + using Purview.Unions; + + namespace Test + { + public readonly record struct FactoryCase(int Id); + + public readonly record struct FactoryOtherCase(string Reason); + + [GenerateUnion] + [GenerateResult] + public readonly partial struct FactoryError + { + readonly object? _value; + + FactoryError(object? value) => _value = value; + + public static partial FactoryError FromFactoryCase(FactoryCase value); + + public static partial FactoryError FromFactoryCase(FactoryCase value) => new(value); + + public static partial FactoryError FromFactoryOtherCase(FactoryOtherCase value); + + public static partial FactoryError FromFactoryOtherCase(FactoryOtherCase value) => new(value); + + public static implicit operator FactoryError(FactoryCase value) => new(value); + + public static implicit operator FactoryError(FactoryOtherCase value) => new(value); + + public object? Value => _value; + } + } + """; + + // Act + using var result = await GenerateAsync( + [TestSources.GenerateUnionAttributeDeclaration, source], + cancellationToken + ); + + // Assert + result.AssertNoCompilationErrors(); + await Assert.That(result).HasGeneratedClass("FactoryErrorResultExtensions"); + + var generated = result.AssertSingleGeneratedSource(); + await Assert.That(generated).Contains("this global::Test.FactoryCase error"); + await Assert.That(generated).Contains("this global::Test.FactoryOtherCase error"); + await Assert.That(generated).Contains("extension(global::Test.FactoryError)"); + } + + [Test] + public async Task GenerateAsync_GivenGenerateUnionNullableValueTypeAttributeCase_NormalisesToTheUnderlyingType( + CancellationToken cancellationToken + ) + { + // Arrange + // int? is exposed as int, exactly as the union generator exposes it. + const string source = """ + using Purview.Unions; + + namespace Test + { + [GenerateUnion(typeof(int?))] + [GenerateResult] + public readonly struct NullableGeneratedError + { + readonly object? _value; + + NullableGeneratedError(object? value) => _value = value; + + public static implicit operator NullableGeneratedError(int value) => new(value); + + public object? Value => _value; + } + } + """; + + // Act + using var result = await GenerateAsync( + [TestSources.GenerateUnionAttributeDeclaration, source], + cancellationToken + ); + + // Assert + result.AssertNoCompilationErrors(); + + var generated = result.AssertSingleGeneratedSource(); + await Assert.That(generated).Contains("this int error"); + await Assert.That(generated).DoesNotContain("int?"); + } + + [Test] + public async Task GenerateAsync_GivenGenerateUnionNullableReferenceFactoryCase_StripsTheAnnotation( + CancellationToken cancellationToken + ) + { + // Arrange + // A nullable reference annotation is not part of the case identity; typeof(string?) is illegal, so the + // source list is the partial factory surface. + const string source = """ + using Purview.Unions; + + namespace Test + { + [GenerateUnion] + [GenerateResult] + public readonly partial struct NullableReferenceError + { + readonly object? _value; + + NullableReferenceError(object? value) => _value = value; + + public static partial NullableReferenceError FromText(string? value); + + public static partial NullableReferenceError FromText(string? value) => new(value); + + public static implicit operator NullableReferenceError(string value) => new(value); + + public object? Value => _value; + } + } + """; + + // Act + using var result = await GenerateAsync( + [TestSources.GenerateUnionAttributeDeclaration, source], + cancellationToken + ); + + // Assert + result.AssertNoCompilationErrors(); + + var generated = result.AssertSingleGeneratedSource(); + await Assert.That(generated).Contains("this string error"); + await Assert.That(generated).DoesNotContain("string?"); + } + + [Test] + public async Task GenerateAsync_GivenGenerateUnionAsAnIncludedCase_ExpandsItsSourceVisibleCases( + CancellationToken cancellationToken + ) + { + // Arrange + // IncludedError is a [GenerateUnion] union (no source [Union] attribute) and is a case of the outer + // declaration union. Its Leaf case is only in its typeof list, so inclusion has to read the same + // source-visible list. + const string source = """ + using Purview.Unions; + + namespace Test + { + public readonly record struct Leaf(int Id); + + public readonly record struct Other(string Reason); + + [GenerateUnion(typeof(Leaf))] + [GenerateResult] + public readonly struct IncludedError + { + readonly object? _value; + + public IncludedError(Leaf value) => _value = value; + + public static implicit operator IncludedError(Leaf value) => new(value); + + public object? Value => _value; + } + + [GenerateResult] + public readonly union OuterError(IncludedError, Other); + } + """; + + // Act + using var result = await GenerateAsync( + [TestSources.GenerateUnionAttributeDeclaration, source], + cancellationToken + ); + + // Assert + result.AssertNoCompilationErrors(); + + var outerTree = result.PrimarySyntaxTrees.Single(static tree => + tree.FilePath.EndsWith("OuterErrorResultExtensions.g.cs", StringComparison.Ordinal) + ); + var outerSource = (await outerTree.GetTextAsync(cancellationToken)).ToString(); + + await Assert.That(outerSource).Contains("new global::Test.OuterError(new global::Test.IncludedError(error))"); + } + + [Test] + public async Task GenerateAsync_GivenGenerateUnionWithNoSourceVisibleCases_ReportsUnionHasNoCasesAndEmitsNoHelpers( + CancellationToken cancellationToken + ) + { + // Arrange + // The target is a union because it carries [GenerateUnion], but it has no typeof list, no partial + // factory, no IUnionMembers provider and no source constructor, so there is no case list to read. + const string source = """ + using Purview.Unions; + + namespace Test + { + [GenerateUnion] + [GenerateResult] + public readonly partial struct EmptyError; + } + """; + + // Act + using var result = await GenerateAsync( + [TestSources.GenerateUnionAttributeDeclaration, source], + new ResultsSourceGeneratorTestOptions + { + AnalyzerTypes = [typeof(ResultsDiagnosticAnalyzer)], + ThrowOnGenerationException = false, + }, + cancellationToken + ); + + // Assert + await Assert.That(result.AnalyzerResult).IsNotNull(); + + var analyzerDiagnostics = result + .AnalyzerResult!.Diagnostics.Select(static diagnostic => diagnostic.Id) + .ToArray(); + + await Assert.That(analyzerDiagnostics).Contains("RSG1001"); + await Assert.That(analyzerDiagnostics).DoesNotContain("RSG1000"); + await Assert.That(result.PrimarySyntaxTrees).IsEmpty(); + } +} diff --git a/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs b/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs index 56e9f03..18a7a0d 100644 --- a/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs +++ b/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs @@ -198,16 +198,14 @@ public bool TryGetValue(out Label value) public sealed record ClassCase(int Id); /// A class-based custom union, which uses reference semantics and a public constructor. +/// Creates the union from its case. +/// The case value. [Union] [GenerateResult] -public class ClassUnion : IUnion +public class ClassUnion(ClassCase value) : IUnion { - /// Creates the union from its case. - /// The case value. - public ClassUnion(ClassCase value) => Value = value; - /// - public object? Value { get; } + public object? Value { get; } = value; } /// A generic union with a constraint that the generated extension block must repeat. @@ -233,9 +231,7 @@ public readonly union ConstrainedMaybe(ConstrainedNone, ConstrainedSome) [GenerateResult] public readonly struct ProvidedError : IUnion, ProvidedError.IUnionMembers { - readonly object? _value; - - ProvidedError(object? value) => _value = value; + ProvidedError(object? value) => Value = value; /// Provides the union's creation and access members. public interface IUnionMembers @@ -249,10 +245,10 @@ public interface IUnionMembers object? Value { get; } } - object? IUnionMembers.Value => _value; + object? IUnionMembers.Value => Value; /// - public object? Value => _value; + public object? Value { get; } } /// A named case of a generic member-provider union. @@ -269,9 +265,7 @@ public interface IUnionMembers [GenerateResult] public readonly struct ProvidedOption : IUnion, ProvidedOption.IUnionMembers { - readonly object? _value; - - ProvidedOption(object? value) => _value = value; + ProvidedOption(object? value) => Value = value; /// Provides the union's creation and access members. public interface IUnionMembers @@ -290,8 +284,8 @@ public interface IUnionMembers object? Value { get; } } - object? IUnionMembers.Value => _value; + object? IUnionMembers.Value => Value; /// - public object? Value => _value; + public object? Value { get; } } diff --git a/src/tests/SourceGenerator.UnitTests/GeneratedUnionDiscoveryTestTypes.cs b/src/tests/SourceGenerator.UnitTests/GeneratedUnionDiscoveryTestTypes.cs new file mode 100644 index 0000000..deba836 --- /dev/null +++ b/src/tests/SourceGenerator.UnitTests/GeneratedUnionDiscoveryTestTypes.cs @@ -0,0 +1,92 @@ +using Purview.Unions; + +namespace Purview.Results.SourceGenerator; + +// Union-shaped types that stand in for a union emitted by Purview.Unions. The generator runs against this +// project as an analyzer, so these declarations prove the source-visible discovery path end to end: there is +// no [Union] attribute and no public single-parameter constructor for the results generator to read. + +/// A case of the typeof-list generated-union discovery union. +/// The identifier. +public readonly record struct DiscoveredSourceCase(int Id); + +/// A second case of the typeof-list generated-union discovery union. +/// The reason. +public readonly record struct DiscoveredOtherCase(string Reason); + +/// +/// A union-shaped type whose case list is only the [GenerateUnion] typeof list, which is what +/// the results generator sees for a union another generator emits. +/// +/// +/// The implicit conversions stand in for the members the union generator emits into the final compilation, +/// so the generated helpers compile and can be executed without exposing source constructors to discovery. +/// +[GenerateUnion(typeof(DiscoveredSourceCase), typeof(DiscoveredOtherCase))] +[GenerateResult] +public readonly struct DiscoveredError +{ + DiscoveredError(object? value) => Value = value; + + /// Converts a case into the union. + /// The case value. + public static implicit operator DiscoveredError(DiscoveredSourceCase value) => new(value); + + /// Converts a case into the union. + /// The case value. + public static implicit operator DiscoveredError(DiscoveredOtherCase value) => new(value); + + /// Gets the boxed case value. + public object? Value { get; } +} + +/// A case of the factory-list generated-union discovery union. +/// The identifier. +public readonly record struct FactoryDiscoveredCase(int Id); + +/// A second case of the factory-list generated-union discovery union. +/// The reason. +public readonly record struct FactoryDiscoveredOtherCase(string Reason); + +/// +/// A union-shaped type whose case list is only its partial static factory signatures. The signatures are the +/// source-visible case list; the implementations exist only so the test compilation is valid. +/// +[GenerateUnion] +[GenerateResult] +public readonly partial struct FactoryDiscoveredError +{ + FactoryDiscoveredError(object? value) => Value = value; + + /// Creates the union from a case. + /// The case value. + /// The union holding the case. + public static partial FactoryDiscoveredError FromFactoryDiscoveredCase(FactoryDiscoveredCase value); + + /// Creates the union from a case. + /// The case value. + /// The union holding the case. + public static partial FactoryDiscoveredError FromFactoryDiscoveredCase(FactoryDiscoveredCase value) => new(value); + + /// Creates the union from a case. + /// The case value. + /// The union holding the case. + public static partial FactoryDiscoveredError FromFactoryDiscoveredOtherCase(FactoryDiscoveredOtherCase value); + + /// Creates the union from a case. + /// The case value. + /// The union holding the case. + public static partial FactoryDiscoveredError FromFactoryDiscoveredOtherCase(FactoryDiscoveredOtherCase value) => + new(value); + + /// Converts a case into the union. + /// The case value. + public static implicit operator FactoryDiscoveredError(FactoryDiscoveredCase value) => new(value); + + /// Converts a case into the union. + /// The case value. + public static implicit operator FactoryDiscoveredError(FactoryDiscoveredOtherCase value) => new(value); + + /// Gets the boxed case value. + public object? Value { get; } +} diff --git a/src/tests/SourceGenerator.UnitTests/Purview/Unions/GenerateUnionAttribute.cs b/src/tests/SourceGenerator.UnitTests/Purview/Unions/GenerateUnionAttribute.cs new file mode 100644 index 0000000..f21a968 --- /dev/null +++ b/src/tests/SourceGenerator.UnitTests/Purview/Unions/GenerateUnionAttribute.cs @@ -0,0 +1,21 @@ +namespace Purview.Unions; + +/// +/// A test stand-in for the Purview.Unions opt-in attribute. +/// +/// +/// The real package's generator is not on a CI-safe feed yet, so the test project declares the attribute with +/// the same metadata identity. The results generator matches it by metadata name only, exactly as it would +/// match the packaged attribute. +/// +/// The case types of the generated union. +[AttributeUsage(AttributeTargets.Struct | AttributeTargets.Class, Inherited = false, AllowMultiple = false)] +// Deliberately internal, which is the default for a top-level type: the generator test compilations declare +// their own public stand-in from a source string, and an imported public copy would conflict (CS0436) with it. +sealed class GenerateUnionAttribute(params Type[] cases) : Attribute +{ + /// + /// Gets the case types of the generated union, in declaration order. + /// + public IReadOnlyList Cases { get; } = cases; +} diff --git a/src/tests/SourceGenerator.UnitTests/ResultsDiagnosticAnalyzerTests.cs b/src/tests/SourceGenerator.UnitTests/ResultsDiagnosticAnalyzerTests.cs index 7da051f..7bf1928 100644 --- a/src/tests/SourceGenerator.UnitTests/ResultsDiagnosticAnalyzerTests.cs +++ b/src/tests/SourceGenerator.UnitTests/ResultsDiagnosticAnalyzerTests.cs @@ -71,6 +71,65 @@ CancellationToken cancellationToken } } + [Test] + public async Task AnalyzeAsync_GivenGenerateUnionWithNoSourceVisibleCases_ReportsUnionHasNoCases( + CancellationToken cancellationToken + ) + { + // Arrange + // The target is a union because of [GenerateUnion], even though no [Union] attribute is visible; + // it has no source-visible case list, so RSG1001 rather than RSG1000 is the one finding. + const string source = """ + using Purview.Unions; + + namespace Test + { + [GenerateUnion] + [GenerateResult] + public readonly partial struct EmptyError; + } + """; + + // Act + var result = await AnalyzeAsync( + [TestSources.GenerateResultAttributeDeclaration, TestSources.GenerateUnionAttributeDeclaration, source], + cancellationToken + ); + + // Assert + await Assert.That(result).HasDiagnostic("RSG1001"); + await Assert.That(result).DoesNotHaveDiagnostic("RSG1000"); + } + + [Test] + public async Task AnalyzeAsync_GivenGenerateUnionWithSourceVisibleCases_ReportsNothing( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using Purview.Unions; + + namespace Test + { + public readonly record struct SourceCase(int Id); + + [GenerateUnion(typeof(SourceCase))] + [GenerateResult] + public readonly partial struct GeneratedError; + } + """; + + // Act + var result = await AnalyzeAsync( + [TestSources.GenerateResultAttributeDeclaration, TestSources.GenerateUnionAttributeDeclaration, source], + cancellationToken + ); + + // Assert + await Assert.That(result).HasNoDiagnostics(); + } + [Test] public async Task AnalyzeAsync_GivenUnionWithNoCases_ReportsUnionHasNoCases(CancellationToken cancellationToken) { diff --git a/src/tests/SourceGenerator.UnitTests/TestSources.cs b/src/tests/SourceGenerator.UnitTests/TestSources.cs index 663d1aa..a265cc6 100644 --- a/src/tests/SourceGenerator.UnitTests/TestSources.cs +++ b/src/tests/SourceGenerator.UnitTests/TestSources.cs @@ -5,6 +5,25 @@ namespace Purview.Results.SourceGenerator; /// static class TestSources { + /// + /// Declares the Purview.Unions opt-in attribute exactly as its package does, so a test compilation + /// can exercise the source-visible discovery path without referencing that package (whose analyzer is not + /// on a CI-safe feed yet). Matching is by metadata identity only, so this declaration is equivalent to the + /// real attribute for the generator. + /// + public const string GenerateUnionAttributeDeclaration = """ + namespace Purview.Unions + { + [System.AttributeUsage(System.AttributeTargets.Struct | System.AttributeTargets.Class, Inherited = false, AllowMultiple = false)] + public sealed class GenerateUnionAttribute : System.Attribute + { + public GenerateUnionAttribute(params System.Type[] cases) => Cases = cases; + + public System.Collections.Generic.IReadOnlyList Cases { get; } + } + } + """; + /// /// Declares the opt-in attribute so analyzer tests can exercise the shared diagnostics library without /// running the generator (which is what emits the attribute in a real compilation). diff --git a/src/tests/ZodSharp.AspNetCore.UnitTests/ZodResultsFailureMapperTests.cs b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodResultsFailureMapperTests.cs index 75aa5c3..1bdc331 100644 --- a/src/tests/ZodSharp.AspNetCore.UnitTests/ZodResultsFailureMapperTests.cs +++ b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodResultsFailureMapperTests.cs @@ -356,9 +356,8 @@ public async Task Map_GivenDecliningOriginRule_FallsThroughToTheDefaultValidatio // Arrange // The strict origin rule declines a mixed failure, so the failure keeps the default validation response. await using var services = CreateServices(zod => - zod.MapOrigin( - ValueObjectOrigin, - (errors, _) => errors.Length == 1 && errors[0].Code == InvalidNameCode ? TypedResults.NotFound() : null + zod.MapOrigin(ValueObjectOrigin, (errors, _) => + errors.Length == 1 && errors[0].Code == InvalidNameCode ? TypedResults.NotFound() : null ) ); var mapper = services.GetRequiredService(); @@ -425,9 +424,8 @@ public async Task Map_GivenCodeRuleFactoryThatDeclines_FallsThroughToTheDefaultV // Arrange // A factory can tighten the match rule: here it only answers a failure whose every error is the code. await using var services = CreateServices(zod => - zod.MapCode( - InvalidNameCode, - (errors, _) => errors.Length == 1 && errors[0].Code == InvalidNameCode ? TypedResults.NotFound() : null + zod.MapCode(InvalidNameCode, (errors, _) => + errors.Length == 1 && errors[0].Code == InvalidNameCode ? TypedResults.NotFound() : null ) ); var mapper = services.GetRequiredService(); diff --git a/src/tests/ZodSharp.AspNetCore.UnitTests/ZodValidationProblemsTests.cs b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodValidationProblemsTests.cs index cecadb3..52111d9 100644 --- a/src/tests/ZodSharp.AspNetCore.UnitTests/ZodValidationProblemsTests.cs +++ b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodValidationProblemsTests.cs @@ -211,21 +211,21 @@ public async Task WithDefaultStatusCode_GivenADifferentStatus_CopiesEverySettabl { // Arrange - every settable option set to a non-default value, so a member the copy misses shows up here. ErrorTypeRegistry registry = new(); - Func resolver = _ => null; - Func, int> selector = _ => 418; - Func, int, int> statusOverride = (_, status) => status; + static ErrorType? Resolver(string _) => null; + static int Selector(ImmutableArray _) => 418; + static int StatusOverride(ImmutableArray _, int status) => status; ZodProblemDetailsOptions options = new() { Registry = registry, - ErrorTypeResolver = resolver, + ErrorTypeResolver = Resolver, FormatMessages = false, PathNaming = ZodPathNaming.Json, IncludeIssues = false, MergeParametersIntoExtensions = true, ProjectDataAnnotationsIssues = false, DefaultStatusCode = StatusCodes.Status400BadRequest, - StatusCodeSelector = selector, - StatusCodeOverride = statusOverride, + StatusCodeSelector = Selector, + StatusCodeOverride = StatusOverride, }; // Act diff --git a/src/tests/ZodSharp.UnitTests/ValidationTestUnions.cs b/src/tests/ZodSharp.UnitTests/ValidationTestUnions.cs index 5410339..b2a81e5 100644 --- a/src/tests/ZodSharp.UnitTests/ValidationTestUnions.cs +++ b/src/tests/ZodSharp.UnitTests/ValidationTestUnions.cs @@ -19,7 +19,4 @@ namespace Purview.Results.ZodSharp; /// /// The inconsistent aggregate. /// The validation errors reported by OperationResultSchema. -public readonly record struct OperationResultInvalid( - OperationResult Result, - ImmutableArray Errors -); +public readonly record struct OperationResultInvalid(OperationResult Result, ImmutableArray Errors); diff --git a/src/tests/ZodSharp.UnitTests/ZodValidationResultExtensionsTests.cs b/src/tests/ZodSharp.UnitTests/ZodValidationResultExtensionsTests.cs index f10eb2d..8667966 100644 --- a/src/tests/ZodSharp.UnitTests/ZodValidationResultExtensionsTests.cs +++ b/src/tests/ZodSharp.UnitTests/ZodValidationResultExtensionsTests.cs @@ -406,7 +406,7 @@ public async Task ToUnitResultAsync_GivenNullTask_ThrowsArgumentNullException() // Act & Assert await Assert - .That(async () => await validation.ToUnitResultAsync(errors => "rejected")) + .That(async () => await validation.ToUnitResultAsync(errors => "rejected")) .Throws(); } } From dd3814825676d8ba6527761e3fdc640281bf067f Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 12 Nov 2026 12:44:59 +0000 Subject: [PATCH 5/6] feat: supporting net 11 and unions/ zodsharp updates --- .../purview-architecture-testing/SKILL.md | 5 +- .agents/skills/purview-architecture/SKILL.md | 3 +- .../references/contract-strategy.md | 2 +- .../references/project-responsibilities.md | 2 +- .../skills/purview-buildsdk-adoption/SKILL.md | 11 +- .../purview-buildsdk-configuration/SKILL.md | 25 +- .../references/properties.md | 28 +- .../SKILL.md | 16 +- .../references/detection.md | 17 +- .../SKILL.md | 21 +- .../SKILL.md | 138 ++++-- .../references/repository-root.md | 4 +- .../purview-buildsdk-skills-justfile/SKILL.md | 177 +++++++ .../purview-component-compatibility/SKILL.md | 116 +++++ .../references/lowest-compatible.md | 93 ++++ .../references/purview-components.md | 60 +++ .../scripts/dependencies.mjs | 239 +++++++++ .../purview-engineering-principles/SKILL.md | 39 +- .agents/skills/purview-org-harvest/SKILL.md | 197 ++++++++ .../references/best-in-class.md | 77 +++ .../references/harvest-commands.md | 121 +++++ .../purview-org-harvest/scripts/inventory.mjs | 466 ++++++++++++++++++ .agents/skills/purview-persistence/SKILL.md | 65 ++- .../references/ef-core-integration.md | 83 ++++ .../references/guid-generation.md | 26 +- .../references/postgresql.md | 44 ++ .../references/sqlserver.md | 49 +- .../references/well-known-identifiers.md | 93 ++++ .../SKILL.md | 146 ++++++ .../SKILL.md | 2 +- .../SKILL.md | 22 +- .../SKILL.md | 20 +- .../purview-sourcegenerator-testing/SKILL.md | 48 +- .../SKILL.md | 18 +- .agents/skills/purview-telemetry/SKILL.md | 3 +- .../purview-unions-diagnostics/SKILL.md | 113 +++++ .../skills/purview-unions-interop/SKILL.md | 88 ++++ .../skills/purview-unions-modelling/SKILL.md | 136 +++++ .../skills/purview-unions-quickstart/SKILL.md | 130 +++++ .../purview-value-objects-efcore/SKILL.md | 21 +- .../purview-value-objects-zodsharp/SKILL.md | 62 ++- .agents/skills/purview-value-objects/SKILL.md | 8 + .../purview-zodsharp-aspnetcore/SKILL.md | 399 ++++++++------- .../skills/purview-zodsharp-blazor/SKILL.md | 149 ++++++ .agents/skills/purview-zodsharp-core/SKILL.md | 105 +++- .../references/error-model.md | 112 +++++ .../purview-zodsharp-custom-rules/SKILL.md | 29 +- .../purview-zodsharp-diagnostics/SKILL.md | 35 +- .agents/skills/purview-zodsharp-json/SKILL.md | 124 ++++- .../SKILL.md | 123 ++++- .../references/dataannotations.md | 70 ++- .../references/generated-api.md | 101 ++++ .config/dotnet-tools.json | 2 +- .editorconfig | 7 +- AGENTS.md | 62 ++- CHANGELOG.md | 25 + Directory.Packages.props | 25 +- Justfile | 17 + Justfile.skills.just | 19 + README.md | 8 + docs/wiki/Blazor-and-Native-AOT.md | 15 + docs/wiki/Contributing.md | 7 +- docs/wiki/Getting-Started.md | 3 + docs/wiki/Guarantees-and-Limitations.md | 4 +- docs/wiki/Testing.md | 22 +- docs/wiki/Union-Errors.md | 5 + global.json | 2 +- scripts/packed-consumers/AotConsole/Model.cs | 2 +- .../packed-consumers/Shared/TenantError.cs | 4 +- skills-lock.json | 116 +++-- skills/purview-results-union-errors/SKILL.md | 5 + src/Directory.Build.props | 19 +- src/Directory.Build.targets | 41 +- src/Results.slnx | 3 + src/src/AspNetCore/Sdk/README.md | 4 +- .../Components/App.razor | 21 + .../Components/Layout/MainLayout.razor | 9 + .../Components/Layout/MainLayout.razor.css | 20 + .../Components/Layout/ReconnectModal.razor | 31 ++ .../Layout/ReconnectModal.razor.css | 154 ++++++ .../Components/Layout/ReconnectModal.razor.js | 63 +++ .../Components/Pages/Error.razor | 40 ++ .../Components/Pages/Home.razor | 72 +++ .../Components/Pages/NotFound.razor | 5 + .../Components/Routes.razor | 6 + .../Components/_Imports.razor | 13 + .../Examples.Blazor.Server.csproj | 53 ++ src/src/Examples.Blazor.Server/Program.cs | 19 + .../Properties/launchSettings.json | 14 + src/src/Examples.Blazor.Server/Tenant.cs | 14 + .../Examples.Blazor.Server/TenantDirectory.cs | 45 ++ src/src/Examples.Blazor.Server/TenantError.cs | 37 ++ src/src/Examples.Blazor.Server/TenantInput.cs | 25 + .../appsettings.Development.json | 8 + .../Examples.Blazor.Server/appsettings.json | 9 + .../Examples.Blazor.Server/wwwroot/app.css | 67 +++ src/src/Examples.Blazor.Wasm/App.razor | 6 + .../Examples.Blazor.Wasm.csproj | 57 +++ .../Layout/MainLayout.razor | 11 + src/src/Examples.Blazor.Wasm/Pages/Home.razor | 72 +++ .../Examples.Blazor.Wasm/Pages/NotFound.razor | 5 + src/src/Examples.Blazor.Wasm/Program.cs | 10 + .../Properties/launchSettings.json | 14 + src/src/Examples.Blazor.Wasm/Tenant.cs | 14 + .../Examples.Blazor.Wasm/TenantDirectory.cs | 45 ++ src/src/Examples.Blazor.Wasm/TenantError.cs | 37 ++ src/src/Examples.Blazor.Wasm/TenantInput.cs | 25 + src/src/Examples.Blazor.Wasm/_Imports.razor | 12 + .../Examples.Blazor.Wasm/wwwroot/css/app.css | 144 ++++++ .../Examples.Blazor.Wasm/wwwroot/favicon.png | Bin 0 -> 1148 bytes .../Examples.Blazor.Wasm/wwwroot/index.html | 33 ++ .../Examples.Unions/Examples.Unions.csproj | 48 ++ src/src/Examples.Unions/Program.cs | 83 ++++ src/src/Examples.Unions/Tenant.cs | 15 + src/src/Examples.Unions/TenantDirectory.cs | 41 ++ src/src/Examples.Unions/TenantError.cs | 43 ++ src/src/Examples.ValueObjects.Zod/Program.cs | 4 +- src/src/Examples.Zod/Program.cs | 8 +- src/src/Results/Results.csproj | 10 + src/src/Results/Sdk/README.md | 12 +- src/src/SourceGenerator/README.md | 4 + src/src/ZodSharp.AspNetCore/Sdk/README.md | 4 +- src/src/ZodSharp/Sdk/README.md | 2 +- src/src/ZodSharp/ZodSharp.csproj | 8 + .../ClosedHierarchyTests.cs | 3 +- .../GenerateUnionDiscoveryRuntimeTests.cs | 66 ++- .../GenerateUnionDiscoveryTests.cs | 9 +- .../GeneratedTestUnions.cs | 4 + .../GeneratedUnionDiscoveryTestTypes.cs | 102 ++-- .../Purview/Unions/GenerateUnionAttribute.cs | 21 - .../SourceGenerator.UnitTests.csproj | 15 + .../SourceGenerator.UnitTests/TestSources.cs | 9 +- .../ZodValidationResultIntegrationTests.cs | 6 +- 133 files changed, 5848 insertions(+), 577 deletions(-) create mode 100644 .agents/skills/purview-buildsdk-skills-justfile/SKILL.md create mode 100644 .agents/skills/purview-component-compatibility/SKILL.md create mode 100644 .agents/skills/purview-component-compatibility/references/lowest-compatible.md create mode 100644 .agents/skills/purview-component-compatibility/references/purview-components.md create mode 100644 .agents/skills/purview-component-compatibility/scripts/dependencies.mjs create mode 100644 .agents/skills/purview-org-harvest/SKILL.md create mode 100644 .agents/skills/purview-org-harvest/references/best-in-class.md create mode 100644 .agents/skills/purview-org-harvest/references/harvest-commands.md create mode 100644 .agents/skills/purview-org-harvest/scripts/inventory.mjs create mode 100644 .agents/skills/purview-persistence/references/ef-core-integration.md create mode 100644 .agents/skills/purview-persistence/references/well-known-identifiers.md create mode 100644 .agents/skills/purview-sourcegenerator-attribute-data-models/SKILL.md create mode 100644 .agents/skills/purview-unions-diagnostics/SKILL.md create mode 100644 .agents/skills/purview-unions-interop/SKILL.md create mode 100644 .agents/skills/purview-unions-modelling/SKILL.md create mode 100644 .agents/skills/purview-unions-quickstart/SKILL.md create mode 100644 .agents/skills/purview-zodsharp-blazor/SKILL.md create mode 100644 .agents/skills/purview-zodsharp-core/references/error-model.md create mode 100644 .agents/skills/purview-zodsharp-source-generator/references/generated-api.md create mode 100644 Justfile.skills.just create mode 100644 src/src/Examples.Blazor.Server/Components/App.razor create mode 100644 src/src/Examples.Blazor.Server/Components/Layout/MainLayout.razor create mode 100644 src/src/Examples.Blazor.Server/Components/Layout/MainLayout.razor.css create mode 100644 src/src/Examples.Blazor.Server/Components/Layout/ReconnectModal.razor create mode 100644 src/src/Examples.Blazor.Server/Components/Layout/ReconnectModal.razor.css create mode 100644 src/src/Examples.Blazor.Server/Components/Layout/ReconnectModal.razor.js create mode 100644 src/src/Examples.Blazor.Server/Components/Pages/Error.razor create mode 100644 src/src/Examples.Blazor.Server/Components/Pages/Home.razor create mode 100644 src/src/Examples.Blazor.Server/Components/Pages/NotFound.razor create mode 100644 src/src/Examples.Blazor.Server/Components/Routes.razor create mode 100644 src/src/Examples.Blazor.Server/Components/_Imports.razor create mode 100644 src/src/Examples.Blazor.Server/Examples.Blazor.Server.csproj create mode 100644 src/src/Examples.Blazor.Server/Program.cs create mode 100644 src/src/Examples.Blazor.Server/Properties/launchSettings.json create mode 100644 src/src/Examples.Blazor.Server/Tenant.cs create mode 100644 src/src/Examples.Blazor.Server/TenantDirectory.cs create mode 100644 src/src/Examples.Blazor.Server/TenantError.cs create mode 100644 src/src/Examples.Blazor.Server/TenantInput.cs create mode 100644 src/src/Examples.Blazor.Server/appsettings.Development.json create mode 100644 src/src/Examples.Blazor.Server/appsettings.json create mode 100644 src/src/Examples.Blazor.Server/wwwroot/app.css create mode 100644 src/src/Examples.Blazor.Wasm/App.razor create mode 100644 src/src/Examples.Blazor.Wasm/Examples.Blazor.Wasm.csproj create mode 100644 src/src/Examples.Blazor.Wasm/Layout/MainLayout.razor create mode 100644 src/src/Examples.Blazor.Wasm/Pages/Home.razor create mode 100644 src/src/Examples.Blazor.Wasm/Pages/NotFound.razor create mode 100644 src/src/Examples.Blazor.Wasm/Program.cs create mode 100644 src/src/Examples.Blazor.Wasm/Properties/launchSettings.json create mode 100644 src/src/Examples.Blazor.Wasm/Tenant.cs create mode 100644 src/src/Examples.Blazor.Wasm/TenantDirectory.cs create mode 100644 src/src/Examples.Blazor.Wasm/TenantError.cs create mode 100644 src/src/Examples.Blazor.Wasm/TenantInput.cs create mode 100644 src/src/Examples.Blazor.Wasm/_Imports.razor create mode 100644 src/src/Examples.Blazor.Wasm/wwwroot/css/app.css create mode 100644 src/src/Examples.Blazor.Wasm/wwwroot/favicon.png create mode 100644 src/src/Examples.Blazor.Wasm/wwwroot/index.html create mode 100644 src/src/Examples.Unions/Examples.Unions.csproj create mode 100644 src/src/Examples.Unions/Program.cs create mode 100644 src/src/Examples.Unions/Tenant.cs create mode 100644 src/src/Examples.Unions/TenantDirectory.cs create mode 100644 src/src/Examples.Unions/TenantError.cs delete mode 100644 src/tests/SourceGenerator.UnitTests/Purview/Unions/GenerateUnionAttribute.cs diff --git a/.agents/skills/purview-architecture-testing/SKILL.md b/.agents/skills/purview-architecture-testing/SKILL.md index 0619225..55d0ca8 100644 --- a/.agents/skills/purview-architecture-testing/SKILL.md +++ b/.agents/skills/purview-architecture-testing/SKILL.md @@ -9,6 +9,7 @@ license: MIT metadata: author: Purview-Dev version: "2.0" + repository: purview-dev/engineering-playbook --- # Purview architecture testing @@ -83,7 +84,7 @@ debugging the package. Then add a test that the documentation matches the policy, so the table in the wiki is falsifiable too. Full worked example: -[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Project-Responsibilities.md). +[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/foundations/Project-Responsibilities.md). ## 3. Read project files, and assemblies @@ -180,4 +181,4 @@ project classified so every dependency rule still applies to it. Prefer a waiver - `purview-architecture` — what the projects are for, and how they are named - `purview-buildsdk-architecture-tests` — the package's own skill, authoritative on its API -- [Architecture as Tests](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Architecture-As-Tests.md) +- [Architecture as Tests](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/foundations/Architecture-As-Tests.md) diff --git a/.agents/skills/purview-architecture/SKILL.md b/.agents/skills/purview-architecture/SKILL.md index c04f3d6..0c43596 100644 --- a/.agents/skills/purview-architecture/SKILL.md +++ b/.agents/skills/purview-architecture/SKILL.md @@ -10,6 +10,7 @@ license: MIT metadata: author: Purview-Dev version: "1.0" + repository: purview-dev/engineering-playbook --- # Purview architecture @@ -173,4 +174,4 @@ small telemetry — it does not get none. See `purview-telemetry`. - `purview-telemetry` — instrumentation at every scale - `purview-persistence` — storage and GUID-key guidance, only when persistence is real - [Engineering Principles](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Engineering-Principles.md) -- [Adopting This At Your Scale](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Adopting-This-At-Your-Scale.md) +- [Adopting This At Your Scale](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/governance/Adopting-This-At-Your-Scale.md) diff --git a/.agents/skills/purview-architecture/references/contract-strategy.md b/.agents/skills/purview-architecture/references/contract-strategy.md index e281a41..62b5a17 100644 --- a/.agents/skills/purview-architecture/references/contract-strategy.md +++ b/.agents/skills/purview-architecture/references/contract-strategy.md @@ -230,7 +230,7 @@ combines" stays a review obligation, because a dependency matrix cannot express ## See also -- [Contracts and Data Exchange](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Contracts-And-Data-Exchange.md) +- [Contracts and Data Exchange](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/data/Contracts-And-Data-Exchange.md) — `Create` and `Hydrate`, and the schema as the source of truth for a wire shape - [zodsharp](https://github.com/purview-dev/zodsharp) — authoritative on `[ZodSchema]` and JSON Schema export diff --git a/.agents/skills/purview-architecture/references/project-responsibilities.md b/.agents/skills/purview-architecture/references/project-responsibilities.md index d74d2cf..dc4e67c 100644 --- a/.agents/skills/purview-architecture/references/project-responsibilities.md +++ b/.agents/skills/purview-architecture/references/project-responsibilities.md @@ -3,7 +3,7 @@ The catalogue in brief. The authoritative definition is [`.config/architecture-policy.json`](https://github.com/purview-dev/engineering-playbook/blob/main/.config/architecture-policy.json), and the full page — with diagrams — is -[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Project-Responsibilities.md). +[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/foundations/Project-Responsibilities.md). Do not restate either here or in another skill; link to them. ## The catalogue diff --git a/.agents/skills/purview-buildsdk-adoption/SKILL.md b/.agents/skills/purview-buildsdk-adoption/SKILL.md index b1f222c..bb24fa3 100644 --- a/.agents/skills/purview-buildsdk-adoption/SKILL.md +++ b/.agents/skills/purview-buildsdk-adoption/SKILL.md @@ -100,9 +100,14 @@ silently gets no runner, no coverage, no `InternalsVisibleTo` and no category. ## 5. Agent skills are a separate, explicit step -The SDK does not install agent skills, and nothing it does during a build writes to an agent -directory. It reads which skills each referenced package ships and recommends the command that -installs them; the skills CLI does the installing. See `purview-buildsdk-skill-installation`. +Nothing the SDK does during a restore or a build touches agent skills. Installing, updating and +cleaning them up are separate targets a person runs by name — `InstallPurviewSkills`, +`UpdatePurviewSkills`, `CheckPurviewSkills`, `FixPurviewSkills` — and the ones that change the +repository refuse to run inside a build (`PRSKL0009`). Do not hook them into one. The skills CLI does +the installing, through `bun x` by default; with `PurviewSkillsAgents` unset it auto-detects agents. + +If the repository uses `just`, add recipes for them — see `purview-buildsdk-skills-justfile`. For what each +target does and the `PRSKL*` diagnostics, see `purview-buildsdk-skill-installation`. ## Constraints diff --git a/.agents/skills/purview-buildsdk-configuration/SKILL.md b/.agents/skills/purview-buildsdk-configuration/SKILL.md index d82768b..fca74cc 100644 --- a/.agents/skills/purview-buildsdk-configuration/SKILL.md +++ b/.agents/skills/purview-buildsdk-configuration/SKILL.md @@ -41,7 +41,7 @@ in `Directory.Build.props` unless the setting is deliberately project-specific. | `TestingFramework` | `TUnit` | `TUnit`, `Xunit` or `None` | | `SubstituteFramework` | `TUnitMocks` | `TUnitMocks`, `NSubstitute` or `None` | | `TestDataFramework` | `Bogus` | `Bogus` or `None` | -| `TargetFramework` | `net10.0` | `netstandard2.0` when `IsRoslynComponent=true` | +| `TargetFramework` | `net10.0;net11.0` | `netstandard2.0` when `IsRoslynComponent=true` | | `IsPackable` | `false` | Opts a project into the packable defaults | | `UsePackageJsonVersion` | `true` | `true`, `false` or `Strict` — version from `package.json` | | `ExcludePurviewTelemetry` | `false` | Drops the telemetry source generator | @@ -50,6 +50,11 @@ in `Directory.Build.props` unless the setting is deliberately project-specific. | `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 | +| `MemorySafetyRules` | `2` | C# 15 memory-safety tier on `net11.0`+: `0` off, `1` preview syntax, `2` updated rules | +| `PurviewRuntimeAsync` | `false` | Opts a `net11.0`+ target into `runtime-async=on`; `UseRuntimeAsync=true` also opts in | +| `IsClientSideProject` | *(computed)* | Whether the project's assemblies can reach a browser; overrides the `ServerShared` classification | +| `PurviewAutoServerSharedReference` | `true` | Adds a sibling `ServerShared` reference to a non-client-side project | +| `PurviewMultiThreadedBuild` | `true` | Bootstraps a `Directory.Build.rsp` enabling MSBuild `-mt` | 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 — @@ -89,11 +94,15 @@ is the SDK's answer, and it stays correct when the rules change. The exported se ## Repository bootstrap -The SDK bootstraps two repository-wide files from the package: `.editorconfig` and `global.json`. +The SDK bootstraps three repository-wide files from the package: `.editorconfig`, `global.json` and +`Directory.Build.rsp`. - `PurviewRepoBootstrapMode` — `IfMissing` (default), `Always`, `WarnOnDrift`, `Never` - `DisableAutoCopySdkFiles` — the master off switch - `PurviewRepoBootstrapCopyRetries` / `...RetryDelayMilliseconds` / `...FailureAsError` +- `BootstrapMultiThreadedBuildRspToRepoRoot` / `PurviewMultiThreadedBuild` — the response file that + enables MSBuild `-mt` (and `-nr:false`, which avoids a `RoslynCodeTaskFactory` cache left by a + non-`-mt` build) Every project in a solution writes those same destinations, so the write is race-tolerant by design: an existing file is never overwritten unless asked, each write is staged into a temporary @@ -105,6 +114,18 @@ the repository; that is gone, along with `EnableAgentFolderInPackage`, `AgentPac and the `PurviewAgentFolder*` properties. Skills have exactly one installer, the skills CLI — see `purview-buildsdk-skill-installation`. +The skill targets read their settings when they run, so these can go in `Directory.Build.props` or +on the command line, and a repository-wide choice belongs in `Directory.Build.props`: + +| Property | Default | Controls | +| -- | -- | -- | +| `PurviewSkillsRunner` | `bun` (`bun x skills`) | Which package runner launches the skills CLI | +| `PurviewSkillsAgents` | empty — the CLI auto-detects | Agents to install to; when set, the only agent directories `CheckPurviewSkills` allows | +| `PurviewSkillsDryRun` | `false` | Install and update print commands instead of running them | + +On a command line, `dotnet msbuild -p:` splits values on `,` and `;` — write +`-p:PurviewSkillsAgents=claude-code%2Ccodex`. For `just` recipes, see `purview-buildsdk-skills-justfile`. + ## Guidance when changing SDK configuration 1. Preserve existing defaults unless the task explicitly changes product behaviour. diff --git a/.agents/skills/purview-buildsdk-configuration/references/properties.md b/.agents/skills/purview-buildsdk-configuration/references/properties.md index 1fb1f81..8a8f139 100644 --- a/.agents/skills/purview-buildsdk-configuration/references/properties.md +++ b/.agents/skills/purview-buildsdk-configuration/references/properties.md @@ -21,7 +21,7 @@ consuming project supplies nothing. | Property | Default | Behaviour | | -- | -- | -- | -| `TargetFramework` | `net10.0` | Applied when neither `TargetFramework` nor `TargetFrameworks` is set. A project that explicitly declares `IsRoslynComponent=true` defaults to `netstandard2.0` | +| `TargetFramework` | `net10.0;net11.0` | Applied when neither `TargetFramework` nor `TargetFrameworks` is set. A project that explicitly declares `IsRoslynComponent=true` defaults to `netstandard2.0` | | `IsRoslynComponent` | *(unset)* | When explicitly `true`: a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, SourceLink with `EmbedUntrackedSources=true`, no dependency file, telemetry exclusion, and `PrivateAssets=all` on `Microsoft.CodeAnalysis.*` references | | `IsRoslynComponentOnly` | `true` for Roslyn components | Analyzer-only package: `IncludeBuildOutput=false`, `IncludeSymbols=false`, portable PDB packed beside the analyzer under `analyzers/dotnet/cs/`. Set `false` for a dual-role component using normal library symbol packaging | | `DisableRoslynCompilerDefaultsValidation` | `false` | Opts out of `ValidateRoslynComponentCompilerSettings`, which fails the pack of a packable Roslyn component missing `LangVersion`, `Nullable`, `TreatWarningsAsErrors` or `EnforceExtendedAnalyzerRules` | @@ -36,6 +36,19 @@ consuming project supplies nothing. | `DisablePurviewTestContextRuleSet` | `false` | Makes test and shared-testing projects enforce the production API-surface rules too | | `PurviewPolicyExemptNoWarn` | *(derived)* | The SDK-injected `NoWarn` entries `ValidatePurviewStylePolicy` accepts | +## .NET 11 features and ServerShared + +| Property | Default | Behaviour | +| -- | -- | -- | +| `MemorySafetyRules` | `2` | C# 15 memory-safety tier on `net11.0`+ targets: `0` off, `1` preview pointer syntax (sets `LangVersion=preview` when none was chosen), `2` `updated-memory-safety-rules`. Unknown values fail with `PRCFG0001`. Never touches `AllowUnsafeBlocks` | +| `PurviewRuntimeAsync` | `false` | Set to `true` for `runtime-async=on` on `net11.0`+ targets. `UseRuntimeAsync=true` also opts in; an explicit `false` under either name keeps it off | +| `PurviewMultiThreadedBuild` | `true` | Bootstraps a managed `Directory.Build.rsp` with `-mt`/`-nr:false`; `false` writes `-mt:false` | +| `BootstrapMultiThreadedBuildRspToRepoRoot` | `true` | Whether that bootstrapped response file is written at all | +| `IsClientSideProject` | *(computed)* | `true` for a WebAssembly client, `PurviewBrowserSafe` library, Razor class library, or `*.Client`/`*.Browser`/`*.Wasm`/`*.WebAssembly`/`*.Components` name. Explicit value wins | +| `PurviewAutoServerSharedReference` | `true` | Adds `../ServerShared*/ServerShared*.csproj` to a non-client-side project | +| `PurviewServerSharedReferenceCheckAsError` | `false` | Makes `PRSHR0001`/`PRSHR0002` errors | +| `DisablePurviewServerSharedReferenceCheck` | `false` | Turns `PRSHR0001`/`PRSHR0002` off | + ## Testing | Property | Default | Behaviour | @@ -119,6 +132,9 @@ reference was judged browser-safe when it cannot say so itself. | `RepositoryEditorConfigFilePath` | *(derived)* | Overrides the destination `.editorconfig` | | `BootstrapGlobalJsonToRepoRoot` | `true` | | | `RepositoryGlobalJsonFilePath` | *(derived)* | Overrides the destination `global.json` | +| `BootstrapMultiThreadedBuildRspToRepoRoot` | `true` | Creates a managed `Directory.Build.rsp` enabling `-mt` | +| `RepositoryDirectoryBuildRspFilePath` | *(derived)* | Overrides the destination `Directory.Build.rsp` | +| `PurviewMultiThreadedBuild` | `true` | `false` rewrites a managed rsp to `-mt:false` | | `PurviewBuildSdkVersionForGlobalJson` | restored package version, else the version it was packed as | | | `PurviewRepoBootstrapCopyRetries` | `3` | | | `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | `500` | | @@ -136,7 +152,7 @@ nothing else was left for the mirror to deliver. Covered in full by `purview-buildsdk-skill-installation`: `PurviewSkillsRunner`, `PurviewSkillsRunnerCandidates`, `PurviewSkillsCommand`, `PurviewSkillsPackage`, `PurviewSkillsAgents`, `PurviewSkillsSource`, `PurviewSkillsInstallRoot`, `PurviewSkillsRepoRoot`, -`EnablePurviewSkillRecommendations`, `PurviewSkillRecommendationsInCI`. +`PurviewSkillsDryRun`, and the `PurviewSkillsAgentDirectory` item group. ## Compiler-visible properties @@ -148,7 +164,11 @@ Exported to analyzers and generators as `build_property.`: - `ExcludePurviewTelemetry`, `ExcludeMSTelemetryExtension` - `EnableAssemblyNameGeneration`, `DisableAutoInternalsVisibleTo`, `DisableGenerateAssemblyInfoClass`, `DisableGenerateAssemblyIdentifierClass`, `AssemblyIdentifierClassName` -- `IsCSharpProject`, `IsTestProject`, `IsSharedTestingProject`, `IsSharedProject` +- `IsCSharpProject`, `IsTestProject`, `IsSharedTestingProject`, `IsSharedProject`, + `IsServerSharedProject`, `IsClientSideProject` +- `PurviewAutoServerSharedReference`, `DisablePurviewServerSharedReferenceCheck`, + `PurviewServerSharedReferenceCheckAsError` +- `MemorySafetyRules`, `PurviewRuntimeAsync`, `PurviewMultiThreadedBuild` - `TestingType`, `TargetProjectName` - `IsContainerProject`, `IsSdkProject`, `SdkProjectName`, `IsWebProject`, `IsWebSdkProject`, `IsWorkerSdkProject`, `IsAspireHostProject`, `IsCLIProject` @@ -163,6 +183,6 @@ Exported to analyzers and generators as `build_property.`: - `PurviewSuppressCopyRetryWarnings` - `PurviewSkillsSourceFolder`, `PurviewSkillsAgents`, `PurviewSkillsInstallRoot`, `PurviewRepositoryRootMarkers`, - `PurviewSkillsRunner` + `PurviewSkillsRunner`, `PurviewSkillsDryRun` - `PurviewBuildSdkVersionForGlobalJson`, `CurrentYear`, `AutoGeneratedAssemblyInfoFile`, `AutoGeneratedAssemblyIdentifierFile` diff --git a/.agents/skills/purview-buildsdk-project-detection/SKILL.md b/.agents/skills/purview-buildsdk-project-detection/SKILL.md index 8573ca4..bd80b3f 100644 --- a/.agents/skills/purview-buildsdk-project-detection/SKILL.md +++ b/.agents/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, 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. +description: Explain why Purview.BuildSdk classified a project as test, shared, shared-testing, ServerShared, client-side, 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 @@ -25,7 +25,8 @@ dotnet msbuild -getProperty:PurviewLogicalProjectName -getProperty:Roo The values worth inspecting first: `MSBuildProjectName`, `NamespacePrefix`, `PurviewLogicalProjectName`, `RootNamespace`, `TestingType`, `TargetProjectName`, `SdkProjectName`, -`IsTestProject`, `IsSharedProject`, `IsSharedTestingProject`, `IsContainerProject`, `IsCLIProject`, +`IsTestProject`, `IsSharedProject`, `IsServerSharedProject`, `IsClientSideProject`, +`IsSharedTestingProject`, `IsContainerProject`, `IsCLIProject`, `IsWebSdkProject`, `IsAspireHostProject`, `IsBlazorWebAssemblyProject`, `IsRazorClassLibraryProject`, `IsBlazorHostProject`, `IsBffProject`. @@ -52,11 +53,16 @@ a non-test project grants access to `{AssemblyName}.{TestType}Tests` for each na suffix outside it still builds, runs and gets a category — it just cannot see `internal` members. The 53 names are in [references/test-types.md](references/test-types.md). -### Shared and shared-testing projects +### Shared, shared-testing and ServerShared projects -Recognised by **exact name**, never by substring. The two lists are in +Recognised by **exact name**, never by substring. The three lists are in [references/detection.md](references/detection.md). Derived: `IsSharedProject`, -`IsSharedTestingProject`. +`IsSharedTestingProject`, `IsServerSharedProject`. + +`ServerShared` is separate from `Shared` for a reason: a `Shared` project is referenced by almost +everything, including code that ends up in a browser, so server-only helpers get their own name and +are referenced automatically only by a project that cannot be downloaded. `IsClientSideProject` is the +classification that decides it. ### SDK and content based diff --git a/.agents/skills/purview-buildsdk-project-detection/references/detection.md b/.agents/skills/purview-buildsdk-project-detection/references/detection.md index 85fde09..51243c9 100644 --- a/.agents/skills/purview-buildsdk-project-detection/references/detection.md +++ b/.agents/skills/purview-buildsdk-project-detection/references/detection.md @@ -5,6 +5,20 @@ `Shared`, `SharedFramework`, `SharedInfrastructure`, `SharedInfra`, `SharedUtilities`, `SharedUtils`, `SharedLibrary`, `SharedLib`, `SharedHelpers` +## ServerShared project names (exact) + +`ServerShared`, `ServerSharedFramework`, `ServerSharedInfrastructure`, `ServerSharedInfra`, +`ServerSharedUtilities`, `ServerSharedUtils`, `ServerSharedLibrary`, `ServerSharedLib`, +`ServerSharedHelpers` + +A `ServerShared` project is referenced automatically by every non-client-side project, and never by a +WebAssembly client, a `PurviewBrowserSafe` library, a Razor class library, a project named with a +client-side segment (`*.Client`, `*.ClientShared`, `*.Browser`, `*.Wasm`, `*.WebAssembly`, +`*.Components`, or exactly `Components`/`ClientShared`), or a `Shared`/`SharedTesting` project. +`IsClientSideProject` overrides the classification. `PRSHR0001` reports a client-side reference and +`PRSHR0002` a `Shared` reference; `PurviewBrowserSafe="true"` on the reference records an accepted +decision. + ## Shared testing project names (exact) `SharedTestingFramework`, `SharedTestingInfrastructure`, `SharedTestingInfra`, @@ -64,7 +78,8 @@ 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 WebAssembly client | not packable, no container defaults, no automatic `Shared`/`ServerShared` reference, the browser-safe reference check - see [blazor.md](blazor.md) | +| ServerShared project | referenced automatically by every non-client-side project; never by a WebAssembly client, browser-safe library, Razor class library, client-side name, or `Shared`/`SharedTesting` project | | 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) | diff --git a/.agents/skills/purview-buildsdk-repository-review/SKILL.md b/.agents/skills/purview-buildsdk-repository-review/SKILL.md index ce1fd29..2be4c08 100644 --- a/.agents/skills/purview-buildsdk-repository-review/SKILL.md +++ b/.agents/skills/purview-buildsdk-repository-review/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-buildsdk-repository-review -description: Review a repository that uses Purview.BuildSdk for naming, placement and test-structure alignment, and report what the SDK can and cannot do automatically as a result. Use when asked to audit, rationalise or health-check a repo against the SDK's conventions, when onboarding an existing codebase onto Purview.BuildSdk, or when deciding whether a layout quirk is worth changing. +description: Review a repository that uses Purview.BuildSdk for naming, placement, test-structure and agent-skill-management alignment, and report what the SDK can and cannot do automatically as a result. Use when asked to audit, rationalise or health-check a repo against the SDK's conventions, when onboarding an existing codebase onto Purview.BuildSdk, or when deciding whether a layout quirk is worth changing. license: MIT metadata: package: Purview.BuildSdk @@ -89,7 +89,24 @@ go stale: is not any more. - A package the SDK already adds, restated in a project file, is maintenance cost with no effect. -## 6. Explain consequences, not just mismatches +## 6. Check how agent skills are managed + +Only relevant when the repository has a `skills-lock.json`, an agent skills directory, or a package +reference that ships skills: + +- **Anything that runs `InstallPurviewSkills`, `UpdatePurviewSkills` or `FixPurviewSkills` + implicitly** — an `AfterTargets`/`BeforeTargets` hook, a `*DependsOn` property, a git hook, a + `just` recipe dependency — is structural debt: the SDK fails it with `PRSKL0009`, and skill changes + should only happen when somebody asks. +- **Hand-written skill scripts** (`update-agent-skills.ts`, `ensure-universal-skills.ts`, + `package.json` `skills:*` entries) are an incremental opportunity: the SDK targets replace them, and + pin package skills to their package's commit where the scripts install latest. See + `purview-buildsdk-skills-justfile`. +- **Drift**: run `dotnet msbuild -t:RecommendPurviewSkills` and quote any `PRSKL0010`/`PRSKL0011`. +- **Layout**: if `PurviewSkillsAgents` is set, run `-t:CheckPurviewSkills` and quote any `PRSKL0015`. + If it is not set, every agent directory is legitimate — that is acceptable variance, not a finding. + +## 7. Explain consequences, not just mismatches For each finding, name the specific automatic behaviour that is lost: `RootNamespace`, `AssemblyName`, `PackageId`, `TargetProjectName`, the automatic target-project reference, the diff --git a/.agents/skills/purview-buildsdk-skill-installation/SKILL.md b/.agents/skills/purview-buildsdk-skill-installation/SKILL.md index 66c20a3..5c2694d 100644 --- a/.agents/skills/purview-buildsdk-skill-installation/SKILL.md +++ b/.agents/skills/purview-buildsdk-skill-installation/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-buildsdk-skill-installation -description: Install, list and diagnose the agent skills a Purview.BuildSdk package ships. Use for the InstallPurviewSkills, ListPurviewSkills and RecommendPurviewSkills MSBuild targets, for any PRSKL0001-PRSKL0014 diagnostic, for choosing a package runner (npx, pnpm dlx, bunx, yarn dlx, deno), for skills-lock.json, or when a package ships skills but no agent can see them. +description: Install, update, list, check and diagnose the agent skills a Purview.BuildSdk package ships. Use for the InstallPurviewSkills, UpdatePurviewSkills, CheckPurviewSkills, FixPurviewSkills, ListPurviewSkills, RecommendPurviewSkills and VerifyPurviewSkills MSBuild targets, for updating skills after a package upgrade, for choosing agents with PurviewSkillsAgents, for any PRSKL diagnostic, for choosing a package runner (bun x, npx, pnpm dlx, bunx, yarn dlx, deno), for skills-lock.json, or when a package ships skills but no agent can see them. license: MIT metadata: package: Purview.BuildSdk @@ -9,7 +9,8 @@ metadata: # Installing the skills a package ships -For *authoring* skills, use `purview-buildsdk-skill-tests`. +For *authoring* skills, use `purview-buildsdk-skill-tests`; for `just` recipes that run these +targets, `purview-buildsdk-skills-justfile`. ## The one thing to understand first @@ -30,10 +31,22 @@ restore, a build, a design-time build or a test run. ```bash dotnet msbuild -t:ListPurviewSkills # diagnostics only, installs nothing dotnet msbuild -t:InstallPurviewSkills # delegates to the skills CLI +dotnet msbuild -t:UpdatePurviewSkills # re-add every skills-lock.json source: refresh, and add new skills +dotnet msbuild -t:CheckPurviewSkills # only the PurviewSkillsAgents directories exist +dotnet msbuild -t:FixPurviewSkills # remove the CLI's links from any other agent directory ``` -Neither target has `BeforeTargets` or `AfterTargets`. If skills are appearing or changing on their -own, something has been wired up that should not be. +They run **only when named**. None has `BeforeTargets` or `AfterTargets`, nothing in the SDK depends +on them, and `InstallPurviewSkills`, `UpdatePurviewSkills` and `FixPurviewSkills` refuse to run when +something else pulls them into a build, a restore or a design-time build (`PRSKL0009`). Do not hook +them into a build, a `*DependsOn` property or a git hook. If skills are changing on their own, +something has been wired up that should not be. + +Name **the solution**. Skills belong to the repository, so each target runs once however many +projects the solution holds: every project hands the work to the same elected leader, and MSBuild +runs it once. Package resolution reads every project in the solution, so a package only one project +references is still found. A single project works too, but sees only its own packages. For `just` +recipes that run these targets, see `purview-buildsdk-skills-justfile`. ## How a skill is resolved, and why the version is always right @@ -48,6 +61,57 @@ So the resolved ref is the commit the *restored package version* was built from. `PackageReference` gets that version's skills, not whatever is on the default branch. Compatibility is exact by construction rather than by convention. +## Updating + +```bash +# after bumping the PackageReference +dotnet restore +dotnet msbuild -t:UpdatePurviewSkills +``` + +`UpdatePurviewSkills` re-adds every unique source recorded in `skills-lock.json` with `skills add`: + +- a source a restored package ships is re-added at **that package's commit**, naming every skill + that package version ships — the same pinned `add` `PRSKL0011` recommends, so the skills match the + package version referenced; +- every other source is re-added at its latest with `--skill "*"`. + +That is the whole update. `skills add` reinstalls every skill it selects, so one re-add both +refreshes the installed skills and adds any the source has gained; `skills update` would only +re-fetch what the lockfile already lists, and is never run. `-p:PurviewSkillsDryRun=true` prints every +command and runs none. Each failed source is named (`PRSKL0008`). + +`InstallPurviewSkills` also works as an update for package skills alone: it re-adds each at its +package's commit. Do not reach for a bare `skills update` instead; see below. + +## Choosing agents + +`PurviewSkillsAgents` is **empty by default**, which passes no `--agent` and lets the CLI auto-detect +the agents on the machine. Set it to choose — in a project file `universal`, +or on a command line with the separator escaped or the value quoted, because `dotnet msbuild -p:` +itself splits on `,` and `;`: + +```bash +dotnet msbuild -t:UpdatePurviewSkills -p:PurviewSkillsAgents=claude-code%2Ccodex +``` + +Each agent becomes its own `--agent` flag; the CLI does not split a comma-separated value. + +When `PurviewSkillsAgents` is set, `CheckPurviewSkills` fails (`PRSKL0015`) for any agent skill +directory at the repository root that belongs to an agent it does not name. `.agents/skills` is always +allowed — it is the CLI's canonical store — and the authored `skills/` folder is never touched. +`FixPurviewSkills` removes links (symlinks, junctions) from those directories, never their targets, +and then any folder left empty; a real file is kept and reported, because it is not provably the +CLI's. The agent-to-directory map is the `PurviewSkillsAgentDirectory` item group, taken from the +CLI's own agent table; an agent it does not know is `PRSKL0016`, fixed by adding an item: + +```xml + +``` + +With `PurviewSkillsAgents` empty, both targets say so and do nothing: every agent directory is then a +legitimate destination. + ## Installing from an explicit source For a skill you want but have no `PackageReference` for: @@ -66,58 +130,78 @@ The caller owns the ref here — append `#` to pin it. This path needs no r | `PRSKL0001` | `project.assets.json` is missing | Restore. Resolution reads the restore graph and deliberately does not trigger a restore of its own | | `PRSKL0002` | The package's nuspec has no GitHub repository URL, or no commit | The package cannot be pinned. It is reported rather than installed from a guessed branch | | `PRSKL0003` | No repository root resolved | Pass `-p:PurviewSkillsInstallRoot=` | -| `PRSKL0004` | No skills CLI and no package runner found | Install Node, or set `PurviewSkillsCommand` | +| `PRSKL0004` | No skills CLI and no package runner found | Install Bun or Node, or set `PurviewSkillsCommand` | | `PRSKL0005` | An authored skill is malformed | See `purview-buildsdk-skill-tests` | -| `PRSKL0010` | A skill a referenced package ships is missing | Warning. `npx skills add ...` | +| `PRSKL0006` | Retired, with `PurviewSkillsUpdateMode` | Remove the property; update no longer runs `skills update` | +| `PRSKL0007` | `UpdatePurviewSkills` found no `skills-lock.json` | Install first with `-t:InstallPurviewSkills` | +| `PRSKL0008` | Re-adding a lockfile source failed | Read the CLI output above it | +| `PRSKL0009` | Install, update or fix was pulled into a build, a restore or a design-time build | Remove the hook; run the target on its own | +| `PRSKL0010` | A skill a referenced package ships is missing | Warning. `bun x skills add ...` | | `PRSKL0011` | An installed skill is pinned to a different ref than the referenced package | Warning. Install the package's own commit | | `PRSKL0012` | A skill cannot be verified | Message | | `PRSKL0013` | An orphaned lockfile entry | Message | | `PRSKL0014` | Recorded in the lockfile but never restored | Message | +| `PRSKL0015` | An agent skill directory exists for an agent `PurviewSkillsAgents` does not name | `-t:FixPurviewSkills`, then remove anything it kept by hand | +| `PRSKL0016` | `PurviewSkillsAgents` names an agent with no known skills directory | Warning. Add a `PurviewSkillsAgentDirectory` item | +| `PRSKL0017` | Projects in the solution reference one skill-shipping package at different versions | Warning. Align the versions; only one version's skills can be installed | + +## Nothing about skills runs during a build -## Recommendations run during an ordinary build +This used to be the opposite: a `RecommendPurviewSkillsOnBuild` hook ran on `AfterTargets="Build"`. +It was removed after measurement — on a 39-project solution it read and regex-scanned every project's +`project.assets.json` (≈500 KB, 256 libraries each), made thousands of failing `Directory.Exists` +calls, read the lockfile and walked the repository root, on every build, to print advice that a +repository managing its own skills had already acted on. All of its inputs are repository-scoped, so +the per-project cost bought nothing. -This is the **one** skill feature that does. `RecommendPurviewSkillsOnBuild` runs after `Build` and -delegates to `RecommendPurviewSkills`, which can also be invoked by name: +The work is now paid only when somebody names a target: ```bash -dotnet msbuild -t:RecommendPurviewSkills +dotnet msbuild -t:RecommendPurviewSkills # report, never fail +dotnet msbuild -t:VerifyPurviewSkills # report, and fail on PRSKL0010/0011 ``` `EvaluatePurviewSkillState` joins the skills each restored package ships against the CLI's own `skills-lock.json`. Both inputs are local files, so it stays offline and spawns no process. -Four things keep it quiet, and all of them are load-bearing: +`VerifyPurviewSkills` is the gate to call from CI. It escalates only `PRSKL0010` (missing) and +`PRSKL0011` (drifted) to errors: both are factual, both are fixed by one command, and neither can fire +without a referenced package that really ships skills. `PRSKL0012` stays a warning because a package +published without repository metadata is not the consumer's to fix, and `PRSKL0013`/`PRSKL0014` stay +messages. Failing over something the caller cannot clear is how a gate gets switched off. + +Three things shape the output: -- Design-time builds, CI builds, non-default TFM passes and - `EnablePurviewSkillRecommendations=false` are skipped outright. - `PurviewSkillRecommendationsInCI=true` re-enables CI. -- A fingerprint stamp at `/.purview/skill-recommendations.cache` means a given state is - reported once per repository, not once per project and again on every rebuild. The explicit target - ignores the stamp. - Missing and drifted are **warnings** (fixable in one command); unverifiable and orphaned are - **messages**. No amount of skill advice can break a build using `TreatWarningsAsErrors`. + **messages**. `VerifyPurviewSkills` turns missing and drifted into failures, and promotes + unverifiable to a warning. - A correctly installed set produces no output at all. +- There is no deduplication stamp any more. `EnablePurviewSkillRecommendations`, + `PurviewSkillRecommendationsInCI` and `PurviewSkillRecommendationStampPath` were retired with the + hook: a stamp existed so a per-build hook could report one state once per repository, and a target + a person invokes should answer every time it is asked. Orphan detection is **owner-scoped on purpose**: a lockfile cannot record who asked for an entry, so only an entry under an owner the project references — but from a repository it no longer references — is reported. A skill from any other owner is the developer's own, and is never mentioned. -### Drift never recommends `npx skills update` +### Drift never recommends a bare `skills update` -`update` installs the *newest* published skill, which is only correct when the newest skill happens -to describe the referenced package version. `PRSKL0011` names the referenced package's own commit in -a pinned `npx skills add` instead. +Re-adding a source without a ref installs the *newest* published skill, which is only correct when +the newest skill happens to describe the referenced package version. `PRSKL0011` names the referenced +package's own commit in a pinned `skills add` instead — which is what `-t:InstallPurviewSkills` and +`-t:UpdatePurviewSkills` run for package skills. ## Package runners The CLI is published to npm, and every runner spells "run this npm package" differently. -`PurviewSkillsRunner` selects one, defaulting to `npx` because npx ships with Node itself. +`PurviewSkillsRunner` selects one, defaulting to `bun` (`bun x skills`). | Runner | Invocation | | -- | -- | +| `bun` *(default)* | `bun x skills@1` | | `npx` | `npx -y skills@1` | | `bunx` | `bunx skills@1` | -| `bun` | `bun x skills@1` | | `pnpm` | `pnpm dlx skills@1` | | `yarn` | `yarn dlx skills@1` — Yarn 2+ only | | `deno` | `deno run -A npm:skills@1` | @@ -156,11 +240,9 @@ two-pass behaviour and its home-directory bound are both load-bearing. The CLI owns the agent-specific destinations and their discovery conventions, which differ per agent and change independently of this SDK. It also owns `skills-lock.json`, so a repository can restore -the same set later with `npx skills experimental_install`. - -`PurviewSkillsAgents` (default `*`) narrows the target agents. +the same set later with `bun x skills experimental_install`. -The lockfile is read **directly** rather than by parsing `npx skills list --json`, because that +The lockfile is read **directly** rather than by parsing `skills list --json`, because that listing omits the `ref` — and the ref is the whole compatibility question. Reading a file also needs no Node tooling, which is what keeps recommendations working on a machine that can build .NET but has no Node. There is deliberately no competing lockfile. diff --git a/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md b/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md index e90b695..d556174 100644 --- a/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md +++ b/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md @@ -44,7 +44,9 @@ an agent directory somewhere that is not the repository. A property a dependency computes is still empty in the depending target's `Condition`. That is why the unresolved-root guard sits on the `EvaluatePurviewSkillState` task rather than on -`RecommendPurviewSkills` — and equally why the build hook's CI and design-time gating works at all. +`RecommendPurviewSkills`, and why the explicit-only guard (`PRSKL0009`) is a dependency target with +its own `Error` rather than a `Condition` on `InstallPurviewSkills`, `UpdatePurviewSkills` or +`FixPurviewSkills`. ## The managed port diff --git a/.agents/skills/purview-buildsdk-skills-justfile/SKILL.md b/.agents/skills/purview-buildsdk-skills-justfile/SKILL.md new file mode 100644 index 0000000..2da36a2 --- /dev/null +++ b/.agents/skills/purview-buildsdk-skills-justfile/SKILL.md @@ -0,0 +1,177 @@ +--- +name: purview-buildsdk-skills-justfile +description: Add or update Justfile recipes that run Purview.BuildSdk's agent-skill targets - skills-update, skills-check, skills-fix, skills-install, skills-recommend and skills-verify. Use when a repository wants a just recipe to update, install, check or clean up its agent skills, when replacing hand-written skill scripts (update-agent-skills.ts, ensure-universal-skills.ts, package.json skills:* scripts) with the SDK targets, or when an existing skills recipe runs the wrong project, the wrong runner or the wrong agents. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Justfile recipes for the agent-skill targets + +The recipes are thin: each one names one SDK target. The behaviour lives in the targets, which are +covered by `purview-buildsdk-skill-installation`; this skill is about wiring them into a `Justfile` +correctly. + +## The rule that matters most + +**Every skill recipe is run by a person, on purpose.** `InstallPurviewSkills`, `UpdatePurviewSkills` +and `FixPurviewSkills` change the repository, and the SDK refuses to run them inside a build, a +restore or a design-time build (`PRSKL0009`). Keep the Justfile consistent with that: + +- never make a skills recipe a dependency of `build`, `test`, `default` or any other recipe; +- never call `skills-update`, `skills-install` or `skills-fix` from a git hook (`lefthook.yml`, + `.husky/`) or a CI job that commits; +- `skills-verify` is the one recipe CI may call: it is read-only and fails on missing or drifted skills. + +## Recipes + +Put them in their own file beside the `Justfile`, and import it from the `Justfile`: + +```just +# Justfile +import "Justfile.skills.just" +``` + +The only line that differs between repositories is `skills_project`. Work it out from the repository +as described in [Choosing `skills_project`](#choosing-skills_project) — do not ask for it, and do not +leave a placeholder: + +```just +skills_project := solution_file # or a path, when the Justfile has no such variable + +# Update agent skills: re-add every skills-lock.json source, refreshing installed skills and adding new ones +[group('Agent skills')] +skills-update *args: + dotnet msbuild "{{ skills_project }}" "-t:UpdatePurviewSkills;FixPurviewSkills" -v:n {{ args }} + +# Check that only the skill directories of the agents in PurviewSkillsAgents exist +[group('Agent skills')] +skills-check *args: + dotnet msbuild "{{ skills_project }}" -t:CheckPurviewSkills -v:n {{ args }} + +# Remove the skills CLI's links from agent directories PurviewSkillsAgents does not select +[group('Agent skills')] +skills-fix *args: + dotnet msbuild "{{ skills_project }}" -t:FixPurviewSkills -v:n {{ args }} + +# Install the skills the referenced packages ship (pass -p:PurviewSkillsSource=owner/repo for others) +[group('Agent skills')] +skills-install *args: + dotnet msbuild "{{ skills_project }}" -t:InstallPurviewSkills -v:n {{ args }} + +# Report missing, drifted or unverifiable skills without changing anything +[group('Agent skills')] +skills-recommend *args: + dotnet msbuild "{{ skills_project }}" -t:RecommendPurviewSkills -v:n {{ args }} + +# Fail when a referenced package's skills are missing or drifted - the CI gate +[group('Agent skills')] +skills-verify *args: + dotnet msbuild "{{ skills_project }}" -t:VerifyPurviewSkills -v:n {{ args }} +``` + +Only add the recipes the repository will use. `skills-update` and `skills-check` are the usual pair. + +### Choosing `skills_project` + +Take the first of these that applies: + +1. **The `Justfile` already names the solution in a variable** — `solution_file`, `solution`, `sln` + or similar, typically used by `build`, `test` and `restore`. Reference it: + `skills_project := solution_file`. An imported file shares the importing file's variables, so the + skill recipes then follow the solution if it is ever renamed or moved, with nothing to keep in step. +2. **An existing recipe runs `dotnet build`, `test` or `restore` on a literal solution path.** Use that + path. If several recipes repeat it, offer to lift it into a `solution_file` variable and reference + that, but do not restructure the `Justfile` unasked. +3. **No `Justfile`, or one that never names a solution.** Search for `*.slnx` and `*.sln`, ignoring + `bin/`, `obj/`, `node_modules/` and `artifacts/`. If there is exactly one, use its path relative to + the `Justfile`. With several, prefer the one the CI workflow or README builds; ask only if that + still leaves more than one. +4. **No solution at all.** Name a project that imports the SDK and references every package that + ships skills, and say in a comment that it sees only its own packages. + +An existing `Justfile.skills.just` pointed at a hand-picked project gets the same treatment: replace +the project with the solution, chosen as above. Remove any comment explaining why that project was +picked, because the reason no longer holds. + +### Details that break a recipe if missed + +- **Name the solution.** Every skill target runs once for the whole solution, however many projects + it holds: each project hands the work to the same elected leader, and MSBuild runs it once. Package + resolution reads every project in the solution, so a package only a test project references is + still pinned. There is no project to choose and nothing to keep in step as projects come and go. + A single project also works, but sees only that project's packages. (SDK releases before this + behaviour ran the target once per project; there, name one project that references every package + that ships skills.) +- **Restore first.** The targets read `project.assets.json` and never restore on their own + (`PRSKL0001`). A fresh clone needs `dotnet restore` (or `just restore`) before `skills-update`. +- **Quote a multi-target `-t:`.** `"-t:UpdatePurviewSkills;FixPurviewSkills"` — unquoted, `sh` treats + the `;` as the end of the command and runs `FixPurviewSkills` as a separate, failing command. +- **Keep `*args` last** so a caller can pass properties: `just skills-update -p:PurviewSkillsDryRun=true`. +- `FixPurviewSkills` after `UpdatePurviewSkills` does nothing unless `PurviewSkillsAgents` is set, so + it is safe in the recipe either way. + +## Runner and agents belong in MSBuild, not in the recipe + +Set repository-wide choices once in `Directory.Build.props`, not as flags repeated in each recipe, +so a plain `dotnet msbuild -t:...` behaves the same as the recipe: + +```xml + + + bun + + universal + +``` + +One-off overrides go on the command line. `dotnet msbuild -p:` splits values on `,` and `;`, so +escape the separator: `just skills-update -p:PurviewSkillsAgents=claude-code%2Ccodex`. + +Useful properties to pass through `*args`: + +| Property | Effect | +| -- | -- | +| `PurviewSkillsDryRun=true` | Print every CLI command; run none | +| `PurviewSkillsRunner=npx` | Launch the CLI through another runner (`bunx`, `pnpm`, `yarn`, `deno`) | +| `PurviewSkillsSource=owner/repo#ref` | `skills-install` from an explicit source with no package behind it | + +## Replacing hand-written skill scripts + +Repositories often carry their own scripts for this. The SDK targets replace them: + +| Hand-written | Replace with | +| -- | -- | +| `scripts/update-agent-skills.ts`, `"skills:update"` | `skills-update` (`UpdatePurviewSkills`) | +| `--update` / `--no-update` | Nothing: the re-add refreshes installed skills and adds new ones, so there is no separate `skills update` step to switch | +| `--dry-run` | `-p:PurviewSkillsDryRun=true` | +| `scripts/ensure-universal-skills.ts check`, `"skills:check"` | `skills-check` with `PurviewSkillsAgents=universal` | +| `scripts/ensure-universal-skills.ts fix`, `"skills:fix"` | `skills-fix` | +| a hard-coded `-a universal` | `PurviewSkillsAgents` — or leave it empty to auto-detect | + +Two behaviours differ, deliberately — mention them when migrating: + +- A source a referenced package ships is re-added **at that package's commit**, not at latest, so its + skills keep matching the package version. Other sources are re-added at latest, as before. +- Update is `skills add`, not `skills update`, so it also installs skills a source has **gained**: a + package source gets every skill its referenced version ships, and any other source is re-added with + `--skill "*"`. + +After switching, delete the scripts and their `package.json` entries, and remove `package.json` +itself if nothing else uses it. Run `just skills-update -p:PurviewSkillsDryRun=true` and compare the +printed commands with what the old script ran before deleting anything. + +## Updating an existing Justfile + +1. Read the whole `Justfile` first: match its group names, comment style, and any `set shell` + (a PowerShell shell still needs the `-t:` value quoted). +2. Keep recipe names the repository already uses; change only what they run. If `skills-check` + already means "report", rename it to `skills-recommend` rather than silently changing its meaning, + and say so. A recipe pointed at a hand-picked "small" project, to avoid running once per project, + can point at the solution instead. +3. Check nothing depends on a changing recipe: `just --list`, then search the Justfile, CI workflows + and hook configuration for the recipe name. +4. Verify: `just --list` shows the group, `just skills-update -p:PurviewSkillsDryRun=true` prints the + commands, and `just skills-check` passes or names a real problem. diff --git a/.agents/skills/purview-component-compatibility/SKILL.md b/.agents/skills/purview-component-compatibility/SKILL.md new file mode 100644 index 0000000..b017681 --- /dev/null +++ b/.agents/skills/purview-component-compatibility/SKILL.md @@ -0,0 +1,116 @@ +--- +name: purview-component-compatibility +description: >- + Use when developing, reviewing or releasing a published component - a NuGet package, or an npm + package - and choosing which dependency version to declare, when a consumer reports a version + conflict or an unexpected upgrade, or when a component's supported target frameworks change. Applies + the golden rule that a published component declares the LOWEST compatible version rather than the + latest, walks the Purview component hierarchy to find each floor, and verifies it by building + against it. Not for applications: they take the latest compatible version instead. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" + repository: purview-dev/engineering-playbook +--- + +# Purview component compatibility + +A published component makes a promise with every version it declares: *this is the least I require of +you*. Raise that number and every consumer is pulled up with you, whether or not they wanted it. + +**The golden rule: declare the lowest version that compiles, passes your tests, and is supported on +every target framework you claim.** The version you happen to have installed is never the answer, and +neither is the latest. + +## Which direction? + +| What you are publishing | Declare | Why | +| -- | -- | -- | +| an application, a test suite, a tool — it runs, and nobody installs it | the **latest** compatible version | you own the runtime, and a version you skip is a fix you did not take | +| a component — a NuGet or npm package somebody else installs | the **lowest** compatible version | dependency resolution takes the highest minimum in the graph, so your floor becomes theirs | + +This skill is about the second row. If the repository only ever runs, stop here and take the latest. + +## Why a floor matters + +NuGet and npm resolve a graph to the **highest** minimum among all constraints. If a package declares +`Testcontainers` 4.16 because 4.16 is what its author had installed, every consumer is forced onto 4.16, +including the one whose own constraint is 4.0 and whose runtime is pinned to it. The consumer cannot +refuse an upgrade the package never needed to ask for, and the symptom appears in *their* build, not +yours. + +## The workflow + +### 1. Name the component and its frameworks + +The floor is per target framework, not per package. Write down every `TargetFramework` the component +publishes; a multi-targeting component has a different floor on each, and the one that matters is the +oldest framework still supported, not the newest one you build against. + +### 2. Read the hierarchy, do not remember it + +The dependencies — and their minimums — are in the published `.nuspec`, not in anyone's memory: + +```bash +node scripts/dependencies.mjs Purview.Containers +node scripts/dependencies.mjs Purview.Architecture.Testing --tfm net11.0 +node scripts/dependencies.mjs Purview.Containers --walk +``` + +A bare version is a **minimum**; a bracketed one (`[1.2.3]`, `[1.0,2.0)`) is a range. The script prints +the distinction, because it is the whole rule: a minimum you raise is a minimum every consumer inherits. + +`references/purview-components.md` records which Purview package depends on which, and which target +frameworks each family supports. Where the two disagree the registry wins — that file is the map, the +`.nuspec` is the territory. + +### 3. Fix the floor, per framework + +For each dependency, find the oldest version that: + +- exposes every API the component calls (the compiler decides this, not the changelog); +- passes the component's own tests and its consumer-verification suite; +- is supported on every framework in that dependency group. + +Then declare that version. Not `latest`, not floating (`1.*`), and never the version that resolved on +your machine by accident. + +### 4. Prove the floor + +A floor nobody builds against is a guess. Add a CI leg that restores the component with every dependency +held at its declared floor and runs the suite — the ordinary leg keeps resolving the latest, which is +what a consumer will actually get. `references/lowest-compatible.md` has the mechanics. + +### 5. Keep the ceiling visible, and the floor written down + +A component's README carries a badge for the current release and a sentence naming the floor it +supports. Badges are generated from the registry, so they cannot go stale; a version written into prose +is wrong within one release. + +## Pitfalls that produce a floor nobody meant + +| Pitfall | What it looks like | The fix | +| -- | -- | -- | +| Declaring what is installed | `dotnet add package X` writes the latest; right for an application, wrong for a component | edit the version down, then prove it in step 4 | +| Transitive uplift | a dependency pulls in a newer transient package than your floor allows | override it deliberately — the persistence sample does this for a security-patched package — and comment why | +| One framework's floor | a `net10.0-windows` group needs a higher floor than `net10.0` | read each group; never average them | +| Prerelease in the graph | a family that ships prereleases depends on them at the same version | pin the family to one property so the floor moves together | +| Analyzer and source-generator packages | compile-time only, so they rarely constrain a consumer | still declare a real floor; they appear in the graph and in transitive audits | +| Floating versions | `1.*` resolves differently on every restore | banned: a floor is a number | + +## Checks before you answer + +- Is each declared version the lowest that passes, or the one that happened to be installed? +- Does every declared target framework have a floor that compiles on that framework? +- Was the floor proved by a build, or only by your machine? +- Does any page state a version number? It should be a badge or a link to the registry. +- If a floor was raised, is that a breaking change the version number admits to? + +## See also + +`references/lowest-compatible.md` for the CI mechanics and the override patterns, and +`references/purview-components.md` for the Purview hierarchy. The rule itself, and the application-side +half of it, is stated in the Engineering Playbook's Dependency Versions page; the +`purview-engineering-principles` skill summarises it as one of the eleven conventions. + diff --git a/.agents/skills/purview-component-compatibility/references/lowest-compatible.md b/.agents/skills/purview-component-compatibility/references/lowest-compatible.md new file mode 100644 index 0000000..ccdb3b6 --- /dev/null +++ b/.agents/skills/purview-component-compatibility/references/lowest-compatible.md @@ -0,0 +1,93 @@ +# Proving the floor + +The rule is "declare the lowest compatible version". This file is the mechanics: how to find the floor, +how to prove it in a build, and the patterns that keep it from silently rising. + +## What "compatible" means, precisely + +A version is a valid floor when all three hold: + +1. **It compiles against every API the component uses.** The compiler decides this, not a changelog. If + you cannot say which member forced the version up, you have not found the floor. +2. **The component's own tests pass against it**, including the consumer-verification suite, which + builds a throwaway consumer rather than the component's own test project. +3. **It is supported on every target framework in that dependency group.** A floor is per group, and a + multi-targeting component can have several. + +## Finding it + +```bash +dotnet list package --outdated # what you declared against what exists +dotnet list package --include-transitive # what actually resolved +dotnet nuget why # which path pulled a version in +``` + +Then work downward: lower the declared version, build, and read the compiler. The first version that +compiles *and* passes is the floor. Do not stop at the first failure and pick the version above it — +that is how a floor ends up one minor above what the code needs. + +## Making the floor a build leg + +Keep one property per family (so a family cannot be bumped halfway) and make each conditional on a floor +switch. Nothing here is NuGet-specific magic: it is MSBuild, and central package management already +gives you the single place to put it. + +```xml + + LATEST + FLOOR + +``` + +```bash +dotnet restore -p:DependencyFloor=true +dotnet build -p:DependencyFloor=true --no-restore +dotnet test -p:DependencyFloor=true --no-build +``` + +`LATEST` and `FLOOR` are literals in the file; the point is that they are two properties, not two files. +Run the ordinary leg at `LATEST` and a second, slower leg at `FLOOR`, and give the floor leg its own CI +job so a failure names the floor rather than the build. + +Where a component multi-targets, add the framework dimension and run the pair per framework: + +```bash +for tfm in net8.0 net9.0 net10.0; do + dotnet build -p:DependencyFloor=true -f "$tfm" +done +``` + +## Transitive pins + +A floor can be raised by something you never declared. Transitive pinning puts those versions where you +can see them: + +```xml + + true + +``` + +Then an override is a deliberate line with a reason beside it — the persistence sample in this +repository does exactly that for a security-patched package the SQL client drags in. An override without +a comment is indistinguishable from an accident, and the next person deletes it. + +## The ceiling, and the API surface + +- The floor leg proves the **bottom**; the ordinary leg proves the **top**. Both, or you know neither. +- An API-compatibility check against the previous release catches the other direction of the same + mistake: a floor can stay put while the public surface grows a member that only the newest dependency + provides. `Microsoft.DotNet.ApiCompat.Task` wires that into the build; `Microsoft.DotNet.ApiCompat.Tool` + runs it from a pipeline. +- A prerelease dependency is a floor with an expiry: it cannot be consumed by anyone who has not opted + into prereleases. Say so in the package's README rather than letting the error message explain it. + +## What not to do + +| Temptation | Why it is wrong | +| -- | -- | +| `dotnet add package X` and commit | it writes the latest; correct for an application, wrong for a component | +| floating versions (`1.*`) | resolves differently on every restore, so the floor is nobody's decision | +| "it restores on my machine" | that proves one graph, on one date, with one SDK | +| raising a floor in a patch release | the consumer's build changes without their version changing | +| one floor leg for all frameworks | the framework with the lowest floor is the one nobody tested | diff --git a/.agents/skills/purview-component-compatibility/references/purview-components.md b/.agents/skills/purview-component-compatibility/references/purview-components.md new file mode 100644 index 0000000..6e13b65 --- /dev/null +++ b/.agents/skills/purview-component-compatibility/references/purview-components.md @@ -0,0 +1,60 @@ +# The Purview component hierarchy + +Which Purview package depends on which, and which target frameworks each family supports, so a harvest +or a floor decision starts from the shape rather than from a memory of it. + +Every row was read from the registry with `scripts/dependencies.mjs`, which reads the published +`.nuspec`. **The registry is the authority; this file is the map.** Re-run the script before acting on +any row — a family that multi-targets changes its floors on every release. + +## The families + +| Family | Packages | Target frameworks | Depends on | +| -- | -- | -- | -- | +| Build SDK | `Purview.BuildSdk` | `netstandard2.0` (an MSBuild SDK) | nothing | +| Test-support rule sets | `Purview.Skills.Testing`, `Purview.Architecture.Testing`, `Purview.Architecture.Testing.Cli` | `net10.0`; `Architecture.Testing` also `net11.0` | `TUnit.Core`, `TUnit.Assertions`, `JsonSchema.Net` | +| Telemetry | `Purview.Telemetry.SourceGenerator` | `netstandard2.0` (an analyzer) | nothing | +| Containers | `Purview.Containers` + `.Core`, `.Docker`, `.Wsl`, `.MsSql`, `.PostgreSql` | `net10.0`; `.Wsl` adds `net10.0-windows10.0.19041` | its own family at the same version, `Microsoft.Extensions.Telemetry.Abstractions`, `Testcontainers` | +| Results | `Purview.Results`, `.AspNetCore`, `.ZodSharp`, `.ZodSharp.AspNetCore` | `net10.0`, `net11.0` | nothing; the adapters depend on the core and on ZodSharp | +| ZodSharp | `Purview.ZodSharp`, `.AspNetCore`, `.SystemTextJson`, `.NewtonsoftJson` | `net9.0`, `net10.0`, `net11.0` | `Microsoft.Extensions.Options`, `Microsoft.Extensions.DependencyInjection.Abstractions` | +| Value Objects | `Purview.ValueObjects` | `net8.0`, `net9.0`, `net10.0`, `net11.0` | nothing | +| Source Generator Framework | `Purview.SourceGeneratorFramework`, `.Testing`, `.Testing.TUnit` | `netstandard2.0` | Roslyn (`Microsoft.CodeAnalysis.CSharp`), `System.Collections.Immutable`, `System.Reflection.Metadata` | +| Aspire ResourceKit | `Purview.Aspire.ResourceKit` | `net8.0`, `net9.0`, `net10.0` | `Aspire.Hosting`, OpenTelemetry exporter and hosting, `Microsoft.Extensions.*` | +| Astroc4 | `@purview-dev/astroc4` (npm, not NuGet) | an Astro/Starlight plugin | `likec4`, `@astrojs/starlight`, `astro` as peers | + +## Reading the shape + +Three patterns are worth internalising, because each one changes what "the floor" means: + +- **A family ships at one version.** `Purview.Containers.Core` is required at the umbrella's own version, + and `Purview.Results.ZodSharp` requires `Purview.Results` and `Purview.ZodSharp` at their current ones. + Declare the family with one property, so its floor moves as a unit. +- **An analyzer has no consumer-side floor worth arguing about.** `Purview.BuildSdk`, + `Purview.Telemetry.SourceGenerator` and `Purview.SourceGeneratorFramework` target `netstandard2.0` and do + their work at compile time. They still appear in a transitive audit, so they still get a real version. +- **A multi-targeting component has a different floor per framework**, and ZodSharp shows it most + clearly: the `net11.0` group declares older `Microsoft.Extensions.*` minimums than the `net10.0` group, + because the lower framework carries the newer requirements. Reading one group and applying it to + another is how a floor is set wrong in the direction that breaks consumers. + +## What the script prints + +```text +Purview.Containers 1.0.0-prerelease.11 + net10.0: Purview.Containers.Core >= 1.0.0-prerelease.11 + net10.0: Purview.Containers.Docker >= 1.0.0-prerelease.11 + net10.0: Purview.Containers.Wsl >= 1.0.0-prerelease.11 + net10.0: Microsoft.Extensions.Telemetry.Abstractions >= 10.11.0 + net10.0: Testcontainers >= 4.16.0 + Purview.Containers.Core 1.0.0-prerelease.11 + net10.0: Microsoft.Extensions.Telemetry.Abstractions >= 10.11.0 +``` + +A bare version is a minimum; brackets would mean a range. The numbers above are what the registry said +when this page was written — the shape is the part that lasts, which is why the command is here and the +versions are not presented as targets. + +## Related components that are not Purview packages + +`AspireC4.Hosting` is published under its own name and versions on its own schedule, so it is not part of +a Purview family floor. Read it the same way, with the same script. diff --git a/.agents/skills/purview-component-compatibility/scripts/dependencies.mjs b/.agents/skills/purview-component-compatibility/scripts/dependencies.mjs new file mode 100644 index 0000000..3069ef6 --- /dev/null +++ b/.agents/skills/purview-component-compatibility/scripts/dependencies.mjs @@ -0,0 +1,239 @@ +#!/usr/bin/env node +/** + * Prints a package's dependency groups exactly as NuGet declares them, so the floor a component + * inherits can be read rather than guessed. + * + * The golden rule of package development is to declare the LOWEST compatible version, and that decision + * needs the real graph: which dependency, at what minimum, on which target framework. This script reads + * it from the registry - the `.nuspec` the published package carries - instead of from a table that + * would be stale by the next release. + * + * Zero dependencies: it uses the platform `fetch`, so it runs anywhere Node 18 or later does. + * + * node dependencies.mjs Purview.Containers + * node dependencies.mjs Purview.Architecture.Testing --tfm net11.0 + * node dependencies.mjs Purview.Containers --walk # recurse into Purview.* dependencies + * node dependencies.mjs Purview.Results --json + * + * An npm package is not a NuGet id; read it with `npm view versions --json`. + * + * Exit codes: 0 on success, 1 when the package, version or network is not available. + */ + +/** The NuGet flat-container root: the same host that serves the package's own `.nuspec`. */ +const REGISTRY = "https://api.nuget.org/v3-flatcontainer"; + +/** How deep `--walk` follows dependencies before it stops. */ +const MAX_DEPTH = 3; + +/** True when a version string carries a prerelease label. */ +function isPrerelease(version) { + return version.includes("-"); +} + +/** Every published version of a package, oldest first. */ +async function versionsOf(id) { + const response = await fetch(`${REGISTRY}/${id.toLowerCase()}/index.json`); + if (!response.ok) { + throw new Error(`${id} is not on nuget.org (HTTP ${response.status}).`); + } + + const body = await response.json(); + return body.versions; +} + +/** The version to describe: the one asked for, else the newest stable, else the newest anything. */ +function chooseVersion(available, requested) { + if (requested) { + const match = available.find((version) => version.toLowerCase() === requested.toLowerCase()); + if (!match) { + throw new Error(`'${requested}' is not a published version. Newest is ${available.at(-1)}.`); + } + + return match; + } + + const stable = available.filter((version) => !isPrerelease(version)); + return stable.at(-1) ?? available.at(-1); +} + +/** The package's `.nuspec`, which is what a consumer's restore actually reads. */ +async function readNuspec(id, version) { + const name = id.toLowerCase(); + const response = await fetch(`${REGISTRY}/${name}/${version.toLowerCase()}/${name}.nuspec`); + if (!response.ok) { + throw new Error(`No .nuspec for ${id} ${version} (HTTP ${response.status}).`); + } + + return response.text(); +} + +/** The value of one XML attribute on a tag, or null. */ +function attribute(tag, name) { + const match = new RegExp(`${name}\\s*=\\s*"([^"]*)"`).exec(tag); + return match ? match[1] : null; +} + +/** + * How a nuspec version string constrains a consumer: a bare version is a MINIMUM, and a bracketed one is + * a range. This is the distinction the whole rule turns on, because a minimum you raise is a minimum + * every consumer inherits. + */ +function classify(range) { + if (!range) { + return "any"; + } + + return range.startsWith("[") || range.startsWith("(") ? "range" : "minimum"; +} + +/** The dependency elements inside a fragment, normalized. */ +function dependenciesIn(fragment) { + const dependencies = []; + const pattern = /]*)\/?>/g; + let match; + + while ((match = pattern.exec(fragment)) !== null) { + const tag = match[1]; + const version = attribute(tag, "version"); + dependencies.push({ id: attribute(tag, "id"), version, constraint: classify(version) }); + } + + return dependencies; +} + +/** The dependency groups a nuspec declares, one per target framework. */ +function dependencyGroups(nuspec) { + const groups = []; + const pattern = /]*)>([\s\S]*?)<\/group>/g; + let match; + + while ((match = pattern.exec(nuspec)) !== null) { + groups.push({ + framework: attribute(match[1], "targetFramework") ?? "(any)", + dependencies: dependenciesIn(match[2]), + }); + } + + if (groups.length === 0) { + const flat = dependenciesIn(nuspec); + if (flat.length > 0) { + groups.push({ framework: "(any)", dependencies: flat }); + } + } + + return groups; +} + +/** A printable name for a target framework, spelled the way the ecosystem usually writes it. */ +function shortFramework(framework) { + return framework + .replace(/^\.NETStandard/i, "netstandard") + .replace(/^\.NETCoreApp/i, "net") + .replace(/^\.NETFramework/i, "netframework") + .toLowerCase(); +} + +/** Prints one package's groups, recursing into Purview components when asked. */ +async function describe(id, requestedVersion, options, depth, seen) { + const version = chooseVersion(await versionsOf(id), requestedVersion); + const groups = dependencyGroups(await readNuspec(id, version)); + const indent = " ".repeat(depth); + + console.log(`${indent}${id} ${version}`); + + for (const group of groups) { + const framework = shortFramework(group.framework); + if (options.tfm && framework !== options.tfm.toLowerCase()) { + continue; + } + + if (group.dependencies.length === 0) { + console.log(`${indent} ${framework}: no dependencies`); + continue; + } + + for (const dependency of group.dependencies) { + const relation = dependency.constraint === "minimum" ? ">=" : dependency.constraint; + console.log( + `${indent} ${framework}: ${dependency.id} ${relation} ${dependency.version ?? "any"}`, + ); + } + + if (options.walk && depth < MAX_DEPTH) { + for (const dependency of group.dependencies) { + const isPurview = /^purview\./i.test(dependency.id ?? ""); + if (!(options.all || isPurview) || seen.has(dependency.id.toLowerCase())) { + continue; + } + + seen.add(dependency.id.toLowerCase()); + await describe(dependency.id, null, options, depth + 1, seen); + } + } + } + + return { id, version, groups }; +} + +/** Parses the command line. An unknown argument is a mistake worth failing on. */ +function parseArguments(argv) { + const options = { packages: [], tfm: null, walk: false, all: false, json: false }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + + if (argument === "--walk" || argument === "-w") { + options.walk = true; + } else if (argument === "--all") { + options.all = true; + } else if (argument === "--json") { + options.json = true; + } else if (argument === "--tfm") { + options.tfm = argv[(index += 1)]; + if (!options.tfm) { + throw new Error("--tfm needs a value, e.g. --tfm net10.0."); + } + } else if (argument.startsWith("-")) { + throw new Error(`Unknown argument '${argument}'.`); + } else { + options.packages.push(argument); + } + } + + if (options.packages.length === 0) { + throw new Error( + "Name at least one package, e.g. `node dependencies.mjs Purview.Containers`.\n" + + "A package may carry a version: `Purview.Containers@1.0.0-prerelease.11`.", + ); + } + + return options; +} + +/** The command. */ +async function main() { + const options = parseArguments(process.argv.slice(2)); + const results = []; + + for (const entry of options.packages) { + const separator = entry.indexOf("@", 1); + const id = separator === -1 ? entry : entry.slice(0, separator); + const version = separator === -1 ? null : entry.slice(separator + 1); + + results.push(await describe(id, version, options, 0, new Set([id.toLowerCase()]))); + + if (!options.walk && !options.json) { + console.log(""); + } + } + + if (options.json) { + console.log(JSON.stringify(results, null, "\t")); + } +} + +main().catch((error) => { + console.error(`error: ${error.message}`); + process.exitCode = 1; +}); diff --git a/.agents/skills/purview-engineering-principles/SKILL.md b/.agents/skills/purview-engineering-principles/SKILL.md index ba4dd9b..24fe707 100644 --- a/.agents/skills/purview-engineering-principles/SKILL.md +++ b/.agents/skills/purview-engineering-principles/SKILL.md @@ -4,8 +4,9 @@ description: >- Use when setting up, reviewing or rationalising a .NET repository against the Purview engineering conventions - convention over configuration, derived identity, architecture as tests, warnings as errors, proving consumer-facing claims, documentation ownership, test data builders, agent - legibility, and tiering verification by risk. Each rule names the failure it prevents and whether a - build or a reviewer enforces it. + legibility, tiering verification by risk, the version policy for applications and for published + components, and the conditional persistence rules. Each rule names the failure it prevents and + whether a build or a reviewer enforces it. metadata: product: Purview engineering playbook repository: purview-dev/engineering-playbook @@ -13,7 +14,7 @@ metadata: # Purview engineering principles -Ten rules, each with the failure it prevents and the mechanism that makes it true. The distinction +Eleven rules, each with the failure it prevents and the mechanism that makes it true. The distinction that matters throughout: **a convention a build can check is checked by the build; a convention only a reviewer can check is labelled a review obligation and never described as enforced.** A rule that claims enforcement it does not have is worse than no rule, because people stop looking. @@ -100,8 +101,9 @@ repository's own tests already are the consumer. ## 7. Documentation is a build artefact of the repository -The wiki lives in the repository it describes, in `docs/wiki`, and the repository owns its -`mkdocs.yml`, its navigation and its `edit_uri`. +The wiki lives in the repository it describes, in `docs/wiki`, as plain Markdown. The repository owns no +site configuration at all: `_Sidebar.md` is the navigation, `Home.md` is the hub, and the site that +publishes them lives in one place outside the repository. Documentation that lives elsewhere is updated elsewhere. A page with no owner and no review path is wrong within one release. @@ -154,6 +156,23 @@ hand-written attribute. *At your scale:* one tier is fine until the slow tests exist. Then split by project name, not by attribute. +## 11. A version is a range, not a number + +An application takes the latest version its runtime supports. A published component declares the +**lowest** version that compiles, passes its tests and satisfies the contract it documents, on each +target framework it claims — because dependency resolution is a set of lower bounds, so the floor a +component declares becomes the floor every consumer inherits. + +A page states no release number. It shows a badge generated from the registry, or links to the +registry, so a version cannot be wrong within a release. + +*Enforced by:* a lowest-compatible CI leg that restores against the declared floor, and an +API-compatibility check per target framework. Nothing enforces the floor of a package that has no +consumer yet — that tier arrives with the first release. + +*At your scale:* with one application there is nothing to earn yet; take the latest and let +`dotnet list package --outdated` report the rest. The golden rule starts at the first published package. + ## Applying these in review Three questions catch most of it: @@ -164,8 +183,16 @@ Three questions catch most of it: 3. **Could an agent — or a new joiner — find this without being told?** If not, it goes in `AGENTS.md` or the wiki. +## The conditional persistence rules + +Three further rules apply only when a repository actually stores data: a value known before storage is +derived with UUIDv5, every other GUID key uses one strategy per store, and EF Core is the persistence +boundary. They are not part of the eleven because a stateless CLI or a pure library must not inherit them. +`purview-persistence` owns them and stays silent when persistence is not real. + ## Related skills `purview-architecture` for project-shape decisions, `purview-architecture-testing` for turning a boundary into a failing build, `purview-telemetry` and `purview-persistence` for the per-concern -guidance. +guidance, and `purview-component-compatibility` for deciding and verifying a published component's +lowest compatible dependency versions. diff --git a/.agents/skills/purview-org-harvest/SKILL.md b/.agents/skills/purview-org-harvest/SKILL.md new file mode 100644 index 0000000..2c05ea1 --- /dev/null +++ b/.agents/skills/purview-org-harvest/SKILL.md @@ -0,0 +1,197 @@ +--- +name: purview-org-harvest +description: >- + Use when a Purview repository's conventions may have fallen behind the organisation - + inventorying every public purview-dev repository at main, deciding which repository is + best-in-class for each concern before adopting anything, and recording the result in a dated + snapshot. Always reads the remote: a local clone is a cache, and it may be stale, renamed or + missing entirely. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" + repository: purview-dev/engineering-playbook +--- + +# Purview organisation harvest + +Seventeen public repositories, each with its own wiki, its own `skills/` folder and its own `AGENTS.md`. +They are not equally up to date, and the one you have cloned is not necessarily the one that has the +answer. Two failures follow, and this skill exists to prevent both. + +**Copying from a stale clone.** A clone is a cache with no expiry. The convention you read there may +be two months behind the one the owner now documents, and nothing about the local file says so. + +**Copying from the wrong owner.** "How do we ship?" is answered by `build`. "How do we keep a boundary +honest?" is answered by `architests`. Asking a library that does neither produces a confident answer +with no evidence, which is worse than no answer. + +So: establish membership, read at `main`, name one owner per concern, write down what you found. + +## 1. Scope + +Name two things before reading anything: + +| Input | Example | +| -- | -- | +| The repository being changed | `containers` | +| The concern in question | "how a test reaches a real database" | + +A harvest without a named concern becomes a tour, and a tour produces a list of differences nobody +acted on. The concern is what makes "best-in-class" a decision rather than a preference. + +## 2. Discover membership + +`gh repo list` is the membership authority. A catalogue, a README table, an `llms.txt` and a local +folder listing are all convenience indexes: they are maintained by hand, so they under-count, and the +omission is silent. + +```bash +gh auth status +gh repo list purview-dev --limit 200 \ + --json name,visibility,isArchived,pushedAt,defaultBranchRef +``` + +Drop `isArchived` and anything that is not `PUBLIC`. **If `gh auth status` fails, stop.** Degrading to +the local clone is the exact failure this skill prevents, and it degrades silently. + +The filter denies by default: a repository must be exactly `PUBLIC` and exactly not archived to be +recorded, so a visibility you have never seen fails closed. `gh repo list` also only lists what your +token can see, which is why the token's visibility and the file's contents are two separate questions: +the section below is about the second one. + +A local clone is admissible only when its `HEAD` equals the SHA the snapshot recorded: + +```bash +git -C ../containers rev-parse HEAD +``` + +That is a speed optimisation, never a substitute. + +## 3. Read it at `main` + +Every repository is read at `main`, and the commit is recorded. The snapshot is what makes the claim +checkable a month later, when nobody remembers which commit the analysis saw. + +```bash +node scripts/inventory.mjs # drift between the organisation and the snapshot +node scripts/inventory.mjs --write # regenerate .config/org-inventory.json +node scripts/inventory.mjs --repo containers +``` + +The script is deterministic and dependency-free: it shells out to `gh`, so it reuses the CLI's own +authentication, and it runs the same on Windows and on a Linux agent — it is Node, not a shell script, +so there is no PowerShell edition and no Bash edition to keep in step. It writes +`.config/org-inventory.json` in the repository you run it from — generated, never hand-edited. + +To inspect a decision without a network call, replay a recorded harvest with `--from` and `--details`. +That is the same script, not a variant, so what you review is what runs. + +## 4. Harvest in two tiers + +Seventeen repositories is not a reading list; it is a budget. Two tiers keep it affordable. + +| Tier | Call | Answers | +| -- | -- | -- | +| 1 — shape | `gh api repos/purview-dev//git/trees/main?recursive=1`, for all seventeen | Which folders exist, how many pages the wiki has, whether `skills/` is authored, whether `AGENTS.md` exists. Cheap enough for everything. | +| 2 — content | `gh api -H 'Accept: application/vnd.github.raw' repos/purview-dev//contents/`, **only for the owner of the concern** | What the convention actually says. | + +Tier 1 tells you where the evidence is. Tier 2 reads it, in the one repository that wrote it down. + +```bash +gh api repos/purview-dev/architests/git/trees/main?recursive=1 --jq '.tree[].path' +gh api -H 'Accept: application/vnd.github.raw' \ + repos/purview-dev/architests/contents/docs/wiki/Architecture-As-Tests.md +``` + +More invocations, and what each one is for: `references/harvest-commands.md`. + +## 5. Classify: one owner per concern + +Pick exactly one owner per concern, from `references/best-in-class.md`. Two signs the classification +is wrong: + +- **Two owners for one concern.** `build-sdk` and this playbook's `purview-engineering-principles` + both answer "review this repository". That overlap is a finding to resolve, not a tie to break by + preferring whichever you read first. +- **An owner that has never run the thing it documents.** Check the tree before trusting the page. + +## 6. Divergence register + +Write the differences down before changing anything, with an adopt-or-reject for each. A divergence +with no decision recorded is the same divergence next quarter. + +| Column | Answers | +| -- | -- | +| Concern | What is being compared | +| Owner | The repository the convention comes from, and the path it is stated at | +| Here | What this repository does now | +| Decision | Adopt, or reject with the reason | +| Mechanism | The build rule, test or review obligation that keeps it true | + +Rejecting is a legitimate outcome and is usually the right one at a smaller scale. What is not +legitimate is adopting a convention whose failure the repository cannot have. + +## 7. Adopt + +A convention arrives as a set of changes that land together, or it is a claim with no mechanism: + +```text +the page states the rule, why, how it is enforced, and what a smaller repository does instead +src/ demonstrates it, or says why it does not +the snapshot records the commit it was read at +the skill points at the owner, and never paraphrases it +``` + +**Link to the owner; do not restate it.** A paraphrase is a second source of truth, and the copy with +no owner is the one that loses. + +## 8. Verify + +```bash +just build # zero warnings +just test # architecture, skill and documentation rules +just lint-check +node scripts/inventory.mjs --print +``` + +If the harvest changed what the snapshot should record, regenerate it and commit the diff. The +snapshot being current is a review obligation rather than a build failure — a build that goes red on +a Tuesday because a clock passed is a build people disable. + +## Private repositories + +A private repository may inform a maintainer's judgement. It is never evidence, and it is never +written down. This is not a preference, and it is not a heuristic an agent gets to weigh against a +good argument. Five statements, in order of how often they are broken: + +1. A committed snapshot contains public, non-archived repositories only. The writer denies by default + and refuses to persist anything else, and a test asserts it. +2. A private repository may be read when `gh` is authenticated for it, and what it teaches may shape + a rule. +3. Nothing published names it: not the snapshot, not the page, not the skill, not a workflow, not a + config, not a commit message. Attribute the rule to "a Purview application repository", or to + nothing. +4. **If a rule cannot be justified without the private source, it is not published.** +5. **If you cannot say where a name came from, do not write the name.** A repository the snapshot + does not list is either private or gone, and both fail the same test. + +The fourth is the one an agent rationalises around, because the evidence is often excellent. A rule +whose only justification is out of reach of the people it is published to cannot be reviewed by them, +which makes it folklore with a citation they cannot follow. + +## Checks before you answer + +- Did you read membership from the organisation rather than from a catalogue or a folder? +- Is every claim about a repository true at the SHA the snapshot recorded, not at your clone? +- Did you name exactly one owner per concern, and resolve any overlap rather than duplicating it? +- Did you reject anything? A harvest with no rejections usually means nothing was understood. +- Does every change you propose have a mechanism, or is it labelled a review obligation? +- Is every repository you named public? + +## See also + +- `references/best-in-class.md` — which repository owns which concern, and where to read it +- `references/harvest-commands.md` — the `gh` invocations, and the trap in each one +- `purview-engineering-principles` — the conventions themselves, once you know which owner to read +- `purview-architecture` — the project-shape decisions a harvest most often touches diff --git a/.agents/skills/purview-org-harvest/references/best-in-class.md b/.agents/skills/purview-org-harvest/references/best-in-class.md new file mode 100644 index 0000000..9ab69cc --- /dev/null +++ b/.agents/skills/purview-org-harvest/references/best-in-class.md @@ -0,0 +1,77 @@ +# Best-in-class, by concern + +One owner per concern. Read the owner, not the nearest repository that happens to have a wiki. + +Every row was read from the organisation with `gh repo list purview-dev` and +`gh api repos//git/trees/main?recursive=1`. The page counts and skill counts are the shape at +the time of writing, not a promise: they are what the snapshot records, and the snapshot is +regenerated rather than trusted. + +## The matrix + +| Concern | Owner | Read it at | +| -- | -- | -- | +| Repository shape, MSBuild defaults, analyzers, skill authoring | `build-sdk` | `docs/wiki`, `templates/`, `skills/purview-buildsdk-*` | +| Pipeline, release, pack validation | `build` | `docs/wiki`, `skills/purview-build-*` | +| Architecture policy: dependency direction, naming rules | `architests` | `docs` (nine pages; `Policy-Reference.md` is the reference, `Extending.md` the how-to), `skills/purview-architests-*` | +| Local dependencies in tests | `containers` | `docs/wiki`, `skills/purview-containers-*` | +| TypeScript contracts generated from C# | `zodsharp` | `docs/wiki`, `skills/purview-zodsharp-*` | +| Logging, tracing, metrics | `telemetry-sourcegenerator` | `docs/wiki`, `skills/purview-telemetry-*` | +| Event streams and event storage | `event-sourcing` | `docs/wiki` — **no skills folder** | +| Operation outcomes without exceptions | `results` | `docs/wiki`, `skills/purview-results-*` | +| Validated identity types | `value-objects` | `docs` — **no `docs/wiki`** | +| Aspire hosting resources | `aspire-resourcekit` | `docs/wiki`, `skills/purview-resourcekit-*` | +| Architecture diagrams generated from source | `aspirec4` | `docs/wiki`, `skills/purview-aspirec4-*` | +| LikeC4 diagrams inside an Astro or Starlight site | `astroc4` | `docs/wiki`, `skills/purview-astroc4-*` — an npm package, not .NET | +| Source generator authoring | `sourcegenerator-framework` | `docs/wiki`, `skills/purview-sourcegenerator-*` | +| Skill-set design, and the dated-snapshot pattern | `pm-skills` | `skills/pm-*` — **no documentation folder at all** | +| OpenAPI generated from XML comments | `openapi-xmlcomments` | `docs/wiki`, `skills/purview-openapi-xmlcomments-*` | +| The reusable conventions themselves | `engineering-playbook` | `docs/wiki`, `skills/purview-*` | +| The public project catalogue | `purview-dev.github.io` | `src/src/data/projects/*.yml` | + +`purview-dev.github.io` is the catalogue, and a catalogue is an index rather than a membership list: +an entry is added by hand, so a repository missing from it is silent. `gh repo list` is the authority. + +## The overlaps worth knowing about + +**`build-sdk` and `engineering-playbook` both answer "review this repository".** They are not +duplicates, and the split is worth stating because a harvest will hit it immediately: + +- `build-sdk` owns *what the tooling does* — project detection, the props and targets, the analyzer + set, the packaged test-support rule sets. +- `engineering-playbook` owns *why a repository is shaped that way, and what it costs* — the rule, + the failure it prevents, and what a smaller repository does instead. + +A question about a property name goes to `build-sdk`. A question about whether a project should exist +goes here. + +**`containers` and `aspire-resourcekit` both look like "local dependencies".** `containers` owns the +test-time resource — a real SQL Server, PostgreSQL or queue — and `aspire-resourcekit` owns the +development-time orchestration. A test that needs a database is `containers`; an AppHost that needs +one is `aspire-resourcekit`. + +**`aspirec4` and `astroc4` both draw LikeC4 diagrams.** `aspirec4` owns the diagram generated from a +running Aspire resource graph; `astroc4` owns putting a `.c4` model inside an Astro or Starlight site — +the fence transform and the LikeC4 syntax highlighting. A question about the .NET host is `aspirec4`; a +question about the documentation site is `astroc4`, which is the one owner here that is not a .NET +package, so it is read for its TypeScript conventions rather than for its MSBuild ones. + +## Reading a repository you have not cloned + +Tier 1 first — the shape, for everything: + +```bash +gh api repos/purview-dev//git/trees/main?recursive=1 --jq '.tree[].path' +``` + +Then Tier 2 — the content, for the one owner: + +```bash +gh api -H 'Accept: application/vnd.github.raw' \ + repos/purview-dev//contents/docs/wiki/_Sidebar.md +``` + +`_Sidebar.md` is the fastest index of a wiki, because the repository's own documentation rule says a +page that is not in it does not exist. Where a repository publishes no wiki, its `AGENTS.md` is the +next best entry point, and where it has neither, read `skills/*/SKILL.md` — the trigger in each +`description` says what the repository thinks it is for. diff --git a/.agents/skills/purview-org-harvest/references/harvest-commands.md b/.agents/skills/purview-org-harvest/references/harvest-commands.md new file mode 100644 index 0000000..f4b4653 --- /dev/null +++ b/.agents/skills/purview-org-harvest/references/harvest-commands.md @@ -0,0 +1,121 @@ +# Harvest commands + +Every invocation here is safe to run read-only, and each one is followed by the trap that cost +somebody time. Both shells are shown because the harvest runs on a developer's Windows machine and on +a Linux agent, and the quoting rules are not the same. + +## Membership + +```bash +gh auth status +gh repo list purview-dev --limit 200 --json name,visibility,isArchived,pushedAt,defaultBranchRef +``` + +```powershell +gh auth status +gh repo list purview-dev --limit 200 --json name,visibility,isArchived,pushedAt,defaultBranchRef | + ConvertFrom-Json | Sort-Object name | + Format-Table name, visibility, isArchived, pushedAt +``` + +**Trap: the default limit is 30.** Without `--limit` the list is silently short, and a short list is +worse than a failed one because the omission never announces itself. + +**Trap: `gh repo list` shows what your token can see.** An organisation member sees private +repositories too; filter to `visibility -eq 'PUBLIC'` and `-not isArchived` before recording anything. + +## The SHA a convention was read at + +```bash +gh api repos/purview-dev//git/ref/heads/main --jq .object.sha +``` + +**Trap: a renamed repository resolves by redirect.** Asking for a repository under its former name +answers `purview-dev/purview-dev.github.io` and exits 0, so a stale name never fails. Compare the +returned `full_name` with the name you asked for. + +## Tier 1: the shape + +```bash +gh api repos/purview-dev//git/trees/main?recursive=1 --jq '.tree[].path' +``` + +**Trap: `truncated`.** GitHub caps a recursive tree, and the response says so with `"truncated": true` +rather than erroring. A truncated tree under-counts skills and pages, so check the flag before +believing a zero. + +**Trap: the tree lists blobs and trees.** Filter to `type == "blob"` when counting files, or a folder +whose name matches your pattern is counted as its contents. + +## Tier 2: the content + +```bash +gh api -H 'Accept: application/vnd.github.raw' \ + repos/purview-dev//contents/docs/wiki/_Sidebar.md +``` + +**Trap: `contents` returns JSON unless you ask for raw.** Without the `Accept` header the response is +a base64 `content` field, and the analysis quietly proceeds on a JSON document. + +**Trap: `contents` caps at 1000 files for a directory listing.** Use the tree API for enumerating and +`contents` for reading one file. + +## Windows PowerShell, specifically + +**Embedded double quotes are stripped from a native command's arguments.** This looks correct and is +not: + +```powershell +gh repo list purview-dev --jq '.[] | [.name, (.pushedAt // "-")] | @tsv' +``` + +`gh` receives `// -` and answers `failed to parse jq expression ... unexpected token`. Either keep the +jq program free of string literals, or do the shaping in PowerShell: + +```powershell +$repos = gh repo list purview-dev --limit 200 --json name,visibility | ConvertFrom-Json +$repos | Sort-Object name | ForEach-Object { '{0}|{1}' -f $_.name, $_.visibility } +``` + +**A filtered result is a scalar when it is one item.** `(... | Where-Object { ... }).Count` returns +nothing rather than `1`, so `.Count` in a summary line reads as empty. Wrap it: `@(...).Count`. + +## The snapshot + +```bash +node skills/purview-org-harvest/scripts/inventory.mjs +node skills/purview-org-harvest/scripts/inventory.mjs --write +node skills/purview-org-harvest/scripts/inventory.mjs --repo containers --write +``` + +It resolves the snapshot path from `git rev-parse --show-toplevel`, so it writes into the repository +you are standing in rather than into the one the script was installed from. That is what lets another +repository adopt it unchanged. + +`--repo` merges on write, so reading one repository does not erase the other sixteen. + +## Replaying a harvest, with no network + +Two flags replace the two `gh` calls. This is how the repository's own tests exercise the script on +every platform without touching GitHub, and how a reviewer reproduces what a harvest decided: + +```bash +node skills/purview-org-harvest/scripts/inventory.mjs \ + --from recorded-membership.json \ + --details recorded-details.json \ + --print +``` + +`--from` takes exactly what `gh repo list --json name,visibility,isArchived,pushedAt,defaultBranchRef` +printed. `--details` takes an object keyed by repository name, each with the `commitSha` a tier-1 +harvest found and the `paths` the tree contained. A repository that is in the membership and missing +from the details is an error, not a silent omission — a replay that quietly skipped a repository would +look like a clean run. + +Two behaviours worth knowing before you trust a run: + +- **A snapshot carrying a byte-order mark is accepted.** It is the one file in this workflow a person + is likely to open and re-save, and a BOM is not a corruption. +- **A snapshot containing anything that is not public and unarchived is refused**, and nothing is + written. This includes an entry a hand edit left behind, which the merge would otherwise carry + forward: delete it and re-run `--write`. diff --git a/.agents/skills/purview-org-harvest/scripts/inventory.mjs b/.agents/skills/purview-org-harvest/scripts/inventory.mjs new file mode 100644 index 0000000..affc46f --- /dev/null +++ b/.agents/skills/purview-org-harvest/scripts/inventory.mjs @@ -0,0 +1,466 @@ +#!/usr/bin/env node +/** + * Snapshots every public, non-archived `purview-dev` repository at `main` into + * `.config/org-inventory.json`, and reports the drift between that snapshot and the organisation. + * + * The snapshot is what makes the harvest rule checkable offline: `OrgInventoryTests` reads the file + * the pipeline has, never the network. This script is the only thing that writes it, so a repository + * cannot appear in the snapshot by being remembered. + * + * Zero dependencies: it shells out to `gh` and so reuses the CLI's own authentication rather than + * asking for a token. + * + * node inventory.mjs # print the drift against the snapshot (default) + * node inventory.mjs --write # rewrite the snapshot from the organisation + * node inventory.mjs --repo containers # narrow to one repository; merges on --write + * node inventory.mjs --out # write somewhere else, e.g. while adopting the skill + * node inventory.mjs --help + * + * Exit codes: 0 on success, including when drift is found - a stale snapshot is a review obligation, + * not a red pull request for whoever happened to push next. 1 when `gh` is missing, unauthenticated, + * or the organisation cannot be read. + */ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +/** The organisation this playbook describes. */ +const ORG = "purview-dev"; + +/** The branch a convention is read at. A local clone is a cache; this is the authority. */ +const BRANCH = "main"; + +/** Bumped when a field is added or its meaning changes. */ +const SCHEMA_VERSION = 1; + +/** Past this age the snapshot is reported as stale. Advisory: nothing fails on a clock. */ +const STALE_AFTER_DAYS = 30; + +/** Files a repository may put at its root to guide an agent. Recorded in this order. */ +const AGENT_GUIDANCE_FILES = ["AGENTS.md", "CLAUDE.md", ".github/copilot-instructions.md", "llms.txt"]; + +/** Directories a published documentation site is read from, most specific first. */ +const DOCS_DIRECTORIES = ["docs/wiki", "docs"]; + +/** + * The concern each repository is best-in-class for, so a harvest reads one owner instead of sixteen + * repositories. `references/best-in-class.md` is the long form of this table and is the file to edit + * first; this map only records the answer in the snapshot. + */ +const CONCERNS = new Map([ + ["architests", ["architecture policy", "dependency and naming rules"]], + ["aspire-resourcekit", ["Aspire hosting resources"]], + ["aspirec4", ["architecture diagrams from source"]], + ["astroc4", ["architecture diagrams in an Astro site"]], + ["build", ["pipeline", "release"]], + ["build-sdk", ["repository shape", "analyzers and style", "MSBuild defaults", "skill authoring"]], + ["containers", ["local dependencies in tests"]], + ["engineering-playbook", ["the conventions themselves"]], + ["event-sourcing", ["event streams", "event storage"]], + ["openapi-xmlcomments", ["OpenAPI from XML comments"]], + ["pm-skills", ["skill-set design"]], + ["purview-dev.github.io", ["the public project catalogue"]], + ["results", ["operation outcomes"]], + ["sourcegenerator-framework", ["source generator authoring"]], + ["telemetry-sourcegenerator", ["logging", "tracing", "metrics"]], + ["value-objects", ["validated identity types"]], + ["zodsharp", ["TypeScript contracts from C#"]], +]); + +/** Runs `gh` and returns stdout, turning a CLI failure into an actionable message. */ +function gh(args) { + try { + return execFileSync("gh", args, { + encoding: "utf8", + maxBuffer: 256 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + const detail = (error.stderr ?? "").toString().trim() || error.message; + throw new Error( + `gh ${args.join(" ")} failed.\n${detail}\n\n` + + "This script reads the organisation, not a local clone. Run `gh auth status`, then retry.", + ); + } +} + +/** Calls one GitHub REST endpoint and parses the JSON response. */ +function api(endpoint) { + return JSON.parse(gh(["api", endpoint])); +} + +/** The root of the repository the command was run in, so the skill works from any repository. */ +function repositoryRoot() { + try { + return execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + } catch { + throw new Error("Not inside a git repository. Run this from a clone, or pass --out ."); + } +} + +/** Membership, as the organisation reports it. The catalogue is a convenience index, not the list. */ +function readMembership(source) { + const listed = source + ? JSON.parse(readFileSync(source, "utf8")) + : JSON.parse( + gh([ + "repo", + "list", + ORG, + "--limit", + "200", + "--json", + "name,visibility,isArchived,pushedAt,defaultBranchRef", + ]), + ); + + return ( + listed + // Deny by default. The filter is "exactly public and not archived" rather than "not + // private", so a visibility value this script has never seen fails closed instead of + // falling through into a file that is committed to a public repository. + .filter((repository) => repository.visibility === "PUBLIC" && repository.isArchived === false) + .sort((left, right) => left.name.localeCompare(right.name, "en")) + ); +} + +/** The recorded commit and file list for each repository, when a run replays a previous harvest. */ +function readDetails(source) { + return new Map(Object.entries(JSON.parse(readFileSync(source, "utf8")))); +} + +/** The Markdown pages directly inside a docs directory. */ +function countPages(paths, directory) { + const prefix = `${directory}/`; + return paths.filter((path) => { + if (!path.startsWith(prefix)) { + return false; + } + + const remainder = path.slice(prefix.length); + return remainder.endsWith(".md") && !remainder.includes("/"); + }).length; +} + +/** A skill is a folder under `skills/` that carries a `SKILL.md`. */ +function authoredSkills(paths) { + const names = paths + .filter((path) => /^skills\/[^/]+\/SKILL\.md$/.test(path)) + .map((path) => path.split("/")[1]); + + return [...new Set(names)].sort((left, right) => left.localeCompare(right, "en")); +} + +/** Everything the snapshot records about one repository, read at `BRANCH`. */ +function describeRepository(repository, details) { + // The filter above already excluded these, and this is the second lock on the same door: a + // private repository must never reach the snapshot, so the guard is here as well as there. + if (repository.visibility !== "PUBLIC" || repository.isArchived !== false) { + throw new Error( + `${ORG}/${repository.name} is ${repository.visibility} and archived ${repository.isArchived}. ` + + "The snapshot records public, unarchived repositories only, so nothing was written.", + ); + } + + const defaultBranch = repository.defaultBranchRef?.name ?? BRANCH; + if (defaultBranch !== BRANCH) { + throw new Error( + `${ORG}/${repository.name} has default branch '${defaultBranch}', not '${BRANCH}'. ` + + "The snapshot pins one branch, so either the rule or the repository has to change.", + ); + } + + const recorded = details?.get(repository.name); + if (details && !recorded) { + throw new Error( + `${repository.name} has no recorded detail, so --details cannot describe it. ` + + "Replay a membership the details file was captured with, or run without --details.", + ); + } + + const commitSha = recorded?.commitSha ?? api(`repos/${ORG}/${repository.name}/git/ref/heads/${BRANCH}`).object.sha; + const paths = recorded?.paths ?? readTree(repository.name); + const docsDir = DOCS_DIRECTORIES.find((candidate) => + paths.some((path) => path.startsWith(`${candidate}/`)), + ); + + return { + name: repository.name, + visibility: repository.visibility.toLowerCase(), + archived: repository.isArchived, + commitSha, + pushedAt: repository.pushedAt, + docsDir: docsDir ?? null, + docsPages: docsDir ? countPages(paths, docsDir) : 0, + skills: authoredSkills(paths), + agentGuidance: AGENT_GUIDANCE_FILES.filter((file) => paths.includes(file)), + concerns: CONCERNS.get(repository.name) ?? [], + }; +} + +/** The blob paths of one repository at `BRANCH`. */ +function readTree(name) { + const tree = api(`repos/${ORG}/${name}/git/trees/${BRANCH}?recursive=1`); + + if (tree.truncated) { + process.stderr.write( + `warning: ${name}'s tree is truncated, so its skills and docs are undercounted\n`, + ); + } + + return tree.tree.filter((entry) => entry.type === "blob").map((entry) => entry.path); +} + +/** The snapshot document. */ +function snapshotOf(repositories) { + return { + schemaVersion: SCHEMA_VERSION, + org: ORG, + branch: BRANCH, + fetchedAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), + repositories, + }; +} + +/** The snapshot on disk, or null when the repository has not committed one yet. */ +function readSnapshot(path) { + if (!existsSync(path)) { + return null; + } + + // The snapshot is the one file in this workflow a person is likely to open and save by hand, and + // an editor that writes a byte-order mark produces a file JSON.parse rejects with a message that + // names neither the file nor the cause. + return JSON.parse(readFileSync(path, "utf8").replace(/^\uFEFF/, "")); +} + +/** A field's value in the form the drift report prints. */ +function render(value) { + if (value === null || value === undefined) { + return "none"; + } + + if (Array.isArray(value)) { + return value.length === 0 ? "none" : value.join(", "); + } + + return String(value); +} + +/** A field's value in the form the drift report compares: structured values compare as one string. */ +function fingerprint(value) { + return JSON.stringify(value ?? null); +} + +/** The fields whose change is worth reporting, in the order a reader cares about them. */ +const COMPARED_FIELDS = [ + "commitSha", + "docsDir", + "docsPages", + "skills", + "agentGuidance", + "concerns", + "visibility", + "archived", +]; + +/** The differences between the organisation and the snapshot. */ +function drift(existing, observed) { + const findings = []; + const before = new Map((existing?.repositories ?? []).map((entry) => [entry.name, entry])); + const after = new Map(observed.map((entry) => [entry.name, entry])); + + for (const [name, entry] of after) { + if (!before.has(name)) { + findings.push(`${name}: not in the snapshot`); + continue; + } + + const previous = before.get(name); + for (const field of COMPARED_FIELDS) { + if (fingerprint(previous[field]) === fingerprint(entry[field])) { + continue; + } + + findings.push(`${name}: ${field} ${render(previous[field])} -> ${render(entry[field])}`); + } + } + + for (const name of before.keys()) { + if (!after.has(name)) { + findings.push(`${name}: in the snapshot but no longer a public repository`); + } + } + + return findings; +} + +/** How long ago the snapshot was taken, in whole days, or null when it carries no usable date. */ +function ageInDays(fetchedAt) { + const fetched = Date.parse(fetchedAt ?? ""); + return Number.isNaN(fetched) ? null : Math.floor((Date.now() - fetched) / 86_400_000); +} + +/** Parses the command line. Unknown arguments are a mistake worth failing on. */ +function parseArguments(argv) { + const options = { write: false, repos: [], out: null, from: null, details: null, help: false }; + + for (let index = 0; index < argv.length; index++) { + const argument = argv[index]; + if (argument === "--write") { + options.write = true; + } else if (argument === "--print") { + options.write = false; + } else if (argument === "--help" || argument === "-h") { + options.help = true; + } else if (argument === "--repo") { + const value = argv[++index]; + if (!value) { + throw new Error("--repo needs a repository name."); + } + + options.repos.push(value); + } else if (argument === "--out") { + const value = argv[++index]; + if (!value) { + throw new Error("--out needs a path."); + } + + options.out = value; + } else if (argument === "--from") { + const value = argv[++index]; + if (!value) { + throw new Error("--from needs a path to a recorded `gh repo list` response."); + } + + options.from = value; + } else if (argument === "--details") { + const value = argv[++index]; + if (!value) { + throw new Error("--details needs a path to a recorded detail file."); + } + + options.details = value; + } else { + throw new Error(`Unknown argument '${argument}'. Run with --help.`); + } + } + + return options; +} + +/** The documented usage. */ +const HELP = `Snapshot every public, non-archived ${ORG} repository at ${BRANCH}. + + node inventory.mjs print the drift between the organisation and the snapshot + node inventory.mjs --write rewrite the snapshot from the organisation + node inventory.mjs --repo narrow to one repository; merges when writing + node inventory.mjs --out write somewhere other than .config/org-inventory.json + node inventory.mjs --from replay a recorded "gh repo list --json" response instead of + asking the organisation + node inventory.mjs --details replay recorded commits and file lists, so a run touches no + network at all; pair it with --from + node inventory.mjs --help this text + +The snapshot is generated, never hand-edited. A stale snapshot is a review obligation rather than a +build failure, so drift prints and the exit code stays 0.`; + +/** Runs the command. */ +function main() { + const options = parseArguments(process.argv.slice(2)); + if (options.help) { + console.log(HELP); + return; + } + + const snapshotPath = options.out ?? join(repositoryRoot(), ".config", "org-inventory.json"); + const existing = readSnapshot(snapshotPath); + + let observed = readMembership(options.from); + if (options.repos.length > 0) { + const known = new Set(observed.map((repository) => repository.name)); + for (const name of options.repos) { + if (!known.has(name)) { + throw new Error( + `'${name}' is not a public, non-archived ${ORG} repository. ` + + `Run without --repo to include all ${observed.length}.`, + ); + } + } + + observed = observed.filter((repository) => options.repos.includes(repository.name)); + } + + const details = options.details ? readDetails(options.details) : null; + const described = observed.map((repository) => describeRepository(repository, details)); + + if (options.write) { + const merged = new Map((existing?.repositories ?? []).map((entry) => [entry.name, entry])); + for (const entry of described) { + merged.set(entry.name, entry); + } + + const written = snapshotOf( + [...merged.values()].sort((left, right) => left.name.localeCompare(right.name, "en")), + ); + + // The merge carries forward whatever the file already held, which is how a hand-edited entry + // would survive a regeneration. Refuse to write a snapshot that records anything else. + const notPublic = written.repositories.filter( + (entry) => entry.visibility !== "public" || entry.archived !== false, + ); + if (notPublic.length > 0) { + throw new Error( + "Refusing to write a snapshot that records a repository which is not public and " + + `unarchived: ${notPublic.map((entry) => entry.name).join(", ")}. ` + + "Delete the entries and re-run --write.", + ); + } + + mkdirSync(dirname(snapshotPath), { recursive: true }); + writeFileSync(snapshotPath, `${JSON.stringify(written, null, "\t")}\n`, "utf8"); + console.log( + `wrote ${written.repositories.length} repositories to ${snapshotPath} at ${written.fetchedAt}`, + ); + return; + } + + const findings = drift(existing, described); + const age = ageInDays(existing?.fetchedAt); + + console.log(`${ORG}: ${observed.length} public, non-archived repositories at ${BRANCH}`); + if (existing) { + console.log( + `snapshot: ${existing.repositories?.length ?? 0} repositories, fetched ${existing.fetchedAt}` + + (age === null ? "" : ` (${age} day${age === 1 ? "" : "s"} old)`), + ); + } else { + console.log(`no snapshot at ${snapshotPath} - run with --write to create one`); + } + + if (findings.length === 0) { + console.log("no drift"); + } else { + console.log(`${findings.length} difference${findings.length === 1 ? "" : "s"}:`); + for (const finding of findings) { + console.log(` ${finding}`); + } + } + + if (age !== null && age > STALE_AFTER_DAYS) { + console.log( + `warning: the snapshot is older than ${STALE_AFTER_DAYS} days. ` + + "Re-run with --write and review the diff.", + ); + } +} + +try { + main(); +} catch (error) { + console.error(`error: ${error.message}`); + process.exitCode = 1; +} diff --git a/.agents/skills/purview-persistence/SKILL.md b/.agents/skills/purview-persistence/SKILL.md index 4667fa2..7d6ad91 100644 --- a/.agents/skills/purview-persistence/SKILL.md +++ b/.agents/skills/purview-persistence/SKILL.md @@ -1,14 +1,15 @@ --- name: purview-persistence description: >- - Use when a .NET project actually persists data and you are choosing a storage approach, deciding - whether to abstract EF Core, or selecting a GUID primary-key strategy. Gives provider-specific - guidance — SQL Server and PostgreSQL differ and must not be treated alike — and stays silent for - projects that do not persist anything. + Use when a .NET project actually persists data and you are choosing a storage approach, integrating + or abstracting EF Core, deriving a well-known identifier, or selecting a GUID primary-key strategy. + Gives provider-specific guidance — SQL Server and PostgreSQL differ and must not be treated alike — + and stays silent for projects that do not persist anything. license: MIT metadata: author: Purview-Dev - version: "1.0" + version: "1.2" + repository: purview-dev/engineering-playbook --- # Purview persistence @@ -40,7 +41,39 @@ development and integration tests — including `MsSql`, `PostgreSql`, `MySql`, `RabbitMq`, `Azurite` and an Azure Key Vault emulator. Prefer a real container over an in-memory provider: the in-memory provider does not enforce the constraints that break you in production. -## 3. GUID keys — only if GUIDs are keys +## 3. EF Core integration + +When EF Core is the store, keep the integration in four places: + +- **`DbContext` is the boundary.** Use it directly; do not add `IRepository` or a unit-of-work wrapper + for testability. `Purview.Containers` gives you a real database in tests. +- **Choose the provider where the context is registered.** Carry the choice as context-options data, not + a `Database.ProviderName` branch at model-build time. +- **Map value objects once.** Configure converters and comparers in the model, not per property. A + value-object key needs a converter-aware value generator. +- **Keep design time and runtime in step.** Both paths make the same provider, strategy and + `ReplaceService` choices, or the migration snapshot describes a different model. + +Migrations are code, reviewed and applied by a deliberate step; the API does not migrate at startup in +production. The full rule, composition sample and checks are in `references/ef-core-integration.md`. + +## 4. Identifiers — derived or generated + +Two decisions live here, and they are independent. Ask them in this order. + +### The deterministic-identifier gate + +Derive an identifier only when its **value must be known before storage** — a system tenant, a seeded +reference row, an installation's service principal, or an ingested object whose provider key is stable +and never recycled. Then derive it with UUIDv5 over an installation-scoped namespace and configure +`ValueGeneratedNever()`, so the sequential convention below leaves it alone. A project with random keys +and no seeding has no use for UUIDv5. An outbox row that merely needs an id before `SaveChanges` takes +the generated strategy below, not a derived one. + +The full rule, catalogue, naming rules, migration caveat and tests are in +`references/well-known-identifiers.md`. + +### The generated-key decision Resolve these before recommending anything; most are answered by the repository: @@ -72,11 +105,14 @@ Use the framework or database capability when it is sufficient — `Guid.CreateV generator is a maintenance obligation and a correctness risk; introduce one only against a measured requirement. -**There is no Purview GUID generation package.** Do not reference one, and do not create one without -first establishing a concrete, measured need that the built-in options cannot meet. If such a -capability is added later, this skill is where the guidance for recommending it belongs. +**No NuGet package provides this pattern.** A reviewed generator such as `FastGuid` supplies a +store-ordered implementation, but the strategy witness, options extension, value generator with +value-converter support, and model-finalising convention are repository-local: copy them, do not depend +on a package that claims to own the wiring. Introduce it only for a measured requirement: a second +provider, or an identifier needed before `SaveChanges`. Until the second provider exists, a built-in +capability is the answer. -## 4. Value objects as keys +## 5. Value objects as keys `Purview.ValueObjects` gives strongly-typed identifiers that stop an `OrderId` being passed where a `CustomerId` belongs. They need an EF Core value converter; configure it once in the model rather @@ -85,7 +121,7 @@ than per property. This is about type safety at the boundary, not about the generation strategy — the two decisions are independent. -## 5. Migrations and schema +## 6. Migrations and schema - Migrations are code: reviewed, tested against a real container, and applied by a deliberate step — not by the application at startup in production. @@ -96,7 +132,14 @@ independent. ## Checks before you answer - Have you confirmed persistence is actually required? +- If the project uses EF Core, are you recommending the `DbContext` directly rather than a repository + wrapper added for testability? +- Does the design-time path make the same provider and strategy choices as the runtime path? +- Does every generated value have one owner — application, store or derivation — and no second writer? +- If you are deriving an identifier, is its value genuinely known before storage, and is it keyed + `ValueGeneratedNever()`? - If you are recommending a GUID strategy, do you know the provider *and* its version? +- If you are proposing the strategy/convention, is there a second provider or a pre-save identifier? - Are you proposing an abstraction you can justify by something other than testability? - Have you preferred a built-in capability over a custom generator? diff --git a/.agents/skills/purview-persistence/references/ef-core-integration.md b/.agents/skills/purview-persistence/references/ef-core-integration.md new file mode 100644 index 0000000..77c7573 --- /dev/null +++ b/.agents/skills/purview-persistence/references/ef-core-integration.md @@ -0,0 +1,83 @@ +# EF Core integration + +Read this when the project actually uses EF Core. The identifier decisions are in `guid-generation.md` +and `well-known-identifiers.md`; this page is the rest of the integration. + +## The rule + +1. **`DbContext` is the boundary.** Read and write through it directly, or through a narrow query type + when a query deserves a name. Do not add `IRepository` or a unit-of-work wrapper for testability. + `DbContext` already is both, and a real container is a more faithful test than a substitute. +2. **The provider is chosen where the context is registered.** Carry the choice as context-options data; + never branch on `Database.ProviderName` while the model is being built. The model is built once and + cached, so that branch serves one provider's model to the other. +3. **Map a value object once.** Configure the converter and comparer in the model, not on each property. + The `Purview.ValueObjects` generator emits the converters when EF Core is referenced. A value-object + key also needs a converter-aware value generator, or the property receives a raw `Guid`. +4. **One owner per generated value.** The application supplies it, the store generates it, or it is + derived — never two. A derived identifier is `ValueGeneratedNever()`; a store default or computed + column is left to the store. +5. **Design time and runtime agree.** The design-time factory or host registration makes the same + provider, strategy and `ReplaceService` choices as the application. Otherwise the migration snapshot + describes a model the application does not run. +6. **The model cache key covers every input that varies the model.** Provider, strategy, tenant and + design time. EF's service-provider cache and model cache are separate; both need a key that matches + the decision. +7. **Migrations are code.** Persistence owns the definitions; a deliberate step applies them. The API + does not migrate at startup in production. +8. **Integration tests use a real store.** The in-memory provider does not enforce foreign keys, unique + indexes, computed columns or SQL semantics. Use `Purview.Containers` and a `.IntegrationTests` + project. +9. **The connection string is configuration.** Keep it out of source, out of `appsettings.json` in the + repository, and out of telemetry. A redaction test is worth more than a convention. + +## The composition + +The provider, the strategy and the service replacements belong together, where the context is +registered: + +```csharp +services.AddDbContext>((serviceProvider, options) => +{ + ConfigureStore(options, connectionString); + + options + .UseSequentialGuids() + .ReplaceService() + .ReplaceService>(); +}); + +services.AddSingleton>(); +``` + +- `ConfigureStore` is the only place that names `UseSqlServer` or `UseNpgsql`. +- `UseSequentialGuids` adds an options extension; the model reads it instead of asking about the provider. +- `IModelCacheKeyFactory` is replaced because the service-provider hash alone does not key the model + cache. +- The application-facing factory is registered separately, because EF's internal registration is not + reachable from an outbox writer or a correlation-id generator. + +## Migrations + +- Generation and application are separate: `dotnet ef migrations add` in development, a controlled + `Migrator` step in a deployment. +- A migration and `HasData` are compiled and replayed, so they need a literal GUID. A derived identifier + cannot be computed there; print the constant from the derivation and guard it with a test. +- Expect the schema to be ahead of or behind the code during a rollout, and design the change to be + compatible in both directions. +- Never let the API apply migrations at startup in production: a deploy then mutates the schema without + review and without a clean rollback. + +## Checks to write + +| Check | Shape | +| -- | -- | +| Model covers every GUID key | Each non-foreign-key GUID key is generated on add with the expected factory | +| Foreign keys are not generated | A foreign-key GUID keeps the value copied from its principal | +| Explicit configuration wins | A `ValueGeneratedNever()` key keeps its supplied value through `SaveChanges` | +| Two strategies, one context type | Different strategies produce different service providers and models | +| Design time matches runtime | Both models produce the same migration snapshot | +| Real store semantics | A unique index, a foreign key and a computed column behave as the model says in a container | + +The Engineering Playbook's *EF Core Integration* page states the same rules with the sample as evidence; +the runnable implementation is `samples/Acme.Persistence` in that repository, run with `just samples`. diff --git a/.agents/skills/purview-persistence/references/guid-generation.md b/.agents/skills/purview-persistence/references/guid-generation.md index 432053a..fb644bb 100644 --- a/.agents/skills/purview-persistence/references/guid-generation.md +++ b/.agents/skills/purview-persistence/references/guid-generation.md @@ -62,14 +62,22 @@ Never use `NEWSEQUENTIALID()` output as a secret. | Provider | Needs value before insert | Locality matters | Recommendation | | -- | -- | -- | -- | -| SQL Server | No | Yes | `NEWSEQUENTIALID()` (EF Core's default for `Guid` keys) | -| SQL Server | Yes | Yes | EF Core `SequentialGuidValueGenerator` | +| SQL Server | No | Yes | `NEWSEQUENTIALID()` as a store default, or EF Core's client-side `SequentialGuidValueGenerator` | +| SQL Server | Yes | Yes | EF Core `SequentialGuidValueGenerator`, or a store-ordered `FastGuid` strategy when both providers share a model | | SQL Server | Either | No | `Guid.NewGuid()` | | PostgreSQL | Yes | Yes | `Guid.CreateVersion7()` with `ValueGeneratedNever()` | | PostgreSQL | No | Yes | `uuidv7()` on 18+, otherwise generate v7 in the application | | PostgreSQL | Either | No | `Guid.NewGuid()` or `gen_random_uuid()` | +| SQL Server **and** PostgreSQL, one context type | Either | Yes | A store-specific strategy carried as context-options data. One strategy per provider; see below. | | Any | Either | Either, but must be unpredictable | Random v4 | +The two-provider row is the one that needs code: an `ISequentialGuidProvider` witness, an options +extension and a model-finalising convention. Copy them from the Engineering Playbook's *GUID Key +Strategy* page. Never expect one byte-ordered generator to order for both stores. + +A value whose identity is known before storage does not belong in this table. Derive it with UUIDv5 and +key it `ValueGeneratedNever()`; see `well-known-identifiers.md`. + ## Prefer built-in capability In order of preference: @@ -77,17 +85,19 @@ In order of preference: 1. A framework capability — `Guid.CreateVersion7()`, EF Core's value generators. 2. A database capability — `NEWSEQUENTIALID()`, `uuidv7()`, `gen_random_uuid()`. 3. A reviewed, tested implementation of a published standard, when neither of the above is available - on your target versions. + on your target versions — `FastGuid` is the one the sample uses for store-ordered values. 4. A bespoke generator — only against a measured requirement the first three cannot meet. A hand-written sequential GUID generator is easy to get subtly wrong — endianness, clock regression, counter rollover within a millisecond, and thread safety are all live hazards, and the failure mode is duplicate or badly-ordered keys found in production. -## There is no Purview GUID package +## No NuGet package provides this -No Purview library provides GUID generation today. Do not reference one and do not invent one. +No NuGet package provides the strategy/convention pattern. It is repository-local: copy the strategy +witness, options extension, value generator and model-finalising convention from the Engineering +Playbook's *GUID Key Strategy* page. Do not depend on a package that claims to own it, and do not +create one without a measured requirement the built-in options cannot meet. -If a concrete need emerges that the built-in options cannot meet — established by measurement, not by -anticipation — that is the point at which creating one is worth discussing. Until then this guidance -is the deliverable, and this page is where a recommendation for such a package would be documented. +The abstraction is earned by a second provider, or by an identifier needed before `SaveChanges` on +both — not by anticipation. Until then a built-in capability is the answer. diff --git a/.agents/skills/purview-persistence/references/postgresql.md b/.agents/skills/purview-persistence/references/postgresql.md index cd557ec..603987d 100644 --- a/.agents/skills/purview-persistence/references/postgresql.md +++ b/.agents/skills/purview-persistence/references/postgresql.md @@ -43,6 +43,49 @@ builder.Property(o => o.Id).ValueGeneratedNever(); Without that, EF Core may treat the key as store-generated and skip your value. +### Application-generated, store-ordered strategy + +When one context type serves both PostgreSQL and SQL Server, or the identifier must exist before +`SaveChanges` on both, use a store-specific strategy carried as context-options data. The PostgreSQL +implementation returns `FastGuid.NewPostgreSqlGuid()`; a model-finalising convention applies it to +every GUID key and leaves explicitly keyed derived identifiers alone. + +```csharp +public sealed class PostgreSqlSequentialGuidProvider : ISequentialGuidProvider +{ + public static string Name => "PostgreSql"; + + public static Guid NewGuid() => FastGuid.NewPostgreSqlGuid(); + + public static Guid NewGuid(DateTime timestampUtc) => + FastGuid.NewPostgreSqlGuid(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static DateTime GetTimestamp(Guid value) => FastGuid.PostgreSql.GetTimestamp(value); + + public static Guid MinValueFor(DateTime timestampUtc) => + FastGuid.PostgreSql.MinGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static Guid MaxValueFor(DateTime timestampUtc) => + FastGuid.PostgreSql.MaxGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); +} +``` + +- PostgreSQL compares `uuid` in plain big-endian byte order, so both `FastGuid.NewPostgreSqlGuid()` + and `Guid.CreateVersion7()` are genuinely sequential here. `FastGuid` is also correct for SQL Server, + which is the point of the strategy abstraction. +- Set `ValueGeneratedNever()` through the convention; never let EF fetch a store-generated value that + does not exist. + +#### RFC caveat + +`FastGuid.NewPostgreSqlGuid()` values are **not** RFC 9562 UUIDs. They are ordinary GUIDs with a +PostgreSQL-ordered layout, so PostgreSQL's `uuid_extract_timestamp()` will not read them even though +they are valid `uuid` values. Use the provider's `GetTimestamp` (above) instead. If another system +needs a standard timestamp, generate `Guid.CreateVersion7()` there and accept that the layout differs. + +The full sample is the Engineering Playbook's *GUID Key Strategy* page. The abstraction is earned by +the second provider, not by anticipation. + ### Database-generated UUIDv7 — PostgreSQL 18+ PostgreSQL 18 added a native `uuidv7()` function. Earlier versions do not have it; on 13–17 you have @@ -87,6 +130,7 @@ Random and unpredictable. Fine at low to moderate write volume, which covers mos | .NET 8 or earlier, high write volume | A reviewed UUIDv7 implementation, or accept v4 after measuring | | Rows inserted outside the application, PostgreSQL 18+ | `uuidv7()` as a column default | | Rows inserted outside the application, PostgreSQL 13–17 | `gen_random_uuid()` | +| One model serving PostgreSQL and SQL Server | A store-specific `FastGuid` strategy carried as context-options data — never one byte-ordered generator for both | | Key must be unpredictable | `Guid.NewGuid()` or `gen_random_uuid()` — v7 leaks creation time | | Low or moderate volume | v4 — the locality difference will not be your bottleneck | diff --git a/.agents/skills/purview-persistence/references/sqlserver.md b/.agents/skills/purview-persistence/references/sqlserver.md index c728295..2262972 100644 --- a/.agents/skills/purview-persistence/references/sqlserver.md +++ b/.agents/skills/purview-persistence/references/sqlserver.md @@ -27,8 +27,13 @@ ALTER TABLE [Orders] ADD CONSTRAINT [DF_Orders_Id] DEFAULT NEWSEQUENTIALID() FOR [Id]; ``` -EF Core, for a `Guid` key on SQL Server, configures this by default — `ValueGeneratedOnAdd` maps to -`NEWSEQUENTIALID()` through the default value-generation strategy. +EF Core does **not** map `ValueGeneratedOnAdd` to `NEWSEQUENTIALID()` by default. For a `Guid` key on +SQL Server the provider generates the value client-side with `SequentialGuidValueGenerator` (below). +Configure a store default explicitly when the database must own the value: + +```csharp +builder.Property(o => o.Id).HasDefaultValueSql("NEWSEQUENTIALID()"); +``` Trade-offs: @@ -53,6 +58,44 @@ builder.Property(o => o.Id) Use this when the identifier must exist before `SaveChanges`. It keeps most of the index locality while letting the application own the value. +### Application-generated, store-ordered strategy + +When one context type serves both SQL Server and PostgreSQL, or the identifier must exist before +`SaveChanges` on both, a single built-in generator is not enough: the two stores order GUIDs +differently. The answer is a store-specific strategy carried as context-options data — a +`static abstract` witness whose SQL Server implementation returns `FastGuid.NewSqlServerGuid()`, plus a +value generator and a model-finalising convention that applies it to every GUID key. + +```csharp +public sealed class SqlServerSequentialGuidProvider : ISequentialGuidProvider +{ + public static string Name => "SqlServer"; + + public static Guid NewGuid() => FastGuid.NewSqlServerGuid(); + + public static Guid NewGuid(DateTime timestampUtc) => + FastGuid.NewSqlServerGuid(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static DateTime GetTimestamp(Guid value) => FastGuid.SqlServer.GetTimestamp(value); + + public static Guid MinValueFor(DateTime timestampUtc) => + FastGuid.SqlServer.MinGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static Guid MaxValueFor(DateTime timestampUtc) => + FastGuid.SqlServer.MaxGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); +} +``` + +- The increasing part must sit where SQL Server sorts it. The trailing six bytes dominate + `uniqueidentifier`; a byte-ordered UUIDv7 does not. +- The generator must hand the value through the property's value converter, or a `readonly record + struct OrderId` key receives a raw `Guid`. +- Apply it at convention precedence, so explicit `ValueGeneratedNever()`, a store default and a + computed column all win. + +The full sample is the Engineering Playbook's *GUID Key Strategy* page. The abstraction is earned by +the second provider; on one SQL Server provider `NEWSEQUENTIALID()` or EF Core's generator is enough. + ### `Guid.NewGuid()` — random Correct, portable and unpredictable; poor index locality. @@ -88,7 +131,7 @@ reaching for a sequential generator. | Situation | Use | | -- | -- | | High insert volume, key may be database-generated | `NEWSEQUENTIALID()` | -| High insert volume, identifier needed before persisting | EF Core `SequentialGuidValueGenerator` | +| High insert volume, identifier needed before persisting | EF Core `SequentialGuidValueGenerator`, or a store-ordered `FastGuid` strategy when both providers share a model | | Key must be unpredictable (public surface, security boundary) | `Guid.NewGuid()`, with a non-clustered PK | | Low or moderate volume | `Guid.NewGuid()` — do not optimise what does not hurt | | You want a sortable timestamp in the key and accept the locality cost | `Guid.CreateVersion7()` | diff --git a/.agents/skills/purview-persistence/references/well-known-identifiers.md b/.agents/skills/purview-persistence/references/well-known-identifiers.md new file mode 100644 index 0000000..372d585 --- /dev/null +++ b/.agents/skills/purview-persistence/references/well-known-identifiers.md @@ -0,0 +1,93 @@ +# Deriving well-known identifiers with UUIDv5 + +Read this only when an identifier's **value must be known before storage**: a system tenant, a seeded +reference row, an installation's service principal, or an ingested object whose provider key is stable +and never recycled. A project with random keys and no seeding has no use for UUIDv5 — say nothing and +move on. An outbox row that merely needs an id before `SaveChanges` takes a generated strategy instead. + +## The rule + +1. Derive the value: `Uuid5.Create(namespace, name)` is RFC 4122 version 5 (SHA-1), byte-identical for + the same namespace and name in every process, language and restart. +2. Scope the namespace to the isolation boundary — one installation or one deployment. Never a global + constant shared by every deployment. +3. Fix the name as a canonical constant of the identity kind. Not a display name, not user data, not a + secret. +4. Once persisted, the value is authoritative. Never recompute it and update the row. +5. Key it explicitly with `ValueGeneratedNever()`, so a sequential-GUID convention never generates over + a value the application already knows. + +## The catalogue + +Write the known identities down in one place. + +| Identity | Namespace | Name | Derived when | Immutable | +| -- | -- | -- | -- | -- | +| System tenant of an installation | installation id | `system-tenant` | first bootstrap | yes | +| System principal of an installation | installation id | `system-service-principal` | first bootstrap | yes | +| Seeded reference row | installation id | `:` | seed step | yes | +| Ingested external object (optional) | installation id | `:` | first ingest | yes | + +Derive the ingested row only when the provider guarantees the external key is stable and never +recycled. If it can be recycled, keep the local key sequential and make the external key a unique +column. + +## Naming rules + +- Lowercase ASCII, hyphen-separated, no whitespace, no locale-dependent casing. +- Never from a display name, email, URL, title, or anything a user can edit. +- Never from a secret, and never as a capability token. A name is guessable. +- Renaming a name creates a new identity. Renaming a persisted one is a data migration. + +## Bootstrap sequence + +Read the persisted identity; if present it wins, and a configured value must match or the operation +fails. If absent, insert it. Derive dependants from the persisted id. Save once. A second run changes +nothing. + +The guard matters as much as the insert: + +```csharp +if (persisted is not null && persisted.Id != configured) +{ + throw new InvalidOperationException( + $"Configured installation id '{configured.Value}' does not match the persisted id '{persisted.Id.Value}'."); +} +``` + +## Migrations cannot call the helper + +`HasData` and migration SQL need a literal GUID. A small script prints ready-to-paste constants from +the same derivation, and a test asserts every printed constant equals `Uuid5.Create(...)`. Changing the +namespace or a name after a migration has shipped is a data migration, not an edit. + +## Checks to write + +| Check | Shape | +| -- | -- | +| RFC 4122 conformance | Fixed vectors for DNS/URL/OID/X500 and the empty name; exact GUIDs | +| Determinism | Same pair twice equal; different names differ | +| Version and variant bits | Byte 6 high nibble `0x5`; byte 8 high bits `10` | +| Cross-language equality | Python `uuid.uuid5` or Node `crypto` reproduces the vectors | +| Catalogue uniqueness | The catalogue's constants are distinct | +| Bootstrap idempotency | Seeding twice changes zero rows; a mismatched configured id throws | +| Explicit keying | The key is `ValueGenerated.Never`, and the sequential convention leaves it alone | + +```text +uuid5(6ba7b810-9dad-11d1-80b4-00c04fd430c8, "example.com") = cfbff0d1-9375-5685-968c-48ce8b15ae17 +uuid5(6ba7b811-9dad-11d1-80b4-00c04fd430c8, "www.example.com") = b63cdfa4-3df9-568e-97ae-006c5b8fd652 +uuid5(6ba7b812-9dad-11d1-80b4-00c04fd430c8, "1.2.3.4") = 67448b45-6d15-536b-bef7-8a78c3d10ac6 +uuid5(6ba7b814-9dad-11d1-80b4-00c04fd430c8, "CN=Bob") = 0840e90d-9ca0-5f14-970e-26294d44cb1d +``` + +The hash path is big-endian. `Guid.ToByteArray()` and `new Guid(byte[])` use a mixed-endian layout and +produce a different hash; that is the most common way a "UUIDv5" implementation is wrong. + +**UUIDv5 is not sequential.** Never use it for a high-write table's primary key. It is for a small, +known set of identities; everything else takes `guid-generation.md`. + +RFC 4122 is the version-5 specification; RFC 9562 is the current UUID specification and retains the +algorithm. The .NET BCL has no UUIDv5 API, so the helper is a small hand-written function. + +The Engineering Playbook's *Well-Known Identifiers* page carries the full helper, catalogue and +bootstrap samples, and their test shapes. diff --git a/.agents/skills/purview-sourcegenerator-attribute-data-models/SKILL.md b/.agents/skills/purview-sourcegenerator-attribute-data-models/SKILL.md new file mode 100644 index 0000000..714ddbf --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-attribute-data-models/SKILL.md @@ -0,0 +1,146 @@ +--- +name: purview-sourcegenerator-attribute-data-models +description: >- + Use when declaring or fixing a [Generate] attribute-data model in Purview.SourceGeneratorFramework - + mapping constructor and named arguments with [Argument]/[Property], defaults, reusing a shape with + [NestedModel], reading an ADM0001-ADM0015 diagnostic, or testing that a model reads the values an + attribute application supplies. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Attribute-data models + +`AttributeDataModelGenerator` turns a `readonly partial record struct` marked `[Generate(typeof(TAttribute))]` +into a parser for `TAttribute`'s `AttributeData`: `Exists`, `Empty`, `TargetAttribute`, the guarded +`FromAttributeData`/`TryFromAttributeData`/`AllAttributeData` overloads, and `FromAttributeDataUnchecked`. +The generated model is a value type, so it is safe to hold in an incremental pipeline (an `AttributeData` +is not). + +The one thing to keep in mind: **the generated code looks names up case-sensitively at run time, and a +name it cannot find quietly yields the default value.** A mapping mistake compiles and produces `null`/`0`. +The naming analyzer (`ADM0011`–`ADM0015`) exists to report those mistakes where they are declared; do not +suppress it without understanding the finding, and test the model against real applications. + +## Mapping each property + +Each primary-constructor parameter becomes a property, PascalCased (`message_template` → `MessageTemplate`). +Its sources are read in this order, first hit wins: + +| Declaration | Reads | +| --- | --- | +| *(no mapping attribute)* | the named argument with the property's name: `[Log(EventId = 7)]` | +| `[Property]` / `[Property(Name = "X")]` | the named argument `X` (default: the property name) | +| `[Argument("messageTemplate")]` | the constructor parameter **named** `messageTemplate` | +| `[Argument]` | the constructor parameter named after the property, camel-cased: `MessageTemplate` → `messageTemplate` | +| `[Argument(1)]` | the constructor argument at **position** 1 | +| `[Argument(...)] [Property]` | the named argument first, then the constructor argument | +| `[NestedModel]` | another generated model, read from the same attribute (below) | +| `[GenericTypeArgument]` | a generic type argument of the attribute class, as a `TypeIdentity` | +| `[Exclude]` | nothing; the parameter is left out of the model | + +Methodology: + +- **Map a constructor parameter by name, not by index.** A name survives a new overload or a reordered + parameter list; an index silently starts reading a different parameter. +- **Map both sources when the attribute accepts both.** `[Argument("name")] [Property] string? Name` reads + `[A(name: "x")]`, `[A("x")]` and `[A(Name = "x")]`. The named argument is read first because it is assigned + after the constructor runs, so it is the attribute instance's effective value. +- **Only map `[Property]` to a public settable property or field.** A property with a private or `internal` + setter can never be a named argument; read it through the constructor parameter that sets it (`ADM0012`). +- **Match case exactly.** `[Argument("MessageTemplate")]` never matches a parameter called + `messageTemplate` (`ADM0011`). +- **Give every property that might be absent a `DefaultValue`**: `[Property(DefaultValue = -1)] int EventId`. + A missing argument takes it; without one, it takes `default(T)`. `Empty` always uses `default(T)`. +- **Use `IsEnum = true` with a `string` property** when the enum type is not available to the generator; a + bare member-name default (`DefaultValue = "Inherit"`) is expanded to the fully-qualified member. +- **Never name a property `Exists`, `Empty`, `TargetAttribute`, `AllAttributeData`, `FromAttributeData`, + `TryFromAttributeData` or `FromAttributeDataUnchecked`** — they are generated (`ADM0014`, generation is + skipped). Keyword names (`Event`, `Class`) are fine. + +## Reusing a shape with `[NestedModel]` + +A `[NestedModel]` parameter is populated by the nested model's `FromAttributeDataUnchecked`, from the +**outer** attribute's arguments, with no `TargetAttribute` check. The outer attribute does not have to derive +from the nested model's target; it only has to use the same constructor parameter and property names. + +```csharp +[Generate(typeof(LogAttribute))] +readonly partial record struct LogAttributeData( + [Argument("level")] [Property(DefaultValue = 99)] int Level, + [Argument("messageTemplate")] [Property] string? MessageTemplate, + [Argument("eventId")] [Property(DefaultValue = -1)] int EventId, + [Property] string? Name +); + +// InfoAttribute has messageTemplate, eventId and Name, but no level. +[Generate(typeof(InfoAttribute))] +readonly partial record struct InfoAttributeData([NestedModel] LogAttributeData Log); +``` + +- The nested model's `Exists` is always `true`; a mapping the outer attribute does not declare (`level` + here) takes its `DefaultValue`. `ADM0013` (info) lists such mappings, so an intended absence is visible + and an accidental one is caught. +- Map nested properties **by name**. A constructor index means a different parameter on each attribute + (`ADM0015`). +- `MatchByInheritance = true` is for using a model on its own against derived attributes. A nested model + does not need it. +- Call `FromAttributeData`/`TryFromAttributeData` from generator code. `FromAttributeDataUnchecked` skips the + type check and is for nested models. + +## Diagnostics + +| Rule | Severity | Meaning and fix | +| --- | --- | --- | +| `ADM0001` | Error | The `[Generate]` target cannot be resolved. Use `typeof`, a resolvable full name, or a type-library `…FullName` constant. | +| `ADM0002` | Error | Unsupported property type (array, pointer, function pointer). | +| `ADM0003` | Error | `[Argument("name")]`/`[Argument(index)]` names no constructor parameter of the target. | +| `ADM0004` | Error | A `[NestedModel]` type is not itself a `[Generate]` model. | +| `ADM0005` | Error | A `DefaultValue` cannot be emitted for the property type. | +| `ADM0006` | Error | A non-nullable reference property has no default. Add a `DefaultValue` or make it nullable. | +| `ADM0007` | Error | `AutoDiscover` needs the `typeof` target overload. | +| `ADM0008` | Error | A `[GenericTypeArgument]` property must be `TypeIdentity`. | +| `ADM0009` | Error | An `IsEnum` property must be `string`/`string?`. | +| `ADM0010` | Error | An `ISymbol`/`Type` property is not cacheable; use `TypeIdentity` or a string. | +| `ADM0011` | Warning | A mapped name matches an attribute member only when case is ignored, so it is never read. Fix the case. | +| `ADM0012` | Warning | The mapped named argument is not a public settable member of the attribute, or a bare `[Argument]` implies a constructor parameter it lacks. Map the member that actually carries the value. | +| `ADM0013` | Info | A nested model property reads only names the outer attribute lacks, so it always takes its default. Expected for a deliberately absent value; otherwise rename to match. | +| `ADM0014` | Error | A property has a generated member's name. Rename the parameter. | +| `ADM0015` | Warning | A nested model reads a constructor index that names a different parameter on the outer attribute. Map it by name. | + +## Testing a model + +Test the attribute side and the model side separately, against what Roslyn actually produces. Use +`AttributeDataFactory` from `Purview.SourceGeneratorFramework.Testing` to compile an application, and the +`AttributeDataAssertions` from the TUnit package to pin each argument by ordinal, parameter name or named +argument, including how it was supplied (`AttributeArgumentForm`): + +```csharp +var options = new MyTestOptions { AdditionalNamespaces = ["My.Attributes"] }; +var attribute = AttributeDataFactory.FromUsage("[Info(\"custom {Value}\", Name = \"N\")]", options); + +await Assert.That(attribute).HasConstructorArgument("messageTemplate", "custom {Value}"); +await Assert.That(attribute).HasConstructorArgument("eventId", -1, AttributeArgumentForm.Defaulted); +await Assert.That(attribute).HasNamedArgument("Name", "N"); + +var model = InfoAttributeData.FromAttributeData(attribute); +await Assert.That(model.Log.MessageTemplate).IsEqualTo("custom {Value}"); +await Assert.That(model.Log.Level).IsEqualTo(99); +``` + +Cover, for each model: positional, by-name and named-argument applications; an application omitting every +optional argument (proves the `DefaultValue`s); for a nested model, an outer attribute that lacks a mapped +member; and the nested target's own `FromAttributeData` returning `Empty` for the outer attribute. +`FromUsage` places the application on a declaration its `AttributeUsage` allows and throws on a compile +error, so a misspelt argument name fails the test. `FromSource` reads applications from a full source. + +Generated-code assertions (`Contains("FromAttributeDataUnchecked(attributeData)")`) prove *what* the +generator emits; the tests above prove the model *reads the right values*. Prefer the latter. + +## Related skills + +`purview-sourcegenerator-testing` for the runner layer, `AttributeDataFactory` and `AttributeArguments`; +`purview-sourcegenerator-tunit-tests` for the assertion extensions; `purview-sourcegenerator-pipeline` for +holding models in an incremental pipeline; `purview-sourcegenerator-diagnostics` for the other analyzer +families. diff --git a/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md b/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md index ee363c9..68e79d3 100644 --- a/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md +++ b/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md @@ -234,7 +234,7 @@ Guidelines: - The `Microsoft.CodeAnalysis.*` version used to compile the analyser/generator sets the minimum compiler-host requirement. - The consumer's `TargetFramework` does not determine analyser compatibility. - Choose the oldest Roslyn version that contains the APIs you need. -- Common baselines: Roslyn 4.8 for VS 17.8 / .NET 8, 4.12 for VS 17.12 / .NET 9, 5.0 for VS 2026 18.0 / .NET 10. +- Common baselines: Roslyn 4.8 for VS 17.8 / .NET 8, 4.12 for VS 17.12 / .NET 9, 5.0 for VS 2026 18.0 / .NET 10, 5.12 for .NET 11. - Ship one `netstandard2.0` analyser/generator binary unless you have a deliberate multi-version strategy. - Do not mistake multi-targeting for automatic analyser asset selection. - Use `PrivateAssets="all"` for Roslyn development dependencies. diff --git a/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md b/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md index 21d9522..b80ebe7 100644 --- a/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md +++ b/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md @@ -1,7 +1,7 @@ --- name: purview-sourcegenerator-diagnostics description: >- - Use when a Purview.SourceGeneratorFramework build reports a PSGFR, PSGF, PRSGD or TLB diagnostic, + Use when a Purview.SourceGeneratorFramework build reports a PSGFR, PSGF, PRSGD, TLB or ADM diagnostic, when choosing which code fix applies, or when a source generator, diagnostic analyzer or code fix provider is silently not running at all. metadata: @@ -106,8 +106,21 @@ Opt out with `PurviewSourceGeneratorFrameworkAnalyzerValidation=false` (`PRSGD00 ## Type-library and attribute-model diagnostics The bundled generators carry their own families: `TypeLibraryValidationAnalyzer` -(`TLB0001`–`TLB0019`, plus `TLB0021`) and the attribute-data-model validation analyzers. They are -documented on their own wiki pages, `Type-Library.md` and `Attribute-Data-Models.md`. +(`TLB0001`–`TLB0019`, plus `TLB0021`) and the attribute-data-model analyzers. They are documented on their +own wiki pages, `Type-Library.md` and `Attribute-Data-Models.md`. + +The attribute-data-model rules come from three analyzers: + +| Rules | Analyzer | Covers | +| --- | --- | --- | +| `ADM0001`–`ADM0009` | `AttributeDataModelValidationAnalyzer` | the target, property types, defaults, explicit constructor members | +| `ADM0010` | `AttributeDataModelSymbolPropertyAnalyzer` | non-cacheable `ISymbol`/`Type` properties | +| `ADM0011`–`ADM0015` | `AttributeDataModelNamingAnalyzer` | mapped names the generated code would never find, generated-member name clashes, `[NestedModel]` mappings the outer attribute does not line up with | + +Treat the naming rules as real defects: the generated code reads names case-sensitively and silently falls +back to a default, so a naming mistake never fails the build on its own. `ADM0013` is `Info` because a +nested model may deliberately read a value the outer attribute does not have; check each one. The +`purview-sourcegenerator-attribute-data-models` skill has the full table and the fix for each rule. ## Code fixes @@ -139,4 +152,5 @@ Most rules above have a fix in `Purview.SourceGeneratorFramework.CodeFixers`: ## Related skills `purview-sourcegenerator-codewriter` for the emission patterns the `CodeWriter` rules push you -towards, and `purview-sourcegenerator-testing` for proving a pipeline actually caches. +towards, `purview-sourcegenerator-testing` for proving a pipeline actually caches, and +`purview-sourcegenerator-attribute-data-models` for the `ADM` rules. diff --git a/.agents/skills/purview-sourcegenerator-packaging/SKILL.md b/.agents/skills/purview-sourcegenerator-packaging/SKILL.md index 92350db..a34f72c 100644 --- a/.agents/skills/purview-sourcegenerator-packaging/SKILL.md +++ b/.agents/skills/purview-sourcegenerator-packaging/SKILL.md @@ -76,10 +76,14 @@ identity. Both are `IsRoslynComponent`; only the generator references the framew - The referenced component is returned as **its own merged assembly** and is never IL-merged into the dependent one — that would duplicate its types, including `InternalsVisibleTo`-visible internals, inside a second analyzer in the same host. -- The framework assembly is declared as copy-to-output content beside the generator's `bin`, and - content with `CopyToOutputDirectory` flows transitively through `ProjectReference`. That is what - lets Visual Studio load the dependent code-fix provider from its own `bin` without a - `FileNotFoundException` for the framework assembly. +- The framework assembly is copied beside the generator's `bin` (a plain copy, not copy-to-output + content). A dependent component that references the generator as an assembly gets it copied + beside its own `bin` by ordinary assembly-reference resolution, which is what lets Visual Studio + load the dependent code-fix provider without a `FileNotFoundException` for the framework assembly. +- A project that references a component only as an analyzer (`ReferenceOutputAssembly="false"`) + never receives the framework assembly, and neither does any application downstream of it. Since + 1.2.2 it no longer leaks into app build output, `deps.json`, publish output, single-file bundles or + Blazor WebAssembly `_framework`, where it broke Native AOT and WebAssembly AOT publishing. - Visual Studio's project system resolves an `OutputItemType=Analyzer` project reference to the referenced project's **unmerged** `bin` assembly and adds it to the compiler's analyzers — the command-line build does not. `Purview.BuildSdk` removes that item before adding the resolved @@ -181,10 +185,12 @@ marker is the preferred configuration. | 4.8 | VS 2022 17.8 | C# 12 / .NET 8 | | 4.12 | VS 2022 17.12 | C# 13 / .NET 9 | | 5.0 | VS 2026 18.0 | C# 14 / .NET 10 | +| 5.12 | VS 2026 18.x | C# 15 / .NET 11 | -**This framework is built against Roslyn 5.0**, so a host loading it must be Roslyn 5.0 or later -(.NET 10 SDK / Visual Studio 2026 18.0). The testing packages multi-target `net8.0`–`net10.0`, and -Roslyn 5.x ships `net8.0`/`net9.0` assets, so those test targets still load the runner. +**This framework is built against Roslyn 5.9**, so a host loading it must ship Roslyn 5.9 or later +(.NET 11 SDK / Visual Studio 2026). The .NET 11 SDK ships Roslyn 5.12. The testing packages +multi-target `netstandard2.0`, `net10.0`, and `net11.0`, and Roslyn 5.9 ships `net10.0`/ +`netstandard2.0` assets, so both test targets load the runner. ## A generator project to start from diff --git a/.agents/skills/purview-sourcegenerator-testing/SKILL.md b/.agents/skills/purview-sourcegenerator-testing/SKILL.md index aaeff09..5590a89 100644 --- a/.agents/skills/purview-sourcegenerator-testing/SKILL.md +++ b/.agents/skills/purview-sourcegenerator-testing/SKILL.md @@ -3,8 +3,9 @@ name: purview-sourcegenerator-testing description: >- Use when writing or fixing tests for source generators, diagnostic analyzers, code fixes or refactorings built on Purview.SourceGeneratorFramework - picking the right runner and base class, - configuring SourceGeneratorTestOptions, querying produced code with CodeQuery, and asserting - incremental pipeline caching stage by stage. + configuring SourceGeneratorTestOptions, querying produced code with CodeQuery, testing attribute-data + models against real AttributeData with AttributeDataFactory, and asserting incremental pipeline caching + stage by stage. metadata: product: Purview.SourceGeneratorFramework repository: purview-dev/sourcegenerator-framework @@ -167,16 +168,55 @@ an assertion, nor calling a helper that is handed the result — an extension on parameter: both hand the same instance back, so `await Assert.That(result).SomeAssertion()` and `result.AssertNoGenerationExceptions()` are not flagged. +## Testing attribute applications and attribute-data models + +A generator that reads attributes has two halves to test: what Roslyn gives it for an application, and what +its parsing (usually a generated attribute-data model) makes of that. `AttributeDataFactory` produces the +real `AttributeData` without running a generator; `AttributeArguments` describes it. + +```csharp +var options = new MyTestOptions { AdditionalNamespaces = ["My.Attributes"] }; + +// One application. Placed on a class, or on the first declaration its AttributeUsage allows. +AttributeData attribute = AttributeDataFactory.FromUsage("[Log(\"text\", eventId: 7, Name = \"N\")]", options); + +// Every application on a type, or on one of its members. +var onMember = AttributeDataFactory.FromSource(source, "Sample.IEvents", "Raised", options); + +var arguments = AttributeArguments.From(attribute); +arguments.TryGetConstructorArgument(0, out var byOrdinal); // #0 message: "text" +arguments.TryGetConstructorArgument("eventId", out var byName); // Form = NamedParameter +arguments.TryGetNamedArgument("Name", out var named); // Form = NamedArgument +arguments.ToString(); // [LogAttribute(#0 message: "text", #1 eventId: 7 (by name), Name = "N")] + +var model = LogAttributeData.FromAttributeData(attribute); +``` + +- References and usings come from the options: `AdditionalAssemblyTypes` for the assembly declaring the + attribute, `AdditionalNamespaces` for its namespace, or declare the attribute in `AdditionalSources`. +- A compilation error throws `InvalidOperationException` listing the errors, so a misspelt parameter name + fails the test rather than producing an application with a missing argument. +- `AttributeArgumentForm` tells `Positional`, `NamedParameter` (`message: "x"`), `Defaulted` (an optional + parameter that was not written) and `NamedArgument` (`Name = "x"`) apart, which `AttributeData` alone + cannot. +- `AttributeArgument.ValueEquals` compares numbers across types, enums by value or member name, `typeof` + by `Type`/`TypeIdentity`/name, and arrays element-wise. Names are case-sensitive, as generated models are. +- The TUnit assertions over these (`HasConstructorArgument`, `HasNamedArgument`, …) are in + `purview-sourcegenerator-tunit-tests`; what to cover for a model is in + `purview-sourcegenerator-attribute-data-models`. + ## Best practices +- **Test attribute parsing against real `AttributeData`** (`AttributeDataFactory`), not only by asserting + on emitted parsing code: emitted code can look right and still read the wrong name. - **Deterministic output**: `WriteAutoGeneratedHeader` is timestamp-free; assert with `ContainsGeneratedCode`/`GeneratesCode` (whitespace-flattened) or `CodeQuery`, never with timestamps. - **Generator references**: to use a generated type in the test project AND pass the generator type to a runner, reference the generator project twice — once `OutputItemType="Analyzer"` and once as a normal reference. - **Multi-target**: build generators against the Roslyn version that supports the test matrix; the - framework is built against Roslyn 5.0 (net8.0/net9.0 assets keep a .NET 8–10 matrix loading); keep - `System.Collections.Immutable` version pinned to the shared one. + framework is built against Roslyn 5.9 (`net10.0`/`netstandard2.0` assets keep a .NET 10–11 matrix + loading); keep `System.Collections.Immutable` version pinned to the shared one. - **Scope validation**: keep `PurviewSourceGeneratorFrameworkValidateCodeWriterScopes` enabled; it makes undisposed `CodeWriter` scopes fail tests. - **Prefer `CodeQuery` over string matching** for structural assertions (members, signatures, namespaces). diff --git a/.agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md b/.agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md index bc711c5..2a3a061 100644 --- a/.agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md +++ b/.agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md @@ -3,7 +3,8 @@ name: purview-sourcegenerator-tunit-tests description: >- Use when writing TUnit tests for source generators, diagnostic analyzers, code fixes or refactorings built on Purview.SourceGeneratorFramework - choosing the correct TUnit base class and - method, customising SourceGeneratorTestOptions, using the TUnit assertion extensions, and + method, customising SourceGeneratorTestOptions, using the TUnit assertion extensions (including + attribute-argument assertions for testing attribute-data models), and modernising existing tests. metadata: product: Purview.SourceGeneratorFramework.Testing.TUnit @@ -125,6 +126,21 @@ when awaited. ``` - **`DiagnosticAssertions`** — `HasDiagnostic(descriptor|id)`, `HasDiagnostics(count)`, `DoesNotHaveDiagnostic`, `HasNoDiagnostics`, `HasNoErrorDiagnostics` on generator/analyzer/code-fix results. +- **`AttributeDataAssertions`** — on an `AttributeData` (typically from `AttributeDataFactory`): + `HasConstructorArgument(ordinal, expected)`, `HasConstructorArgument(parameterName, expected[, form])`, + `DoesNotHaveConstructorArgument(parameterName)` (defaulted or absent), `HasNamedArgument(name, expected)`, + `DoesNotHaveNamedArgument(name)`. Each returns the matched `AttributeArgument`, and a failure lists every + argument of the application with how it was supplied. Pin the attribute side with these before asserting + on the model, so a failure says which half is wrong: + ```csharp + var attribute = AttributeDataFactory.FromUsage("[Info(\"custom {Value}\")]", options); + + await Assert.That(attribute).HasConstructorArgument("messageTemplate", "custom {Value}"); + await Assert.That(attribute).HasConstructorArgument("eventId", -1, AttributeArgumentForm.Defaulted); + await Assert.That(attribute).DoesNotHaveNamedArgument("Name"); + + await Assert.That(InfoAttributeData.FromAttributeData(attribute).Log.MessageTemplate).IsEqualTo("custom {Value}"); + ``` - **`TypeIdentityAssertions`** — `HasSymbol(TypeIdentity)` / `HasSymbol("Namespace.Type")`. - **`GeneratedCodeAssertionsExtensions`** — `GeneratesCode(expected)`, `ContainsGeneratedCode(expected)` (whitespace-flattened string comparison). diff --git a/.agents/skills/purview-telemetry/SKILL.md b/.agents/skills/purview-telemetry/SKILL.md index 6d83173..d2f42d7 100644 --- a/.agents/skills/purview-telemetry/SKILL.md +++ b/.agents/skills/purview-telemetry/SKILL.md @@ -9,6 +9,7 @@ license: MIT metadata: author: Purview-Dev version: "1.0" + repository: purview-dev/engineering-playbook --- # Purview telemetry @@ -120,4 +121,4 @@ Instrumentation is identical across all three rows. Only the right-hand column g ## See also - `purview-architecture` — establishing project context and scale before deciding -- [Purview.Telemetry.SourceGenerator](https://github.com/purview-dev/purview-telemetry-sourcegenerator) +- [Purview.Telemetry.SourceGenerator](https://github.com/purview-dev/telemetry-sourcegenerator) diff --git a/.agents/skills/purview-unions-diagnostics/SKILL.md b/.agents/skills/purview-unions-diagnostics/SKILL.md new file mode 100644 index 0000000..936b867 --- /dev/null +++ b/.agents/skills/purview-unions-diagnostics/SKILL.md @@ -0,0 +1,113 @@ +--- +name: purview-unions-diagnostics +description: "Use when a Purview.Unions build reports a UNI diagnostic (UNI0001 to UNI0016 or UNI9000), when a generated union is missing its members, when a union conversion or exhaustive switch does not compile, or when deciding whether a finding is a design error or a code fix." +--- + +# Purview.Unions diagnostics + +The package ships one analyzer and one generator. The analyzer reports every per-target rule; the +generator uses the same findings to decide whether it can emit, and reports only `UNI9000`. A rule is +never reported twice. + +| ID | Severity | Meaning | Fix | +| --- | --- | --- | --- | +| `UNI0001` | Error | The union declaration is not `partial` | Add `partial` (code fix) | +| `UNI0002` | Error | Unsupported declaration kind (interface, enum, static/abstract class, `ref struct`) | Use a struct, readonly struct, class or record | +| `UNI0003` | Error | No case list was found | Add a `typeof` list, partial factories, a provider, or public constructors | +| `UNI0004` | Error | The same case type appears twice | Remove one; `int?` and `int` are the same case | +| `UNI0005` | Error | A case type is less accessible than the union | Make the case type at least as accessible as the union (code fix adds `public`) | +| `UNI0006` | Error | More cases than a `ushort` tag can address | Split the union | +| `UNI0007` | Error | `[Union]` is required but missing | Add it (code fix); only on the documented fallback path | +| `UNI0008` | Error | Two case types are implicitly convertible | Replace one, or wrap it in a distinct record | +| `UNI0009` | Error | A case cannot be a union case (pointer, function pointer, `ref struct`, `void`, static class, open generic, `dynamic`) | Use a storable type | +| `UNI0010` | Error | A nullable value-type case has no usable underlying form | Use a value type with a concrete underlying type | +| `UNI0011` | Error | A generic union uses a `typeof` case list | Use partial factories or a provider; `typeof(T)` is `CS0416` | +| `UNI0012` | Error | Existing constructors disagree with the declared list | Keep one source of cases, or make them agree | +| `UNI0013` | Error | A containing type cannot be named from generated code | Make every containing type non-generic and `partial` | +| `UNI0014` | Error | Two case lists disagree | Keep exactly one of the four sources | +| `UNI0015` | Warning | The union has one case | Use the case type directly | +| `UNI0016` | Error | The declaration collides with a required member | Rename the type, or remove the `Value`/`HasValue`/`TryGetValue` member | +| `UNI9000` | Error | The generator failed unexpectedly | Report it with the union name and message | + +## Blocking behaviour + +Every error blocks its union: the generated constructors, storage and access members are one coherent +shape, so there is no partial output to emit. `UNI0015` is the only warning and does not block. + +`UNI0000` and `UNI9001` are **suppression descriptors**, not reported rules. `UNI0000` suppresses `CA1034` +(nested types should not be visible) and `UNI9001` suppresses `CA1515` (public types should be internal) on a +union's public nested `IUnionMembers` provider, which the C# union contract requires. Add either id to +`NoWarn` to turn that suppression off. + +## `UNI0001` — not partial + +The generator adds a second partial declaration. Without `partial` it cannot: + +```csharp +[GenerateUnion(typeof(int), typeof(string))] +public readonly partial struct IntOrString; // add partial +``` + +## `UNI0003` / `UNI0011` — no usable case list + +For a non-generic union, the attribute list is the simplest source: + +```csharp +[GenerateUnion(typeof(int), typeof(string))] +public readonly partial struct IntOrString; +``` + +For a generic union, a `typeof` list cannot name `T`. Use partial factories (surface B) or a nested +`IUnionMembers` provider (surface C), as described in the `purview-unions-modelling` skill. + +## `UNI0005` — a case is less accessible than the union + +A case type must be at least as accessible as the union. A public union's creation members are public API, +so its case types must be public all the way up their containers. An internal union's creation members are +effectively internal, so an internal case type is enough. The code fix adds `public` to the case type and its +containing types. + +## `UNI0008` — creation is ambiguous + +Two case types with an implicit conversion between them make a value convertible to both, and the +compiler requires a single best creation member: + +```csharp +[GenerateUnion(typeof(int), typeof(long))] // UNI0008 +public readonly partial struct IntOrLong; +``` + +Wrap one case in a distinct record (`record struct Count(int Value)`) so the conversion sets are disjoint. + +## `UNI0012` / `UNI0014` — conflicting case lists + +Cases are resolved in this order: the `typeof` list, partial factories, a nested provider, existing +public single-parameter constructors. + +- A second, **non-constructor** list that differs is `UNI0014`. +- Existing constructors that differ from a declared list are `UNI0012`. +- When they agree, the author's constructors win and the generator emits only the attribute and access + members it can see are needed — it never duplicates the constructors. + +## `UNI0016` — a required member collides + +The generated shape owns `Value`, `HasValue` and `TryGetValue`. A union named `Value` cannot have a +`Value` property (`CS0542`), and a source-declared member with one of those names would be duplicated. +Rename the type or the member. + +## `UNI9000` — unexpected generator failure + +The generator catches its own failures so one malformed union cannot discard every other union's output +(`CS8785`). The message names the union and the exception. Please report it with the declaration. + +## Fixing the code, not the warning + +There is deliberately no `NoWarn`/`#pragma` in this stack. The suppressions are `DiagnosticSuppressors`, not +reported rules: `Purview.BuildSdk`'s `PDS0006` for `CA1815` on a union declaration, and this package's +`UNI0000` for `CA1034` and `UNI9001` for `CA1515` on a union's nested `IUnionMembers` provider. + +## Checklist + +1. Read the id from the build output or the IDE. +2. Fix the declaration, not the diagnostic. +3. Rebuild; a union that was blocked has no generated members until the blocking rule is cleared. diff --git a/.agents/skills/purview-unions-interop/SKILL.md b/.agents/skills/purview-unions-interop/SKILL.md new file mode 100644 index 0000000..3dd0bed --- /dev/null +++ b/.agents/skills/purview-unions-interop/SKILL.md @@ -0,0 +1,88 @@ +--- +name: purview-unions-interop +description: "Use when a Purview.Unions union meets another library — Purview.Results error unions, Purview.Telemetry parameters, ZodSharp, JSON serialisation, trimming and Native AOT — or when deciding which downstream generators can see a generated union's cases." +--- + +# Purview.Unions interoperability + +A union is a plain type carrying `System.Runtime.CompilerServices.UnionAttribute`. Everything that +consumes it does so through ordinary metadata, so interop is mostly about **what is visible in source**. + +## The source-visibility rule + +Roslyn generators never see each other's output. A downstream generator sees: + +- the declaration and the attributes written in the user's source; +- the constructors, `Value`, `HasValue` and `TryGetValue` members **only if the author wrote them**; +- never the members this generator emits. + +So a downstream generator can discover a union's cases only when the case list is source-visible. That +is why surfaces B and C exist, and why surface A (`typeof` only) is invisible to them. + +## Purview.Results + +`[GenerateResult]` recognises a `[GenerateUnion]` target by metadata identity and reads its source-visible +case list instead of generated output. Surface A (`typeof` list), B (partial factory signatures) and C +(`IUnionMembers` provider) all work; no `[Union]` attribute or constructors need to be written by hand. A +`[GenerateUnion]` target with no source-visible case list is `RSG1001`. Surface C remains the portable route +when another generator also needs the case list. + +The two-generator integration test is a follow-up in `purview-dev/results` because `Purview.Unions` is not on +a CI-safe package feed yet. Do not work around it by hand-writing the constructors, because that is +`UNI0012`/`UNI0014`. + +## Purview.Telemetry + +`Purview.Telemetry.SourceGenerator` 6.0.0 recognises a `[GenerateUnion]` target and reads its source-visible +case list (the `typeof` list, partial factories, a nested `IUnionMembers` provider, then source-visible +constructors). It emits the accessor against the generated `HasValue`/`TryGetValue` members, so surface A/B/C +unions are unwrapped to their case value without boxing the union. `just test-telemetry-integration` proves +both generators in one compilation from the packages. A `[GenerateUnion]` target with no source-visible case +list is not treated as a union by Telemetry; the unions generator reports `UNI0003`. A telemetry parameter +typed as `Purview.Unions.Union` is visible as a union on `net11.0`, where the package assembly carries +`[Union]`. + +## ZodSharp + +`ZodSharp.Unions.Union` and `NativeUnion` predate this package. `Purview.Unions.Union` +has the same two-case shape and a stable `IUnion` (`Value` + `Tag`). Migrating ZodSharp to it is a +follow-up in `purview-dev/zodsharp`; do not mix the two in one public contract meanwhile. + +## JSON, EF Core and ASP.NET Core + +Out of scope for v1. A union serialises as the runtime shape of whatever `Value` holds if you serialise +it directly, which is rarely what you want. Map a union to a DTO or a `ProblemDetails` in the host, and +keep the union as the domain contract. EF Core mapping and a `JsonConverter` are follow-ups. + +## Trimming and Native AOT + +Everything shipped is trim- and AOT-safe: + +- the runtime is dependency-free and `IsAotCompatible`; +- `Union` uses only typed fields, `EqualityComparer.Default` and the static + `typeof(T).IsValueType`/`Nullable.GetUnderlyingType` metadata checks — no dynamic code, no + `Type.GetType`, no member reflection; +- generated unions use fields, constructors, a `switch` over a tag and `TryGetValue` overloads. + +A trimmed or Native AOT application can use any surface. On .NET 10 the generator emits internal +`[Embedded]` polyfills for `UnionAttribute`/`IUnion`; they do not appear in the public surface. + +## .NET 10 versus .NET 11 + +| | `net10.0` | `net11.0` | +| --- | --- | --- | +| Compiler | C# 15 (`LangVersion=latest`) | C# 15 (the default) | +| `UnionAttribute`/`IUnion` | internal polyfills emitted by the generator | BCL types | +| `Union` | plain struct with the same API | also `[Union]`/`IUnion`; exhaustive and non-boxing patterns | +| Generated unions | compiler unions via the polyfill | compiler unions via the BCL | + +A `net11.0` project needs no `LangVersion`; a `net10.0` project sets `latest` +because its default is C# 14. `preview` is not required. Do not set `EnablePreviewFeatures`: it emits +`RequiresPreviewFeatures` assembly-wide and makes `CA2252` fire on every member a consumer touches. + +## Checklist + +1. If another generator must see the cases, use surface B or C, never surface A. +2. Do not hand-write constructors to satisfy a downstream generator; that is a conflict. +3. Keep unions out of serialisation contracts until a converter exists; map at the boundary. +4. Verify a trimmed/AOT consumer after upgrading the package. diff --git a/.agents/skills/purview-unions-modelling/SKILL.md b/.agents/skills/purview-unions-modelling/SKILL.md new file mode 100644 index 0000000..a0df7ee --- /dev/null +++ b/.agents/skills/purview-unions-modelling/SKILL.md @@ -0,0 +1,136 @@ +--- +name: purview-unions-modelling +description: "Use when modelling a closed set of cases as a C# 15 union with Purview.Unions — choosing between the typeof list, partial factory signatures and an IUnionMembers provider, writing class or struct unions (including generics), or explaining why Value boxes but pattern matching does not." +--- + +# Modelling unions with Purview.Unions + +Use this skill when a type is **exactly one of a fixed set of cases** and you want the compiler to +enforce that set, while value-type cases stay allocation-free. + +## Why not the `union` keyword + +A `union` declaration is a struct holding a single `object?`, so every value-type case is boxed on +entry and again on read. `Purview.Unions` generates the language's **custom union type** instead: + +- `[System.Runtime.CompilerServices.Union]` +- one public constructor per case +- `public object? Value { get; }` +- `public bool HasValue { get; }` +- `public bool TryGetValue(out TCase value)` per case + +The compiler then synthesises implicit conversions, checks `switch` exhaustiveness, and calls +`TryGetValue`/`HasValue` for patterns. `Value` still boxes when you read it — that is inherent to the +contract — but construction, `TryGetValue`, `is` and `switch` do not. + +## Choose a declaration surface + +### A — `typeof` list (non-generic) + +```csharp +using Purview.Unions; + +[GenerateUnion(typeof(int), typeof(string))] +public readonly partial struct IntOrString; +``` + +The generator emits the members and the `[Union]` attribute. Use this whenever every case can be +spelled in an attribute argument. + +### B — partial factory signatures (generic-capable) + +```csharp +[GenerateUnion] +public readonly partial struct Option +{ + internal static partial Option Some(Some value); + internal static partial Option None(None value); +} +``` + +The case type is each method's single parameter. Use this when a case names a type parameter, because +`typeof(T)` in an attribute argument is `CS0416`. + +### C — nested `IUnionMembers` provider (generic-capable) + +```csharp +[GenerateUnion] +public readonly partial struct Outcome : Outcome.IUnionMembers +{ + public interface IUnionMembers + { + static Outcome Create(T? value) => Outcome.CreateCase0(value); + static Outcome Create(Exception? error) => Outcome.CreateCase1(error); + + object? Value { get; } + bool HasValue { get; } + bool TryGetValue(out T value); + bool TryGetValue(out Exception value); + } +} +``` + +The language finds a union's members **only** on a public nested interface named `IUnionMembers` when +one exists. A source generator cannot add bodies to an interface you declared, so the provider owns its +one-line `Create` bodies and delegates to the generated `CreateCaseN` methods. Use this surface when you +want the case list visible to other generators. + +Because `IUnionMembers` must be **public**, a strict analyzer set reports `CA1034` and `CA1515` (nested types +should not be visible; public types should be internal). The package's `UNI0000`/`UNI9001` suppression +descriptors handle both for an `IUnionMembers` provider nested in a union; no attribute is needed. The nested +shape is mandated by the language, not a design choice. + +### D — hand-written `Purview.Unions.Union` + +```csharp +Union value = 42; +if (value.TryGetValue(out int number)) { /* 42 */ } +``` + +No generator and no attributes. On `net10.0` it is an ordinary struct; on `net11.0` it also carries +`[Union]`, so it is a compiler union there too. Use it when you cannot run the generator or need exactly +two cases. + +## Rules of thumb + +- **One case type, one meaning.** Cases are the vocabulary of the domain; a case carrying a + `string Reason` invites callers to match on the string instead of the type. +- **Keep unions small.** A dozen cases is usually several families sharing a name. +- **Prefer records for cases.** They give value equality and readable `ToString`; `Purview.BuildSdk`'s + `PDS0006` suppressor already answers `CA1815` on the union itself. +- **Do not expect equality on a generated union.** v1 emits no `Equals`, `GetHashCode`, `ToString`, + `Deconstruct`, `Match` or `Switch`; match the active case instead. A `record` union gets the compiler's + record equality, which includes the hidden tag and storage fields. +- **A union cannot be named `Value`**, and it cannot already declare `Value`, `HasValue` or + `TryGetValue`; the generated members would collide (`UNI0016`). + +## Nullable value types + +A nullable value-type case is normalised to its **underlying** type. `int?` becomes case `int`, the +constructor takes `int?`, and `TryGetValue(out int)` returns false for null — a null value leaves the +union empty (`Tag == 0`). + +## What the compiler gives you + +```csharp +IntOrString value = 42; // implicit union conversion + +var text = value switch // exhaustive over int and string +{ + int number => $"int {number}", + string message => $"string {message}", + null => "empty", // struct unions can be empty +}; + +if (value is int number) { } // TryGetValue, no boxing +if (value is null) { } // HasValue, no boxing +``` + +## Checklist + +1. Every case type is at least as accessible as the union: public for a public union, internal is enough for + an internal union. +2. The declaration is `partial` and is a `struct`, `readonly struct`, `class` or record. +3. A generic union uses surface B or C, never a `typeof` list. +4. No two cases are implicitly convertible to each other (`UNI0008`). +5. The build is clean of `UNI0001`–`UNI0016` and `UNI9000`. diff --git a/.agents/skills/purview-unions-quickstart/SKILL.md b/.agents/skills/purview-unions-quickstart/SKILL.md new file mode 100644 index 0000000..0e19167 --- /dev/null +++ b/.agents/skills/purview-unions-quickstart/SKILL.md @@ -0,0 +1,130 @@ +--- +name: purview-unions-quickstart +description: "Use when adding Purview.Unions to a .NET project for the first time — installing the package, compiling with C# 15, declaring a first [GenerateUnion] union, matching it with an exhaustive switch, or falling back to the hand-written Union without a generator." +--- + +# Purview.Unions quickstart + +Use this skill when a project needs a closed set of cases and you want value-type cases to stay +allocation-free. A `union` declaration always stores one `object?`, so it boxes every value-type case; +`Purview.Unions` generates the language's **custom union type** instead. + +## Install + +```bash +dotnet add package Purview.Unions +``` + +One `PackageReference` is enough: the runtime, the merged source generator and its code fixes all ship in +the same package under `analyzers/dotnet/cs`. + +## Requirements + +- A **C# 15 compiler**: the .NET 11 SDK (or later). C# 15 is GA, so `LangVersion=preview` is not required. +- Target `net10.0` or `net11.0`. A `net11.0` project gets C# 15 by default and needs no `LangVersion`; a + `net10.0` project sets `latest` because its default is C# 14. +- Do **not** set `EnablePreviewFeatures`. It emits `RequiresPreviewFeatures` assembly-wide and makes + `CA2252` fire on every member a consumer touches; `LangVersion=latest` (only on `net10.0`) is the only + switch needed. + +On `net10.0` the generator emits internal `[Embedded]` polyfills for `UnionAttribute`/`IUnion`. On +`net11.0` the BCL types are used and no polyfill is emitted. + +## First union + +```csharp +using Purview.Unions; + +[GenerateUnion(typeof(int), typeof(string))] +public readonly partial struct IntOrString; +``` + +The declaration must be `partial`. The generator adds: + +- the `[System.Runtime.CompilerServices.Union]` attribute and the union interface; +- one typed field per case and a tag; +- one public constructor per case; +- `public object? Value { get; }` and `public bool HasValue { get; }`; +- `public bool TryGetValue(out TCase value)` per case. + +## Use it + +```csharp +IntOrString value = 42; // implicit union conversion + +var text = value switch +{ + int number => $"int {number}", + string message => $"string {message}", + null => "empty", // a struct union can be empty +}; + +if (value is int number) { } // TryGetValue, no boxing +if (value is null) { } // HasValue, no boxing +``` + +`switch` is exhaustive over the case types. `Value` still boxes a value-type case when you read it — that +is inherent to the language contract — but construction, `TryGetValue`, `is` and `switch` do not. + +## Generic unions + +`typeof(T)` is not a legal attribute argument (`CS0416`), so a generic union names its cases in source. +Either declare partial factory signatures: + +```csharp +[GenerateUnion] +public readonly partial struct Option +{ + internal static partial Option Some(Some value); + internal static partial Option None(None value); +} +``` + +or implement a nested `IUnionMembers` provider when another generator must see the case list. The +`purview-unions-modelling` skill explains when each surface is right. + +## Without the generator + +For exactly two cases, or a project that cannot run the generator: + +```csharp +Purview.Unions.Union value = 42; + +if (value.TryGetValue(out int number)) + Console.WriteLine(number); // 42 + +var text = value.Match(n => $"int {n}", s => $"string {s}"); +``` + +`Union` has `Value`, `HasValue`, `Tag`, `TryGetValue`, `Match`, `Switch`, equality and `ToString`. +On `net10.0` it is an ordinary struct; on `net11.0` it also carries `[Union]`, so it is a compiler union +there too. + +## Turn the generator off + +`DisableUnionsSourceGenerator=true` (MSBuild property) disables generation and the polyfills. It is useful +for inspecting the declared source in isolation; it is not a way to make a failing build pass. + +## Choose a surface + +| Surface | Shape | Use when | +| --- | --- | --- | +| A | `[GenerateUnion(typeof(int), typeof(string))]` | Every case can be named in an attribute argument | +| B | partial static factory signatures | A case names a type parameter | +| C | a nested `IUnionMembers` provider | A generic union, or another generator must see the cases (the provider is public, but `UNI0000`/`UNI9001` suppress the resulting `CA1034`/`CA1515`) | +| D | `Union` | Exactly two cases, or no generator | + +## Checklist + +1. A `net11.0` project needs no `LangVersion`; a `net10.0` project sets `LangVersion=latest`. Neither sets + `EnablePreviewFeatures`. +2. The union is `partial` and is a `struct`, `readonly struct`, `class` or record. +3. Every case type is `public`. +4. A generic union uses surface B or C, never a `typeof` list. +5. The build is clean of `UNI0001`–`UNI0016` and `UNI9000`. + +## Where next + +- `purview-unions-modelling` — choosing and shaping the case list. +- `purview-unions-diagnostics` — what a `UNI` finding means and how to fix it. +- `purview-unions-interop` — Results, Telemetry, ZodSharp, JSON, trimming and AOT. diff --git a/.agents/skills/purview-value-objects-efcore/SKILL.md b/.agents/skills/purview-value-objects-efcore/SKILL.md index 8800c71..2d3bfa3 100644 --- a/.agents/skills/purview-value-objects-efcore/SKILL.md +++ b/.agents/skills/purview-value-objects-efcore/SKILL.md @@ -54,7 +54,7 @@ generated `ModelCustomizer` that runs it after `OnModelCreating`, and works for | --- | --- | --- | | `[Scalar]` | one column of the underlying primitive | via `{Type}.EF.Converter` + `ValueComparer`; `EmailAddress` is a `TEXT` column | | `[ValueObject]` (default) | an EF Core complex type — one column per member | EF Core 8+ only; nested scalars convert too | -| `[ValueObject(EFMapping = EntityFrameworkMapping.Json)]` | a single JSON column | a value converter, so it works on every EF Core version | +| `[ValueObject(EFMapping = EntityFrameworkMapping.Json)]` | a single JSON column | a value converter, so it works on every EF Core version; writes through the generated JSON converter, with no reflection | The provider-to-model direction always goes through `Hydrate(...)`. **Materialization is a replay path**: neither `OnValidate` nor a ZodSharp schema runs when a row is read. That matches @@ -304,15 +304,24 @@ public readonly partial record struct Notes { } ## Version support -Compiled and tested against EF Core **7, 9 and 10** (EF Core 7 needs `net8.0`), in +Compiled and tested against EF Core **10** (`net10.0`) and **11** (`net11.0`), in `src/tests/ValueObjects.EFCompatibility.IntegrationTests`, so version-specific API shifts are caught by this build rather than by a consumer. EF Core 8+ is required for **complex type mapping**, the complex-type block in the registry, and the -compiled-model `JsonReaderWriter` members. On EF Core 7, a complex value object reports `VO1019` and -is left unmapped — use `EFMapping = Json` or configure the property manually. Everything else -(converters, the key value generator, the convention, `ValueObjectSequentialGuid`) works on EF Core 7 -and later. +compiled-model `JsonReaderWriter` members; both supported versions have it. On EF Core 7, a complex +value object reports `VO1019` and is left unmapped — use `EFMapping = Json` or configure the property +manually. Everything else (converters, the key value generator, the convention, +`ValueObjectSequentialGuid`) works on EF Core 8 and later. + +## Trimming and Native AOT + +The generated mapping raises no IL2xxx/IL3xxx warning of its own. A JSON column goes through +`ValueObjectJson` and the type's generated converter, not `JsonSerializer`, unless the type turned +its converter off (`VO1017`). EF Core itself is not fully trim- or AOT-compatible — its `DbContext` +constructor and migrations APIs are annotated — so a trimmed or AOT app warns there; that is EF's +surface, not the generator's. Without reflection-based JSON metadata, an enum or custom member of a +JSON-mapped value object throws `NotSupportedException`. ## Diagnostics diff --git a/.agents/skills/purview-value-objects-zodsharp/SKILL.md b/.agents/skills/purview-value-objects-zodsharp/SKILL.md index ad82684..3b1568b 100644 --- a/.agents/skills/purview-value-objects-zodsharp/SKILL.md +++ b/.agents/skills/purview-value-objects-zodsharp/SKILL.md @@ -23,6 +23,18 @@ ZodSharp source generator is active in any project that references the package. ```text dotnet add package Purview.ZodSharp ``` +**Purview.ZodSharp 3** targets `net10.0`/`net11.0` only, the same two targets as Purview.ValueObjects +(.NET 8 and .NET 9 are no longer supported). The generated integration works +with 2.x and 3.x. In 3.x built-in rules report `Origin`/`Category`, paths render as `Items[0].Name` +(`FullPath.ToDottedString()`), and generated schemas gain `ValidateAsync` (CA1849 flags a synchronous +`Validate` in async code). + +**Blazor** (`Purview.ZodSharp.Blazor`, `net11.0`): register `AddZodSharp{Assembly}Schemas()` and +`AddZodSharpBlazor()`, bind a scalar with an `InputBase` that uses `Hydrate`, and validate with +`` or ``. An error inside a value object member +(`Email.Value`) is shown on the owning member's field (`For="() => model.Email"`) and named after it +(`Field 'Email' must be ...`), on Blazor Server and WebAssembly (trimmed or AOT) alike; a type-level +`[ZodRule]` is located at the member. Repository guide: `docs/Trimming-AOT-and-Blazor.md`. Four ways to use it, in rough order of how often you want them: @@ -253,35 +265,57 @@ reconstructs codes or messages — it forwards `result.Errors` into a `ZodExcept ### ASP.NET Core Problem Details -`Purview.ZodSharp.AspNetCore` maps a thrown `ZodException` to `HttpValidationProblemDetails`: +Bind request bodies with the default `Hydrate` mode and validate the **bound request**: its generated +schema validates each value object member through that value object's own schema, so every invalid member +is reported under its own path (`CustomerEmail.Value`, message `Field 'CustomerEmail' ...`). ```csharp +[ZodSchema] +public sealed partial record PlaceOrderRequest(EmailAddress CustomerEmail, OrderQuantity Quantity); + +builder.Services.AddZodSharpMyAppSchemas(); // generated AddZodSharp{Assembly}Schemas(), AOT-safe builder.Services.AddZodSharpProblemDetails(); builder.Services.AddProblemDetails(); var app = builder.Build(); app.UseExceptionHandler(); +app.MapPost("/orders", (PlaceOrderRequest request) => TypedResults.Ok()).WithZodSharpValidation(); ``` -Make the value object validate during model binding: +An invalid body returns `400 application/problem+json` with `errors` and the structured `issues`, under +the JIT and Native AOT. On `net11.0`, `AddZodSharpValidation()` (`Purview.ZodSharp.Extensions.Validation`) +does it for every endpoint. Under Native AOT (`CreateSlimBuilder` and a source-generated context), also +register the generated converters (`new EmailAddress.EmailAddressJsonConverter()`) on the HTTP JSON options. + +`ValueObjectDeserializationMode.Strict` gives the same contract at the same path: the generated converter +throws `JsonException(inner: ZodException)`, System.Text.Json records `$.customerEmail`, and +`ZodExceptionHandler` re-roots the errors (`CustomerEmail.Value`, `Copies[1].Value`). On .NET 10 set +`AddZodSharpProblemDetails(o => o.HandleBindingFailures = true)`, or binding answers a bare `400`; .NET 11 +needs nothing. Requires Purview.ZodSharp 3.0.0-prerelease.2+. It still stops at the first invalid member, so +prefer bound-request validation for request bodies. Don't register `ScalarJsonConverterFactory`: under Native +AOT every request fails. + +### Typed HTTP clients (`Purview.ZodSharp.Http`) + +`PostAsJsonValidatedAsync`/`PutAsJsonValidatedAsync`/`PatchAsJsonValidatedAsync` validate a body before +sending (an invalid one is never sent), `ReadFromJsonAndValidateAsync` validates a response, and +`TryReadZodValidationProblemAsync` turns the server's ProblemDetails back into ZodSharp errors. Every API +takes a `JsonTypeInfo`, so it is trim-, AOT- and WebAssembly-safe. **Do not pass the context's own +property** (`AppJsonContext.Default.Order`) when the value objects are declared in the same project: that +context cannot see their converters and writes `{"customerEmail":{"value":"..."}}`, which the server rejects. +Build the type info from options that carry the generated converters: ```csharp -[Scalar( - ZodSchemaMode = ZodSchemaMode.InsteadOfHooks, - DeserializationMode = ValueObjectDeserializationMode.Strict)] -[ZodSchema] -public readonly partial record struct EmailAddress +JsonSerializerOptions options = new(AppJsonContext.Default.Options) { - [Required, EmailAddress] - public string Value { get; } -} + Converters = { new EmailAddress.EmailAddressJsonConverter() }, +}; +var orderInfo = (JsonTypeInfo)options.GetTypeInfo(typeof(PlaceOrderRequest)); -builder.Services.ConfigureHttpJsonOptions(options => - options.SerializerOptions.Converters.Add(new ScalarJsonConverterFactory())); +var sent = await http.PostAsJsonValidatedAsync("orders", request, new PlaceOrderRequestSchemaValidator(), orderInfo); ``` -An invalid body now returns `400 application/problem+json` with the structured issues in the `issues` -extension. +Value objects from a referenced library need none of this; the context's own property works. Map error codes to HTTP statuses with `[ErrorType]` on a `static readonly ErrorType` field; the bundled `ErrorTypeGenerator` emits `Create{Field}(...)` and a `[DoesNotReturn]` `Throw{Field}(...)`. diff --git a/.agents/skills/purview-value-objects/SKILL.md b/.agents/skills/purview-value-objects/SKILL.md index 9636d32..a42b1fa 100644 --- a/.agents/skills/purview-value-objects/SKILL.md +++ b/.agents/skills/purview-value-objects/SKILL.md @@ -205,6 +205,14 @@ Both attributes default to `ValueObjectDeserializationMode.Hydrate`, so a JSON r re-validate. Choose `Strict` only when round-trip fidelity genuinely requires re-running validation on read — and then make sure a `Create` overload exists to re-validate through, or you get `VO1007`. +The generated `[JsonConverter]`s use no reflection, so trimmed, Native AOT and Blazor WebAssembly apps +get no IL2026/IL3050 from them (`ScalarJsonConverterFactory` is the reflection-based exception). A +source-generated `JsonSerializerContext` **in the project that declares the value objects** cannot see the +generated `[JsonConverter]` attribute (one generator never sees another's output) and describes each value +object as an object with a `Value` property: register the generated converters on its options +(`new JsonSerializerOptions(AppJsonContext.Default.Options) { Converters = { new EmailAddress.EmailAddressJsonConverter() } }`). +A value object from a referenced library needs nothing. Repository guide: `docs/Trimming-AOT-and-Blazor.md`. + ## Assembly-level defaults Repeat nothing per type that the assembly can say once: diff --git a/.agents/skills/purview-zodsharp-aspnetcore/SKILL.md b/.agents/skills/purview-zodsharp-aspnetcore/SKILL.md index a11c258..2105291 100644 --- a/.agents/skills/purview-zodsharp-aspnetcore/SKILL.md +++ b/.agents/skills/purview-zodsharp-aspnetcore/SKILL.md @@ -2,9 +2,12 @@ name: purview-zodsharp-aspnetcore description: >- Use when wiring Purview.ZodSharp into ASP.NET Core or any DI host - ProblemDetails from a - ValidationResult or a thrown ZodException, minimal-API endpoint validation, registering - IZodSchemaFactory with AddZodSharp or AddZodSharpFactory, validating options objects with - IValidateOptions, and mapping error codes to HTTP statuses with ErrorType. + ValidationResult or a thrown ZodException, minimal-API endpoint and route-group validation with + WithZodSharpValidation or the .NET 11 built-in validation pipeline (AddZodSharpValidation), ZodTypedResults, + the shared ZodProblemDetailsWriter, registering + IZodSchemaFactory with AddZodSharp or AddZodSharpFactory, validating options objects (including .NET 11 + async startup validation), describing validated endpoints in OpenAPI with Purview.ZodSharp.AspNetCore.OpenApi, + and mapping error codes to HTTP statuses with ErrorType. license: MIT metadata: product: Purview.ZodSharp.AspNetCore @@ -13,230 +16,294 @@ metadata: # ASP.NET Core and dependency injection -`Purview.ZodSharp.AspNetCore` turns failed validation into standard `ProblemDetails` / -`HttpValidationProblemDetails` payloads **without losing the structured issues**, and registers -schema resolution into DI. +`Purview.ZodSharp.AspNetCore` turns failed validation into standard `HttpValidationProblemDetails` +payloads **without losing the structured issues**, validates minimal-API parameters, and registers schema +resolution into DI. ```bash dotnet add package Purview.ZodSharp.AspNetCore ``` -## ProblemDetails from a result +Namespaces: the `To…ProblemDetails`/`ToValidationProblem` extensions are in **`ZodSharp`**; the DI +extensions in `Microsoft.Extensions.DependencyInjection`; `WithZodSharpValidation` in +`Microsoft.AspNetCore.Builder`; the types (`ZodProblemDetailsWriter`, `ZodProblemDetailsOptions`, +`ValidationIssue`, `ZodTypedResults`, `ErrorTypeRegistry`) in `ZodSharp.AspNetCore`. -```csharp -using ZodSharp.AspNetCore; - -var result = BasketSchema.Validate(basket); +## Setup -if (!result.IsSuccess) +```csharp +builder.Services.AddZodSharp(o => o.ScanAssemblies.Add(typeof(Order).Assembly)); +builder.Services.AddZodSharpProblemDetails(o => { - var problem = result.ToHttpValidationProblemDetails(); + o.PathNaming = ZodPathNaming.MemberName; // default; Json applies the JsonOptions naming policy +}); - return Results.ValidationProblem( - problem.Errors, - extensions: new Dictionary { ["issues"] = problem.Extensions["issues"] }); -} +var app = builder.Build(); +app.UseExceptionHandler(); // lets ZodExceptionHandler map a thrown ZodException + +var orders = app.MapGroup("/orders").WithZodSharpValidation(); // every endpoint in the group +orders.MapPost("/", (Order order) => TypedResults.Created($"/orders/{order.Id}", order)); ``` -| Member | Produces | -| --- | --- | -| `ToHttpValidationProblemDetails(result, statusCode = 400)` | `HttpValidationProblemDetails`; paths flattened to dotted keys (`user.email`, array indexes as `[0]`) | -| `ToValidationProblemDetails(result, statusCode = 400)` | `ValidationProblemDetails` | +`AddZodSharpProblemDetails(configure?, problemDetails?)` calls `AddProblemDetails`, registers +`ZodExceptionHandler` and the shared `ZodProblemDetailsWriter`, and adds source-generated JSON metadata to +the minimal-API `JsonOptions` (Native AOT safe). It does **not** register `IZodSchemaFactory` - pair it +with `AddZodSharp`/`AddZodSharpFactory`. -Both **throw `InvalidOperationException` when the result is successful** — they are failure -converters, not general mappers. The structured metadata survives in the `issues` extension as a -`ValidationIssue[]`: +## One contract on every path -```csharp -public sealed class ValidationIssue +The endpoint filter, `ZodExceptionHandler`, `ZodTypedResults` and every extension method delegate to +`ZodProblemDetailsWriter`, so they produce the **same body**: + +```json { - public required string Code { get; init; } - public string? Category { get; init; } - public string? Origin { get; init; } - public int? Minimum { get; init; } - public int? Maximum { get; init; } - public bool? Inclusive { get; init; } - public required string[] Path { get; init; } - public required string Message { get; init; } - public IReadOnlyDictionary? Parameters { get; init; } + "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1", + "title": "One or more validation errors occurred.", + "status": 400, + "errors": { "Email": ["Email must be a valid email address"], "Items[0].Quantity": ["Quantity must be at least 1"] }, + "issues": [ + { "path": "Items[0].Quantity", "segments": ["Items", 0, "Quantity"], "code": "too_small", + "category": "too_small", "origin": "number", "minimum": 1, "inclusive": true, + "message": "Quantity must be at least 1" } + ], + "traceId": "00-…" } ``` +- **Keys** are `ValidationError.FullPath.ToDottedString()`: `Items[0].Name`, `Map[en]`, `Map["a.b"]`; + the root is `""`. C# member names by default, matching Microsoft.Extensions.Validation and MVC. +- **`issues[]`** is a `ValidationIssue` (a `readonly record struct`): `path` (dotted), `segments` + (strings and integer indexes, Zod's shape), `code`, `category`, `origin`, typed `minimum`/`maximum` + (numbers, or ISO-8601 for dates), `inclusive`, `message`, `parameters` (camel-cased, safe scalars only) + and `unionErrors` (nested issues per union option, paths relative to the union). In-process code can + still read the CLR `Path` (string array), `Minimum`, `Maximum` and raw `Parameters` (`ErrorTypeParameters`). +- **`traceId`** is `Activity.Current?.Id`, else `HttpContext.TraceIdentifier`. +- Bodies go through `IProblemDetailsService`, so `ProblemDetailsOptions.CustomizeProblemDetails` runs. + ## Minimal APIs ```csharp -// Validate the bound DTO automatically - needs AddZodSharp to have registered the validator. -app.MapPost("/users", (UserDto dto) => dto) - .WithZodSharpValidation(); +app.MapPost("/users", (UserDto dto, [FromServices] IClock clock) => …) + .WithZodSharpValidation(o => o.ThrowIfNoValidator = true); -// Or validate in the handler and return the IResult directly. -app.MapPost("/users", (UserDto dto) => -{ - var result = UserDtoSchema.Validate(dto); - return result.IsSuccess ? TypedResults.Ok(result.Value) : result.ToValidationProblem(); -}); +app.MapPost("/legacy", (UserDto dto) => …).WithZodSharpValidation(); // only T ``` -`WithZodSharpValidation()` adds an endpoint filter that validates the first bound argument of type -`T` against the `IZodSchemaFactory` registered by `AddZodSharp`/`AddZodSharpFactory`. On failure it -returns the same payload as `ToHttpValidationProblemDetails()`; on success it passes through. +`WithZodSharpValidation()` works on any `IEndpointConventionBuilder` (endpoint or `RouteGroupBuilder`). An +endpoint filter **factory** picks the parameters once, when the endpoint is built: + +- Skipped: services (`[FromServices]`, keyed services, anything the container resolves), `HttpContext`, + `HttpRequest`, `HttpResponse`, `ClaimsPrincipal`, `CancellationToken`, form files, streams, and a + `[SkipValidation]` parameter or parameter type. The filter matches the real .NET 11 + `SkipValidationAttribute`, not any same-named attribute (on .NET 10, where it does not exist, a name match is + the fallback). `[FromBody]`/`[FromRoute]`/`[FromQuery]`/`[FromHeader]`/`[FromForm]` force a parameter in. +- Validators resolve through `IZodSchemaFactory.TryGetUntypedValidator(Type)`; **every** selected argument + is validated with `ValidateAsync(value, RequestServices, RequestAborted)`. +- Error keys follow Microsoft.Extensions.Validation: complex parameter unprefixed (`Name`), sequence + parameter prefixed (`items[0].Name`), an error about the value itself keyed by the parameter name. +- `ThrowIfNoValidator` (or the `` form) **fails when the endpoint is built**, naming the handler and + parameter, instead of a 500 per request. +- **.NET 11** runs filters after a route/query/header/form binding failure (status already 400): + `OnBindingFailure = PassThrough` (default) keeps ASP.NET Core's response; `ConvertToProblemDetails` + writes the ZodSharp contract. A malformed JSON body never reaches filters. +- Adds 400 `application/problem+json` response metadata (`ProducesStatusCode`), so OpenAPI shows it. + +## .NET 11: the built-in validation pipeline (Mode A) -## Thrown `ZodException` +```csharp +builder.Services.AddZodSharp(o => o.ScanAssemblies.Add(typeof(Order).Assembly)); +builder.Services.AddZodSharpValidation(); // Purview.ZodSharp.Extensions.Validation bridge +builder.Services.AddValidation(); // only for DataAnnotations parameters/types and unions +builder.Services.AddZodSharpProblemDetails(); -A `ZodException` from `Parse`, `GetValueOrThrow()`, or a value object's generated `Create` under -strict deserialization maps the same way: +var orders = app.MapGroup("/orders"); // validated automatically - no per-endpoint call +orders.MapPost("/", (Order order, [Range(1, 100)] int page) => …); +orders.MapPost("/import", (Import i) => …).DisableValidation(); // the standard opt-out +``` + +On `net11.0`, `AddZodSharpProblemDetails()` also registers the bridge's `IZodValidationReportSink`, which +keeps errors in a per-request `ZodRequestValidationReport` (`HttpContext.Features`) and ignores writes outside a +request, and composes `ProblemDetailsOptions.CustomizeProblemDetails`: + +- **ZodSharp errors reported** - the pipeline's `HttpValidationProblemDetails` is rewritten with + `ZodProblemDetailsWriter.Apply`: ZodSharp keys replaced, DataAnnotations entries kept (with + `dataannotations` issues), status from `ErrorType`/selector/override mirrored to the response. The body + is byte-identical to the filter, exception and manual paths (apart from `traceId`). +- **DataAnnotations only** - left as written, plus the `issues` projection (`ProjectDataAnnotationsIssues`). +- **Ordering** - ZodSharp's callback runs first, then any callback configured before or after (including + the `problemDetails` argument), so the application has the last word. It is a post-configure step: a + later `PostConfigure` that *replaces* the callback drops ZodSharp's. +- `AddProblemDetails()` is registered for you; without it the callback never runs. +- Localize with `AddLocalization()` plus an `IZodMessageFormatter` such as the bridge's + `StringLocalizerZodMessageFormatter(factory, sharedResourceType: typeof(Messages))`. +- Union parameters are claimed by the runtime parameter resolver, so call `AddValidation()`; the active + case is reported under `Value` (`Value.Email`). +- `WithZodSharpValidation()` on a pipeline-validated endpoint skips the parameters the bridge covers (one + `Debug` log when the endpoint is built), so nothing validates twice. With `DisableValidation()` it + validates as usual. +- OpenAPI: the `Purview.ZodSharp.AspNetCore.OpenApi` operation transformer adds the error response (below). + +| | Filter (`WithZodSharpValidation`) | Pipeline (`AddZodSharpValidation`) | +| --- | --- | --- | +| Frameworks | net10.0-net11.0 | net11.0 | +| Scope | endpoints/groups you mark | every endpoint; opt out with `DisableValidation()` | +| DataAnnotations parameters | not validated | validated and merged into one body | +| Binding-failure handling | `OnBindingFailure` | ASP.NET Core's own | +| Endpoint metadata | adds the 400 response | via the OpenAPI transformer | + +The bridge itself (`Purview.ZodSharp.Extensions.Validation`, `net11.0`, WebAssembly-safe) also works outside +HTTP: `ZodValidationOptions` sets `Placement` (`First`/`AfterGenerated`), `AlsoRunDataAnnotations`, `PathNamer`, +`MessageFormatter` and trim-safe `Sources`; `ValidateContext.ZodReport` holds the full ZodSharp errors of a run; +`ValidationOptions.ValidateAsync(value, services)` validates a value in a worker or test. `[ValidatableType]` on a +`[ZodSchema]` type is redundant (`ZODSGEN047`). Blazor forms are covered by the `purview-zodsharp-blazor` skill. + +## OpenAPI (.NET 11) + +```bash +dotnet add package Purview.ZodSharp.AspNetCore.OpenApi +``` ```csharp -catch (ZodException ex) +using Microsoft.AspNetCore.OpenApi; // AddZodSharp on OpenApiOptions lives here + +builder.Services.AddZodSharp(o => o.ScanAssemblies.Add(typeof(Order).Assembly)); +builder.Services.AddOpenApi(options => options.AddZodSharp(zod => { - return Results.ValidationProblem(ex.ToHttpValidationProblemDetails().Errors); -} + zod.EmitVendorExtensions = true; // x-zod-* for rules with no JSON Schema keyword + zod.DiscriminatedUnionsAsOneOf = true; // oneOf + discriminator for [JsonPolymorphic] types + zod.DescribeValidationProblems = true; // 400/422 problem responses on validated operations +})); +app.MapOpenApi(); ``` -Or handle it globally: +`ZodOpenApiOptions` defaults all three to `true`. Schema, operation and document transformers make the 3.1 and +3.2 documents describe what ZodSharp enforces, from the type's own JSON Schema (`BuildJsonSchema()` or a factory +registered runtime schema): lengths, bounds (exclusive bounds in the 3.1+ form), `multipleOf`, integer +narrowing, patterns (several become `allOf`), formats (including `[EmailAddress]`), `enum`/`not`, +`contentEncoding`, `required` and `type: [x, "null"]`. ZodSharp wins when a DataAnnotation disagrees. C# unions +keep the platform's `anyOf` with each case constrained. Operations binding a ZodSharp-validated parameter list +`400` (and `422` when a registered `ErrorType` uses it) as `application/problem+json` with the shared +`ZodValidationProblemDetails`/`ZodValidationIssue` components, merged into a declared +`ProducesValidationProblem()`. Types ZodSharp does not know are left as the platform produced them. + +## Manual results and exceptions ```csharp -builder.Services.AddZodSharpProblemDetails(); +app.MapPost("/users", Results, ValidationProblem> (UserDto dto, HttpContext ctx) => +{ + var result = UserDtoSchema.Validate(dto); + return result.IsSuccess ? TypedResults.Ok(result.Value!) : ZodTypedResults.ValidationProblem(result, ctx); +}); +``` -var app = builder.Build(); -app.UseExceptionHandler(); +| Member | Produces | +| --- | --- | +| `ZodTypedResults.ValidationProblem(result \| exception, ctx)` | ASP.NET's `ValidationProblem` (always 400) | +| `ZodTypedResults.Problem(result \| exception, ctx)` | `ZodProblemDetailsResult` with the computed status | +| `result.ToValidationProblem(ctx)` / `exception.ToValidationProblem(ctx)` | the same, as `IResult` | +| `ToHttpValidationProblemDetails(ctx)` / `(ZodProblemDetailsOptions, ctx?)` | `HttpValidationProblemDetails` | +| `ToHttpValidationProblemDetails(statusCode = 400)` / `(registry, …)` / `(lookup, …)`, `ToValidationProblem()` | **obsolete** (CS0618) 2.x overloads: they ignore the registered `ZodProblemDetailsOptions`. Move to the `ctx` overload, or pass options explicitly (`{ Registry = new(), FormatMessages = false }` reproduces the status-code form) | +| `ToValidationProblemDetails(…)` | MVC `ValidationProblemDetails`, every extension copied | + +The result-based members **throw `InvalidOperationException` for a successful result**. A `ZodException` +thrown from `Parse` inside a handler is mapped by `ZodExceptionHandler` to the identical body. + +A body rejected by a strict value object converter while it is deserialized (`JsonException(inner: ZodException)`, +wrapped in a `BadHttpRequestException` by binding) gets the same body too: errors under the member that held the value +(`CustomerEmail.Value`, mapped from the endpoint's body contract), named after it. On .NET 11 this needs nothing more +than `AddZodSharpProblemDetails()`. On .NET 10, Production binding answers a bare `400` unless +`options.HandleBindingFailures = true`, which sets `RouteHandlerOptions.ThrowOnBadRequest` app-wide (every binding +failure then goes through `UseExceptionHandler()`; non-ZodSharp ones get a standard ProblemDetails with their own +status). + +## The writer and its options + +```csharp +var writer = ZodProblemDetailsWriter.GetOrCreate(httpContext.RequestServices); +HttpValidationProblemDetails problem = writer.Create(errors, httpContext); +writer.Apply(existingProblem, errors, httpContext); // keeps non-ZodSharp `errors` entries; span or sequence +await writer.WriteAsync(httpContext, problem); ``` -`AddZodSharpProblemDetails(configure?, problemDetails?)` registers `ZodExceptionHandler`, calls -`services.AddProblemDetails(problemDetails)`, and configures `ZodProblemDetailsOptions`: +`ZodProblemDetailsOptions`: -- `ErrorTypeRegistry Registry` — resolves codes to `ErrorType`s; defaults to `ErrorTypeRegistry.Default`. -- `bool FormatMessages` — format messages from the matched `ErrorType.MessageFormat`. -- `Func, int>? StatusCodeSelector` — total control of the status code. +- `Registry` (`ErrorTypeRegistry.Default`) or `ErrorTypeResolver` - code to `ErrorType`. +- `FormatMessages` (`true`) - use the matched `ErrorType.MessageFormat`, **only when every placeholder + resolves** (from parameters or built-ins `path`, `code`, `origin`, `minimum`, `maximum`, `displayName`). +- `PathNaming`, `IncludeIssues`, `DefaultStatusCode` (400). +- `MergeParametersIntoExtensions` (`false`) - also copy parameters to the top level (the 2.4 behaviour). Reserved names + (`type`, `title`, `status`, `detail`, `instance`, `errors`, `issues`, `traceId`) become `param_{name}`. +- `IncludeDiagnosticParameters` (`false`) - `declaringType` (a full CLR type name) and `member` are kept off + `issues[].parameters` and the top level; they stay on `ValidationError.Parameters` for localization. +- `ProjectDataAnnotationsIssues` (`true`) - `Apply` gives foreign entries a `dataannotations` issue. +- `StatusCodeSelector(errors)` then `StatusCodeOverride(errors, computed)` - run last and win. -> It does **not** register `IZodSchemaFactory`. Pair it with `AddZodSharp` (or the core -> `AddZodSharpFactory`) when you also want DI validator resolution. +Messages render through a registered `IZodMessageFormatter` (core) in `CultureInfo.CurrentUICulture`; +without one they are used as produced. `DefaultZodMessageFormatter` re-formats `MessageFormat` in a culture. ## Error codes to HTTP statuses -`ErrorType`, the `[ErrorType]` attribute, the generator that emits `Create`/`Throw` helpers and the -`ZODSASP001`–`ZODSASP003` analyzers all ship in the **core** package and need no ASP.NET Core -dependency. The AspNetCore package adds `ErrorTypeRegistry` and the ProblemDetails mapping on top. +`ErrorType`, `[ErrorType]` and the `ZODSASP*` analyzers ship in the **core** package; this package adds +`ErrorTypeRegistry` and the mapping. ```csharp public static partial class ConcurrentErrorType { [ErrorType] public static readonly ErrorType SaveFailed = new( - Code: "aggregate_save_failed", - Category: "invalid_value", + Code: "aggregate_save_failed", Category: "invalid_value", HttpStatus: 409, Description: "The aggregate could not be saved.", - HttpStatus: 409, - MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save", - Parameters: - [ - new("AggregateId", typeof(string)), - ErrorType.Param("AggregateType"), - ]); + MessageFormat: "Aggregate '{AggregateId}' failed to save", + Parameters: [ErrorType.Param("AggregateId")]); } -ErrorTypeRegistry.Default.Register(ConcurrentErrorType.SaveFailed); +builder.Services.AddZodSharpProblemDetails(o => o.MapErrorType(ConcurrentErrorType.SaveFailed)); +var error = ConcurrentErrorType.CreateSaveFailed("agg-123"); // or ThrowSaveFailed(...) ``` -`HttpStatus` lives on `ErrorType` purely for convenience — the core library never reads it; only the -ASP.NET Core mapping does. `Category` is a broad grouping that can span many specific codes (code -`invalid_tenant_id`, category `invalid_value`); the generated helpers copy it onto each -`ValidationError` and it surfaces on the serialized `ValidationIssue`. - -Marking the field `[ErrorType]` and the containing class `partial` gets you strongly typed helpers, -one parameter per declared entry: - -```csharp -var error = ConcurrentErrorType.CreateSaveFailed("agg-123", "Invoice"); -ConcurrentErrorType.ThrowSaveFailed("agg-123", "Invoice"); - -var detailed = ConcurrentErrorType.CreateSaveFailed( - "agg-123", "Invoice", - path: ["order", "items", "[0]"], - origin: "collection", minimum: 1, maximum: 10, inclusive: true); -``` - -`Create` builds a typed `ErrorTypeParameters` (validated against the declared types, exposed through -`ValidationError.Parameters`) and sets the message from `ErrorType.FormatMessage`, so -`error.Message` already reads the formatted text. - -Mapping rules: - -- **Status** — the highest matched `ErrorType.HttpStatus` wins; unmapped codes fall back to `400`. - `StatusCodeSelector` overrides everything. -- **Title / Type / Detail** — from the highest-status matched `ErrorType`, otherwise defaulted. -- **Message** — named placeholders are substituted from `ValidationError.Parameters`. A placeholder - with no matching value is **left as-is**, so a templating gap stays visible rather than silently - disappearing. `ZODSASP001` warns at compile time when a placeholder is not declared in `Parameters`. -- **Parameters** — surfaced per-issue in `issues`, and merged (camel-cased) into the top-level - extensions for client correlation. - -`ToHttpValidationProblemDetails` / `ToValidationProblemDetails` also accept an `ErrorTypeRegistry` or -a `Func` for on-demand mapping. +- **Status** - the highest matched `HttpStatus`, else 400; the selector/override run after and win. +- **Title / Type / Detail** - from that `ErrorType`, else the RFC 9110 defaults. +- **Parameters** - per issue in `issues[].parameters`; values leave the process, so never put internal + context in them. ## Registering the factory -`IZodSchemaFactory` (`ZodSharp.Core`) resolves validators by type: - -| Member | Behaviour | -| --- | --- | -| `Resolve()` | the validator, or `null` | -| `ResolveRequired()` | the validator, or `InvalidOperationException` | -| `Validate(value)` | validate through the registered validator | -| `Register(…)` / `Register(Type, …)` / `TryRegister(…)` / `IsRegistered()` | registration | - -```csharp -builder.Services.AddZodSharp(options => -{ - options.ScanAssemblies.Add(typeof(UserDto).Assembly); - options.ScanAssemblyGraphs.Add(typeof(Program).Assembly); - options.ScanLoadedAssemblies = true; -}); - -// Additive, for modular apps: -builder.Services.AddZodSharpAssembly(typeof(UserDto).Assembly); -builder.Services.AddZodSharpAssemblyGraph(typeof(Program).Assembly); -builder.Services.AddZodSharpLoadedAssemblies(); -``` - -Scanning finds the generator's `[assembly: ZodSchemaGenerated(typeof(…))]` markers and registers each -`{Type}SchemaValidator`. A hand-built schema registers by wrapping: -`factory.Register(new ZodSchemaValidator>(mySchema))`. +| Method | Package | Registers | +| --- | --- | --- | +| `AddZodSharp(options)` | AspNetCore | factory + generated validators from `ScanAssemblies`, `ScanAssemblyGraphs`, `ScanLoadedAssemblies` | +| `AddZodSharpAssembly/AssemblyGraph/LoadedAssemblies` | AspNetCore | additive assembly sources | +| `AddZodSharpFactory(configure)` | core | the factory only | +| `AddZodSharpProblemDetails(…)` | AspNetCore | writer, exception handler, ProblemDetails - not the factory | -> **`TryAdd` semantics.** The **first** `AddZodSharp`/`AddZodSharpFactory` call wins; later calls — -> including their options and `ConfigureFactory` callbacks — are ignored. State is never overwritten, -> so calling it more than once is safe, and the additive helpers still contribute. - -| Method | Package | Registers | Use when | -| --- | --- | --- | --- | -| `AddZodSharp(options)` | AspNetCore | factory + auto-registered generated validators | an ASP.NET Core app | -| `AddZodSharpAssembly/AssemblyGraph/LoadedAssemblies` | AspNetCore | additive assembly sources | modular apps where deeper layers contribute their own schemas | -| `AddZodSharpFactory(configure)` | core | the factory only | any host; you call `RegisterFromAssembly` yourself | -| `AddZodSharpProblemDetails(…)` | AspNetCore | exception handler + ProblemDetails, **not** the factory | mapping thrown `ZodException`s | -| `AddZodSchemaOptionsValidator(…)` | core | `IValidateOptions` | options validation; needs a factory first | +The **first** `AddZodSharp`/`AddZodSharpFactory` call wins; later calls are ignored. A hand-built schema +registers with `factory.Register(new ZodSchemaValidator(schema))`. Native AOT apps register validators +explicitly rather than scanning assembly graphs. ## Options validation ```csharp -builder.Services.AddZodSharpFactory(f => f.RegisterFromAssembly(typeof(UserOptions).Assembly)); -builder.Services.AddZodSchemaOptionsValidator(); - -// or chained: -builder.Services - .AddOptions() - .Bind(configuration.GetSection("User")) - .AddZodSchemaValidator() - .ValidateOnStart(); // fail at startup, not first access -``` +builder.Services.AddOptions().BindAndValidateWithZod("Smtp"); // binds + ValidateOnStart -`MissingValidatorBehavior` decides what happens with no validator registered for `T`: -`Throw` (default — `ResolveRequired()`, so a missing schema is loud rather than a silent skip) or -`Ignore` (passes through as success). +// Native AOT: bind yourself so the configuration binder generator intercepts it. +builder.Services.AddOptions() + .BindConfiguration("Smtp") + .AddZodSchemaValidator(MissingValidatorBehavior.Throw, validateOnStart: true, configurationSection: "Smtp"); +``` -The generator also auto-generates `IValidateOptions` for types whose names end in configurable -suffixes — see the `purview-zodsharp-source-generator` skill. +- Failures read `{name}: {location}: {message} ({code})` - `Smtp:Servers:0:Host: Host is required + (missing_field)` with a section, `Servers[0].Host: …` without; the name is omitted for the default options. +- `ZodValidateOptionsResult` carries the full `Errors`. +- **.NET 11:** `ZodSchemaOptionsValidator` also implements `IAsyncValidateOptions` and registers via + `OptionsBuilder.Validate()`. `ValidateOnStart()` runs async and service-aware rules (in a + scope); synchronous access (`IOptions.Value`, reloads) checks only the synchronous rules. +- `OptionsBuilder.AddZodSchemaValidator()` validates only that builder's options name; + `services.AddZodSchemaOptionsValidator()` validates every name. Both run the async rules under + `ValidateOnStart()` on .NET 11 (the platform honours an `IAsyncValidateOptions` registered as + `IValidateOptions`). +- `MissingValidatorBehavior`: `Throw` (default) or `Ignore`. ## Related skills -`purview-zodsharp-core` for the runtime API, `purview-zodsharp-source-generator` for the compile-time -validators this resolves, and `purview-zodsharp-diagnostics` for `ZODSASP*`. +`purview-zodsharp-core` for the runtime API and error model, `purview-zodsharp-source-generator` for the +validators this resolves, `purview-zodsharp-json` for JSON Schema export, and `purview-zodsharp-diagnostics` +for `ZODSASP*`. diff --git a/.agents/skills/purview-zodsharp-blazor/SKILL.md b/.agents/skills/purview-zodsharp-blazor/SKILL.md new file mode 100644 index 0000000..8392598 --- /dev/null +++ b/.agents/skills/purview-zodsharp-blazor/SKILL.md @@ -0,0 +1,149 @@ +--- +name: purview-zodsharp-blazor +description: >- + Use when validating Blazor EditForm models with Purview.ZodSharp on Blazor Server, Blazor WebAssembly or Auto + render mode - AddZodSharpBlazor, the standard DataAnnotationsValidator route versus ZodValidator, nested and + collection field placement, debounced or per-field validation, GetZodErrors, ZodValidationMessage and + ZodValidationSummary, mapping API ProblemDetails onto fields with TryReadZodValidationProblemAsync and + AddZodProblemDetails, and static-SSR client-side validation with AddZodSharpClientValidation. +license: MIT +metadata: + product: Purview.ZodSharp.Blazor + repository: purview-dev/zodsharp +--- + +# Blazor forms + +`Purview.ZodSharp.Blazor` (`net11.0`) validates `EditForm` models with ZodSharp in every render mode. It references +the component packages, never the ASP.NET Core shared framework, so it runs in WebAssembly. + +```bash +dotnet add package Purview.ZodSharp.Blazor +``` + +## Setup - both Program.cs files + +```csharp +builder.Services.AddZodSharpMyAppSharedSchemas(); // generated: AddZodSharp{AssemblyName}Schemas() +builder.Services.AddZodSharpBlazor(); +``` + +- Call both in the **server and the client** project of a Blazor Web App. Put the models in a shared library; + the ZodSharp generator runs there. Models declared in `.razor` files are invisible to generators. +- Prefer the generated registration. Assembly scanning (`AddZodSharpAssembly*`, `RegisterFromAssembly`) is not + trim-safe and warns in a WebAssembly publish. +- `AddZodSharpBlazor` validates **form models only**: it does not claim minimal-API parameters, so a server's + endpoints are untouched. The .NET 11 endpoint filter validates whatever a resolver claims, so to validate + ZodSharp-typed endpoint parameters too, also call `AddZodSharpValidation()` (either order) or set + `ValidateParameters = true` in `configureValidation`; opt single endpoints out with `DisableValidation()`. +- `AddZodSharpBlazor` does **not** register an `IZodValidationReportSink`; never register a scoped one yourself (a + Blazor Server scope is the whole circuit). The ASP.NET Core sink is request-bound and safe alongside Blazor. + +## Choosing a route + +| Need | Use | +| --- | --- | +| Standard forms over `[ZodSchema]` models, minimal setup | `` | +| Codes, categories, bounds in the UI; custom message rendering | `` | +| A runtime `Z.Object(...)` schema for a CLR model | `` | +| Debounced per-keystroke validation | `` | +| Cross-field re-check when a field changes | `` | +| API validation errors on fields | either, plus `AddZodProblemDetails` | + +Use one validator per form. With both, `ZodValidator` records full errors but leaves messages to +`DataAnnotationsValidator` and logs a warning. + +## Route 1 - DataAnnotationsValidator + +Nothing else is needed. Lead with this guarantee: `[ZodSchema]` models get **nested, collection and dictionary +validation with no `[ValidatableType]`, no `AddValidation()` and no Microsoft.Extensions.Validation generator** - +which is the only way to get nested validation in WebAssembly, where that generator does not flow. Messages land +on the right `FieldIdentifier` (`order.Lines[1].Quantity`). A field change runs only that member's rules. Async +rules drive `IsValidationPending`/`IsValidationFaulted` and the `pending`/`faulted` CSS classes. + +**Struct members (value objects).** An error inside a struct member - `Email.Value`, where `Email` is a value +object on the model - is placed on the nearest reference-type owner's member, `FieldIdentifier(model, "Email")`, +on both routes and on server errors: a struct cannot be a `FieldIdentifier` model. So bind +`` next to the value object's input, not `model.Email.Value`. A +scalar value object's messages use the owning member's display name (`"Email is required"`, not +`"Value is required"`); a non-scalar struct keeps its own member names in the text. + +## Route 2 - ZodValidator + +```razor + + + + + + +``` + +- `ValidateOn`: `Submit` (field changes don't validate), `Change` (default), `Input` (+ `DebounceMilliseconds`; + a newer change cancels the pending one). Submit-time validation always runs. +- Full errors: `editContext.GetZodErrors(() => order.Customer.Email)`, `GetZodErrors(field)`, `GetZodErrors()`. +- Runtime schema: `Schema="ZodModelSchema.For(Z.Object().Field("Reference", Z.String().Min(3)).Build())"` + (field names are property names; nested objects are read too). A field change then validates the whole model + and keeps that field's errors. +- `TimeProvider` is resolved from DI, so tests can fake debounce time. +- A validator that throws is logged through `ILogger` and the field is marked faulted. +- `ZodValidationMessage`/`ZodValidationSummary` match the built-in markup and add `data-code`/`data-category`; + `Template` renders a `RenderFragment`; `ShowUnionDetails` lists each union option's issues. + +## Server errors onto fields + +```csharp +var response = await http.PostAsJsonAsync("/orders", order); +if (await response.TryReadZodValidationProblemAsync() is { } problem) + editContext.AddZodProblemDetails(problem); +``` + +Places `issues[].segments` (or parses the dotted `errors` keys of a plain ValidationProblemDetails), matches +camel-cased names case-insensitively, replaces the previous server errors, and clears a field's server error +when it changes (`clearOnFieldChange: false` keeps them). `ClearZodErrors()` removes every ZodSharp error. +Reading uses source-generated JSON metadata, so it is trim- and AOT-safe. `TryReadZodValidationProblemAsync` and +`ZodValidationProblemDetails` (namespace `ZodSharp.Http`) come from `Purview.ZodSharp.Http`, which this package +references; that package also sends validated bodies (`PostAsJsonValidatedAsync`) and reads validated responses +(`ReadFromJsonAndValidateAsync`) - see the `purview-zodsharp-json` skill. + +`AddZodErrors(errors)` places errors produced anywhere else by their typed paths, with the same store and clearing +rules (each call replaces the previous `AddZodErrors`/`AddZodProblemDetails` errors): + +```csharp +@inject IServiceProvider Services +... +var sent = await Http.PostAsJsonValidatedAsync("/orders", order, + new OrderSchemaValidator(), AppJsonContext.Default.Order, Services); // services: the uniqueness rule runs +if (!sent.IsSuccess) + editContext.AddZodErrors(sent.Errors); // rejected before sending +else if (await sent.Value!.TryReadZodValidationProblemAsync() is { } problem) + editContext.AddZodProblemDetails(problem); // rejected by the server +``` + +For one component in every render mode, put the calls in a typed client: `AddScoped()` in the WebAssembly +client's `Program.cs`, `AddHttpClient(...)` on the server (the server-rendered component then calls the API +service-to-service). The repository's `Examples.Blazor` sample does exactly this: `RegisterCustomerForm` with +`CustomerAPIClient`, on `/register` (WebAssembly) and `/register-server` (Interactive Server). + +## Static SSR client-side validation + +```csharp +builder.Services.AddZodSharpClientValidation(); // Purview.ZodSharp.AspNetCore, server only +``` + +Static-SSR forms then carry client rules for ZodSharp rule attributes beside ASP.NET Core's DataAnnotations +rules; `zodsharp-validation.js` registers the browser validators automatically (a Blazor JavaScript initializer). +Order relative to `AddRazorComponents` does not matter; a provider registered **after** it replaces ZodSharp's, +which a Development startup warning reports. Runtime schemas need `o.AddSchema(schema)`. + +Say plainly in any guidance: client rules are UX only; async, service-aware and culture-sensitive rules are +server-only; the server validates every post. + +## Pitfalls + +- **Circuit scope.** On Blazor Server, validators get the circuit's services for the whole connection. In async + rules use `IDbContextFactory` or create a scope; never resolve a scoped `DbContext` directly. +- **Prerendering.** Don't validate in `OnInitialized`; it runs once per render phase. +- **Synchronous `Validate()`** is obsolete and throws for models with async-only rules; use `ValidateAsync`. +- **WebAssembly culture.** Codes and default messages are invariant; localized messages need `AddLocalization()` + and satellite assemblies (the formatter switches on automatically when an `IStringLocalizerFactory` exists). diff --git a/.agents/skills/purview-zodsharp-core/SKILL.md b/.agents/skills/purview-zodsharp-core/SKILL.md index 98280dd..a88170f 100644 --- a/.agents/skills/purview-zodsharp-core/SKILL.md +++ b/.agents/skills/purview-zodsharp-core/SKILL.md @@ -2,7 +2,8 @@ name: purview-zodsharp-core description: >- Use when validating data with Purview.ZodSharp at runtime - picking the right package, building - schemas with the Z factory, Validate/SafeParse/Parse, reading ValidationResult and ValidationError, + schemas with the Z factory, Validate/SafeParse/Parse, reading ValidationResult and ValidationError + (codes, categories, typed ZodPath paths, bounds, message formats), localizing messages, composing with Transform/Refine/Pipe/Catch/Default, and how null handling and type coercion work. license: MIT metadata: @@ -21,7 +22,11 @@ exceptions, reporting machine-readable issues. dotnet add package Purview.ZodSharp # core, source generator, JSON Schema export dotnet add package Purview.ZodSharp.SystemTextJson # STJ deserialize-and-validate + schema import dotnet add package Purview.ZodSharp.NewtonsoftJson # Newtonsoft equivalent +dotnet add package Purview.ZodSharp.Http # HttpClient: validated bodies + server problems (WASM-safe) dotnet add package Purview.ZodSharp.AspNetCore # ProblemDetails + DI +dotnet add package Purview.ZodSharp.Extensions.Validation # net11: Microsoft.Extensions.Validation bridge +dotnet add package Purview.ZodSharp.AspNetCore.OpenApi # net11: OpenAPI transformers +dotnet add package Purview.ZodSharp.Blazor # net11: Blazor components ``` JSON Schema **export** (`Z.ToJsonSchema`) is in the core package; **import** (`Z.FromJsonSchema`) comes @@ -47,11 +52,15 @@ if (result.IsSuccess) | `Validate(value)` | returns `ValidationResult`; **never throws** | | `SafeParse(value)` | alias of `Validate` | | `Parse(value)` | returns the validated value, or throws `ZodException` (via `GetValueOrThrow()`) | -| `ValidateAsync(value, ct)` | `ValueTask` wrapper. The pipeline is synchronous; the token is observed before validation and throws `OperationCanceledException` if already cancelled. Genuinely async work only happens in the source generator's custom async validation. | +| `ValidateAsync(value, ct)` | `ValueTask` wrapper. The pipeline is synchronous; the token is observed before validation and throws `OperationCanceledException` if already cancelled. Genuinely async work only happens in generated validators (`{Type}Schema.ValidateAsync(value, services, ct)`). | Reach for `Validate` by default and `Parse` only where a throw is the right control flow. Exceptions for expected invalid input are expensive and lose the structured issue list unless you catch. +`Validate` is also the right call inside an async method: the analyzer package suppresses CA1849 for it +(`ZODSUP001`), because the pipeline is synchronous and never blocks. `ValidateAsync` is for a type whose graph has +asynchronous rules. + ## `ValidationResult` A `readonly record struct` in `ZodSharp.Core`: @@ -67,22 +76,60 @@ concatenates the errors otherwise. ## `ValidationError` -A `readonly record struct` carrying machine-readable metadata, not just a message: +A `readonly record struct` in `ZodSharp.Core` carrying machine-readable metadata, not just a message: | Member | Meaning | | --- | --- | -| `Code` | `invalid_type`, `too_small`, `too_big`, `invalid_union`, `missing_field`, `unrecognized_key`, `validation_failed`, `refinement_failed`, `transform_error`, … | -| `Message` | human-readable | -| `Path` | `ImmutableArray` — `["user", "email"]`; array indexes are string segments, `"0"` | -| `Origin` | category for size issues: `"string"`, `"array"`, `"collection"` | -| `Minimum` / `Maximum` / `Inclusive` | the bound, for size issues | -| `Parameters` | `IReadOnlyDictionary`; a union failure carries every option error under `"errors"` | - -Build one with -`ValidationError.Create(code, message, path, parameters, origin, minimum, maximum, inclusive)`. - -`ZodException` carries `ImmutableArray Errors`; its `ToString()` renders one line per -error as `"{joinedPath}: {Message} ({Code})"`. +| `Code` | ZodSharp's specific identity: a rule's `ErrorCode`, or a `ZodIssueCodes` constant (`invalid_type`, `missing_field`, `refinement_failed`, `invalid_union`, `unrecognized_keys`, …). The same failure has the same code whichever producer reports it | +| `Category` | the Zod v4 issue code (`ZodIssueCategories`: `invalid_type`, `too_small`, `too_big`, `invalid_format`, `not_multiple_of`, `unrecognized_keys`, `invalid_union`, `invalid_value`, `custom`, …) | +| `Message` | human-readable; built-in messages never contain the input | +| `MessageFormat` | the named-placeholder template that produced `Message` (`"{displayName} is required"`), or `null` for a literal message | +| `FullPath` | the authoritative typed path (`ZodPath`) | +| `Path` | legacy `ImmutableArray` projection of `FullPath`; indexes are `"0"` | +| `Origin` | `"string"`, `"number"`, `"array"`, `"date"`, `"enum"`, … | +| `Lower` / `Upper` | `ErrorBound?` (`Value`, `Inclusive`) of any comparable type; `Minimum`/`Maximum`/`Inclusive` stay for integer bounds | +| `Parameters` | `ErrorTypeParameters` (also `IReadOnlyDictionary`); names in `ZodIssueParameters` (`expected`, `received`, `options`, `count`, `format`, `pattern`, `displayName`, `member`, `declaringType`, …) | + +Build one with `ValidationError.Create(code, message, path, parameters, origin, minimum, maximum, +inclusive, category)` (path as `ZodPath` or `ImmutableArray`), then `with { Lower = ..., MessageFormat += ... }`. Re-path with `WithPathPrefix(...)` only - it keeps every field. Union failures carry per-option +issues: `error.GetUnionErrors()`. `ZodIssues` has factories for the shared failures (null, invalid type, +missing field, refinement, union, collection size). + +`ZodException` carries `ImmutableArray Errors`; its message lists up to five issues as +`"{path}: {Message} ({Code})"` (root renders as `(root)`), and `ToString()` lists every one. + +## Paths + +`ZodPath` is a sequence of typed `ZodPathSegment`s (`Member`, `At(index)`, `Key(key)`). Render it with +`ToDottedString()` - the canonical key every host uses, matching Microsoft.Extensions.Validation: + +| Path | Dotted | +| --- | --- | +| member, index, member | `Items[0].Name` | +| key `en` | `Map[en]` | +| key `"0"` (all digits) or `"a.b"` | `Map["0"]`, `Map["a.b"]` | +| root | `""` | + +Also `ToJsonPointer()`, `ToJsonPath()`, `ToConfigurationKey()` and `ToZodPath()`; parse with +`ParseDotted`/`ParseJsonPath`. Keys are rendered with the invariant culture. A `ZodPathNamer.FromFunc(...)` +renames members (for example to camelCase) when a host opts into `ZodPathNaming.Json`. + +## Messages, culture and PII + +- Validation is culture-independent: codes, categories, paths, bounds and parameters are invariant, and + messages are produced invariantly. +- `ZodMessageFormat.Format(format, error, provider)` resolves named placeholders (`{displayName}`, + `{minimum}`, `{path}`, …) and never throws; unknown placeholders render literally. +- Hosts re-render messages in `CultureInfo.CurrentUICulture` through an `IZodMessageFormatter` + (`DefaultZodMessageFormatter` in core; `StringLocalizerZodMessageFormatter` in + `Purview.ZodSharp.Extensions.Validation` for `IStringLocalizer` resources). +- `ZodErrorOptions.IncludeReceivedValues` (default `false`) puts the raw input in the `received` + parameter. Development only - never in production. +- `ZodValidationDepth.MaxDepth` (default 32) stops generated validators on cyclic graphs with an + `InvalidOperationException`. + +See [references/error-model.md](references/error-model.md) for the error contract in detail. ## The `Z` factory @@ -93,6 +140,7 @@ error as `"{joinedPath}: {Message} ({Code})"`. | `Z.Optional(schema)` (`T : class`) / `Z.Nullable(schema)` (`T : struct`) | `ZodOptional` / `ZodNullable` | | `Z.Object()` | `ZodObjectBuilder` | | `Z.Union(params ...)` / `Z.Union(...)` | `ZodUnion` / `ZodTypedUnion` | +| `Z.NativeUnion(...)` (net11.0+) | `ZodTypedNativeUnion`, yielding the non-boxing `NativeUnion` | | `Z.DiscriminatedUnion(discriminator)` | `ZodDiscriminatedUnionBuilder` | | `Z.Intersection(left, right)` | `ZodIntersection` | | `Z.Literal(value)` (`T : IEquatable`) | `ZodLiteral` | @@ -105,6 +153,13 @@ error as `"{joinedPath}: {Message} ({Code})"`. `Enum` and `Union` are overloaded — `Enum(params string[])` versus `Enum()`, and `Union(params …)` versus `Union(…)`. +On `net11.0` and later, prefer `Z.NativeUnion`: its result is a C# 15 union declared with the +non-boxing access pattern, so reference-type **and** value-type cases stay allocation-free and an exhaustive +`switch` needs no discard arm. `Z.Union` remains the choice on `net10.0`, or when `Match`/`Switch`/ +equality is needed. On the wire it carries `[JsonUnion]`: System.Text.Json writes the active case and the +`NativeUnionTypeClassifierFactory` reads it back, including a string token under Web defaults and two +object-shaped cases (see the `purview-zodsharp-json` skill). + ```csharp var userSchema = Z.Object() .Field("name", Z.String().Min(1)) @@ -168,9 +223,25 @@ Object fields and union options wrap typed schemas, so boxed values out of a satisfies a `Z.Number()` field. - A non-nullable value type rejects `null`; anything else falls through to failure. -## Related skills +## Trimming and Native AOT + +Every package is `IsTrimmable`/`IsAotCompatible`. Runtime schemas, generated validators and generated +registration are safe as they are. Anything that is not carries `[RequiresUnreferencedCode]` with the +alternative in its message, so read the IL2026/IL3050 text rather than suppressing it: + +- JSON: the `JsonTypeInfo` overloads with a source-generated `JsonSerializerContext`; + Newtonsoft.Json is never trim-safe. +- Options: `BindConfiguration(section).ValidateWithZod(section)`, not `BindAndValidateWithZod`. +- Registration: the generated `AddZodSharp{AssemblyId}Schemas()`, not `AddZodSharpAssemblyGraph`. +- Discriminated unions over CLR types: add each option with `Option`. +`PublishTrimmed`/`PublishAot` turn off the `ZodSharp.Reflection.IsSupported` fallbacks (an unregistered +POCO's discriminator, field walkers for non-generated validators, graph scanning). See the wiki page +Trimming and Native AOT. + +## Related skills `purview-zodsharp-source-generator` for compile-time `[ZodSchema]` validators (which is what you want for a DTO), `purview-zodsharp-custom-rules` for writing rules and rule attributes, -`purview-zodsharp-aspnetcore` for ProblemDetails and DI, and `purview-zodsharp-diagnostics` for the +`purview-zodsharp-aspnetcore` for ProblemDetails, DI, the .NET 11 validation pipeline and OpenAPI, +`purview-zodsharp-json` for JSON integration, and `purview-zodsharp-diagnostics` for the `ZODSGEN`/`ZODSASP` catalogue. diff --git a/.agents/skills/purview-zodsharp-core/references/error-model.md b/.agents/skills/purview-zodsharp-core/references/error-model.md new file mode 100644 index 0000000..13c3412 --- /dev/null +++ b/.agents/skills/purview-zodsharp-core/references/error-model.md @@ -0,0 +1,112 @@ +# The ZodSharp error model in detail + +Everything here is in `ZodSharp.Core`. + +## Code versus Category + +`Code` is ZodSharp's own identity and is what tests and clients branch on when they need precision. +`Category` is the Zod v4 issue code, so a client that already handles TypeScript Zod issues can treat +ZodSharp issues the same way. + +| Failure | `Code` | `Category` | +| --- | --- | --- | +| null where a value is required | `invalid_type` | `invalid_type` | +| missing object field / generated `[Required]` | `missing_field` | `invalid_type` | +| `Min(3)` on a string | the rule's `ErrorCode` | `too_small` | +| `[StringLength]`/`[Length]` | `invalid_length` | `too_small` or `too_big` by direction | +| email, uuid, regex, prefix, … | the rule's `ErrorCode` | `invalid_format` | +| `MultipleOf` | the rule's `ErrorCode` | `not_multiple_of` | +| strict object with extra keys | `unrecognized_keys` (one issue, `keys` parameter) | `unrecognized_keys` | +| union with no matching option | `invalid_union` | `invalid_union` | +| enum / literal / allowed values | `invalid_enum_value`, `invalid_literal`, rule code | `invalid_value` | +| `Refine`, `SuperRefine`, `ApplyRefine`, custom method | `refinement_failed` (or your code) | `custom` | + +Every rule declares `public const string ErrorCode` and `MessageFormat` (diagnostic `ZODSGEN042`), so +assert against `MinLengthRule.ErrorCode` rather than a literal. + +## Bounds + +`Lower` and `Upper` are `ErrorBound?` - `record struct ErrorBound(object Value, bool Inclusive)` with +`ErrorBound.Including(value)` / `ErrorBound.Excluding(value)`. They hold numbers, decimals and dates. +`GreaterThan`/`LessThan` and `[Range(MinimumIsExclusive = true)]` report `Inclusive = false`. +`Minimum`/`Maximum`/`Inclusive` (`int?`) are still populated for integer size bounds; `Lower`/`Upper` +fall back to them. + +## Message templates + +Built-in messages come from named templates (`ZodMessages`, each rule's `MessageFormat`), kept on the +error as `MessageFormat`. `ZodMessageFormat` resolves `{name}` and `{name:format}` from the error's +parameters, then from the built-ins `displayName`, `member`, `path`, `code`, `category`, `origin`, +`minimum`, `maximum` and `inclusive`. `{{`/`}}` escape braces; an unknown placeholder or an unbalanced +brace is written literally. It never throws - a stray brace in a custom message cannot turn a failure +into an exception. `TryFormatComplete` succeeds only when every placeholder resolved. + +Every built-in rule reports its template through `IZodRule.MessageFormat`, so runtime-schema errors carry +one too (`Z.String().Min(3)`: `"String must be at least {minimum} characters long, but got {count}"`). A +custom rule message is reported with its positional placeholders renamed (`{0}` → `{minimum}`); the +value's position becomes `{received}`. + +```csharp +using ZodSharp.Core; + +var error = ValidationError.Create( + "too_short", + "Name is too short", + ZodPath.Member("Name"), + new Dictionary { [ZodIssueParameters.DisplayName] = "Name" }, + category: ZodIssueCategories.TooSmall +) with +{ + Lower = ErrorBound.Including(3), + MessageFormat = "{displayName} must have at least {minimum} characters", +}; + +var text = ZodMessageFormat.Format(error.MessageFormat!, error); // "Name must have at least 3 characters" +``` + +## Localization hook + +```csharp +public interface IZodMessageFormatter +{ + string Format(in ValidationError validationError, CultureInfo culture); +} +``` + +Hosts (ProblemDetails writer, the Microsoft.Extensions.Validation bridge, Blazor) call it with +`CultureInfo.CurrentUICulture`. `DefaultZodMessageFormatter.Instance` re-formats `MessageFormat` when +every placeholder resolves and otherwise returns `Message`. `StringLocalizerZodMessageFormatter` +(`Purview.ZodSharp.Extensions.Validation`) looks keys up in this order: the `MessageFormat` (or literal +`Message`), `{DeclaringType}_{Member}_{Code}`, `{DeclaringType}_{Code}`, `{Code}`; otherwise the message +is kept. `{displayName}` is localized from `{DeclaringType}_{Member}_DisplayName`. Implementations must +never throw. + +## Paths + +- Members join with `.`; indexes are `[n]`; keys are `[key]`, quoted as `["key"]` when the key is all + digits or contains anything other than ASCII letters, digits, `_` or `-`. +- `ZodPath.FromLegacy(strings)` classifies `"0"` and `"[0]"` as indexes, so string paths passed to + `RefineCtx.AddIssue(code, message, string[])` keep working. +- `RefineCtx.FullPath` is the refinement's base path; `AddIssue(code, message, ZodPath, parameters, + category)` and `AddIssue(ValidationError)` add typed issues relative to it. +- Generated `{Type}Schema.Paths.{Member}` constants give typed member paths for refinement hooks. + +## Unions + +A union failure is one `invalid_union` issue at the union's path. Its per-option issues are in the +`unionErrors` parameter as `ImmutableArray>`; read them with +`error.GetUnionErrors()`. The ProblemDetails `issues[]` projects them as nested `unionErrors`. + +## PII + +`ZodErrorOptions.IncludeReceivedValues` is process-wide and defaults to `false`. When off, a rule failure +carries no `received` parameter and an invalid-type failure's `received` holds only the type name +(`"string"`). Built-in default messages never contain the input either way - numeric rules included +(`"Value must be between 1 and 10"`); a custom message can still opt in with the rule's positional value +placeholder (`{0}`, or `{1}`/`{2}` after the bounds). + +## Depth + +`ZodValidationDepth.MaxDepth` (default `ZodValidationDepth.DefaultMaxDepth`, 32) is process-wide. The +root is depth 0; a generated validator throws `InvalidOperationException` when it reaches the limit, +matching Microsoft.Extensions.Validation's `ValidationOptions.MaxDepth`. diff --git a/.agents/skills/purview-zodsharp-custom-rules/SKILL.md b/.agents/skills/purview-zodsharp-custom-rules/SKILL.md index 06215cc..f2151be 100644 --- a/.agents/skills/purview-zodsharp-custom-rules/SKILL.md +++ b/.agents/skills/purview-zodsharp-custom-rules/SKILL.md @@ -52,20 +52,26 @@ the rule rather than re-typing literals: public readonly record struct EmailRule : IValidationRule { public const string ErrorCode = "invalid_string"; - public const string MessageFormat = "Invalid email format: {0}"; + public const string MessageFormat = "Invalid email format"; public string Code => ErrorCode; - public string GetErrorMessage(in string value) => - string.Format(CultureInfo.CurrentCulture, MessageFormat, value); + public string GetErrorMessage(in string value) => RuleMessage.Format(MessageFormat, value); } ``` - **`ErrorCode`** is the canonical code. `IValidationRule.Code` defaults to `"validation_failed"`; a rule accepting a per-usage `code` override returns it (falling back to `ErrorCode`) from both `Code` and `IZodRule.Code`, so every route reports the same effective value. -- **`MessageFormat`** is a `string.Format` template. `{0}` is the offending value; `{1}`, … are - rule-specific arguments. Format with `CultureInfo.CurrentCulture`. +- **`MessageFormat`** is a composite template. `{0}` is the offending value; `{1}`, … are + rule-specific arguments. Format it with `ZodSharp.Rules.RuleMessage.Format`: invariant culture (hosts + localize at the boundary through `IZodMessageFormatter`) and it never throws on a stray brace. +- **Keep the input out of the default message.** Errors reach ProblemDetails, logs and UI, so describe + what was expected rather than quoting the value. Pass the value anyway (after the rule's own + arguments), so a custom message can still use it. +- To make the message localizable by template, implement `IZodRule.MessageFormat` with a + named-placeholder form (`"Must be at least {minimum}"`) and `IZodRule.Parameters` with any value it + needs beyond the built-ins; the built-in rules all do. - The constants may be inherited from a base rule class, and **abstract bases are exempt**, so a shared base can host them for its concrete derivations. @@ -74,7 +80,7 @@ A source-declared rule missing either constant is reported as **`ZODSGEN042`**. ```csharp await Assert.That(error.Code).IsEqualTo(EmailRule.ErrorCode); await Assert.That(error.Message).IsEqualTo( - string.Format(CultureInfo.CurrentCulture, EmailRule.MessageFormat, value)); + RuleMessage.Format(EmailRule.MessageFormat, value)); ``` ### Runtime identity: `IZodRule` @@ -113,8 +119,8 @@ Code and origin resolve first-match-wins: ## Writing one ```csharp -using System.Globalization; using ZodSharp.Core; +using ZodSharp.Rules; namespace MyRules; @@ -122,7 +128,7 @@ namespace MyRules; public readonly record struct NoWhitespaceRule(string? Message = null) : IValidationRule { public const string ErrorCode = "invalid_string"; - public const string MessageFormat = "Whitespace is not allowed in '{0}'."; + public const string MessageFormat = "Whitespace is not allowed."; public bool IsValid(in string value) { @@ -141,7 +147,7 @@ public readonly record struct NoWhitespaceRule(string? Message = null) : IValida public string Code => ErrorCode; public string GetErrorMessage(in string value) => - Message ?? string.Format(CultureInfo.CurrentCulture, MessageFormat, value); + Message ?? RuleMessage.Format(MessageFormat, value); } ``` @@ -228,6 +234,11 @@ with an empty path, and the wrapped rule owns the reported `Code`. Closing such scalar type instead (`NonSentinelRule`) compiles but **always passes**, because the check only knows the primitive. +Such a scalar works under every publish mode, including Blazor WebAssembly with `RunAOTCompilation=true`: +the generated code passes the wrapped rule's identity with a direct `(IZodRule)` cast (or `null` when the +rule has none), never a runtime type test on the boxed rule. 3.0.0-prerelease.1 builds before that fix +crashed the Mono AOT compiler (exit code -1073741819); upgrading is the fix, so do not drop the rule to avoid it. + ## Diagnostics you will meet `ZODSGEN030` (mapped rule does not implement `IValidationRule` for the property type), diff --git a/.agents/skills/purview-zodsharp-diagnostics/SKILL.md b/.agents/skills/purview-zodsharp-diagnostics/SKILL.md index 101e872..b64ada2 100644 --- a/.agents/skills/purview-zodsharp-diagnostics/SKILL.md +++ b/.agents/skills/purview-zodsharp-diagnostics/SKILL.md @@ -2,7 +2,8 @@ name: purview-zodsharp-diagnostics description: >- Use when a Purview.ZodSharp build reports a ZODSGEN or ZODSASP diagnostic - the [ZodSchema] - generator's configuration and usage rules, the custom-rule mapping rules, and the ErrorType + generator's configuration and usage rules, the custom-rule mapping rules, async-only rules, + Microsoft.Extensions.Validation and Razor interplay, error-message placeholders, and the ErrorType analyzers - to find what triggered it and how to fix it. license: MIT metadata: @@ -18,8 +19,9 @@ Two families, both enabled by default: - **`ZODSASP*`** — the `ErrorType` factory's analyzer and generator, which ship in the **core** package and need no ASP.NET Core dependency. -`ZODSGEN033` and `ZODSGEN037`–`ZODSGEN040` and `ZODSGEN042` are warnings; `ZODSGEN041` and -`ZODSGEN043` are informational; the rest are errors. +`ZODSGEN033`, `ZODSGEN037`–`ZODSGEN040`, `ZODSGEN042`, `ZODSGEN045`, `ZODSGEN046`, `ZODSGEN048`, `ZODSGEN049`, +`ZODSGEN051`, `ZODSGEN052`, `ZODSGEN053` and `ZODSGEN055` are warnings; `ZODSGEN041`, `ZODSGEN043` and `ZODSGEN047` are +informational; the rest are errors. IDs `ZODSGEN002` and `ZODSGEN022`–`ZODSGEN026` are intentionally unused. Rule identifiers are never renumbered or reused, so a sighting of one means stale tooling or a stale suppression. @@ -35,6 +37,9 @@ renumbered or reused, so a sighting of one means stale tooling or a stale suppre | `ZODSGEN006` | Unsupported DataAnnotations usage: a string-only attribute on a non-string, `[AllowedValues]`/`[DeniedValues]` on an unsupported type, `[RegularExpression]` on a non-string, `[Range]` on an unsupported type. | | `ZODSGEN020` | `[Compare]` references an unknown property. | | `ZODSGEN021` | `System.ComponentModel.DataAnnotations` reference is missing. | +| `ZODSGEN048` | *(warning)* The type has rules that only run asynchronously (an `AsyncValidationAttribute` member or `IAsyncValidatableObject`), so the generated synchronous `Validate` throws `InvalidOperationException`. Call `ValidateAsync`. | +| `ZODSGEN052` | *(warning)* An attribute's `ErrorMessage` uses a positional placeholder the attribute does not supply (for example `{3}` on `[Required]`) or has an unbalanced brace. Messages are formatted with a never-throw engine, so the text is shown literally; fix the placeholder. | +| `ZODSGEN053` | *(warning)* `[MinLength]`, `[MaxLength]` or `[Length]` on a collection member of a `[ZodSchema]` type in a trimmed or AOT project (`PublishTrimmed`, `PublishAot`, `IsTrimmable`, `IsAotCompatible`). The BCL constructors are `[RequiresUnreferencedCode]`; use `[MinCount]`, `[MaxCount]` or `[CountRange]` from `ZodSharp.Rules` (the code fix swaps it). | ## Custom async validation method @@ -44,9 +49,11 @@ All of these mean the method the generator was told to call cannot be called: | --- | --- | | `ZODSGEN007` | Configured but not found. | | `ZODSGEN008` | Return type is not `ValueTask>`. | -| `ZODSGEN009` | Parameter count is not 2. | +| `ZODSGEN009` | The method takes neither two parameters `(T value, CancellationToken ct)` nor three, the service-aware `(T value, IServiceProvider services, CancellationToken ct)`. | | `ZODSGEN010` | First parameter is not the model type. | -| `ZODSGEN011` | Second parameter is not `CancellationToken`. | +| `ZODSGEN011` | The last parameter is not `CancellationToken`. | +| `ZODSGEN054` | A three-parameter (service-aware) method's second parameter is not `IServiceProvider`. | +| `ZODSGEN055` | *(warning)* A union (`union` declaration or `[Union]` type) has a case type with no `[ZodSchema]` validator in this compilation or a referenced assembly. The case is omitted from dispatch and values of that case fail as `invalid_union`; add a validator to the case type. | | `ZODSGEN012` | The method is generic. | | `ZODSGEN013` | Declared on the model type but not `static`. | | `ZODSGEN014` | Inaccessible from the generated validator (private/protected). | @@ -62,6 +69,7 @@ All of these mean the method the generator was told to call cannot be called: | --- | --- | | `ZODSGEN027` | Requested, but the `Microsoft.Extensions.Options` reference is missing. | | `ZODSGEN028` | Requested on a struct — it requires a class. | +| `ZODSGEN050` | *(error)* The generated `IValidateOptions` validator would conflict with a hand-authored `[OptionsValidator]` class (`SYSLIB1205`), so it is not generated. Remove `[OptionsValidator]` or set `[ZodSchema(SuppressIValidateOptions = true)]`. | ## The refinement hook @@ -93,13 +101,21 @@ All of these mean the method the generator was told to call cannot be called: | --- | --- | | `ZODSGEN046` | *(warning)* `[CollectionBehavior]` is applied to a property that is not a collection of nullable elements or values (a scalar, a collection whose element cannot be `null`, or a dictionary whose value cannot be `null`), so the setting can never take effect. | +## Microsoft.Extensions.Validation and Razor + +| ID | Meaning | +| --- | --- | +| `ZODSGEN047` | *(info)* `[ValidatableType]` on a `[ZodSchema]` type is redundant: the ZodSharp bridge (`AddZodSharpValidation()`) resolves the generated validator first, and the attribute only makes the Microsoft.Extensions.Validation generator emit a second, DataAnnotations-only validator. A code fix removes the attribute. | +| `ZODSGEN051` | *(warning)* A `[ZodSchema]` type is declared in a `.razor` file. Razor compiles components with its own source generator, and generators cannot see each other's output, so no schema is generated. Move the type to a `.cs` file. | + ## Unions, value objects, JSON Schema | ID | Meaning | | --- | --- | -| `ZODSGEN041` | *(info)* A typed `Z.Union` whose option types are all reference types can use the allocation-free native C# 15 union from `Z.NativeUnion` on .NET 11+. A code fix is offered. | +| `ZODSGEN041` | *(info)* A typed `Z.Union` can use the non-boxing native C# 15 union from `Z.NativeUnion` on .NET 11+ (reference- and value-type cases are both allocation-free). A code fix is offered. | | `ZODSGEN044` | A `[ZodSchema]` type using an automatic `[Scalar]` form (`[Scalar]` / `[Scalar(typeof(TValue))]`) cannot produce a usable schema, so the value-object generator's `Create` would reference a `{Type}Schema` that does not exist or cannot validate. Reported when `GenerateValidateMethod = false` suppresses the `Validate` it calls, or the declared underlying type cannot be represented. | | `ZODSGEN045` | A type referenced by an exported type has `GenerateJsonSchema = false`, so the cross-platform contract surface is incomplete. | +| `ZODSGEN049` | *(warning)* A member is typed as a C# 15 closed class whose derived type has no `[ZodSchema]`, so a value of that derived type is not validated. Add `[ZodSchema]` to the derived type. | ## `ErrorType` — `ZODSASP*` @@ -123,3 +139,10 @@ Standard mechanisms: `#pragma warning disable ZODSGEN006`, a `NoWarn` entry, or severity override. The canonical catalogue is the analyzer's `AnalyzerReleases.Shipped.md` / `AnalyzerReleases.Unshipped.md` in the generator project — a new or changed rule must be recorded there in the same change. + +The package also ships `ZODSUP001`, a **suppression** rather than a reportable rule: it removes CA1849 +("call async methods when in an async method") for ZodSharp's `Validate` and `ValidateMember` calls — generated +schemas, validator adapters and fluent schemas — because the synchronous pipeline is the cheap path and never blocks. +`ValidateAsync` is needed only when the graph has asynchronous rules, which `ZODSGEN048` reports. It is scoped to +those call targets: `Task.Wait`, `Thread.Sleep` and other libraries' `Validate`/`ValidateAsync` pairs still report +CA1849. Do not add `NoWarn;CA1849` for ZodSharp's own calls. diff --git a/.agents/skills/purview-zodsharp-json/SKILL.md b/.agents/skills/purview-zodsharp-json/SKILL.md index adaedda..58938f9 100644 --- a/.agents/skills/purview-zodsharp-json/SKILL.md +++ b/.agents/skills/purview-zodsharp-json/SKILL.md @@ -3,8 +3,9 @@ name: purview-zodsharp-json description: >- Use when combining Purview.ZodSharp with JSON - deserialize-and-validate and validating converters for System.Text.Json or Newtonsoft.Json, choosing between the two packages, exporting a schema to - JSON Schema with Z.ToJsonSchema, importing one with Z.FromJsonSchema, and sharing a contract with - TypeScript Zod. + JSON Schema with Z.ToJsonSchema, importing one with Z.FromJsonSchema, the invalid_json error and + converter exceptions, x-zod vendor extensions for OpenAPI, sharing a contract with TypeScript Zod, and + HttpClient bodies and server validation problems with Purview.ZodSharp.Http (WebAssembly-safe). license: MIT metadata: product: Purview.ZodSharp @@ -26,8 +27,52 @@ Each adds deserialize-and-validate, a validating converter, and JSON Schema **im Both can be referenced from one project without `extern alias` — the JSON Schema types live in `ZodSharp.JsonSchema.SystemTextJson` / `ZodSharp.JsonSchema.NewtonsoftJson` — but the -deserialize/serialize extension overloads share names, so **import exactly one package namespace per -file**. +deserialize/serialize extensions (`SystemTextJsonExtensions`, `NewtonsoftJsonExtensions`) are both in the +`ZodSharp` namespace with the same names. With both referenced, a call that omits the options argument +(`schema.DeserializeAndValidate(json)`, `DeserializeAndValidateAsync(stream[, ct])`, `ValidateAndSerialize(value)`, +`ValidateAndSerializeAsync(value, output[, ct])`) resolves to System.Text.Json; pass a `JsonSerializerSettings` +for Newtonsoft. `CreateValidatingConverter(schema)` stays ambiguous (CS0121): call the class directly, +`NewtonsoftJsonExtensions.CreateValidatingConverter(schema)`. + +`Purview.ZodSharp.SystemTextJson` is browser-safe (Blazor WebAssembly). For `HttpClient` calls, add +`Purview.ZodSharp.Http` (also browser-safe, `net10.0`+), which builds on it: + +```csharp +// Validate before sending: an invalid value returns the errors and no request is made. +var sent = await http.PostAsJsonValidatedAsync("api/orders", order, new OrderSchemaValidator(), AppJsonContext.Default.Order); + +// Validate while reading: unreadable JSON is an invalid_json error, never an exception. +var read = await response.Content.ReadFromJsonAndValidateAsync(new OrderSchemaValidator(), AppJsonContext.Default.Order); + +// Read a ZodSharp ProblemDetails response back into ZodSharp errors. +var errors = await response.TryReadZodValidationProblemAsync() is { } problem + ? problem.ToValidationErrors() // or problem.ToValidationResult() + : []; +``` + +Every `Purview.ZodSharp.Http` API takes a `JsonTypeInfo`; there are no `JsonSerializerOptions` overloads. +`ZodJsonContent.Create`/`CreateAsync` build a validated `JsonContent` for any other request. `Put`/`PatchAsJsonValidatedAsync` +mirror `Post`, each with `string?` and `Uri?` URIs. + +**Service-aware rules** (`[ZodAsyncMember]` methods that resolve services) need the caller's `IServiceProvider`. Pass a +generated `{TypeName}SchemaValidator` plus the services - a Blazor component's injected `IServiceProvider` (the +browser's or the circuit's), `HttpContext.RequestServices` on a server: + +```csharp +var sent = await http.PostAsJsonValidatedAsync("api/customers", customer, + new RegisterCustomerRequestSchemaValidator(), AppJsonContext.Default.RegisterCustomerRequest, services, ct); +var read = await response.Content.ReadFromJsonAndValidateAsync( + new CustomerSchemaValidator(), AppJsonContext.Default.Customer, services, ct); +``` + +A generated validator with such a rule throws `InvalidOperationException` through an overload without services, so the +rule is never skipped silently. `SystemTextJsonExtensions` has the same overload for `DeserializeAndValidateAsync` and +`ValidateAndSerializeAsync`. + +Wrap the calls in one typed client and register it per host: `AddScoped()` in a WebAssembly client, +`AddHttpClient(...)` on a server (created in the requesting scope, so its injected `IServiceProvider` is the +circuit's or request's). In Blazor, `editContext.AddZodErrors(sent.Errors)` puts a rejected-before-sending result on the +form's fields, and `AddZodProblemDetails(problem)` a server rejection. ## Deserialize and validate @@ -52,7 +97,32 @@ var json = userSchema.ValidateAndSerialize(user); // ValidationResu await userSchema.ValidateAndSerializeAsync(user, outputStream); ``` -Failures add `deserialization_failed` and `json_error` codes alongside the schema's own. +A failure to read the JSON - malformed text, a `null` document, a value the serializer cannot convert, and on +System.Text.Json a `NotSupportedException`/`InvalidOperationException` - is one error with code `invalid_json` +(`SystemTextJsonExtensions.InvalidJsonCode` / `NewtonsoftJsonExtensions.InvalidJsonCode`), category +`invalid_type` and the fixed message `"The JSON value could not be converted to {expected}."` (`expected` is a +Zod type name). The serializer's path becomes the error's `FullPath` (`$.items[1].age` and Newtonsoft's +`items[1].age` both become `items[1].age`); line and position become the `line` and `position` parameters. A +System.Text.Json syntax error between members is located at the element (`items[1]`), and one inside a value read +by a validating converter at that value, because it is buffered first. The +serializer's own message is never surfaced, because it names CLR types and can quote the input. Schema failures +keep the schema's own codes, and the Newtonsoft `DeserializeAndValidateAsync` awaits the schema's +`ValidateAsync`. + +A converter that rejects its value with `new JsonException(message, zodException)` (a Purview.ValueObjects strict +converter) is not `invalid_json`: System.Text.Json's `DeserializeAndValidate*` and `ReadFromJsonAndValidateAsync` +return the `ZodException`'s errors under the member that held the value (`CustomerEmail.Value`, JSON names mapped +back to member names through the `JsonTypeInfo`), named after that member (`Field 'CustomerEmail' ...`). +`ZodJsonExceptions.TryGetErrors(exception, rootTypeInfo, out errors)` does the same for any exception chain. + +### Native unions (.NET 11) + +`Z.NativeUnion` returns `ZodSharp.Unions.NativeUnion`. System.Text.Json writes the active case +with no wrapper or discriminator, and the type's `[JsonUnion]` classifier reads it back on both the reflection and +source-generated paths. It resolves a string token under `JsonSerializerDefaults.Web` (where a number may also be +read from a string) and two object-shaped cases, with no converter registration. A genuinely ambiguous shape (two +object types with the same property names, or two numeric types) fails as `JsonException`; give it a distinct +shape or use a closed hierarchy with `[JsonPolymorphic(InferClosedTypePolymorphism = true)]`. ## Validating converters @@ -65,8 +135,11 @@ var value = JsonSerializer.Deserialize(json, options); var value2 = JsonConvert.DeserializeObject(json, userSchema.CreateValidatingConverter()); ``` -Invalid JSON **throws** — `JsonException` (STJ) or `JsonSerializationException` (Newtonsoft), with a -`"Validation failed: …"` message. Each converter removes itself from the options/serializer it uses +Invalid data **throws** `ZodJsonValidationException : JsonException` (STJ) or +`ZodNewtonsoftJsonValidationException : JsonSerializationException` (Newtonsoft), both in `ZodSharp.Json`, with a +`"Validation failed: …"` message (existing `catch (JsonException)` blocks keep working) and the full errors in +`ImmutableArray Errors`, located under the converted value's JSON path (`Owner.Name` for a +failing nested `Owner`, including nested converters). `DeserializeAndValidate` unwraps them into the result. Each converter removes itself from the options/serializer it uses internally, to avoid recursing into itself. ## Which package differs how @@ -78,7 +151,7 @@ internally, to avoid recursing into itself. | Converter return | generic `JsonConverter` | non-generic `JsonConverter` | | `JToken` overload | no | yes | | Formatting | via options | explicit `Formatting` argument | -| Invalid-data exception | `JsonException` | `JsonSerializationException` | +| Invalid-data exception | `ZodJsonValidationException` (a `JsonException`) | `ZodNewtonsoftJsonValidationException` (a `JsonSerializationException`) | | Plumbing type | `JsonElement` | `JToken`/`JObject`/`JArray` | ## JSON Schema export @@ -95,14 +168,24 @@ var jsonSchema = Z.ToJsonSchema>(userSchema, new ToJ `ToJsonSchemaOptions`: `IncludeSchema` (default `true`, emits `$schema`), `Id`, `Title`. +`ToJsonSchemaConverter.ConvertUntyped(object schema, options)` (`ZodSharp.JsonSchema`) converts a schema or +validator known only at runtime, unwrapping the `ZodSchemaValidator`/`ZodUntypedValidator` adapters. +Rules with no JSON Schema keyword (`CidrRule`, `JWTRule`, an any-family `IPAddressRule`) are listed as +unsupported and recorded as `x-zod-*` vendor extensions (`x-zod-cidr`, `x-zod-jwt`, `x-zod-ipVersion`) through +`JsonSchemaDefinition.GetVendorExtensions()`/`SetVendorExtension(name, value)`. They are **not** serialized into +the JSON Schema; hosts that carry them (the OpenAPI transformers) read them. + `JsonSchemaDefinition` (`ZodSharp.JsonSchema`) is a mutable POCO mirroring the keywords — identity (`$schema`/`$id`/`$ref`), type and title, string/number/array/object constraints, composition (`anyOf`/`oneOf`/`allOf`), `enum`/`const`, `$defs` and draft-07 `definitions`, and the metadata keywords including OpenAPI 3.0 `nullable`. -Covered schema types: `ZodString`, `ZodNumber`, `ZodBoolean`, `ZodNull`, `ZodObject`, `ZodOptional`, -`ZodUnion`, `ZodArray`, `ZodLiteral`, `ZodNullable`, `ZodNativeEnum`/`ZodEnum`, `ZodLazy`. Notable -behaviours: +Covered schema types: `ZodString`, `ZodNumber`, `ZodBoolean`, `ZodNull`, `ZodDate`, `ZodDateTimeOffset`, +`ZodObject`, `ZodOptional`, `ZodUnion`, `ZodDiscriminatedUnion` (as `oneOf`), `ZodArray`, `ZodLiteral`, +`ZodNullable`, `ZodRecord` (as an object with `additionalProperties`), `ZodNativeEnum`/`ZodEnum`, +`ZodLazy`. String formats: `IPAddress` exports `format: ipv4`/`ipv6`, `Duration` +`format: duration`, `DateString` `format: date`, `DatetimeString` `format: date-time` plus its UTC pattern, +`TimeString` a local-time pattern, and `Base64String` `contentEncoding: base64`. Notable behaviours: - objects emit `additionalProperties: false`; - literals emit `const` plus `type`; @@ -152,6 +235,10 @@ Things worth knowing: wraps that value schema in a `null` branch), and its size constraints use `minProperties`/`maxProperties`. `Z.Record(schema)` exports the same shape, and `Z.FromJsonSchema` reads an object with `additionalProperties` back as a record in both JSON integrations. +- **Union and closed-hierarchy members** export `oneOf`: a C# 15 union member exports its case schemas, and a + `closed` hierarchy member exports its derived schemas. A hierarchy that opts into + `[JsonPolymorphic(InferClosedTypePolymorphism = true)]` (or registers `[JsonDerivedType]`) also constrains + `$type` on each branch to that derived type's name. ## JSON Schema import @@ -183,6 +270,10 @@ Mapped keywords: - Objects — `required` and optional fields. Arrays — `items`, `minItems`, `maxItems` (a nullable item schema — `anyOf` with a `{"type":"null"}` branch — imports as a nullable element). +Imported schemas report JSON kinds, never CLR types (`"Expected string, but got number"`); `minItems`/`maxItems` +failures read like `ZodArray`'s (`"Array must have at least 2 elements, but got 1"`, origin `array`, both bounds); +element and record-value errors use typed index and key segments. + ### Limits `$ref` resolves **local** references only (`#/…`, for example `#/$defs/Name`). An external target @@ -217,7 +308,14 @@ re-checks the fixtures under Zod and parses the C# output — closing the loop i If the C# output directory is empty, the vitest suite tells you to run the C# cross-platform tests first. +## OpenAPI + +On ASP.NET Core 11, the `Purview.ZodSharp.AspNetCore.OpenApi` package feeds the same JSON Schema - including +the `x-zod-*` vendor extensions - into the OpenAPI document (`options.AddZodSharp()` on `OpenApiOptions`), so +the document describes exactly what ZodSharp enforces. See the `purview-zodsharp-aspnetcore` skill. + ## Related skills -`purview-zodsharp-core` for the schema API, and `purview-zodsharp-source-generator` for the generated -validators that export through `BuildJsonSchema`. +`purview-zodsharp-core` for the schema API and the error model, `purview-zodsharp-source-generator` for the +generated validators that export through `BuildJsonSchema`, and `purview-zodsharp-aspnetcore` for +ProblemDetails and OpenAPI. diff --git a/.agents/skills/purview-zodsharp-source-generator/SKILL.md b/.agents/skills/purview-zodsharp-source-generator/SKILL.md index 31d140e..e6b6972 100644 --- a/.agents/skills/purview-zodsharp-source-generator/SKILL.md +++ b/.agents/skills/purview-zodsharp-source-generator/SKILL.md @@ -3,8 +3,9 @@ name: purview-zodsharp-source-generator description: >- Use when generating a compile-time validator with Purview.ZodSharp's [ZodSchema] attribute - which types it emits, the attribute options, DataAnnotations coverage, the OnZodValidate refinement hook, - async custom validation, automatic enum validation, IValidateOptions support, and the MSBuild - switches that control it. + async and service-aware validation, per-member validation and path resolution, typed Paths and + JsonNames, trim-safe registration, IValidatableObject, automatic enum validation, IValidateOptions + support, and the MSBuild switches that control it. license: MIT metadata: product: Purview.ZodSharp @@ -14,8 +15,9 @@ metadata: # The `[ZodSchema]` source generator Mark a class, struct or record with `[ZodSchema]` and a static, zero-allocation validator is emitted -at compile time — no reflection at runtime. The attribute itself is generated into the `ZodSharp` -namespace by `Purview.ZodSharp.SourceGenerators`, so nothing beyond `Purview.ZodSharp` is needed. +at compile time — no reflection at runtime. The attribute itself is `ZodSharp.ZodSchemaAttribute` in the +`Purview.ZodSharp` assembly (as is `[ErrorType]`), so nothing beyond `Purview.ZodSharp` is needed and the +generator emits no marker types into your project. ```csharp using System.ComponentModel.DataAnnotations; @@ -43,14 +45,26 @@ var validated = UserSchema.Parse(user); // throws ZodException on failure | Artifact | Shape | | --- | --- | -| `{TypeName}Schema` | static partial class — the validator. Accessibility mirrors the target. Holds `Validate`, `Parse`, `BuildJsonSchema`, and (with composition on) `ApplyAnd`, `ApplyOr`, `ApplyRefine`. Renamed by `SchemaName`. | -| `{TypeName}SchemaValidator` | `partial class : IZodSchemaValidator<{TypeName}>, IJsonSchemaProvider` — the DI adapter, with `Validate`/`ValidateAsync`/`BuildJsonSchema`. Primary schema only; `{SchemaName}Validator` when renamed. | -| `{TypeName}Validator` | `sealed partial class : IValidateOptions<{TypeName}>` — only when `IValidateOptions` support is on, and the target is a class. | +| `{TypeName}Schema` | static partial class — the validator. Accessibility mirrors the target. Holds `Validate`, `ValidateAsync(value, services, ct)`, `RequiresAsyncValidation`, the member API below, `Parse`, `BuildJsonSchema`, and (with composition on) `ApplyAnd`, `ApplyOr`, `ApplyRefine`. Renamed by `SchemaName`. | +| `{TypeName}Schema.Paths` | one `ZodPath` constant per member (`OrderSchema.Paths.Lines`) for refinements and tests | +| `{TypeName}Schema.IsValidatableMember(name)` | true when a field change can validate that member alone (synchronous rules or a `[ZodAsyncMember]` method) | +| `{TypeName}Schema.JsonNames` | `Get(memberName)` returns the `[JsonPropertyName]` name, or `null`; `ToJsonNamedPath(path, namingPolicy)` renames a whole path without reflection | +| `{TypeName}SchemaValidator` | `partial class` implementing `IZodSchemaValidator`, `IZodMemberValidator`, `IZodMemberResolver`, `IZodUntypedValidator`, `IZodJsonPathMapper` and `IJsonSchemaProvider` — the adapter hosts use. Primary schema only; `{SchemaName}Validator` when renamed. | +| `{TypeName}Validator` | `sealed partial class : IValidateOptions<{TypeName}>` (and, on .NET 11, `IAsyncValidateOptions` when the type has async rules) — only when `IValidateOptions` support is on, and the target is a class. | +| `ZodSharp.Generated.{Assembly}ZodSharpSchemas.Register(factory)` | registers every generated validator in the assembly by name — trim- and AOT-safe | +| `services.AddZodSharp{Assembly}Schemas()` | the DI form of `Register`, emitted when Microsoft.Extensions.DependencyInjection is referenced. `{Assembly}` is the assembly name in PascalCase (`Contoso.Orders.Api` → `AddZodSharpContosoOrdersApiSchemas()`) | | `[assembly: ZodSchemaGenerated(typeof({TypeName}))]` | the marker `IZodSchemaFactory` assembly scanning consumes. Primary, non-nested schemas only. | `BuildJsonSchema` is built from the same metadata as `Validate`, so a generated schema exports through `Z.ToJsonSchema` without rebuilding its rules by hand. +No marker type is emitted: `[ZodSchema]` and `[ErrorType]` are ordinary types in `Purview.ZodSharp` (3.x). ZodSharp +2 generated `[Embedded]` copies of both into every compilation, so a **library** packed against 2.x and rebuilt +against 3.x fails package validation with `CP0001` for `T:ZodSharp.ZodSchemaAttribute` and +`T:ZodSharp.Core.ErrorTypeAttribute`. The removal is safe (no compiler binds to an `[Embedded]` type in a reference): +run `dotnet pack -p:ApiCompatGenerateSuppressionFile=true` and check in the `CompatibilitySuppressions.xml` it writes, +until the baseline is a 3.x build. The changelog's upgrade notes give the entries by hand. + ```csharp var adult = UserSchema.ApplyRefine(user, u => u.Age >= 18, "Must be adult"); var both = UserSchema.ApplyAnd(user, u => u.Name.Length > 5, "Name too short"); @@ -77,7 +91,9 @@ validator all depend on `Validate` — `GenerateValidateMethod = false` removes ## What is validated -- Properties must be public, non-static, non-indexer. +- Properties must be public, non-static, non-indexer. They are read from the type **and its base chain**, so an + inherited public property is validated, exported and resolved like a declared one; an overridden or hidden + property is taken once, from the most derived declaration. - A property gets validation when it carries **any** DataAnnotations attribute, when its type is a source-defined complex type with a nested schema, or when its type is an enum. - Nested complex types are discovered recursively and get their own `{TypeName}Schema`, **even @@ -90,6 +106,12 @@ validator all depend on `Validate` — `GenerateValidateMethod = false` removes `[assembly: ZodSharpSchemaDefaults(AllowNulls = ...)]`, then the `ZodSharpAllowNulls` MSBuild property. The most specific statement wins; an unset level defers to the next. An explicit `AllowNulls = true` exports a `null` branch on the collection's `items` schema. +- **Struct collections:** nullable wrappers are unwrapped only when present. A default `ImmutableArray` + follows missing-value rules (`[Required]` => `missing_field`, optional nullable => accepted, non-nullable + => `invalid_type`). To validate it as empty, set `DefaultImmutableArrayBehavior = DefaultImmutableArrayBehavior.TreatAsEmpty` + on `[CollectionBehavior]` or `[assembly: ZodSharpSchemaDefaults]`, or set the + `ZodSharpDefaultImmutableArrayBehavior` MSBuild property. Precedence: property, assembly, MSBuild, + built-in `ValidationError`. An absent nullable wrapper remains missing; the model is never mutated. - **Dictionaries are maps, not arrays.** A property whose type implements `IDictionary` or `IReadOnlyDictionary` (for example `Dictionary`, `SortedDictionary`, `ConcurrentDictionary`, `ImmutableDictionary`) validates every **value** against the value type's @@ -98,11 +120,45 @@ validator all depend on `Validate` — `GenerateValidateMethod = false` removes `[CollectionBehavior(AllowNulls = true)]` accepts it). A dictionary exports as `{ "type": "object", "additionalProperties": … }` and `[Length]`/`[MinLength]`/`[MaxLength]` map to `minProperties`/`maxProperties` — never `z.array`/`items`. +- **Collection counts in trimmed apps:** `[MinLength]`/`[MaxLength]`/`[Length]` constructors are + `[RequiresUnreferencedCode]` (IL2026). Use `[MinCount(n)]`, `[MaxCount(n)]` and `[CountRange(min, max)]` + from `ZodSharp.Rules`: same generated check, error identity and JSON Schema keywords. `ZODSGEN053` + (with a code fix) flags the BCL attributes on a collection when `PublishTrimmed`, `PublishAot`, + `IsTrimmable` or `IsAotCompatible` is set; on a scalar the count attributes are `ZODSGEN030`. - Classes, structs and records are supported. Structs get no `IValidateOptions` (`ZODSGEN028` if requested). -DataAnnotations coverage, positional-record attribute targets, structured size issues and the -`[Range]` shapes are in [references/dataannotations.md](references/dataannotations.md). +- `IValidatableObject.Validate` runs after the members and type rules, as in DataAnnotations; its + results report code `validation_failed`, category `custom`, at the member names they list. On .NET 11 + `IAsyncValidatableObject` and `AsyncValidationAttribute` members run in `ValidateAsync`; a + synchronous `Validate` of such a type throws `InvalidOperationException` (`ZODSGEN048`). +- **C# 15:** a member typed as a union dispatches to the active case's schema, and a member typed as a + closed class to the derived type's schema (`ZODSGEN049` when a derived type has no `[ZodSchema]`). + Paths never include the union's `Value`. `[ZodSchema]` on a union itself emits a schema that dispatches to + the active case, matching a case with `TryGetValue` when the union implements the non-boxing access pattern; + an empty union or a case with no validator fails as `invalid_union` (`ZODSGEN055` when a case has no + validator). `[Required]`/`[RequiredZod]`/`[NonSentinel]` treat an empty union as absent/default. +- Nested validation is depth-guarded: `ZodValidationDepth.MaxDepth` (32) throws + `InvalidOperationException` instead of overflowing the stack on a cycle. +- Paths are typed: `Items[0].Name`, `Map[en].Value`, `Map["0"].Value` from `FullPath.ToDottedString()`. + +DataAnnotations coverage, codes and categories, positional-record attribute targets, structured size +issues, error messages and the `[Range]` shapes are in +[references/dataannotations.md](references/dataannotations.md). The member API, the async graph and +registration are in [references/generated-api.md](references/generated-api.md). + +## Member validation and resolution + +```csharp +OrderSchema.IsValidatableMember("CustomerName"); // has rules of its own? +var field = OrderSchema.ValidateMember(order, "CustomerName"); +OrderSchema.TryResolveMember(order, error.FullPath, out var container, out var memberName); +``` + +`ValidateMember`/`ValidateMemberAsync` run **one member's own rules** - no nested recursion, type +rules, `IValidatableObject` or refinement - which is what field-level validation (a Blazor field +change) needs. `TryResolveMember` walks an error path to the object that owns the failing member, +without reflection. ## The `OnZodValidate` refinement hook @@ -119,7 +175,7 @@ public partial class Order partial void OnZodValidate(RefineCtx context) { if (context.Value.Total < 0) - context.AddIssue("invalid_range", "Total cannot be negative", [nameof(Total)]); + context.AddIssue("invalid_total", "Total cannot be negative", OrderSchema.Paths.Total); } } ``` @@ -146,21 +202,45 @@ the hook while keeping the hook optional. **It is not part of your API; never im Mutually exclusive with `OnZodValidate` — declaring both is `ZODSGEN029`. ```csharp -public partial class UserSchemaValidator +[ZodSchema] +public partial class SignUp { - public async ValueTask> CustomValidationAsync(User value, CancellationToken ct) + [Required, EmailAddress] + public string Email { get; set; } = ""; + + [ZodAsyncMember(nameof(Email))] // also run by ValidateMemberAsync(value, "Email", ...) + internal static async ValueTask> CustomValidationAsync( + SignUp value, IServiceProvider services, CancellationToken cancellationToken) { - await Task.Delay(1, ct); - return ValidationResult.Success(value); + var directory = services.GetRequiredService(); + return await directory.IsEmailTakenAsync(value.Email, cancellationToken) + ? ValidationResult.Failure( + ValidationError.Create("email_taken", "Email is already registered", SignUpSchema.Paths.Email)) + : ValidationResult.Success(value); } } + +var result = await SignUpSchema.ValidateAsync(model, serviceProvider, cancellationToken); ``` -Signature `ValueTask> Name(T value, CancellationToken ct)`. The default lookup -name is `CustomValidationAsync` unless `CustomValidationMethodName` overrides it. A method on the -**model type** must be `static`; one on the generated `{TypeName}SchemaValidator` partial may be an -instance method. The generated `ValidateAsync` runs the synchronous `Validate`, awaits the custom -method, and merges the error sets. +Signature `ValueTask> Name(T value, CancellationToken ct)`, or the service-aware +`Name(T value, IServiceProvider services, CancellationToken ct)`, which receives the services passed +to `ValidateAsync` (the bridge, ProblemDetails and options hosts pass theirs). The default lookup name +is `CustomValidationAsync` unless `CustomValidationMethodName` overrides it. A method on the +**model type** must be `static` and not `private` (`ZODSGEN013`, `ZODSGEN014`); one on the generated +`{TypeName}SchemaValidator` partial may be an instance method. + +`ValidateAsync` runs the synchronous rules, awaits nested schemas' async validation (re-pathed under +the member), then the custom method, and merges the errors. It completes synchronously when nothing in +the graph is asynchronous; `{Type}Schema.RequiresAsyncValidation` is that compile-time answer. +Without `[ZodAsyncMember]`, the custom method runs only on full validation. + +CA1849, when enabled, would report every `Validate` and `ValidateMember` call inside async code because +`ValidateAsync` exists. The analyzer package suppresses it (`ZODSUP001`), so call the synchronous path freely: it is +cheaper (no `ValueTask`, no state machine) and never blocks. Use `ValidateAsync` when the graph has asynchronous +rules — `RequiresAsyncValidation` is the compile-time answer, and `ZODSGEN048` says so at build time. The suppression +is by call target: CA1849 still reports for `Task.Wait`, `Thread.Sleep` and other libraries' `Validate`/`ValidateAsync` +pairs. ## Automatic enum validation @@ -194,6 +274,8 @@ when the property declares an explicit `[AllowedValues]` allow-list (which gover Enabled by `GenerateIValidateOptions = true`, by auto-detection (unset, target is not a value type, and the type name ends with a configured suffix — `Options` or `Settings` by default), or by MSBuild. +Each failure reads `"{path}: {message} ({code})"`. A hand-authored `[OptionsValidator]` for the same +type conflicts (`ZODSGEN050`). | MSBuild property | Default | Behaviour | | --- | --- | --- | @@ -204,6 +286,7 @@ and the type name ends with a configured suffix — `Options` or `Settings` by d | `ZodSharpEnumSerialization` | `Number` | `String` exports enums as member names, matching a global `JsonStringEnumConverter` | | `ZodSharpEnumNamingPolicy` | `Unchanged` | `CamelCase` applies `JsonNamingPolicy.CamelCase` to string-serialized enum names | | `ZodSharpAllowNulls` | `false` | assembly-wide default for whether collections may contain `null` elements; explicit `true` accepts them | +| `ZodSharpDefaultImmutableArrayBehavior` | `ValidationError` | default immutable arrays follow missing-value rules; `TreatAsEmpty` runs collection rules against empty arrays | | `ZodSharpDateTimeOffsetSerialization` | `PreserveOffset` | `Utc` narrows `[NonSentinel] DateTimeOffset` to a zero-offset wire contract; pair with the integration package's `UtcDateTimeOffsetConverter`. Both modes export an exact sentinel pattern | `DateTimeOffset` precedence: `[DateTimeOffsetBehavior]` on the property/parameter, then on the type, then diff --git a/.agents/skills/purview-zodsharp-source-generator/references/dataannotations.md b/.agents/skills/purview-zodsharp-source-generator/references/dataannotations.md index 30e92ec..c9a10de 100644 --- a/.agents/skills/purview-zodsharp-source-generator/references/dataannotations.md +++ b/.agents/skills/purview-zodsharp-source-generator/references/dataannotations.md @@ -5,25 +5,38 @@ direct, typed codegen. Nothing is reflected at runtime. ## Supported attributes -| Attribute | Generated behaviour | Failure code | +| Attribute | Generated behaviour | `Code` / `Category` | | --- | --- | --- | -| `[Required]` | nullable property must not be null; a string with `AllowEmptyStrings=false` must be non-empty | `missing_field` | -| `[Length(min, max)]` | min/max size; strings, arrays (including jagged and rectangular), countable collections | `too_small` / `too_big` | -| `[StringLength(max)]`, `[StringLength(max, MinimumLength=min)]` | string size via direct `.Length` | `too_small` / `too_big` | -| `[MinLength(n)]` | checked only when `n > 0` | `too_small` | -| `[MaxLength(n)]` | checked only when `n >= 0` | `too_big` | -| `[Range(…)]` | inclusive (or exclusive) numeric/parsed bounds | `invalid_range` | -| `[RegularExpression(pattern)]` | compiled `Regex` field, checked on non-empty strings | `invalid_string` | -| `[AllowedValues(…)]` | typed equality against the allowed set | `invalid_value` | -| `[DeniedValues(…)]` | typed equality against the denied set; on an enum property the values are absorbed into the automatic enum rule instead | `invalid_value` | -| `[EmailAddress]` | reuses `ZodSharp.Rules.EmailRule` on non-empty strings | `invalid_string` | -| `[Url]`, `[Phone]`, `[CreditCard]`, `[Base64String]` | reuse `UrlRule`, `PhoneRule`, `CreditCardRule`, `Base64StringRule` | `invalid_string` | -| `[Compare(otherProperty)]` | typed equality between two properties | `mismatch` | -| `[Display(Name=…)]` | not validated; `Name` is the display name in messages and `{0}` placeholders | — | - +| `[Required]` | `RequiredAttribute` semantics: a string is missing when `null`, empty or whitespace-only; `AllowEmptyStrings = true` rejects only `null` | `missing_field` / `invalid_type` | +| `[Length(min, max)]` | min/max size; strings, arrays (including jagged and rectangular), countable collections | `invalid_length` / `too_small`, `too_big` (both bounds) | +| `[StringLength(max)]`, `[StringLength(max, MinimumLength=min)]` | string size via direct `.Length` | `invalid_length` / `too_small`, `too_big` (both bounds) | +| `[MinLength(n)]` | checked only when `n > 0` | `too_small` / `too_small` | +| `[MaxLength(n)]` | checked only when `n >= 0` | `too_big` / `too_big` | +| `[Range(…)]` | inclusive or exclusive (`MinimumIsExclusive`/`MaximumIsExclusive`) bounds in `Lower`/`Upper`; origin from the value's Zod type | `invalid_range` / `too_small`, `too_big` | +| `[RegularExpression(pattern)]` | compiled `Regex` field, checked on non-empty strings; `pattern` parameter | `invalid_string` / `invalid_format` | +| `[AllowedValues(…)]` | typed equality against the allowed set; enums listed by name in `options` | `invalid_value` / `invalid_value` | +| `[DeniedValues(…)]` | typed equality against the denied set; on an enum property the values are absorbed into the automatic enum rule instead | `invalid_value` / `invalid_value` | +| `[EmailAddress]` | reuses `ZodSharp.Rules.EmailRule` on non-empty strings | `invalid_string` / `invalid_format` | +| `[Url]`, `[Phone]`, `[CreditCard]`, `[Base64String]` | reuse `UrlRule`, `PhoneRule`, `CreditCardRule`, `Base64StringRule` | `invalid_string` / `invalid_format` | +| `[Compare(otherProperty)]` | typed equality between two properties | `mismatch` / `invalid_value` | +| `[Display(Name=…)]`, `[Display(Name=…, ResourceType=…)]` | not validated; the display name in messages (`{displayName}`, `{0}`); `ResourceType` is read at runtime | — | +| `[DisplayName(…)]` | fallback display name when `[Display]` is absent (matching .NET 11's Blazor `DisplayName` component) | — | `[Length]` follows DataAnnotations null semantics: `null` is valid unless `[Required]` is also present. +## Attributes without a dedicated rule + +Any other `ValidationAttribute` still runs: `[CustomValidation]` on a property or the class, +`[FileExtensions]`, `[EnumDataType]`, and a consumer's own attribute are constructed in a generated +field and called through `GetValidationResult` (or `GetValidationResultAsync` for the .NET 11 +`AsyncValidationAttribute`). Class-level attributes are object-level rules and are collected from the +base chain; a non-multiple attribute is taken from its most derived declaration. Attributes already +mapped to a Zod rule are never run twice. + +`[SkipValidation]` (`Microsoft.Extensions.Validation`; classes, properties, parameters) removes a +class or property from generated validation, `Paths`/`JsonNames` and JSON Schema. A minimal-API +parameter or its type is skipped by the endpoint filter and the bridge. + `[RequiredZod]` — the generated `RequiredRule` attribute — mirrors `[Required]`: it is emitted *outside* the generator's non-null guard, so an absent value is reported as `missing_field` even though every other rule bound to the member is skipped for `null`. Its `AllowEmptyStrings` has the @@ -62,10 +75,11 @@ Size attributes generate direct `Length`/`Count` access where possible: - `IEnumerable` / `IEnumerable` → one counted pass via `CollectionCountHelper.GetCount`, with fast paths for `ICollection`, `IReadOnlyCollection` and non-generic `ICollection` -A structured size failure carries the same metadata as the runtime API: `Code` (`too_small` / -`too_big`), `Origin` (`string` for strings, `array` for arrays, `collection` for countable and -`IEnumerable` collections), `Minimum`/`Maximum` (the inclusive bound), `Inclusive` (`true`) and -`Path`. +A structured size failure carries the same metadata as the runtime API: `Code` (`invalid_length` for +`[Length]`/`[StringLength]`, `too_small`/`too_big` for `[MinLength]`/`[MaxLength]`), `Category` +(`too_small`/`too_big` by direction), `Origin` (`string` for strings, `record` for dictionaries, `array` +for arrays and every other countable or `IEnumerable` collection), `Lower`/`Upper` (both bounds for `[Length]` and +`[StringLength]`) and `FullPath`. ```csharp [ZodSchema] @@ -77,7 +91,7 @@ public sealed class Basket } var result = BasketSchema.Validate(new Basket { Items = ["apple"] }); -// Code == "too_small", Minimum == 2, Origin == "array", Inclusive == true +// Code == "invalid_length", Category == "too_small", Lower == ErrorBound(2, Inclusive: true) ``` ## `[Range]` @@ -94,11 +108,17 @@ Bounds are emitted as static typed fields and compared without running the attri ## Error messages -`ErrorMessage`, or `ErrorMessageResourceName` + `ErrorMessageResourceType`, are honoured, with `{0}` -(display name), `{1}` and `{2}` (bound) formatted through -`string.Format(CultureInfo.CurrentCulture, …)`. Supplying only one half of the resource name/type -pair is `ZODSGEN005`. - +Messages are formatted **at validation time** by `ZodMessageFormat` from a named template kept in +`ValidationError.MessageFormat`, with `displayName`, `member`, `declaringType` and the rule's values +(`minimum`, `maximum`, `pattern`, `options`, …) in `Parameters`. Formatting never throws: a stray brace +in a custom message renders literally. + +`ErrorMessage`, or `ErrorMessageResourceName` + `ErrorMessageResourceType`, replace the text only (the +code, category and bounds are kept). Positional placeholders keep their DataAnnotations meaning: `{0}` is +the display name and `{1}`/`{2}` the attribute's values - for `[Length]` `{1}` is the minimum and `{2}` +the maximum, as `LengthAttribute` formats them; for `[StringLength]` `{1}` is the maximum and `{2}` the +minimum. Supplying only one half of the resource name/type pair is `ZODSGEN005`; a placeholder the +attribute does not supply is `ZODSGEN052`. ## Applicability is a compile error, not a silent skip - `[Length]` with `min > max` → `ZODSGEN003`. diff --git a/.agents/skills/purview-zodsharp-source-generator/references/generated-api.md b/.agents/skills/purview-zodsharp-source-generator/references/generated-api.md new file mode 100644 index 0000000..32e1c44 --- /dev/null +++ b/.agents/skills/purview-zodsharp-source-generator/references/generated-api.md @@ -0,0 +1,101 @@ +# The generated validator API + +For a `[ZodSchema] partial class Order`, the generator emits these public members on `OrderSchema` +(all `static`): + +| Member | Purpose | +| --- | --- | +| `Validate(Order value)` | full synchronous validation; one generated method per member, then type rules, `IValidatableObject`, then the `OnZodValidate` hook | +| `ValidateAsync(Order value, IServiceProvider? services = null, CancellationToken cancellationToken = default)` | full validation including nested async schemas, `IAsyncValidatableObject`, `AsyncValidationAttribute` members and the custom async method | +| `const bool RequiresAsyncValidation` | `true` when anything in the type's graph is asynchronous | +| `IsValidatableMember(string memberName)` | whether the member has rules of its own | +| `ValidateMember(Order value, string memberName)` | the member's own rules only; an unknown member succeeds | +| `ValidateMemberAsync(value, memberName, services, cancellationToken)` | as above plus the member's async rules and any `[ZodAsyncMember]` custom method for it | +| `TryResolveMember(Order value, ZodPath path, out object? container, out string memberName)` | follows an error path to the object that owns the failing member | +| `Paths.{Member}` | `ZodPath` constants | +| `JsonNames.Get(string memberName)` | the member's `[JsonPropertyName]`, or `null` | +| `ToJsonNamedPath(ZodPath path, Func? namingPolicy = null)` | renames member segments to JSON names; members without `[JsonPropertyName]` use `namingPolicy` | +| `Parse`, `BuildJsonSchema`, `ApplyAnd`, `ApplyOr`, `ApplyRefine` | as before | + +`ValidateCore`/`ValidateCoreAsync`/`AppendJsonNamedPath` overloads with a `depth` or `offset` parameter +are public only so nested schemas can call them; do not call them directly. + +## Field-level semantics + +`ValidateMember` never recurses into a nested schema and never runs type-level rules, +`IValidatableObject` or refinements, so every error it returns belongs to the member that changed. +Collection members still check for `null` elements. Hosts reach it through `IZodMemberValidator` +or the untyped `IZodUntypedValidator.ValidateMemberAsync`. + +`TryResolveMember` returns `true` when it followed the full path. For a path ending in an index or key +it resolves to the owner of the collection member; for a path it cannot follow it resolves to the +nearest object reached. `memberName` is empty for a model-level error. + +```csharp +var result = OrderSchema.Validate(order); +foreach (var error in result.Errors) +{ + OrderSchema.TryResolveMember(order, error.FullPath, out var container, out var memberName); + // error.FullPath = Lines[0].Sku -> container = order.Lines[0], memberName = "Sku" +} +``` + +## JSON names + +```csharp +var jsonPath = OrderSchema.ToJsonNamedPath(error.FullPath, JsonNamingPolicy.CamelCase.ConvertName); +var key = jsonPath.ToDottedString(); // customer_name (from [JsonPropertyName]), lines[0].sku +``` + +The adapter implements `IZodJsonPathMapper`, which is what a host uses when it renders +`ZodPathNaming.Json` keys under trimming. + +## Registration + +Each assembly gets one registration class naming every generated validator: + +```csharp +// Without DI +IZodSchemaFactory factory = new ZodSchemaFactory(); +ZodSharp.Generated.ContosoOrdersApiZodSharpSchemas.Register(factory); + +// With DI (Microsoft.Extensions.DependencyInjection referenced) +services.AddZodSharpContosoOrdersApiSchemas(); +``` + +`AddZodSharp{Assembly}Schemas()` calls `services.AddZodSchemaRegistration(...)`, which also registers +the factory (`AddZodSharpFactory`) when it is missing; the factory applies every `ZodSchemaRegistration` +when it is first resolved. Unlike `RegisterFromAssembly` and `AddZodSharp(o => o.ScanAssemblies...)`, +nothing is discovered through metadata, so it is the form to use for trimmed and Native AOT apps, +including Blazor WebAssembly. + +## Async graph + +- Nested schemas' `ValidateAsync` is awaited and its errors are re-pathed under the member, so a nested + type's custom async method reports its errors (it used to be dropped). +- A custom async method may take `(T value, CancellationToken ct)` or + `(T value, IServiceProvider services, CancellationToken ct)`. The services are the ones passed to + `ValidateAsync`; calling `ValidateAsync` without services on a type with a service-aware method + throws `InvalidOperationException`. +- `[ZodAsyncMember(params string[] members)]` on the custom method makes `ValidateMemberAsync` run it + when one of the listed members is validated, keeping only the errors it reports for that member. +- The method must be reachable from the generated code: on the model type it must be `static` and + `internal` or `public` (`ZODSGEN014` for `private`). + +## Options validators + +The generated `{Type}Validator : IValidateOptions` reports each failure as +`"{path}: {message} ({code})"`. On .NET 11 it also implements `IAsyncValidateOptions` when the type +has asynchronous rules, so `ValidateOnStart()` runs them. + +## C# 15 unions and closed hierarchies + +A member typed as a C# union validates the active case with that case's schema; a member typed as a +closed class validates with the runtime derived type's schema. Error paths are those of the case (no +`Value` segment). Each derived type of a closed class needs `[ZodSchema]` (`ZODSGEN049`). + +`[ZodSchema]` on the union itself emits a schema that dispatches to the active case; a case with +`TryGetValue` is matched without boxing (the non-boxing access pattern), otherwise through `Value`. An +empty union or an active case with no validator fails as `invalid_union` (`ZODSGEN055` when a case type has +no validator). A union member's `[Required]`, `[RequiredZod]` and `[NonSentinel]` treat an empty union as +absent/default. diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index bc9bc27..1978504 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -17,7 +17,7 @@ "rollForward": false }, "purview.build": { - "version": "0.9.0", + "version": "0.9.1", "commands": [ "purview-build" ], diff --git a/.editorconfig b/.editorconfig index 2abb49f..229c475 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,4 @@ -# Purview.BuildSdk 2.0.0 - managed file, do not edit +# Purview.BuildSdk 2.0.1 - managed file, do not edit # # This file is written by Purview.BuildSdk and is replaced whenever the SDK version changes. The version # on the first line is how the SDK recognises a file it owns: a .editorconfig without that marker is @@ -2690,11 +2690,6 @@ dotnet_diagnostic.RCS1205.severity = none # PDS0004: Use correct acronym capitalization dotnet_diagnostic.PDS0004.severity = none -[**/SourceGenerator.UnitTests/Purview/Unions/**.{cs,vb}] -# The test-only stand-in for Purview.Unions' GenerateUnionAttribute must declare that package's namespace so -# the results generator matches it by metadata identity, exactly as it matches the packaged attribute. -dotnet_diagnostic.IDE0130.severity = none - [**/Extensions/**.{cs,vb}] # Files under the Extensions/ namespace-reset convention must not force developers to add pragmas; # suppress only the namespace-conflict diagnostics that arise from declaring framework-style diff --git a/AGENTS.md b/AGENTS.md index 564cac5..943a310 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,15 @@ generator that makes C# 15 union error cases ergonomic), and the ZodSharp and AS - The runtime packages target `net10.0` and `net11.0` only (Purview.BuildSdk's `Supported` set). .NET 8 and .NET 9 are out of support and must not be reintroduced. Runtime-adjacent packages are pinned to the lowest shared servicing band (`10.0.15`), never the newer `net11.0` band. +- Runtime async (`PurviewRuntimeAsync=true`) is enabled **only for a non-browser example on `net11.0`**. It is + forced off for every packable project, every test project and every browser-hosted example, because a shipped + assembly or a WebAssembly client must keep the stable async lowering. `Directory.Build.targets` derives this + before the SDK import (the SDK reads the switch as it imports), so a project cannot opt a packable or + WebAssembly assembly in by declaring the property. `Examples.Blazor.Wasm` is the stable-lowering proof; + `Examples.Blazor.Server` is the runtime-async proof. +- `PurviewMultiThreadedBuild=false` is deliberate: the .NET 11 GA WebAssembly SDK's `JsonToItemsTaskFactory` + does not support the out-of-process task host that `-mt` uses, and the solution contains a Blazor WebAssembly + example. `Directory.Build.rsp` carries `-mt:false`. - Compile with `LangVersion=latest`, never `preview`. C# 15 is the default for `net11.0`, and nothing here needs a preview language feature. Union declarations and `closed` hierarchies need the .NET 11 runtime types (`IUnion`/`UnionAttribute`, `IsClosedTypeAttribute`), so they belong in net11-only projects (examples, tests, @@ -127,6 +136,12 @@ including the diagnostics table, build properties and activation rules. source-visible public single-parameter constructors. Normalise a case exactly as the union generator exposes it (nullable value type → underlying type, nullable reference annotation stripped, constructed generics and arrays preserved). A `[GenerateUnion]` target with no source-visible case list is `RSG1001`, not `RSG1000`. +- **Both generators are proved in one compilation from the package.** `SourceGenerator.UnitTests` references + `Purview.Unions` and declares real `[GenerateUnion]` unions, so the results generator and the union generator + run side by side and `GenerateUnionDiscoveryRuntimeTests` executes the helpers against the generated + `TryGetValue` members. `Examples.Unions` is the consumer-facing proof. Do not reintroduce a stand-in + `Purview.Unions.GenerateUnionAttribute` in the test project; the isolated source-string tests exist only to + vary the case list per test, and they never replace the package-level integration. - **Keep the pipeline value-equatable.** Do not let `ISymbol`, `Compilation`, `SemanticModel`, `IOperation`, `SyntaxNode`, `SyntaxTree` or `Location` reach cached models; convert them during discovery (`ResultUnionModel`, `ResultUnionCaseModel`, `ResultSourceLocation`) and use `EquatableArray`. @@ -183,9 +198,8 @@ including the diagnostics table, build properties and activation rules. code fix needs `Microsoft.CodeAnalysis.*.Workspaces`, which must not enter the generator's analyzer closure. The code fix never references the generator and there is no `InternalsVisibleTo`; the generator references the code fix analyzer-style only (`ReferenceOutputAssembly="false"`), so no reference cycle can form, and the shared well-known names are pinned by the code-fix tests. -- Union-declaring test files (and files using the C# 15 `closed` modifier) are excluded from CSharpier through - `.csharpierignore`, because CSharpier 1.3.0 cannot parse that syntax. Add any new file that declares a union or a - closed hierarchy to that list. +- CSharpier 1.4.0 parses C# 15 union declarations and `closed` hierarchies, so union-declaring files are + formatted like every other file. There is no `.csharpierignore`; a new union file needs no exclusion. ## ASP.NET Core integration invariants @@ -312,6 +326,11 @@ including the diagnostics table, build properties and activation rules. `src/src/Examples.*` holds one runnable example per integration aspect, all on the Tenant* domain the READMEs document: `Examples.Basic` (the result type, the value-less `Result`, the generated `AsFailure()` and `AsFailure()` helpers, and the union-receiver `Union.Failure(...)`/`Union.Success(...)` factories), +`Examples.Unions` (`Purview.Unions` generating the union itself, read by `[GenerateResult]` in the same +compilation, including non-boxing `TryGetValue`, an exhaustive `switch`, and both generated helper forms), +`Examples.Blazor.Wasm` (a Blazor WebAssembly form validated by the compile-time `[ZodSchema]` generator and +folded into a result entirely in the browser, proving browser safety and the stable async lowering), +`Examples.Blazor.Server` (the same pipeline under interactive server rendering, with runtime async enabled), `Examples.Zod` (`Purview.Results.ZodSharp`, including `ToUnitResult`), `Examples.AspNetCore` (`Purview.Results.AspNetCore`, including a unit result answering `204` and the filter-free `ToHttpResult()` form), @@ -323,8 +342,12 @@ result and the HTTP mapping). `true`), keep them out of test discovery (no `*Tests` suffix, and they are not under `src/tests`), and keep each one's references equal to exactly the aspect it demonstrates. - Each example declares its own local union rather than sharing a domain project, matching how the test - projects declare `HttpTestError`/`ValidationTestError`. Anything that declares a union must be added to - `.csharpierignore`. + projects declare `HttpTestError`/`ValidationTestError`. `Examples.Unions` is the reference for a union + generated by `Purview.Unions`; every other example uses the language `union` declaration. +- The Blazor pair is deliberately two projects: `Examples.Blazor.Wasm` must stay browser-safe (both referenced + projects set `PurviewBrowserSafe=true`) and must never opt into runtime async, while `Examples.Blazor.Server` + exercises the same page with the feature on. Keep the domain and page behaviour identical apart from that + host difference. - The generator is referenced analyzer-style (`OutputItemType="Analyzer"`, `ReferenceOutputAssembly="false"`, `PrivateAssets="all"`), exactly as the test projects reference it. - When public behaviour changes, update the example that demonstrates it, the example's snippet in the root @@ -350,10 +373,10 @@ result and the HTTP mapping). ## Installed skills (`.agents/skills/`) Specialist guidance this repository depends on - the Purview engineering playbook, `Purview.BuildSdk` -configuration, the `Purview.SourceGeneratorFramework` generator and test APIs, ZodSharp, and Purview.ValueObjects -composition - is published by the repositories that own it, and installed here by the skills CLI. The installed skills are -**committed**: `.agents/skills/**` and the CLI's `skills-lock.json` (source, skill path and content hash of every -entry) are both in source, so a clone has them without running anything. +configuration, the `Purview.SourceGeneratorFramework` generator and test APIs, ZodSharp, Purview.ValueObjects +composition, and Purview.Unions modelling - is published by the repositories that own it, and installed here by the +skills CLI. The installed skills are **committed**: `.agents/skills/**` and the CLI's `skills-lock.json` (source, +skill path and content hash of every entry) are both in source, so a clone has them without running anything. | Skill | Source | Load before | | --- | --- | --- | @@ -363,15 +386,19 @@ entry) are both in source, so a clone has them without running anything. | `purview-testing-conventions` | `purview-dev/engineering-playbook` | writing or reviewing tests (TUnit, AAA, naming, builders) | | `purview-persistence` | `purview-dev/engineering-playbook` | choosing a storage approach, abstracting EF Core, or a GUID primary-key strategy | | `purview-telemetry` | `purview-dev/engineering-playbook` | adding or reviewing observability, including the results HTTP telemetry | -| `purview-buildsdk-*` (8 skills) | `purview-dev/build-sdk` | build/SDK configuration, project layout and detection, testing, skill installation and skill tests | +| `purview-component-compatibility` | `purview-dev/engineering-playbook` | choosing a published component's lowest compatible dependency version, or changing its target frameworks | +| `purview-org-harvest` | `purview-dev/engineering-playbook` | inventorying the organisation's repositories and comparing this one with the best-in-class | +| `purview-buildsdk-*` (9 skills) | `purview-dev/build-sdk` | build/SDK configuration, project layout and detection, testing, skill installation, skill tests and the skills Justfile recipes | | `purview-sourcegenerator-codewriter` | `purview-dev/sourcegenerator-framework` | writing or reviewing emitter code (`CodeWriter`, scopes) | | `purview-sourcegenerator-pipeline` | `purview-dev/sourcegenerator-framework` | changing the incremental pipeline or its value-equatable models | | `purview-sourcegenerator-diagnostics` | `purview-dev/sourcegenerator-framework` | adding or changing an `RSG` diagnostic or the analyzer | | `purview-sourcegenerator-packaging` | `purview-dev/sourcegenerator-framework` | changing how the generator and code fix are packed or merged | | `purview-sourcegenerator-testing` | `purview-dev/sourcegenerator-framework` | writing generator, analyzer or code-fix tests (`CodeQuery`) | | `purview-sourcegenerator-tunit-tests` | `purview-dev/sourcegenerator-framework` | the TUnit base classes and assertion extensions | -| `purview-zodsharp-*` (6 skills) | `purview-dev/zodsharp` | the ZodSharp bridge and ProblemDetails integration: `[ZodSchema]`, rules, JSON, ASP.NET Core, `ZODSGEN` diagnostics | +| `purview-sourcegenerator-attribute-data-models` | `purview-dev/sourcegenerator-framework` | declaring or fixing a `[Generate]` attribute-data model | +| `purview-zodsharp-*` (7 skills) | `purview-dev/zodsharp` | the ZodSharp bridge and ProblemDetails integration: `[ZodSchema]`, rules, JSON, ASP.NET Core, Blazor, `ZODSGEN` diagnostics | | `purview-value-objects*` (4 skills) | `purview-dev/value-objects` | the value-objects composition example, tests and packed consumer | +| `purview-unions-*` (4 skills) | `purview-dev/unions` | modelling a `[GenerateUnion]` union, its `UNI` diagnostics, and the Results/Telemetry/JSON interop | Skills are installed into the universal `.agents/skills/` location **only**. Never install copies into `.claude/` or any other per-agent folder, and never add a `CLAUDE.md`: `AGENTS.md` is the one instruction file, and @@ -384,11 +411,17 @@ npx -y skills@1 add purview-dev/build-sdk -s '*' -a universal -y npx -y skills@1 add purview-dev/sourcegenerator-framework -s '*' -a universal -y npx -y skills@1 add purview-dev/zodsharp -s '*' -a universal -y npx -y skills@1 add purview-dev/value-objects -s '*' -a universal -y +npx -y skills@1 add purview-dev/unions -s '*' -a universal -y # restore the committed set from skills-lock.json npx -y skills@1 experimental_install ``` +`just skills-update` runs the same re-add for every `skills-lock.json` source, `just skills-check` enforces the +universal-only layout, and `just skills-fix` removes the links the CLI leaves in directories +`PurviewSkillsAgents` does not select. They are the recipes in `Justfile.skills.just`, imported by the `Justfile`; +the update, install and fix targets refuse to run inside a build, so keep them off every other recipe. + Commit the changed `.agents/skills/**` and `skills-lock.json` together, in their own `chore` commit. Do not edit anything under `.agents/skills/` by hand - the CLI is its only writer, and a hand edit no longer matches the lockfile's hash. `Purview.BuildSdk`'s `InstallPurviewSkills` target delegates to the same CLI; @@ -459,8 +492,7 @@ package to an isolated feed and cache and publishes and runs, from the packages WebAssembly app (trimmed and `RunAOTCompilation`, in a headless browser), a Blazor Server app, a `net10.0` console and a Native AOT minimal API (handlers returning `ToHttpResult()`, plus a Purview.ZodSharp.Http client self-test), then leak-scans every output for compiler-host assemblies. It needs the Native AOT toolchain, the `wasm-tools` workload and Chrome or Edge; dependency versions not yet on nuget.org restore from -`LOCAL_NUGET_FEED_PATH`. Run it when the generator, packaging or a dependency version changes. The consumers' -union-declaring files are listed in `.csharpierignore` like every other union file. +`LOCAL_NUGET_FEED_PATH`. Run it when the generator, packaging or a dependency version changes. Local CI-equivalent validation: @@ -530,8 +562,8 @@ Before handing work back: - Add or update focused tests for code changes, including incremental-cache coverage for pipeline changes. - Update the affected package `Sdk/README.md`, the root `README.md`, `docs/wiki`, `AGENTS.md` and `AnalyzerReleases` when the change affects them. -- Watch for the packaging traps: `true` missing from a new package project, a new - union-declaring file missing from `.csharpierignore`, and a new diagnostic missing from +- Watch for the packaging traps: `true` missing from a new package project, an example + that references a package the aspect it demonstrates does not need, and a new diagnostic missing from `AnalyzerReleases.Unshipped.md`. - Run appropriate build, test, formatting and pack checks in proportion to risk. - State exactly what validation ran. If a check was skipped or blocked, give the concrete reason and the diff --git a/CHANGELOG.md b/CHANGELOG.md index d020ca9..c784b18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,18 @@ Released versions correspond to `v` GitHub releases. - **Lowest common runtime bits.** `Microsoft.AspNetCore.TestHost` and the `net10.0` runtime-adjacent version are pinned to `10.0.15`, the oldest servicing band the packages claim to support, rather than the newer `net11.0` band. +- **Purview.BuildSdk 2.0.1 and its .NET 11 feature switches.** The solution keeps the SDK's `Supported` target set + (`net10.0;net11.0`), takes the C# 15 memory-safety tier from `MemorySafetyRules` (default `2`, + `updated-memory-safety-rules`), and bootstraps `Directory.Build.rsp`. Runtime async is requested with + `PurviewRuntimeAsync=true` instead of a hand-written `Features` append, and is now enabled **only for a + non-browser example**: packable projects, tests and `Examples.Blazor.Wasm` are forced to the stable async + lowering even if a project declares the property. +- **The multithreaded build is off (`PurviewMultiThreadedBuild=false`, `-mt:false`).** The .NET 11 GA WebAssembly + SDK's `JsonToItemsTaskFactory` does not support the out-of-process task host that `-mt` uses, so a Blazor + WebAssembly project cannot build under it (`MSB4060`). The repository now carries a first-class Blazor + WebAssembly sample, so it takes the SDK's supported opt-out instead of excluding the sample. +- **Purview.ZodSharp 3.0.0-prerelease.6.** The bridge and the examples take the release that fixes the Blazor + WebAssembly `InvalidProgramException` caused by runtime async in the net11.0 assemblies. ### Added @@ -42,6 +54,17 @@ Released versions correspond to `v` GitHub releases. custom shape is the supported way to avoid that on a hot error path. - **Closed-hierarchy coverage.** A C# 15 `closed` hierarchy used as `TError`, switched exhaustively without a discard arm, is compiled and executed by the test suite. +- **The two-generator integration runs from the packages.** `SourceGenerator.UnitTests` references + `Purview.Unions` 1.0.0-prerelease.2, so real `[GenerateUnion]` unions and the results generator compile in one + project; the tests cover the `typeof` list, partial factory signatures and a nested `IUnionMembers` provider and + execute the generated `TryGetValue` members. A new `Examples.Unions` console example is the consumer proof, and + the previous hand-written `GenerateUnionAttribute` stand-in is gone. +- **Blazor WebAssembly and Blazor Server samples.** `Examples.Blazor.Wasm` and `Examples.Blazor.Server` run the + same page: a `[ZodSchema]`-validated tenant form folded into `Result` through + `Purview.Results.ZodSharp`, with the validation errors rendered from `ValidationError.FullPath`. The WebAssembly + sample is the browser-safety and stable-async proof; the server sample runs with runtime async enabled. + `Purview.Results` and `Purview.Results.ZodSharp` now declare `PurviewBrowserSafe=true`, so the SDK's trim, AOT + and browser-unsupported-API checks run on them. ### Fixed @@ -64,6 +87,8 @@ Released versions correspond to `v` GitHub releases. - README, wiki, package READMEs, `AGENTS.md` and the union skill now state that C# 15 unions are GA, need a `net11.0` target rather than `LangVersion=preview`, and explain the non-boxing custom-union option and closed hierarchies. +- The wiki's testing page describes the package-level two-generator integration instead of the retired stand-in, + and the contributing pages no longer mention a `.csharpierignore` (CSharpier 1.4.0 parses C# 15 unions). ## 1.1.0-prerelease.2 diff --git a/Directory.Packages.props b/Directory.Packages.props index 71e53d4..51c70ac 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,10 +8,22 @@ the build exercises the oldest runtime these packages promise to support. --> 10.0.15 + + 11.0.0 - 3.0.0-prerelease.5 - 2.0.0-prerelease.2 + 3.0.0-prerelease.6 + 2.0.0-prerelease.1 + + 1.0.0-prerelease.3 2.0.0 @@ -19,13 +31,14 @@ 1.83.0 - 2.0.0 + 2.0.1 + @@ -43,6 +56,12 @@ + + + diff --git a/Justfile b/Justfile index 602c7f5..e8d20cf 100644 --- a/Justfile +++ b/Justfile @@ -15,6 +15,8 @@ pipeline_tool := "dotnet purview-build" current_version := `bun -p "require('./package.json').version"` +import "Justfile.skills.just" + [private] default: just --list @@ -135,6 +137,21 @@ example-basic *args: example-zod *args: dotnet run --project src/src/Examples.Zod {{ args }} +# Run the Purview.Unions example ([GenerateUnion] and [GenerateResult] in one compilation, listening on none) +[group('Examples')] +example-unions *args: + dotnet run --project src/src/Examples.Unions {{ args }} + +# Run the Blazor WebAssembly example (results + ZodSharp validation in the browser), listening on http://localhost:5217 +[group('Examples')] +example-blazor-wasm *args: + dotnet run --project src/src/Examples.Blazor.Wasm --urls http://localhost:5217 {{ args }} + +# Run the Blazor Server example (the same pipeline with interactive server rendering), listening on http://localhost:5218 +[group('Examples')] +example-blazor-server *args: + dotnet run --project src/src/Examples.Blazor.Server --urls http://localhost:5218 {{ args }} + # Run the ASP.NET Core example (result-to-response mapping), listening on http://localhost:5215 [group('Examples')] example-aspnetcore *args: diff --git a/Justfile.skills.just b/Justfile.skills.just new file mode 100644 index 0000000..3bbe63e --- /dev/null +++ b/Justfile.skills.just @@ -0,0 +1,19 @@ +# Agent-skill recipes. Imported by the Justfile; every recipe names one Purview.BuildSdk target, which runs +# once for the whole solution. See AGENTS.md ("Installed skills") for the sources and the universal-only rule. + +skills_project := solution + +# Update agent skills: re-add every skills-lock.json source, refreshing installed skills and adding new ones +[group('Agent skills')] +skills-update *args: + dotnet msbuild "{{ skills_project }}" "-t:UpdatePurviewSkills;FixPurviewSkills" -v:n {{ args }} + +# Check that only the skill directories of the agents in PurviewSkillsAgents exist +[group('Agent skills')] +skills-check *args: + dotnet msbuild "{{ skills_project }}" -t:CheckPurviewSkills -v:n {{ args }} + +# Remove the skills CLI's links from agent directories PurviewSkillsAgents does not select +[group('Agent skills')] +skills-fix *args: + dotnet msbuild "{{ skills_project }}" -t:FixPurviewSkills -v:n {{ args }} diff --git a/README.md b/README.md index 7d758c7..30c96ec 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,9 @@ Tenancy domain the Quick start uses, so one error-union vocabulary drives every | Example | Packages | Demonstrates | | --- | --- | --- | | [`Examples.Basic`](src/src/Examples.Basic) | `Purview.Results` | States, `Match`/`Map`/`Bind`/`MapError`/`Ensure`, probing, the throw-on-misuse contract, the generated `AsFailure()` and `AsFailure()` helpers and the union-receiver `Union.Failure(...)`/`Union.Success(...)` factories, cross-service error-union widening, value-less `Result` commands, and `Throw()` | +| [`Examples.Unions`](src/src/Examples.Unions) | + `Purview.Unions` | A `[GenerateUnion]` union generated by `Purview.Unions` used as a `[GenerateResult]` error union, with both generators running in one compilation, non-boxing `TryGetValue`, an exhaustive `switch`, and the generated `AsFailure`/union-factory helpers | +| [`Examples.Blazor.Wasm`](src/src/Examples.Blazor.Wasm) | + `Purview.Results.ZodSharp`, `Purview.ZodSharp` | A Blazor WebAssembly form validated by the compile-time `[ZodSchema]` generator and folded into `Result` entirely in the browser; the proof that the pipeline is browser-safe and compiles without runtime async | +| [`Examples.Blazor.Server`](src/src/Examples.Blazor.Server) | + `Purview.Results.ZodSharp`, `Purview.ZodSharp` | The same validation-and-result pipeline under interactive server rendering, where runtime async is enabled | | [`Examples.Zod`](src/src/Examples.Zod) | + `Purview.Results.ZodSharp` | A `[ZodSchema]` input validated into a result, where the rejection carries its `ValidationError`s, and a value-discarding `ToUnitResult` validation | | [`Examples.AspNetCore`](src/src/Examples.AspNetCore) | + `Purview.Results.AspNetCore` | `AddResultsHttp`/`Map`/`WithResultsHttp`, the filter-free `ToHttpResult()` form, nested error-union leaf mapping, a successful unit result answering `204`, the mapping-gap and uninitialized-result paths | | [`Examples.AspNetCore.Zod`](src/src/Examples.AspNetCore.Zod) | + `Purview.Results.ZodSharp.AspNetCore` | A validation-carrying failure rendered as `HttpValidationProblemDetails`, with a case mapping winning over the fallback | @@ -104,7 +107,10 @@ Tenancy domain the Quick start uses, so one error-union vocabulary drives every ```bash dotnet run --project src/src/Examples.Basic +dotnet run --project src/src/Examples.Unions dotnet run --project src/src/Examples.Zod +dotnet run --project src/src/Examples.Blazor.Server --urls http://localhost:5218 +dotnet run --project src/src/Examples.Blazor.Wasm --urls http://localhost:5217 dotnet run --project src/src/Examples.AspNetCore --urls http://localhost:5215 dotnet run --project src/src/Examples.AspNetCore.Zod --urls http://localhost:5216 dotnet run --project src/src/Examples.ValueObjects.Zod @@ -364,6 +370,8 @@ just smoke-packed-consumers # the packed-consumer gate (Native AOT, Blazor WebAs ``` `just pipeline-pr` restores the repository's .NET local tools, which include the pinned `Purview.Build`. +`Directory.Build.rsp` carries `-mt:false` because the .NET 11 GA WebAssembly SDK cannot build under MSBuild's +multithreaded task host, and the solution contains a Blazor WebAssembly example. Local CI-equivalent validation: ```bash diff --git a/docs/wiki/Blazor-and-Native-AOT.md b/docs/wiki/Blazor-and-Native-AOT.md index ad59464..979ab6d 100644 --- a/docs/wiki/Blazor-and-Native-AOT.md +++ b/docs/wiki/Blazor-and-Native-AOT.md @@ -1,6 +1,8 @@ # Blazor and Native AOT All four packages are marked `IsAotCompatible` and build clean under the trim and AOT analyzers. +`Purview.Results` and `Purview.Results.ZodSharp` also set `PurviewBrowserSafe=true`, so the SDK's trim, AOT and +browser-unsupported-API checks run on them whenever a WebAssembly consumer references them. - `Purview.Results` and `Purview.Results.ZodSharp` contain no reflection, no dynamic code and no runtime type discovery, and the generator's `AsFailure` helpers and union-receiver factories are plain constructor calls. A @@ -18,6 +20,19 @@ it publishes a Native AOT console, a Native AOT minimal API (with a `Purview.Zod loaded in a headless browser), a Blazor Server app and a `net10.0` console from the `.nupkg` files alone, with warnings as errors, runs each one, and scans every output for compiler-host assemblies. +Two runnable examples show the same integration from the other side: + +- [`Examples.Blazor.Wasm`](https://github.com/purview-dev/results/tree/main/src/src/Examples.Blazor.Wasm) runs the + compile-time `[ZodSchema]` validator and the result pipeline entirely in the browser and is compiled with the + stable async lowering, because Mono has no runtime-async support. +- [`Examples.Blazor.Server`](https://github.com/purview-dev/results/tree/main/src/src/Examples.Blazor.Server) runs + the same page under interactive server rendering, where runtime async is enabled. + +```bash +dotnet run --project src/src/Examples.Blazor.Wasm --urls http://localhost:5217 +dotnet run --project src/src/Examples.Blazor.Server --urls http://localhost:5218 +``` + ## Target frameworks | Package | Target frameworks | diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md index 2d94234..47f566d 100644 --- a/docs/wiki/Contributing.md +++ b/docs/wiki/Contributing.md @@ -16,6 +16,9 @@ src/ ZodSharp/ ZodSharp ValidationResult bridge ZodSharp.AspNetCore/ Validation-problem mapping for validation-carrying failures Examples.Basic/ Runnable examples, one project per integration aspect + Examples.Unions/ + Examples.Blazor.Server/ + Examples.Blazor.Wasm/ Examples.Zod/ Examples.AspNetCore/ Examples.AspNetCore.Zod/ @@ -102,8 +105,8 @@ Conventions: ## Packaging traps - A project is packable **only** when its own `.csproj` declares `true`. -- A new union-declaring file (or a file that uses the C# 15 `closed` modifier) must be added to `.csharpierignore`, - because CSharpier 1.3.0 cannot parse that syntax yet. +- A new union-declaring file needs no formatter exclusion: CSharpier 1.4.0 parses C# 15 unions and `closed` + hierarchies. - A new diagnostic must be added to `AnalyzerReleases.Unshipped.md`. - A property the generator reads must be declared as a `CompilerVisibleProperty` and shipped in `Sdk/buildTransitive/*.props`. diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index b62de89..10dcfd2 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -162,7 +162,10 @@ Every example is a non-packable project under `src/src/Examples.*`, built on the ```bash dotnet run --project src/src/Examples.Basic +dotnet run --project src/src/Examples.Unions dotnet run --project src/src/Examples.Zod +dotnet run --project src/src/Examples.Blazor.Server --urls http://localhost:5218 +dotnet run --project src/src/Examples.Blazor.Wasm --urls http://localhost:5217 dotnet run --project src/src/Examples.AspNetCore --urls http://localhost:5215 dotnet run --project src/src/Examples.AspNetCore.Zod --urls http://localhost:5216 dotnet run --project src/src/Examples.ValueObjects.Zod diff --git a/docs/wiki/Guarantees-and-Limitations.md b/docs/wiki/Guarantees-and-Limitations.md index 2a37a9b..7de3615 100644 --- a/docs/wiki/Guarantees-and-Limitations.md +++ b/docs/wiki/Guarantees-and-Limitations.md @@ -106,7 +106,9 @@ reference, so no telemetry package dependency is added. member providers that declare a public static single-parameter `Create` per case. A bare union type parameter case gets the union-receiver factory only. A case that names a type parameter the union does not declare is `RSG1002`; an - array case is `RSG1009`; a provider with no usable `Create` is `RSG1007`. + array case is `RSG1009`; a provider with no usable `Create` is `RSG1007`. The `[GenerateUnion]` path is proved + end to end against the packaged generators (`Purview.Unions` 1.0.0-prerelease.2) in + `SourceGenerator.UnitTests` and `Examples.Unions`; `Purview.Results` takes no dependency on it. - Generated implicit conversions are not possible — see [Union Errors](Union-Errors.md) for the five compiler rules. The per-case `AsFailure()` and `AsFailure()` helpers, the union-receiver `Union.Failure(...)`/`Union.Success(...)` factories and the IDE code fix are the ergonomics the language allows; diff --git a/docs/wiki/Testing.md b/docs/wiki/Testing.md index 648ef3f..b5765b0 100644 --- a/docs/wiki/Testing.md +++ b/docs/wiki/Testing.md @@ -19,10 +19,11 @@ dotnet test src/Results.slnx --no-build --treenode-filter "/*/*/*/*[Category=Uni The `*.UnitTests` project naming is what the SDK uses to derive `TestingType=Unit` and apply the matching test category, which is what the filter matches. Use `--treenode-filter` for filtering — not `dotnet test --filter`. -The non-packable `net11.0` projects (tests and examples) compile with runtime async -(`Features=runtime-async=on`), which is still a preview feature in .NET 11 but gives cleaner live call stacks -and lower async overhead while debugging. The shipped libraries deliberately do not: Blazor WebAssembly's Mono -runtime does not implement it. The tests therefore call the same stable lowering a consumer runs. +The non-packable `net11.0` examples compile with runtime async +(`PurviewRuntimeAsync=true`, which appends `runtime-async=on` to `Features`) unless they are browser-hosted: +Blazor WebAssembly's Mono runtime does not implement it, so `Examples.Blazor.Wasm` compiles with the stable +lowering. The shipped libraries and the test projects never opt in. The tests therefore call the same stable +lowering a consumer runs. ## Test projects @@ -55,12 +56,13 @@ code with the framework's `CodeQuery` and terminal assertion extensions (`result it drives `GenerateIncrementalAsync`, asserts `New` on the first run, `Cached`/`Unchanged` on an identical rerun, and `Modified` only for the stages whose inputs changed. -**Cross-generator discovery is covered by a source-visible stand-in.** `GenerateUnionDiscoveryTests` declares a -stand-in `Purview.Unions.GenerateUnionAttribute` (and source-visible conversions where the generated helpers need -them), because `Purview.Unions` is not on a CI-safe package feed yet. It exercises the `typeof` list, partial -factory signatures, an included `[GenerateUnion]` case and the `RSG1001` gap; `GenerateUnionDiscoveryRuntimeTests` -executes the helpers against the real result types. Referencing `Purview.Unions` so both generators run in one -compilation is recorded as a follow-up for when that package is on the feed the CI can restore from. +**Cross-generator discovery runs against the packaged generators.** `SourceGenerator.UnitTests` references +`Purview.Unions`, so `GeneratedUnionDiscoveryTestTypes` declares real `[GenerateUnion]` unions and the results +generator runs in the same compilation as the union generator. The test project therefore covers the `typeof` +list, partial factory signatures and a nested `IUnionMembers` provider end to end; `GenerateUnionDiscoveryRuntimeTests` +executes the generated helpers and probes the generated `TryGetValue` members against the real result types. +`GenerateUnionDiscoveryTests` keeps the isolated source-string cases (including the `RSG1001` gap and an included +`[GenerateUnion]` case), because a focused compilation is the only way to vary the case list per test. ## Compiler experiments diff --git a/docs/wiki/Union-Errors.md b/docs/wiki/Union-Errors.md index 6643aa2..98f77e4 100644 --- a/docs/wiki/Union-Errors.md +++ b/docs/wiki/Union-Errors.md @@ -230,6 +230,11 @@ public single-parameter constructors. It uses the first non-empty list. A generi use one of the last three surfaces, because `typeof(T)` is not a legal attribute argument; a `[GenerateUnion]` union with no source-visible case list is reported as `RSG1001`. +That integration is verified against the packaged generators: `src/tests/SourceGenerator.UnitTests` references +`Purview.Unions` and declares real `[GenerateUnion]` unions alongside `[GenerateResult]`, and +`Examples.Unions` is the runnable version. The shipped results generator still has no dependency on +`Purview.Unions`; it matches the attribute by metadata identity. + ## Generic unions diff --git a/global.json b/global.json index a81043e..0abc88c 100644 --- a/global.json +++ b/global.json @@ -4,7 +4,7 @@ "rollForward": "latestMajor" }, "msbuild-sdks": { - "Purview.BuildSdk": "2.0.0" + "Purview.BuildSdk": "2.0.1" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/scripts/packed-consumers/AotConsole/Model.cs b/scripts/packed-consumers/AotConsole/Model.cs index 6d0ad71..f3fa450 100644 --- a/scripts/packed-consumers/AotConsole/Model.cs +++ b/scripts/packed-consumers/AotConsole/Model.cs @@ -1,4 +1,4 @@ -// Declares a C# 15 union, which CSharpier cannot parse yet, so this file is listed in .csharpierignore. +// Declares a C# 15 union; CSharpier 1.4.0 parses the syntax, so the file needs no formatter exclusion. using System.Collections.Immutable; using System.ComponentModel.DataAnnotations; using Purview.Results; diff --git a/scripts/packed-consumers/Shared/TenantError.cs b/scripts/packed-consumers/Shared/TenantError.cs index 1d426be..e7c330f 100644 --- a/scripts/packed-consumers/Shared/TenantError.cs +++ b/scripts/packed-consumers/Shared/TenantError.cs @@ -1,5 +1,5 @@ -// Shared by the Blazor consumers (copied in by scripts/test-packed-consumers.ps1). Declares a C# 15 union, which -// CSharpier cannot parse yet, so this file is listed in .csharpierignore. +// Shared by the Blazor consumers (copied in by scripts/test-packed-consumers.ps1). Declares a C# 15 union; +// CSharpier 1.4.0 parses the syntax, so the file needs no formatter exclusion. using System.Collections.Immutable; using Purview.Results; using Purview.Results.ZodSharp; diff --git a/skills-lock.json b/skills-lock.json index 806c0b7..baa0e28 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,185 +1,239 @@ { - "version": 1, + "version": 2, "skills": { "purview-architecture": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-architecture/SKILL.md", - "computedHash": "d194bd0f30091ffab6ef35c29582e56511d2daca32fdbb1942c50134198f9270" + "computedHash": "2500e935ff2ccb350f26ebb9be321dc9f99800121aa9412fdc597f95c2c465c8" }, "purview-architecture-testing": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-architecture-testing/SKILL.md", - "computedHash": "5907c6bf28788b755b752041e2f0cd88222604a949988d487c25940b6284fce4" + "computedHash": "23af6d9baeb408e1e3f4034cef6539d7efa3fecb1b4da918cb539bd8dfbd5a00" }, "purview-buildsdk-adoption": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-adoption/SKILL.md", - "computedHash": "ca2e13fc8074281f8ca2b510b742ae99db28f97d78f5fb36a6f27936a91941fc" + "computedHash": "5ddad1fe7d1dc65c1b352743e5cd616d4720a33228b06faa6259572cd4a8c265" }, "purview-buildsdk-configuration": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-configuration/SKILL.md", - "computedHash": "88b4a655efdc058b9a10adb1b5cbcd44e89bff6ed8c858e066092e5be0ca3e0b" + "computedHash": "91c4997f20ca3f4b66202e8f4c50c9fb224a57622c953a26dd8df8d0ef957cd9" }, "purview-buildsdk-project-detection": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-project-detection/SKILL.md", - "computedHash": "7c30062fe592105b7a2539384843759521c5a3f90ace8f7c60f0e6ae2405b0b1" + "computedHash": "9a3cf78ad4df9c335780bbbfbc9f66f7986b6293359aab19edaadb5c79f70be8" }, "purview-buildsdk-project-layout": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-project-layout/SKILL.md", - "computedHash": "101f09c92cf97e93387b926c48adab9598a0cf0a64a4932d41085ab0a0078cfa" + "computedHash": "d8b1fb6b95ae5e91f81734616f70a9f3d2d7547dc7406e4eda0f8d674ebf808b" }, "purview-buildsdk-repository-review": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-repository-review/SKILL.md", - "computedHash": "d366821995ff410caa436ccb996eaa6429f1ba51ba985b210d74ec3963f6c06f" + "computedHash": "babfe657a1eef5be67dc1e4ec387e9e8215fb0263956c4c926465fa27b648604" }, "purview-buildsdk-skill-installation": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-skill-installation/SKILL.md", - "computedHash": "b61cce9f0f213469c3acab4dd5ebaec450ee2be730680b166c9718c901aedffe" + "computedHash": "8e1dc7c0f499eb742264d5cc293784758c5f0499f849671a0be3353f91f89491" }, "purview-buildsdk-skill-tests": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-skill-tests/SKILL.md", - "computedHash": "807b64466de0a871e5f8e678e7686c48a06d8bb9d42a03448f604a35d40d3935" + "computedHash": "27b3520ced5c5a8009cfb47149f0fc1826246275d3ec2991667e1543b011d657" + }, + "purview-buildsdk-skills-justfile": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-skills-justfile/SKILL.md", + "computedHash": "a41f7bbd28333e7f2e40cadbbdd35a75b2f626d72e53901cbc79c7325ac2e50c" }, "purview-buildsdk-testing": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-testing/SKILL.md", - "computedHash": "d6a2ac1167faf68e8c27493de81c2e5b0d8183e523de4411aca5f5b9249c8a9c" + "computedHash": "e7e00ede43f5ee8aa4517329e4fdbde00a5a01bd8e98ea42990c851060bcaf9e" + }, + "purview-component-compatibility": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-component-compatibility/SKILL.md", + "computedHash": "070634caa90d032e2319d6799edbee973be0f9380008917474909328e74f143b" }, "purview-engineering-principles": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-engineering-principles/SKILL.md", - "computedHash": "fd38d374b816ecf77964c41486e7c734ea34998c3f2a5538c90a2ba793905994" + "computedHash": "d1b0d2b50e635160f96d03d2790cb725242434bb28b1b47913371bb39d83215b" + }, + "purview-org-harvest": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-org-harvest/SKILL.md", + "computedHash": "040b34d9cd41f23e71dbe1500b0748793377703d1032e1135ed0a3eeeee4a7f8" }, "purview-persistence": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-persistence/SKILL.md", - "computedHash": "0523ecc210bf3571e2fccb859128a499fd80694e9d4089b377a8d39d73c028f4" + "computedHash": "28e92fa8bccc241ca864b6731148bd721f47699baf3a1a2ec07cce42ed650bae" + }, + "purview-sourcegenerator-attribute-data-models": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-attribute-data-models/SKILL.md", + "computedHash": "475bf4ef2c6e5c29e02725383f97fb0aea60243c05a2d779b03533c392c6d395" }, "purview-sourcegenerator-codewriter": { "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-codewriter/SKILL.md", - "computedHash": "f24ae680340784e62236d044cbc3658d5ca7bdc3cea3fc44f2bb5cf75a84f5e4" + "computedHash": "5874c7773f395a5fea123f447bc500a687760f256eb5f7646022f9fbd22cd111" }, "purview-sourcegenerator-diagnostics": { "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-diagnostics/SKILL.md", - "computedHash": "c3c7920701e0e688f34ddece9e448472e010e69767e8c08914638128d34a8f17" + "computedHash": "8e3571221c2a2225f3826b0c501cd28ea2a27e91defd121f45117c0e36a308fd" }, "purview-sourcegenerator-packaging": { "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-packaging/SKILL.md", - "computedHash": "e0e6372e92aefc8df026a73010d9e7436b0a8c2378a96fdebd8b4de9e025d4a0" + "computedHash": "6044272af5982bbf9750a198ca8094c17106579de36dc259712cedc166b7b2fb" }, "purview-sourcegenerator-pipeline": { "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-pipeline/SKILL.md", - "computedHash": "7d48394d7e3890f288949c79f040646207f34af9ae85827a63d056bae1b1b985" + "computedHash": "62835d55eab1ccf233241796d34d17bfd7b2fbe4be03424bf9a3a40d8ad2e6a7" }, "purview-sourcegenerator-testing": { "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-testing/SKILL.md", - "computedHash": "d88bf2353073013ff9ddf9d3fe1a2e9415eb60d319bbd053baa105f50fa3f889" + "computedHash": "ef37df157bd9a589d487e79cffdecc4160aee59ca9000aea55d7204af097e883" }, "purview-sourcegenerator-tunit-tests": { "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-tunit-tests/SKILL.md", - "computedHash": "14809d3727643e9a779d590b6627b3283af7b36c835e902cb80162552cb45b4f" + "computedHash": "29f966d9331a556bc3a7d5aa8ad8fe7b1a9091557d629e9c23f65ebd14e3aee7" }, "purview-telemetry": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-telemetry/SKILL.md", - "computedHash": "08ba26e1379dfa2bb826ddfd011e95ac84cd583b865f0b89673d7302b2c2c27d" + "computedHash": "f92aab10e3d2be7b24be559316537ba7d0485d0e479c67f4d5c6126f5f2fc652" }, "purview-testing-conventions": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-testing-conventions/SKILL.md", - "computedHash": "388b0a79b565ed45a8513d77e8ed6e3d4491f96b43328cb09933cec7811c10f6" + "computedHash": "e053513db856852a23aad0e22625e1408fdd63377acb793e6d720fe37b9f7214" + }, + "purview-unions-diagnostics": { + "source": "purview-dev/unions", + "sourceType": "github", + "skillPath": "skills/purview-unions-diagnostics/SKILL.md", + "computedHash": "e7379533cceafa38713b163d6675cafda1e9e5f4e4f5070382b35a179d4b2f5f" + }, + "purview-unions-interop": { + "source": "purview-dev/unions", + "sourceType": "github", + "skillPath": "skills/purview-unions-interop/SKILL.md", + "computedHash": "ac1f8899bb26198efa413190e46ad8cdc96bceb853dce84d6506d9d4934b7df5" + }, + "purview-unions-modelling": { + "source": "purview-dev/unions", + "sourceType": "github", + "skillPath": "skills/purview-unions-modelling/SKILL.md", + "computedHash": "31527414e943f3681dfe77ce1bf49837612a2b67dc206bbb9fdab24b78de300b" + }, + "purview-unions-quickstart": { + "source": "purview-dev/unions", + "sourceType": "github", + "skillPath": "skills/purview-unions-quickstart/SKILL.md", + "computedHash": "d7026a87701f002796e5d3459ce3f9dd453d0dafcee248b817f17ffa13b903a1" }, "purview-value-objects": { "source": "purview-dev/value-objects", "sourceType": "github", "skillPath": "skills/purview-value-objects/SKILL.md", - "computedHash": "8d7c2474bfa729a19c8b03537550ca369d5b727915bba278912666030d774a7d" + "computedHash": "16a5a2156b1d5fb5a2b25923a78a3c22e7540724ffb8ca732da983bade1f344b" }, "purview-value-objects-diagnostics": { "source": "purview-dev/value-objects", "sourceType": "github", "skillPath": "skills/purview-value-objects-diagnostics/SKILL.md", - "computedHash": "148557f3811631efb3e57bc64e4a6f60cebc3d32676d7c489874e79de2d090ca" + "computedHash": "b5b3d2a2c3b70f8027df328ce9bce3540d1e00cddf6ec9719a25c6a06237588e" }, "purview-value-objects-efcore": { "source": "purview-dev/value-objects", "sourceType": "github", "skillPath": "skills/purview-value-objects-efcore/SKILL.md", - "computedHash": "9c7a8ab0f8299c71691f6810222df584b43a9424dcafa193c2eef8755c4cd13e" + "computedHash": "99626e0da21d559399e4911927c4ee30ec65f646df53a62d3f8d35522c1c0433" }, "purview-value-objects-zodsharp": { "source": "purview-dev/value-objects", "sourceType": "github", "skillPath": "skills/purview-value-objects-zodsharp/SKILL.md", - "computedHash": "e42f9ee98ce66b59ae4ee0d7037d683de232955b35c627cb6bab13ac08204134" + "computedHash": "7a0f0cb8c6c105ecec6de91976ba9283dbc1e9d010e237c30ec707abe7537ba4" }, "purview-zodsharp-aspnetcore": { "source": "purview-dev/zodsharp", "sourceType": "github", "skillPath": "skills/purview-zodsharp-aspnetcore/SKILL.md", - "computedHash": "a1c1f9fc008be609cb12622ca08022965497fe306cc1b4203333440a0edb4206" + "computedHash": "5eb63e5679a9e277a83a06ef65d74abe59055831bebd452d6838e24de0b7c98b" + }, + "purview-zodsharp-blazor": { + "source": "purview-dev/zodsharp", + "sourceType": "github", + "skillPath": "skills/purview-zodsharp-blazor/SKILL.md", + "computedHash": "9b82c48aa59517088b6c194519001030e9f4a7422768159fad0526fbe3dffe44" }, "purview-zodsharp-core": { "source": "purview-dev/zodsharp", "sourceType": "github", "skillPath": "skills/purview-zodsharp-core/SKILL.md", - "computedHash": "e89c1000bb305420b85c1b93f3f77d9b500fc6b7d52b7016f670430fab1659b0" + "computedHash": "f5f42e314139495d5975111c9c56a5aa8c08cc8087b6a927abcbfe22fb149c29" }, "purview-zodsharp-custom-rules": { "source": "purview-dev/zodsharp", "sourceType": "github", "skillPath": "skills/purview-zodsharp-custom-rules/SKILL.md", - "computedHash": "3726a476b75b842daf67898e19b197331a1195061053183d66e9e05a66294266" + "computedHash": "033b0c0491e7abeb46090a261ad853fae063709eeaa7ad8167437de1888828d1" }, "purview-zodsharp-diagnostics": { "source": "purview-dev/zodsharp", "sourceType": "github", "skillPath": "skills/purview-zodsharp-diagnostics/SKILL.md", - "computedHash": "19e480b1eca26e417642b8c55f689580eb7b741f02e78387041ffebba1216ddb" + "computedHash": "f51b1926c21f92f8af14da70788a5bbf8e2ba1ad6305436ab4d91f72649a27b3" }, "purview-zodsharp-json": { "source": "purview-dev/zodsharp", "sourceType": "github", "skillPath": "skills/purview-zodsharp-json/SKILL.md", - "computedHash": "82da551adff5506232d5419d925ed198d71844f7901ad13ef7a3a0bead4a826f" + "computedHash": "105a094e56bf712a95d2b666c50e58c782429db189029a4eb8ca5df8136fb955" }, "purview-zodsharp-source-generator": { "source": "purview-dev/zodsharp", "sourceType": "github", "skillPath": "skills/purview-zodsharp-source-generator/SKILL.md", - "computedHash": "7172450983d6130c3335276fdcec80ca87b473ab7fbb43dfd08304b55c1632b0" + "computedHash": "207b891a41d074d116e23e8155c8f440a7976e05996b973376dce4f1df220155" } } } diff --git a/skills/purview-results-union-errors/SKILL.md b/skills/purview-results-union-errors/SKILL.md index ae7efc7..f3aa0a0 100644 --- a/skills/purview-results-union-errors/SKILL.md +++ b/skills/purview-results-union-errors/SKILL.md @@ -119,6 +119,11 @@ when another generator (such as telemetry) also needs a source-visible list. Cas union generator exposes them: nullable value type → underlying type, nullable reference annotation stripped, constructed generics and arrays preserved. +That integration is verified against the packaged generators: this repository's `SourceGenerator.UnitTests` +references `Purview.Unions` and declares real `[GenerateUnion]` unions beside `[GenerateResult]`, and +`Examples.Unions` runs the same pairing. `Purview.Results` itself never takes a package reference on +`Purview.Unions`. + ## Chaining across services When one service calls another, the caller's operation-family union takes the callee's **error union** as a case, diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 02e580e..35f58e8 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -28,9 +28,14 @@ latest true + + false diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index aabdfe2..b08d1ab 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -1,27 +1,40 @@ - - - - $(Features);runtime-async=on + + <_PurviewResultsProjectName>$([System.String]::Copy('$(MSBuildProjectName)').ToLowerInvariant()) + <_PurviewResultsIsExample>$([System.String]::Copy('$(MSBuildProjectName)').StartsWith('Examples.')) + <_PurviewResultsIsBrowserHosted + Condition="'$(UsingMicrosoftNETSdkBlazorWebAssembly)' == 'true' OR '$(PurviewBrowserSafe)' == 'true' OR $([System.String]::Copy('$(_PurviewResultsProjectName)').EndsWith('.wasm')) OR $([System.String]::Copy('$(_PurviewResultsProjectName)').EndsWith('.browser')) OR $([System.String]::Copy('$(_PurviewResultsProjectName)').EndsWith('.client')) OR $([System.String]::Copy('$(_PurviewResultsProjectName)').EndsWith('.webassembly')) OR $([System.String]::Copy('$(_PurviewResultsProjectName)').EndsWith('.components'))" + >true + false + true + + + + net11.0 + + + + enable + enable + true + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/src/Examples.Blazor.Server/Program.cs b/src/src/Examples.Blazor.Server/Program.cs new file mode 100644 index 0000000..12d6046 --- /dev/null +++ b/src/src/Examples.Blazor.Server/Program.cs @@ -0,0 +1,19 @@ +using Purview.Results.Examples.Blazor.Server.Components; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddRazorComponents().AddInteractiveServerComponents(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); +} +app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); +app.MapStaticAssets(); +app.MapRazorComponents().AddInteractiveServerRenderMode(); + +app.Run(); diff --git a/src/src/Examples.Blazor.Server/Properties/launchSettings.json b/src/src/Examples.Blazor.Server/Properties/launchSettings.json new file mode 100644 index 0000000..0280d50 --- /dev/null +++ b/src/src/Examples.Blazor.Server/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5006", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } + } diff --git a/src/src/Examples.Blazor.Server/Tenant.cs b/src/src/Examples.Blazor.Server/Tenant.cs new file mode 100644 index 0000000..9bebb43 --- /dev/null +++ b/src/src/Examples.Blazor.Server/Tenant.cs @@ -0,0 +1,14 @@ +namespace Purview.Results.Examples.Blazor.Server; + +/// +/// The identifier of a tenant. +/// +/// The identifier value. +readonly record struct TenantId(string Value); + +/// +/// A tenant. +/// +/// The identifier of the tenant. +/// The display name of the tenant. +readonly record struct Tenant(TenantId Id, string Name); diff --git a/src/src/Examples.Blazor.Server/TenantDirectory.cs b/src/src/Examples.Blazor.Server/TenantDirectory.cs new file mode 100644 index 0000000..cbb88e1 --- /dev/null +++ b/src/src/Examples.Blazor.Server/TenantDirectory.cs @@ -0,0 +1,45 @@ +using Purview.Results.ZodSharp; + +namespace Purview.Results.Examples.Blazor.Server; + +/// +/// A tenant directory the Blazor sample runs entirely in the browser. +/// +static class TenantDirectory +{ + static readonly HashSet Existing = [with(StringComparer.OrdinalIgnoreCase), "acme"]; + + /// + /// Validates a submitted tenant and folds the validation outcome into a result. + /// + /// The submitted form. + /// The registered tenant, or the union case describing why it was rejected. + /// + /// ToResult carries the validation errors into , and the generated + /// AsFailure<TValue>() helper turns a domain case into the same result type. No exception is + /// thrown for either an invalid form or an existing tenant; both are ordinary values the component renders. + /// + public static Result Register(TenantInput input) => + TenantInputSchema + .Validate(input) + .ToResult(errors => new TenantInputInvalid(errors)) + .Bind(valid => + Existing.Contains(valid.TenantId!) + ? new TenantAlreadyExists(new TenantId(valid.TenantId!)).AsFailure() + : (Result)new Tenant(new TenantId(valid.TenantId!), valid.Name!) + ); + + /// + /// Looks up a tenant by identifier. + /// + /// The identifier to look up. + /// The tenant, or as a result failure. + public static Result Find(string tenantId) + { + TenantId id = new(tenantId); + + return Existing.Contains(tenantId) + ? (Result)new Tenant(id, tenantId.ToUpperInvariant()) + : new TenantNotFound(id).AsFailure(); + } +} diff --git a/src/src/Examples.Blazor.Server/TenantError.cs b/src/src/Examples.Blazor.Server/TenantError.cs new file mode 100644 index 0000000..59335d0 --- /dev/null +++ b/src/src/Examples.Blazor.Server/TenantError.cs @@ -0,0 +1,37 @@ +using System.Collections.Immutable; +using Purview.Results.ZodSharp; +using ZodSharp.Core; + +namespace Purview.Results.Examples.Blazor.Server; + +/// +/// The expected failures of a tenant operation. +/// +/// +/// The union is the error type of Result<Tenant, TenantError>. TenantInputInvalid carries the +/// ZodSharp errors, and implements so a host can render them as a +/// validation problem without knowing this sample's error type. +/// +[GenerateResult] +readonly union TenantError(TenantInputInvalid, TenantNotFound, TenantAlreadyExists); + +/// +/// The submitted input failed its [ZodSchema] validation. +/// +/// The validation errors reported by the schema. +readonly record struct TenantInputInvalid(ImmutableArray Errors) : IValidationErrorCarrier +{ + ImmutableArray IValidationErrorCarrier.ValidationErrors => Errors; +} + +/// +/// The requested tenant does not exist. +/// +/// The identifier of the tenant. +readonly record struct TenantNotFound(TenantId TenantId); + +/// +/// A tenant with the requested identifier already exists. +/// +/// The identifier of the tenant. +readonly record struct TenantAlreadyExists(TenantId TenantId); diff --git a/src/src/Examples.Blazor.Server/TenantInput.cs b/src/src/Examples.Blazor.Server/TenantInput.cs new file mode 100644 index 0000000..7c9718d --- /dev/null +++ b/src/src/Examples.Blazor.Server/TenantInput.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; +using ZodSharp; + +namespace Purview.Results.Examples.Blazor.Server; + +/// +/// The form the Blazor sample submits. +/// +/// +/// [ZodSchema] emits TenantInputSchema at compile time, so validation runs with no reflection and +/// is safe in a trimmed WebAssembly client. +/// +[ZodSchema] +sealed class TenantInput +{ + /// Gets or sets the identifier of the tenant. + [Required] + [StringLength(40, MinimumLength = 2)] + public string? TenantId { get; set; } + + /// Gets or sets the display name of the tenant. + [Required] + [StringLength(60, MinimumLength = 2)] + public string? Name { get; set; } +} diff --git a/src/src/Examples.Blazor.Server/appsettings.Development.json b/src/src/Examples.Blazor.Server/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/src/Examples.Blazor.Server/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/src/Examples.Blazor.Server/appsettings.json b/src/src/Examples.Blazor.Server/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/src/src/Examples.Blazor.Server/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/src/Examples.Blazor.Server/wwwroot/app.css b/src/src/Examples.Blazor.Server/wwwroot/app.css new file mode 100644 index 0000000..3076dc3 --- /dev/null +++ b/src/src/Examples.Blazor.Server/wwwroot/app.css @@ -0,0 +1,67 @@ +h1:focus { + outline: none; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid #e50000; +} + +.validation-message { + color: #e50000; +} + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.darker-border-checkbox.form-check-input { + border-color: #929292; +} + +.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { + color: var(--bs-secondary-color); + text-align: end; +} + +.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { + text-align: start; +} + +.form { + display: grid; + gap: 0.75rem; + max-width: 28rem; +} + +.form label { + display: grid; + gap: 0.25rem; +} + +.form input { + padding: 0.4rem 0.5rem; + font: inherit; +} + +.buttons { + display: flex; + gap: 0.5rem; +} + +.result { + margin-top: 1.25rem; +} + +.errors { + color: #b32121; +} diff --git a/src/src/Examples.Blazor.Wasm/App.razor b/src/src/Examples.Blazor.Wasm/App.razor new file mode 100644 index 0000000..a8a79e5 --- /dev/null +++ b/src/src/Examples.Blazor.Wasm/App.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/src/Examples.Blazor.Wasm/Examples.Blazor.Wasm.csproj b/src/src/Examples.Blazor.Wasm/Examples.Blazor.Wasm.csproj new file mode 100644 index 0000000..7f5d16a --- /dev/null +++ b/src/src/Examples.Blazor.Wasm/Examples.Blazor.Wasm.csproj @@ -0,0 +1,57 @@ + + + + + net11.0 + + + + enable + enable + true + true + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/src/Examples.Blazor.Wasm/Layout/MainLayout.razor b/src/src/Examples.Blazor.Wasm/Layout/MainLayout.razor new file mode 100644 index 0000000..8517c2c --- /dev/null +++ b/src/src/Examples.Blazor.Wasm/Layout/MainLayout.razor @@ -0,0 +1,11 @@ +@inherits LayoutComponentBase + +
+ @Body +
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
diff --git a/src/src/Examples.Blazor.Wasm/Pages/Home.razor b/src/src/Examples.Blazor.Wasm/Pages/Home.razor new file mode 100644 index 0000000..1fcbd70 --- /dev/null +++ b/src/src/Examples.Blazor.Wasm/Pages/Home.razor @@ -0,0 +1,72 @@ +@page "/" +@using Purview.Results +@using Purview.Results.ZodSharp + +Purview.Results + ZodSharp in Blazor WebAssembly + +

Tenant registration

+ +

+ The form is validated by the compile-time [ZodSchema] generator and folded into + Result<Tenant, TenantError>. Both the schema and the result pipeline run in the browser; + this sample never calls the server. +

+ +
+ + +
+ + +
+
+ +@if (result is { } outcome) +{ +

+ @outcome.Match(DescribeSuccess, DescribeError) +

+ + @if (outcome.TryGetError(out var error) && error is TenantInputInvalid invalid) + { +
    + @foreach (var validationError in invalid.Errors) + { +
  • + @validationError.FullPath.ToDottedString() — @validationError.Message + (@validationError.Code) +
  • + } +
+ } +} + +@code { + Result? result; + string tenantId = "acme"; + string name = "Acme"; + + void Register() => + result = TenantDirectory.Register( + new TenantInput { TenantId = tenantId, Name = name } + ); + + void Find() => result = TenantDirectory.Find(tenantId); + + static string DescribeSuccess(Tenant tenant) => $"Registered {tenant.Name} ({tenant.Id.Value})"; + + static string DescribeError(TenantError error) => + error switch + { + TenantInputInvalid invalid => $"Rejected by the schema ({invalid.Errors.Length} error(s))", + TenantNotFound notFound => $"Not found: {notFound.TenantId.Value}", + TenantAlreadyExists exists => $"Already exists: {exists.TenantId.Value}", + _ => nameof(TenantError), + }; +} diff --git a/src/src/Examples.Blazor.Wasm/Pages/NotFound.razor b/src/src/Examples.Blazor.Wasm/Pages/NotFound.razor new file mode 100644 index 0000000..091b6b8 --- /dev/null +++ b/src/src/Examples.Blazor.Wasm/Pages/NotFound.razor @@ -0,0 +1,5 @@ +@page "/not-found" +@layout MainLayout + +

Not Found

+

Sorry, the content you are looking for does not exist.

diff --git a/src/src/Examples.Blazor.Wasm/Program.cs b/src/src/Examples.Blazor.Wasm/Program.cs new file mode 100644 index 0000000..463ecc1 --- /dev/null +++ b/src/src/Examples.Blazor.Wasm/Program.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); + +await builder.Build().RunAsync(); diff --git a/src/src/Examples.Blazor.Wasm/Properties/launchSettings.json b/src/src/Examples.Blazor.Wasm/Properties/launchSettings.json new file mode 100644 index 0000000..222fdae --- /dev/null +++ b/src/src/Examples.Blazor.Wasm/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5070", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/src/Examples.Blazor.Wasm/Tenant.cs b/src/src/Examples.Blazor.Wasm/Tenant.cs new file mode 100644 index 0000000..d0774c5 --- /dev/null +++ b/src/src/Examples.Blazor.Wasm/Tenant.cs @@ -0,0 +1,14 @@ +namespace Purview.Results.Examples.Blazor.Wasm; + +/// +/// The identifier of a tenant. +/// +/// The identifier value. +readonly record struct TenantId(string Value); + +/// +/// A tenant. +/// +/// The identifier of the tenant. +/// The display name of the tenant. +readonly record struct Tenant(TenantId Id, string Name); diff --git a/src/src/Examples.Blazor.Wasm/TenantDirectory.cs b/src/src/Examples.Blazor.Wasm/TenantDirectory.cs new file mode 100644 index 0000000..79a540b --- /dev/null +++ b/src/src/Examples.Blazor.Wasm/TenantDirectory.cs @@ -0,0 +1,45 @@ +using Purview.Results.ZodSharp; + +namespace Purview.Results.Examples.Blazor.Wasm; + +/// +/// A tenant directory the Blazor sample runs entirely in the browser. +/// +static class TenantDirectory +{ + static readonly HashSet Existing = [with(StringComparer.OrdinalIgnoreCase), "acme"]; + + /// + /// Validates a submitted tenant and folds the validation outcome into a result. + /// + /// The submitted form. + /// The registered tenant, or the union case describing why it was rejected. + /// + /// ToResult carries the validation errors into , and the generated + /// AsFailure<TValue>() helper turns a domain case into the same result type. No exception is + /// thrown for either an invalid form or an existing tenant; both are ordinary values the component renders. + /// + public static Result Register(TenantInput input) => + TenantInputSchema + .Validate(input) + .ToResult(errors => new TenantInputInvalid(errors)) + .Bind(valid => + Existing.Contains(valid.TenantId!) + ? new TenantAlreadyExists(new TenantId(valid.TenantId!)).AsFailure() + : (Result)new Tenant(new TenantId(valid.TenantId!), valid.Name!) + ); + + /// + /// Looks up a tenant by identifier. + /// + /// The identifier to look up. + /// The tenant, or as a result failure. + public static Result Find(string tenantId) + { + TenantId id = new(tenantId); + + return Existing.Contains(tenantId) + ? (Result)new Tenant(id, tenantId.ToUpperInvariant()) + : new TenantNotFound(id).AsFailure(); + } +} diff --git a/src/src/Examples.Blazor.Wasm/TenantError.cs b/src/src/Examples.Blazor.Wasm/TenantError.cs new file mode 100644 index 0000000..ab177e5 --- /dev/null +++ b/src/src/Examples.Blazor.Wasm/TenantError.cs @@ -0,0 +1,37 @@ +using System.Collections.Immutable; +using Purview.Results.ZodSharp; +using ZodSharp.Core; + +namespace Purview.Results.Examples.Blazor.Wasm; + +/// +/// The expected failures of a tenant operation. +/// +/// +/// The union is the error type of Result<Tenant, TenantError>. TenantInputInvalid carries the +/// ZodSharp errors, and implements so a host can render them as a +/// validation problem without knowing this sample's error type. +/// +[GenerateResult] +readonly union TenantError(TenantInputInvalid, TenantNotFound, TenantAlreadyExists); + +/// +/// The submitted input failed its [ZodSchema] validation. +/// +/// The validation errors reported by the schema. +readonly record struct TenantInputInvalid(ImmutableArray Errors) : IValidationErrorCarrier +{ + ImmutableArray IValidationErrorCarrier.ValidationErrors => Errors; +} + +/// +/// The requested tenant does not exist. +/// +/// The identifier of the tenant. +readonly record struct TenantNotFound(TenantId TenantId); + +/// +/// A tenant with the requested identifier already exists. +/// +/// The identifier of the tenant. +readonly record struct TenantAlreadyExists(TenantId TenantId); diff --git a/src/src/Examples.Blazor.Wasm/TenantInput.cs b/src/src/Examples.Blazor.Wasm/TenantInput.cs new file mode 100644 index 0000000..8206639 --- /dev/null +++ b/src/src/Examples.Blazor.Wasm/TenantInput.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations; +using ZodSharp; + +namespace Purview.Results.Examples.Blazor.Wasm; + +/// +/// The form the Blazor sample submits. +/// +/// +/// [ZodSchema] emits TenantInputSchema at compile time, so validation runs with no reflection and +/// is safe in a trimmed WebAssembly client. +/// +[ZodSchema] +sealed class TenantInput +{ + /// Gets or sets the identifier of the tenant. + [Required] + [StringLength(40, MinimumLength = 2)] + public string? TenantId { get; set; } + + /// Gets or sets the display name of the tenant. + [Required] + [StringLength(60, MinimumLength = 2)] + public string? Name { get; set; } +} diff --git a/src/src/Examples.Blazor.Wasm/_Imports.razor b/src/src/Examples.Blazor.Wasm/_Imports.razor new file mode 100644 index 0000000..2902946 --- /dev/null +++ b/src/src/Examples.Blazor.Wasm/_Imports.razor @@ -0,0 +1,12 @@ +@using System.Net.Http +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Microsoft.AspNetCore.Components.Web.Virtualization +@using Microsoft.AspNetCore.Components.WebAssembly.Http +@using Microsoft.JSInterop +@using Purview.Results.Examples.Blazor.Wasm +@using Purview.Results.Examples.Blazor.Wasm.Layout +@using Purview.Results +@using Purview.Results.ZodSharp diff --git a/src/src/Examples.Blazor.Wasm/wwwroot/css/app.css b/src/src/Examples.Blazor.Wasm/wwwroot/css/app.css new file mode 100644 index 0000000..94d68fb --- /dev/null +++ b/src/src/Examples.Blazor.Wasm/wwwroot/css/app.css @@ -0,0 +1,144 @@ +html, body { + font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; +} + +h1:focus { + outline: none; +} + +a, .btn-link { + color: #0071c1; +} + +.btn-primary { + color: #fff; + background-color: #1b6ec2; + border-color: #1861ac; +} + +.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { + box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb; +} + +.content { + padding-top: 1.1rem; +} + +.valid.modified:not([type=checkbox]) { + outline: 1px solid #26b050; +} + +.invalid { + outline: 1px solid red; +} + +.validation-message { + color: red; +} + +#blazor-error-ui { + color-scheme: light only; + background: lightyellow; + bottom: 0; + box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); + box-sizing: border-box; + display: none; + left: 0; + padding: 0.6rem 1.25rem 0.7rem 1.25rem; + position: fixed; + width: 100%; + z-index: 1000; +} + + #blazor-error-ui .dismiss { + cursor: pointer; + position: absolute; + right: 0.75rem; + top: 0.5rem; + } + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } + +.loading-progress { + position: absolute; + display: block; + width: 8rem; + height: 8rem; + inset: 20vh 0 auto 0; + margin: 0 auto 0 auto; +} + + .loading-progress circle { + fill: none; + stroke: #e0e0e0; + stroke-width: 0.6rem; + transform-origin: 50% 50%; + transform: rotate(-90deg); + } + + .loading-progress circle:last-child { + stroke: #1b6ec2; + stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%; + transition: stroke-dasharray 0.05s ease-in-out; + } + +.loading-progress-text { + position: absolute; + text-align: center; + font-weight: bold; + inset: calc(20vh + 3.25rem) 0 auto 0.2rem; +} + + .loading-progress-text:after { + content: var(--blazor-load-percentage-text, "Loading"); + } + +code { + color: #c02d76; +} + +.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder { + color: var(--bs-secondary-color); + text-align: end; +} + +.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { + text-align: start; +} + +.form { + display: grid; + gap: 0.75rem; + max-width: 28rem; +} + +.form label { + display: grid; + gap: 0.25rem; +} + +.form input { + padding: 0.4rem 0.5rem; + font: inherit; +} + +.buttons { + display: flex; + gap: 0.5rem; +} + +.result { + margin-top: 1.25rem; +} + +.errors { + color: #b32121; +} diff --git a/src/src/Examples.Blazor.Wasm/wwwroot/favicon.png b/src/src/Examples.Blazor.Wasm/wwwroot/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..8422b59695935d180d11d5dbe99653e711097819 GIT binary patch literal 1148 zcmV-?1cUpDP)9h26h2-Cs%i*@Moc3?#6qJID|D#|3|2Hn7gTIYEkr|%Xjp);YgvFmB&0#2E2b=| zkVr)lMv9=KqwN&%obTp-$<51T%rx*NCwceh-E+=&e(oLO`@Z~7gybJ#U|^tB2Pai} zRN@5%1qsZ1e@R(XC8n~)nU1S0QdzEYlWPdUpH{wJ2Pd4V8kI3BM=)sG^IkUXF2-j{ zrPTYA6sxpQ`Q1c6mtar~gG~#;lt=s^6_OccmRd>o{*=>)KS=lM zZ!)iG|8G0-9s3VLm`bsa6e ze*TlRxAjXtm^F8V`M1%s5d@tYS>&+_ga#xKGb|!oUBx3uc@mj1%=MaH4GR0tPBG_& z9OZE;->dO@`Q)nr<%dHAsEZRKl zedN6+3+uGHejJp;Q==pskSAcRcyh@6mjm2z-uG;s%dM-u0*u##7OxI7wwyCGpS?4U zBFAr(%GBv5j$jS@@t@iI8?ZqE36I^4t+P^J9D^ELbS5KMtZ z{Qn#JnSd$15nJ$ggkF%I4yUQC+BjDF^}AtB7w348EL>7#sAsLWs}ndp8^DsAcOIL9 zTOO!!0!k2`9BLk25)NeZp7ev>I1Mn={cWI3Yhx2Q#DnAo4IphoV~R^c0x&nw*MoIV zPthX?{6{u}sMS(MxD*dmd5rU(YazQE59b|TsB5Tm)I4a!VaN@HYOR)DwH1U5y(E)z zQqQU*B%MwtRQ$%x&;1p%ANmc|PkoFJZ%<-uq%PX&C!c-7ypis=eP+FCeuv+B@h#{4 zGx1m0PjS~FJt}3mdt4c!lel`1;4W|03kcZRG+DzkTy|7-F~eDsV2Tx!73dM0H0CTh zl)F-YUkE1zEzEW(;JXc|KR5{ox%YTh{$%F$a36JP6Nb<0%#NbSh$dMYF-{ z1_x(Vx)}fs?5_|!5xBTWiiIQHG<%)*e=45Fhjw_tlnmlixq;mUdC$R8v#j( zhQ$9YR-o%i5Uc`S?6EC51!bTRK=Xkyb<18FkCKnS2;o*qlij1YA@-nRpq#OMTX&RbL<^2q@0qja!uIvI;j$6>~k@IMwD42=8$$!+R^@5o6HX(*n~ + + + + + + Purview.Results + ZodSharp in Blazor WebAssembly + + + + + + + + + +
+ + + + +
+
+ +
+ An unhandled error has occurred. + Reload + 🗙 +
+ + + + diff --git a/src/src/Examples.Unions/Examples.Unions.csproj b/src/src/Examples.Unions/Examples.Unions.csproj new file mode 100644 index 0000000..ca18f87 --- /dev/null +++ b/src/src/Examples.Unions/Examples.Unions.csproj @@ -0,0 +1,48 @@ + + + + + net11.0 + + + + Exe + + + + + + + + + + + + + + + + + diff --git a/src/src/Examples.Unions/Program.cs b/src/src/Examples.Unions/Program.cs new file mode 100644 index 0000000..125aed4 --- /dev/null +++ b/src/src/Examples.Unions/Program.cs @@ -0,0 +1,83 @@ +// Demonstrates Purview.Unions and Purview.Results in one compilation over the Tenancy domain the package +// README documents. +// +// dotnet run --project src/src/Examples.Unions +// +// The union generator emits the C# 15 custom union shape with typed case storage; the results generator sees +// only the source-visible case list and emits the AsFailure helpers and the union-receiver factories. Neither +// generator is aware of the other's output, and Purview.Results never references Purview.Unions. + +TenantDirectory directory = new(); +TenantId acmeId = new("acme"); +TenantId globexId = new("globex"); +TenantId missingId = new("initech"); + +Heading("1. A union generated by Purview.Unions is the error type of a result"); + +ShowResult("Find(acme)", directory.Find(acmeId)); +ShowResult("Find(globex)", directory.Find(globexId)); +ShowResult("Find(initech)", directory.Find(missingId)); + +Heading("2. The generated AsFailure helper converts a case for a different success type"); + +TenantNotFound notFound = new(missingId); + +ShowResult("AsFailure()", notFound.AsFailure()); + +Heading("3. The union-receiver factory names the union, so a shared case stays unambiguous"); + +ShowResult("TenantError.Failure()", TenantError.Failure(notFound)); +ShowUnit("TenantError.Failure()", TenantError.Failure(notFound)); + +Heading("4. TryGetValue probes the active case without boxing"); + +var failure = directory.Find(missingId).Error; + +if (failure.TryGetValue(out TenantNotFound activeNotFound)) + Show("TryGetValue(TenantNotFound)", activeNotFound.TenantId.Value); + +Show("HasValue", failure.HasValue); + +Heading("5. The union is exhaustive over its cases"); + +// A generated union is a compiler union on net11.0, so the switch covers the case types and needs no +// discard arm. A struct union can also be empty, which the null arm covers. +Console.WriteLine($" switch -> {DescribeError(failure)}"); + +Heading("6. A unit result deletes a tenant or reports the same union case"); + +ShowUnit("Delete(acme)", directory.Delete(acmeId)); +ShowUnit("Delete(acme) again", directory.Delete(acmeId)); + +static void Heading(string title) +{ + Console.WriteLine(); + Console.WriteLine(title); + Console.WriteLine(new string('-', title.Length)); +} + +static void Show(string label, object? value) => Console.WriteLine($" {label, -32} -> {value}"); + +static void ShowResult(string label, Result result) +{ + var text = result.Match(value => $"Success({value})", DescribeError); + + Console.WriteLine($" {label, -32} -> {text}"); +} + +static void ShowUnit(string label, Result result) +{ + // A unit result's success arm takes no value; only the failure arm receives the error. + var text = result.Match(() => "Success", DescribeError); + + Console.WriteLine($" {label, -32} -> {text}"); +} + +static string DescribeError(TenantError error) => + error switch + { + TenantNotFound notFound => $"TenantNotFound({notFound.TenantId.Value})", + TenantDisabled disabled => $"TenantDisabled({disabled.TenantId.Value})", + TenantAlreadyExists exists => $"TenantAlreadyExists({exists.TenantId.Value})", + null => "empty", + }; diff --git a/src/src/Examples.Unions/Tenant.cs b/src/src/Examples.Unions/Tenant.cs new file mode 100644 index 0000000..e07fa11 --- /dev/null +++ b/src/src/Examples.Unions/Tenant.cs @@ -0,0 +1,15 @@ +namespace Purview.Results.Examples.Unions; + +/// +/// The identifier of a tenant. +/// +/// The identifier value. +readonly record struct TenantId(string Value); + +/// +/// A tenant. +/// +/// The identifier of the tenant. +/// The display name of the tenant. +/// Whether the tenant may be used. +readonly record struct Tenant(TenantId Id, string Name, bool Enabled); diff --git a/src/src/Examples.Unions/TenantDirectory.cs b/src/src/Examples.Unions/TenantDirectory.cs new file mode 100644 index 0000000..ea5d241 --- /dev/null +++ b/src/src/Examples.Unions/TenantDirectory.cs @@ -0,0 +1,41 @@ +namespace Purview.Results.Examples.Unions; + +/// +/// A tenant lookup that reports its expected failures as a result. +/// +sealed class TenantDirectory +{ + readonly Dictionary _tenants = new() + { + [new TenantId("acme")] = new Tenant(new TenantId("acme"), "Acme", Enabled: true), + [new TenantId("globex")] = new Tenant(new TenantId("globex"), "Globex", Enabled: false), + }; + + /// + /// Finds a tenant, or reports why it could not be used. + /// + /// The identifier of the tenant. + /// The tenant, or the union case describing the failure. + /// + /// A bare case value cannot convert to the result because C# never composes two user-defined conversions: + /// the case converts to the union, and the union converts to the result, but not in one sequence. The two + /// generated forms this method uses are the per-case AsFailure<TValue>() helper and the + /// union-receiver TenantError.Failure<TValue>(...) factory. + /// + public Result Find(TenantId id) + { + if (!_tenants.TryGetValue(id, out var tenant)) + return new TenantNotFound(id).AsFailure(); + + // The tenant exists, but is disabled. + return tenant.Enabled ? tenant : TenantError.Failure(new TenantDisabled(id)); + } + + /// + /// Deletes a tenant, or reports that it did not exist. + /// + /// The identifier of the tenant. + /// A unit result: success carries no value, so only the failure has to be carried. + public Result Delete(TenantId id) => + _tenants.Remove(id) ? Result.Success() : new TenantNotFound(id).AsFailure(); +} diff --git a/src/src/Examples.Unions/TenantError.cs b/src/src/Examples.Unions/TenantError.cs new file mode 100644 index 0000000..f02cec2 --- /dev/null +++ b/src/src/Examples.Unions/TenantError.cs @@ -0,0 +1,43 @@ +using Purview.Unions; + +namespace Purview.Results.Examples.Unions; + +/// +/// The expected failures of a tenant operation, generated by Purview.Unions. +/// +/// +/// +/// names the cases in its typeof list. The union generator emits +/// the [Union] attribute, one typed field and public constructor per case, Value, +/// HasValue and a non-boxing TryGetValue per case, so construction and pattern matching never +/// box a value-type case. +/// +/// +/// [GenerateResult] runs in the same compilation and reads that source-visible typeof list — +/// generators never see each other's output — so it emits the per-case AsFailure<TValue>() and +/// AsFailure() helpers and the union-receiver TenantError.Failure(...)/ +/// TenantError.Success(...) factories without Purview.Results depending on +/// Purview.Unions. +/// +/// +[GenerateUnion(typeof(TenantNotFound), typeof(TenantDisabled), typeof(TenantAlreadyExists))] +[GenerateResult] +readonly partial struct TenantError; + +/// +/// The requested tenant does not exist. +/// +/// The identifier of the tenant. +readonly record struct TenantNotFound(TenantId TenantId); + +/// +/// The requested tenant exists but is disabled. +/// +/// The identifier of the tenant. +readonly record struct TenantDisabled(TenantId TenantId); + +/// +/// A tenant with the requested identifier already exists. +/// +/// The identifier of the tenant. +readonly record struct TenantAlreadyExists(TenantId TenantId); diff --git a/src/src/Examples.ValueObjects.Zod/Program.cs b/src/src/Examples.ValueObjects.Zod/Program.cs index 8fce660..c8df352 100644 --- a/src/src/Examples.ValueObjects.Zod/Program.cs +++ b/src/src/Examples.ValueObjects.Zod/Program.cs @@ -63,7 +63,7 @@ static void PrintErrors(Result result) foreach (var validationError in invalid.Errors) { - var path = string.Join(".", validationError.Path); + var path = validationError.FullPath.ToDottedString(); Console.WriteLine($" '{path}': {validationError.Message} [{validationError.Code}]"); } @@ -78,7 +78,7 @@ static void PrintIdentity(Result result) { // A type-level rule validates the value object as a unit, so the path is empty and the origin is the // one the rule owns. - var path = string.Join(".", validationError.Path); + var path = validationError.FullPath.ToDottedString(); Console.WriteLine( $" code={validationError.Code}, origin={validationError.Origin ?? ""}, path=[{path}]" diff --git a/src/src/Examples.Zod/Program.cs b/src/src/Examples.Zod/Program.cs index 7d8ea7c..9b558fe 100644 --- a/src/src/Examples.Zod/Program.cs +++ b/src/src/Examples.Zod/Program.cs @@ -107,8 +107,10 @@ static void PrintErrors(Result result) foreach (var validationError in invalid.Errors) { - var path = string.Join(".", validationError.Path); - - Console.WriteLine($" {path}: {validationError.Message} [{validationError.Code}]"); + // FullPath is the authoritative typed path; ToDottedString is the canonical key + // Microsoft.Extensions.Validation and the ProblemDetails mapper use. + Console.WriteLine( + $" {validationError.FullPath.ToDottedString()}: {validationError.Message} [{validationError.Code}]" + ); } } diff --git a/src/src/Results/Results.csproj b/src/src/Results/Results.csproj index 5bab9cb..b9a1419 100644 --- a/src/src/Results/Results.csproj +++ b/src/src/Results/Results.csproj @@ -14,6 +14,16 @@ true + + + true + + true Purview Results diff --git a/src/src/Results/Sdk/README.md b/src/src/Results/Sdk/README.md index 7f86673..f8bcb45 100644 --- a/src/src/Results/Sdk/README.md +++ b/src/src/Results/Sdk/README.md @@ -167,7 +167,8 @@ Another generator's output is never visible to this generator, so the `[Union]` `Purview.Unions` emits are not read. `[GenerateResult]` instead uses the union's source-visible case list — the `[GenerateUnion]` `typeof` list, partial static factory signatures, a nested `IUnionMembers` provider, then any source-visible public single-parameter constructors, first non-empty list wins. A `[GenerateUnion]` union with -no source-visible case list is reported as `RSG1001`. +no source-visible case list is reported as `RSG1001`. That path is proved with the packaged generators in this +repository's test suite and in `Examples.Unions`, so it is not a hand-written stand-in. The generator cannot declare that conversion for you: C# forbids user-defined operators in a static class, forbids conversion operators in extension members, and permits only one user-defined conversion per conversion @@ -199,8 +200,15 @@ widening one error union into another, and `Throw()`. dotnet run --project src/src/Examples.Basic ``` +[`src/src/Examples.Unions`](https://github.com/purview-dev/results/tree/main/src/src/Examples.Unions) shows the +same package with a union generated by `Purview.Unions`: + +```bash +dotnet run --project src/src/Examples.Unions +``` + The [repository README](https://github.com/purview-dev/results#examples) lists the ZodSharp, ASP.NET Core, -ASP.NET Core + Zod and value-objects composition examples too. +ASP.NET Core + Zod, value-objects composition and Blazor WebAssembly/Server examples too. ## Related packages diff --git a/src/src/SourceGenerator/README.md b/src/src/SourceGenerator/README.md index 1a7c3ba..f2b4bbe 100644 --- a/src/src/SourceGenerator/README.md +++ b/src/src/SourceGenerator/README.md @@ -184,6 +184,10 @@ source `[Union]` attribute or constructor is required. Case types are normalised generator exposes them: a nullable value type becomes its underlying type, a nullable reference annotation is stripped, and constructed generic types and arrays keep their shape. +That integration runs against the packaged generators: `SourceGenerator.UnitTests` references `Purview.Unions` +and declares real `[GenerateUnion]` unions in the same compilation, and `Examples.Unions` is the runnable +consumer proof. The shipped generator still has no dependency on `Purview.Unions`. + ## Generic and member-provider unions A generic union's own type parameters and constraints are repeated on the generated helpers and on a generic diff --git a/src/src/ZodSharp.AspNetCore/Sdk/README.md b/src/src/ZodSharp.AspNetCore/Sdk/README.md index cd99601..4c91c8d 100644 --- a/src/src/ZodSharp.AspNetCore/Sdk/README.md +++ b/src/src/ZodSharp.AspNetCore/Sdk/README.md @@ -120,8 +120,8 @@ validation problem, while the host's own `TenantAlreadyExists` mapping still ret dotnet run --project src/src/Examples.AspNetCore.Zod --urls http://localhost:5216 ``` -The [repository README](https://github.com/purview-dev/results#examples) lists the Basic, ZodSharp and -ASP.NET Core examples too. The +The [repository README](https://github.com/purview-dev/results#examples) lists the Basic, ZodSharp, +ASP.NET Core and Blazor WebAssembly/Server examples too. The [value-objects composition example](https://github.com/purview-dev/results/tree/main/src/src/Examples.ValueObjects.Zod) is a console app with no HTTP layer: it shows a type-level `[ZodRule]` reporting `Origin = "value_object"` into a result failure — the identity a `MapOrigin("value_object", …)` rule in this package keys on. diff --git a/src/src/ZodSharp/Sdk/README.md b/src/src/ZodSharp/Sdk/README.md index 303aa6c..ec2911f 100644 --- a/src/src/ZodSharp/Sdk/README.md +++ b/src/src/ZodSharp/Sdk/README.md @@ -104,7 +104,7 @@ dotnet run --project src/src/Examples.Zod ``` The [repository README](https://github.com/purview-dev/results#examples) lists the Basic, ASP.NET Core, -ASP.NET Core + Zod and value-objects composition examples too. +ASP.NET Core + Zod, value-objects composition and Blazor WebAssembly/Server examples too. ## Related packages diff --git a/src/src/ZodSharp/ZodSharp.csproj b/src/src/ZodSharp/ZodSharp.csproj index 6bdd186..77c0d13 100644 --- a/src/src/ZodSharp/ZodSharp.csproj +++ b/src/src/ZodSharp/ZodSharp.csproj @@ -11,6 +11,14 @@ true + + + true + + true Purview Results ZodSharp diff --git a/src/tests/SourceGenerator.UnitTests/ClosedHierarchyTests.cs b/src/tests/SourceGenerator.UnitTests/ClosedHierarchyTests.cs index f3e5e78..54ad778 100644 --- a/src/tests/SourceGenerator.UnitTests/ClosedHierarchyTests.cs +++ b/src/tests/SourceGenerator.UnitTests/ClosedHierarchyTests.cs @@ -1,5 +1,4 @@ -// CSharpier 1.3.0's parser does not understand the C# 15 'closed' modifier yet, so this file is excluded in -// .csharpierignore. +// CSharpier 1.4.0 parses the C# 15 'closed' modifier, so this file needs no formatter exclusion. namespace Purview.Results.SourceGenerator; diff --git a/src/tests/SourceGenerator.UnitTests/GenerateUnionDiscoveryRuntimeTests.cs b/src/tests/SourceGenerator.UnitTests/GenerateUnionDiscoveryRuntimeTests.cs index 61e3cc1..922c228 100644 --- a/src/tests/SourceGenerator.UnitTests/GenerateUnionDiscoveryRuntimeTests.cs +++ b/src/tests/SourceGenerator.UnitTests/GenerateUnionDiscoveryRuntimeTests.cs @@ -1,8 +1,8 @@ namespace Purview.Results.SourceGenerator; /// -/// Executes the helpers generated for union-shaped types whose cases come from a source-visible -/// [GenerateUnion] list rather than source constructors. +/// Executes the helpers generated for unions produced by the real Purview.Unions generator, whose cases +/// the results generator discovered from the source-visible list rather than from generated constructors. /// public class GenerateUnionDiscoveryRuntimeTests { @@ -13,16 +13,12 @@ public async Task AsFailure_GivenGenerateUnionTypeofListCase_ProducesValueFailur DiscoveredSourceCase value = new(7); // Act - // The explicit type proves the helper produces the result for the discovered union. -#pragma warning disable IDE0007 // Use 'var' instead of explicit type - Result result = value.AsFailure(); -#pragma warning restore IDE0007 // Use 'var' instead of explicit type + var result = value.AsFailure(); // Assert - // The test stand-in has no [Union] attribute (the real generator would emit one), so the active case - // is read through its Value property rather than by union pattern matching. await Assert.That(result.IsFailure).IsTrue(); - await Assert.That(result.Error.Value is DiscoveredSourceCase { Id: 7 }).IsTrue(); + await Assert.That(result.Error.TryGetValue(out DiscoveredSourceCase activeCase)).IsTrue(); + await Assert.That(activeCase.Id).IsEqualTo(7); } [Test] @@ -33,13 +29,12 @@ public async Task UnionFactory_GivenGenerateUnionTypeofListCase_ProducesValueFai // Act // The union-receiver factory names the union discovered from the typeof list. -#pragma warning disable IDE0007 // Use 'var' instead of explicit type - Result result = DiscoveredError.Failure(value); -#pragma warning restore IDE0007 // Use 'var' instead of explicit type + var result = DiscoveredError.Failure(value); // Assert await Assert.That(result.IsFailure).IsTrue(); - await Assert.That(result.Error.Value is DiscoveredSourceCase { Id: 7 }).IsTrue(); + await Assert.That(result.Error.TryGetValue(out DiscoveredSourceCase activeCase)).IsTrue(); + await Assert.That(activeCase.Id).IsEqualTo(7); } [Test] @@ -49,13 +44,12 @@ public async Task AsFailure_GivenGenerateUnionPartialFactoryCase_ProducesValueFa FactoryDiscoveredCase value = new(7); // Act -#pragma warning disable IDE0007 // Use 'var' instead of explicit type - Result result = value.AsFailure(); -#pragma warning restore IDE0007 // Use 'var' instead of explicit type + var result = value.AsFailure(); // Assert await Assert.That(result.IsFailure).IsTrue(); - await Assert.That(result.Error.Value is FactoryDiscoveredCase { Id: 7 }).IsTrue(); + await Assert.That(result.Error.TryGetValue(out FactoryDiscoveredCase activeCase)).IsTrue(); + await Assert.That(activeCase.Id).IsEqualTo(7); } [Test] @@ -65,12 +59,42 @@ public async Task UnionFactory_GivenGenerateUnionPartialFactoryCase_ProducesUnit FactoryDiscoveredOtherCase value = new("rejected"); // Act -#pragma warning disable IDE0007 // Use 'var' instead of explicit type - Result result = FactoryDiscoveredError.Failure(value); -#pragma warning restore IDE0007 // Use 'var' instead of explicit type + var result = FactoryDiscoveredError.Failure(value); // Assert await Assert.That(result.IsFailure).IsTrue(); - await Assert.That(result.Error.Value is FactoryDiscoveredOtherCase { Reason: "rejected" }).IsTrue(); + await Assert.That(result.Error.TryGetValue(out FactoryDiscoveredOtherCase activeCase)).IsTrue(); + await Assert.That(activeCase.Reason).IsEqualTo("rejected"); + } + + [Test] + public async Task AsFailure_GivenGenerateUnionProviderCase_ProducesValueFailure() + { + // Arrange + ProviderDiscoveredCase value = new(7); + + // Act + // The provider surface is the portable route when another generator must also see the case list. + var result = value.AsFailure(); + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error.TryGetValue(out ProviderDiscoveredCase activeCase)).IsTrue(); + await Assert.That(activeCase.Id).IsEqualTo(7); + } + + [Test] + public async Task UnionFactory_GivenGenerateUnionProviderCase_ProducesUnitFailure() + { + // Arrange + ProviderDiscoveredOtherCase value = new("rejected"); + + // Act + var result = ProviderDiscoveredError.Failure(value); + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error.TryGetValue(out ProviderDiscoveredOtherCase activeCase)).IsTrue(); + await Assert.That(activeCase.Reason).IsEqualTo("rejected"); } } diff --git a/src/tests/SourceGenerator.UnitTests/GenerateUnionDiscoveryTests.cs b/src/tests/SourceGenerator.UnitTests/GenerateUnionDiscoveryTests.cs index c4e028e..753463d 100644 --- a/src/tests/SourceGenerator.UnitTests/GenerateUnionDiscoveryTests.cs +++ b/src/tests/SourceGenerator.UnitTests/GenerateUnionDiscoveryTests.cs @@ -8,10 +8,11 @@ namespace Purview.Results.SourceGenerator; /// visible constructors in this compilation; its cases must come from the source-visible list instead. ///
/// -/// The tests declare a stand-in Purview.Unions.GenerateUnionAttribute (and, where the generated -/// helpers need a case-to-union conversion that the real generator would emit, source-visible conversion -/// operators). The two-generator integration test is recorded as an interop follow-up, because -/// Purview.Unions is not on a CI-safe package feed yet. +/// These tests isolate the discovery path in a source string, so they declare a stand-in +/// Purview.Unions.GenerateUnionAttribute (and, where the generated helpers need a case-to-union +/// conversion the real generator would emit, source-visible conversion operators). The two-generator +/// integration itself runs against the packaged generator in and its +/// siblings. /// public class GenerateUnionDiscoveryTests : TUnitSourceGeneratorTestBase diff --git a/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs b/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs index 18a7a0d..46be12c 100644 --- a/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs +++ b/src/tests/SourceGenerator.UnitTests/GeneratedTestUnions.cs @@ -164,6 +164,10 @@ public bool TryGetValue(out Label value) /// A generic union with a bare type-parameter case, which is factory-only. /// The value or text. +// RSG1006 is deliberate here: NullableValue also owns a string case (string? normalises to string), so +// it keeps the per-case AsFailure helper and TypedOrText uses the shared-case safe factory instead. +// GenericUnionTests exercises TypedOrText.Failure(...) on that path. Suppressed, with the +// full reason, by the RSG1006 NoWarn entry in SourceGenerator.UnitTests.csproj. [GenerateResult] public readonly union TypedOrText(T, string); diff --git a/src/tests/SourceGenerator.UnitTests/GeneratedUnionDiscoveryTestTypes.cs b/src/tests/SourceGenerator.UnitTests/GeneratedUnionDiscoveryTestTypes.cs index deba836..87d2102 100644 --- a/src/tests/SourceGenerator.UnitTests/GeneratedUnionDiscoveryTestTypes.cs +++ b/src/tests/SourceGenerator.UnitTests/GeneratedUnionDiscoveryTestTypes.cs @@ -2,9 +2,10 @@ namespace Purview.Results.SourceGenerator; -// Union-shaped types that stand in for a union emitted by Purview.Unions. The generator runs against this -// project as an analyzer, so these declarations prove the source-visible discovery path end to end: there is -// no [Union] attribute and no public single-parameter constructor for the results generator to read. +// Unions generated by the real Purview.Unions generator in this project. The results generator runs against +// this project as an analyzer at the same time, so these declarations are the two-generator integration surface: +// the results generator sees only the source-visible case list, because a Roslyn generator never sees another +// generator's output. /// A case of the typeof-list generated-union discovery union. /// The identifier. @@ -15,30 +16,13 @@ namespace Purview.Results.SourceGenerator; public readonly record struct DiscoveredOtherCase(string Reason); /// -/// A union-shaped type whose case list is only the [GenerateUnion] typeof list, which is what -/// the results generator sees for a union another generator emits. +/// A union whose case list is only the [GenerateUnion] typeof list. The union generator emits +/// the [Union] attribute, storage, constructors and TryGetValue members; the results generator +/// must read the cases from the attribute instead of from that generated output. /// -/// -/// The implicit conversions stand in for the members the union generator emits into the final compilation, -/// so the generated helpers compile and can be executed without exposing source constructors to discovery. -/// [GenerateUnion(typeof(DiscoveredSourceCase), typeof(DiscoveredOtherCase))] [GenerateResult] -public readonly struct DiscoveredError -{ - DiscoveredError(object? value) => Value = value; - - /// Converts a case into the union. - /// The case value. - public static implicit operator DiscoveredError(DiscoveredSourceCase value) => new(value); - - /// Converts a case into the union. - /// The case value. - public static implicit operator DiscoveredError(DiscoveredOtherCase value) => new(value); - - /// Gets the boxed case value. - public object? Value { get; } -} +public readonly partial struct DiscoveredError; /// A case of the factory-list generated-union discovery union. /// The identifier. @@ -49,44 +33,68 @@ public readonly struct DiscoveredError public readonly record struct FactoryDiscoveredOtherCase(string Reason); /// -/// A union-shaped type whose case list is only its partial static factory signatures. The signatures are the -/// source-visible case list; the implementations exist only so the test compilation is valid. +/// A union whose case list is only its partial static factory signatures. The signatures are the +/// source-visible case list; the union generator emits their implementations. /// [GenerateUnion] [GenerateResult] public readonly partial struct FactoryDiscoveredError { - FactoryDiscoveredError(object? value) => Value = value; - /// Creates the union from a case. /// The case value. /// The union holding the case. public static partial FactoryDiscoveredError FromFactoryDiscoveredCase(FactoryDiscoveredCase value); - /// Creates the union from a case. - /// The case value. - /// The union holding the case. - public static partial FactoryDiscoveredError FromFactoryDiscoveredCase(FactoryDiscoveredCase value) => new(value); - /// Creates the union from a case. /// The case value. /// The union holding the case. public static partial FactoryDiscoveredError FromFactoryDiscoveredOtherCase(FactoryDiscoveredOtherCase value); +} - /// Creates the union from a case. - /// The case value. - /// The union holding the case. - public static partial FactoryDiscoveredError FromFactoryDiscoveredOtherCase(FactoryDiscoveredOtherCase value) => - new(value); - - /// Converts a case into the union. - /// The case value. - public static implicit operator FactoryDiscoveredError(FactoryDiscoveredCase value) => new(value); +/// A case of the provider-list generated-union discovery union. +/// The identifier. +public readonly record struct ProviderDiscoveredCase(int Id); - /// Converts a case into the union. - /// The case value. - public static implicit operator FactoryDiscoveredError(FactoryDiscoveredOtherCase value) => new(value); +/// A second case of the provider-list generated-union discovery union. +/// The reason. +public readonly record struct ProviderDiscoveredOtherCase(string Reason); - /// Gets the boxed case value. - public object? Value { get; } +/// +/// A union whose case list is only its nested provider. The provider is public and +/// nested because the C# union contract requires that shape, and its Create bodies delegate to the +/// generator's CreateCaseN methods. +/// +[GenerateUnion] +[GenerateResult] +public readonly partial struct ProviderDiscoveredError : ProviderDiscoveredError.IUnionMembers +{ + /// The union member provider the C# union contract requires. + public interface IUnionMembers + { + /// Creates the union from a case. + /// The case value. + /// The union holding the case. + static ProviderDiscoveredError Create(ProviderDiscoveredCase value) => CreateCase0(value); + + /// Creates the union from a case. + /// The case value. + /// The union holding the case. + static ProviderDiscoveredError Create(ProviderDiscoveredOtherCase value) => CreateCase1(value); + + /// Gets the boxed case value. + object? Value { get; } + + /// Gets whether the union holds a case. + bool HasValue { get; } + + /// Gets the case value when the active case is the first case. + /// The case value. + /// Whether the active case is the first case. + bool TryGetValue(out ProviderDiscoveredCase value); + + /// Gets the case value when the active case is the second case. + /// The case value. + /// Whether the active case is the second case. + bool TryGetValue(out ProviderDiscoveredOtherCase value); + } } diff --git a/src/tests/SourceGenerator.UnitTests/Purview/Unions/GenerateUnionAttribute.cs b/src/tests/SourceGenerator.UnitTests/Purview/Unions/GenerateUnionAttribute.cs deleted file mode 100644 index f21a968..0000000 --- a/src/tests/SourceGenerator.UnitTests/Purview/Unions/GenerateUnionAttribute.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace Purview.Unions; - -/// -/// A test stand-in for the Purview.Unions opt-in attribute. -/// -/// -/// The real package's generator is not on a CI-safe feed yet, so the test project declares the attribute with -/// the same metadata identity. The results generator matches it by metadata name only, exactly as it would -/// match the packaged attribute. -/// -/// The case types of the generated union. -[AttributeUsage(AttributeTargets.Struct | AttributeTargets.Class, Inherited = false, AllowMultiple = false)] -// Deliberately internal, which is the default for a top-level type: the generator test compilations declare -// their own public stand-in from a source string, and an imported public copy would conflict (CS0436) with it. -sealed class GenerateUnionAttribute(params Type[] cases) : Attribute -{ - /// - /// Gets the case types of the generated union, in declaration order. - /// - public IReadOnlyList Cases { get; } = cases; -} diff --git a/src/tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj b/src/tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj index eb0eba4..65117f5 100644 --- a/src/tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj +++ b/src/tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj @@ -7,6 +7,15 @@ --> net11.0 + + $(NoWarn);RSG1006 + diff --git a/src/tests/SourceGenerator.UnitTests/TestSources.cs b/src/tests/SourceGenerator.UnitTests/TestSources.cs index a265cc6..de574c2 100644 --- a/src/tests/SourceGenerator.UnitTests/TestSources.cs +++ b/src/tests/SourceGenerator.UnitTests/TestSources.cs @@ -6,10 +6,11 @@ namespace Purview.Results.SourceGenerator; static class TestSources { /// - /// Declares the Purview.Unions opt-in attribute exactly as its package does, so a test compilation - /// can exercise the source-visible discovery path without referencing that package (whose analyzer is not - /// on a CI-safe feed yet). Matching is by metadata identity only, so this declaration is equivalent to the - /// real attribute for the generator. + /// Declares the Purview.Unions opt-in attribute exactly as its package does, so an isolated test + /// compilation can exercise the source-visible discovery path without referencing that package. Matching is + /// by metadata identity only, so this declaration is equivalent to the real attribute for the generator. + /// The package-level integration runs in this project's own compilation; see + /// GeneratedUnionDiscoveryTestTypes. /// public const string GenerateUnionAttributeDeclaration = """ namespace Purview.Unions diff --git a/src/tests/ZodSharp.UnitTests/ZodValidationResultIntegrationTests.cs b/src/tests/ZodSharp.UnitTests/ZodValidationResultIntegrationTests.cs index 90389e1..f140543 100644 --- a/src/tests/ZodSharp.UnitTests/ZodValidationResultIntegrationTests.cs +++ b/src/tests/ZodSharp.UnitTests/ZodValidationResultIntegrationTests.cs @@ -26,7 +26,7 @@ public async Task OperationSchema_GivenMissingName_FailsWithTheMemberPath() // Assert await Assert.That(validation.IsSuccess).IsFalse(); await Assert.That(validation.Errors.Length).IsGreaterThanOrEqualTo(1); - await Assert.That(validation.Errors[0].Path).Contains(nameof(Operation.Name)); + await Assert.That(validation.Errors[0].FullPath.ToDottedString()).Contains(nameof(Operation.Name)); } [Test] @@ -51,7 +51,7 @@ public async Task ExecuteGuard_GivenRejectedOperation_ReturnsTheUnionCaseAsAFail await Assert.That(rejected).IsNotNull(); await Assert.That(rejected!.Value.Operation).IsEqualTo(operation); - await Assert.That(rejected.Value.Errors[0].Path).Contains(nameof(Operation.Name)); + await Assert.That(rejected.Value.Errors[0].FullPath.ToDottedString()).Contains(nameof(Operation.Name)); // The guard idiom: the method succeeds with a different type than the validated value, so the generated // AsFailure() helper produces the failure. @@ -223,7 +223,7 @@ CancellationToken cancellationToken await Assert.That(rejected).IsNotNull(); await Assert.That(rejected!.Value.Operation).IsEqualTo(operation); - await Assert.That(rejected.Value.Errors[0].Path).Contains(nameof(Operation.Name)); + await Assert.That(rejected.Value.Errors[0].FullPath.ToDottedString()).Contains(nameof(Operation.Name)); } [Test] From 50189cefba6a3023eba5b34c4d328ce5b71bf7c0 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 12 Nov 2026 12:48:41 +0000 Subject: [PATCH 6/6] build: fix the build --- .github/workflows/pr.yml | 19 ++++++++++++------- .github/workflows/release.yml | 5 +---- AGENTS.md | 10 ++++++---- docs/wiki/Release-Flow.md | 10 ++++++---- 4 files changed, 25 insertions(+), 19 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2bfc905..289a908 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -16,11 +16,9 @@ jobs: name: Build and test uses: purview-dev/build/.github/workflows/purview-build.yml@main with: - # This repository targets net11.0 and pins the GA .NET 11 SDK in global.json. The shared workflow - # defaults to the 10.0.x SDK, so without the matching SDK the runner cannot resolve global.json - # ("A compatible .NET SDK was not found") and every step fails. - # Keep in sync with `sdk.version` in global.json. - dotnet-version: "11.0.100" + # The shared workflow installs the SDK `global.json` pins through + # `purview-dev/build/actions/dotnet-sdk`; do not repeat the version here. `global.json` is the + # single source of truth, and a second copy only drifts. # Mirrors Build:TestFilter in purview-build.json: every test project is a *.UnitTests project # whose tests the SDK categorises as Unit. test-filter: "/*/*/*/*[Category=Unit]" @@ -45,10 +43,17 @@ jobs: steps: - uses: actions/checkout@v7 - # The SDK from global.json, plus the .NET 10 runtime the net10.0 consumer runs on. + # The SDK global.json pins (11.0.100), through the organisation's one SDK-setup action. + # `actions/setup-dotnet`'s `global-json-file` input is not a substitute: it reads a `rollForward` + # of `latestMajor` as "the latest" and installs the LTS channel (10.0.x), reports success, and + # leaves the preflight to fail with "A compatible .NET SDK was not found" for the 11.0.100 pin. + - uses: purview-dev/build/actions/dotnet-sdk@main + + # The net10.0 consumer has to execute, so it needs the .NET 10 runtime as well as the pinned SDK. + # This is a second runtime for that consumer, not a second opinion on the SDK: global.json still + # selects 11.0.100 for every dotnet command in this repository. - uses: actions/setup-dotnet@v6 with: - global-json-file: global.json dotnet-version: 10.0.x # First, before any toolchain is installed: the generator is built against the pinned diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0805c84..f549b0a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,10 +29,7 @@ jobs: - uses: purview-dev/build/actions/purview-release@main with: release-mode: NuGet - # This repository targets net11.0 and pins the GA .NET 11 SDK in global.json; without it the - # release job cannot resolve global.json. - # Keep in sync with `sdk.version` in global.json (and with .github/workflows/pr.yml). - dotnet-version: "11.0.100" + # The action installs the SDK `global.json` pins; a `dotnet-version` input would be ignored. trusted-publishing: true # The nuget.org profile name, not the sign-in email. nuget-user: kieronlanning diff --git a/AGENTS.md b/AGENTS.md index 943a310..bc57299 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -545,12 +545,14 @@ all). Prefer a new version, as the local release flow does. - Release is automatic on push to `main`: `.github/workflows/release.yml` calls the shared `purview-dev/build` release pipeline with `Release:Mode=NuGet`, which packs, publishes and creates the `v` GitHub release only when that tag does not already exist. -- `.github/workflows/pr.yml` runs the shared build pipeline for pull requests, pinned to the SDK version in - `global.json` so the runner can resolve it. +- `.github/workflows/pr.yml` runs the shared build pipeline for pull requests, and `.github/workflows/release.yml` + runs the shared release action. Neither repeats the SDK version: `purview-dev/build`'s `dotnet-sdk` action and + `purview-release` action install the `sdk.version` that `global.json` pins. Only the `packed-consumers` job adds + a second `actions/setup-dotnet` step for the .NET 10 runtime the `net10.0` consumer executes on; it never uses + `global-json-file`, because `setup-dotnet` resolves `rollForward: latestMajor` as the LTS channel. - Never create release tags or publish packages manually unless the user explicitly requests a documented recovery procedure. -- `.github/workflows/*` and `global.json` must stay in sync: the workflow's `dotnet-version` input has to match - `sdk.version`, and `.config/purview-build.json` has to keep pointing at `src/Results.slnx`. +- `.config/purview-build.json` has to keep pointing at `src/Results.slnx`. ## Completion checklist diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index b936d27..60b17c1 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -36,11 +36,13 @@ A push to `main` publishes, so everything below has to be true *before* the merg | Workflow | Trigger | Pipeline | | --- | --- | --- | | `.github/workflows/pr.yml` | pull requests against `main` (opened, synchronize, reopened, ready_for_review) | `build`: shared `purview-build.yml`, with the `[Category=Unit]` test filter, `run-pack: true` and `validate-pack: true`; `packed-consumers`: packs to an isolated feed, publishes and runs the Native AOT, Blazor WebAssembly and Blazor Server consumers, leak-scans their output and proves every `ForbiddenContent` rule rejects a package | -| `.github/workflows/release.yml` | push to `main` | shared `purview-release.yml` with `release-mode: NuGet` | +| `.github/workflows/release.yml` | push to `main` | shared `purview-release` action with `release-mode: NuGet` | -Both workflows pin `dotnet-version` to the SDK in `global.json` — the shared workflow defaults to the 10.0.x SDK, -which cannot resolve a repository that targets `net11.0`. Keep the two workflow files and `global.json` in sync, -and read the pinned version from `global.json` rather than from this page. +Both workflows take the SDK from `global.json` through `purview-dev/build`'s `dotnet-sdk` action and +`purview-release` action; neither repeats the version. The `packed-consumers` job adds a second +`actions/setup-dotnet` step for the .NET 10 runtime the `net10.0` consumer runs on. `setup-dotnet`'s +`global-json-file` input is not used, because it resolves `rollForward: latestMajor` as the LTS channel and +leaves the job without the pinned SDK. Read the pinned version from `global.json` rather than from this page. The release workflow packs, publishes to NuGet, and creates the `v` GitHub release only when that tag does not already exist. Do not create release tags or publish packages manually.