# Results > Result for .NET — expected failures as values the caller must handle. A small, dependency-free Result for .NET that makes expected failures part of a method's contract: not found, invalid, and conflict are values rather than exceptions. A Roslyn source generator adds C# 15 union ergonomics for the error cases, and companion packages carry the result into ASP.NET Core responses and ZodSharp validation problems. - Repository: https://github.com/purview-dev/results - Package: https://www.nuget.org/packages/Purview.Results - Project page: https://purview.dev/projects/results/ - Documentation: https://purview.dev/docs/results/ - Full machine-readable content: https://purview.dev/projects/results/llms-full.txt # Getting Started This guide installs the packages, models an error union, returns a result, and maps it onto an HTTP response. ## Requirements - **.NET 11 SDK or later** — the runtime packages target `net11.0`; the source generator targets `netstandard2.0` so any compiler host can load it. - **C# 15 preview** — union declarations are a preview language feature, so a project that declares one needs `LangVersion=preview` (the repository sets it centrally). ## 1. Reference the packages ```bash dotnet add package Purview.Results dotnet add package Purview.Results.SourceGenerator # only when you model errors as a union ``` `Purview.Results.SourceGenerator` is a Roslyn component: add it as an ordinary `PackageReference` and it applies to the compilation automatically. It also brings the analyzer, the `CS0029` code fix, and the `CA1815` suppression for opted-in unions. ## 2. Model the error as a union (optional) A C# 15 union makes the error cases strongly typed without giving up the single `TError` the result needs: ```csharp using Purview.Results; [GenerateResult] public readonly union TenantError(TenantNotFound, TenantDisabled, TenantAlreadyExists); public readonly record struct TenantNotFound(TenantId TenantId); public readonly record struct TenantDisabled(TenantId TenantId); public readonly record struct TenantAlreadyExists(TenantId TenantId); ``` `[GenerateResult]` is generated by the source generator itself, so it needs no separate reference. For every case the generator emits `AsFailure()` in the union's namespace: ```csharp public static class TenantErrorResultExtensions { public static Result AsFailure(this TenantNotFound error) => Result.Failure(error); // ... one overload per case type } ``` See [Union Errors](union-errors/) for the modelling rules and [Source Generator](source-generator/) for the generated shape. ## 3. Return a result ```csharp using Purview.Results; Result GetTenant(TenantId tenantId) => _tenants.TryGetValue(tenantId, out var tenant) ? Result.Success(tenant) : new TenantNotFound(tenantId).AsFailure(); ``` `Result.Success(value)` and `Result.Failure(error)` are the same factories without repeating the value type. Both member states are also reachable through the result's implicit conversions, so a non-union error or a value can be returned directly: ```csharp Result ok = 42; Result failed = "not a number"; ``` ## 4. Read the result ```csharp var result = GetTenant(tenantId); if (result.IsSuccess) Console.WriteLine(result.Value.Name); var message = result.Match( tenant => $"Found {tenant.Name}", error => $"Could not load the tenant: {error}" ); ``` `default` is *uninitialized*: `IsInitialized`, `IsSuccess` and `IsFailure` are all `false`, and `Value`, `Error`, `Match`, `Map`, `Bind` and `MapError` throw `InvalidOperationException` rather than guessing. See [Core Concepts](core-concepts/) and [Combinators](combinators/). ## 5. Map it onto HTTP ```bash dotnet add package Purview.Results.AspNetCore ``` ```csharp builder.Services.AddResultsHttp(options => options .Map(error => TypedResults.NotFound()) .Map(error => TypedResults.Problem(statusCode: StatusCodes.Status403Forbidden)) .Map(error => TypedResults.Problem(statusCode: StatusCodes.Status409Conflict))); app.MapGet("/tenants/{id}", (string id) => GetTenant(new TenantId(id))).WithResultsHttp(); ``` A mapping for the **case** type wins, the mapping for the **error** type covers the remaining cases, and an unmapped failure is a `500` that names the unmapped case, so a mapping gap is never silent. See [ASP.NET Core Integration](aspnetcore-integration/). ## 6. Validate without exceptions ```bash dotnet add package Purview.Results.ZodSharp ``` ZodSharp's `Validate` already returns a `ValidationResult`; `ToResult` turns that into a result whose error type you choose: ```csharp return TenantInputSchema .Validate(input) .ToResult(errors => new TenantInputInvalid(input, errors)); ``` See [ZodSharp Integration](zodsharp-integration/) and, for ProblemDetails rendering, [ZodSharp Problem Details](zodsharp-problemdetails/). ## Runnable examples Every example is a non-packable project under `src/examples`, built on the same Tenancy domain: ```bash dotnet run --project src/examples/Examples.Basic dotnet run --project src/examples/Examples.Zod dotnet run --project src/examples/Examples.AspNetCore --urls http://localhost:5215 dotnet run --project src/examples/Examples.AspNetCore.Zod --urls http://localhost:5216 ``` ## Next steps - [Core Concepts](core-concepts/) — the three states and the throw-on-misuse contract. - [Combinators](combinators/) — `Match`, `Map`, `Bind`, `Ensure`, probing, and the asynchronous forms. - [Guarantees and Limitations](guarantees-and-limitations/) — what the suite deliberately does not do. --- # Core Concepts `Result` is a `readonly record struct` with three states. Everything else in the suite — the generator, the HTTP adapter, the ZodSharp bridge — is built on the behaviour described here. ## The three states | State | How it is reached | `IsInitialized` | `IsSuccess` | `IsFailure` | | --- | --- | --- | --- | --- | | `Uninitialized` | `default(Result)` | `false` | `false` | `false` | | `Success` | `Result.Success(value)` | `true` | `true` | `false` | | `Failure` | `Result.Failure(error)` | `true` | `false` | `true` | `IsSuccess` and `IsFailure` carry `[MemberNotNullWhen]`, so reading `Value` after an `IsSuccess` check flows the nullability information the compiler needs. ## Throw on misuse, never coerce An expected failure is a value; a misuse is a bug. The contract is deliberately loud: - `Value` throws `InvalidOperationException` (`"The value of a non-successful result cannot be accessed."`) unless the result is a success. - `Error` throws `InvalidOperationException` (`"The error of a non-failed result cannot be accessed."`) unless the result is a failure. - `Match`, `Map`, `Bind`, `MapError` and the extension operations throw `InvalidOperationException` (`"The result is uninitialized."`) for `default`. An uninitialized result is never treated as a failure or as a success. If that is too strict for a boundary — inspecting a result you did not create — use the probing methods instead of the throwing ones. ## Probing without throwing `TryGetValue` and `TryGetError` in `ResultExtensions` are the deliberate exception to the throwing contract: they report whether a value or an error is available and never throw, even for `default`. ```csharp if (result.TryGetError(out var error)) logger.LogWarning("Failed: {Error}", error); ``` ## Factories and conversions | Form | Example | | --- | --- | | Generic factory | `Result.Success(tenant)` | | Non-generic factory | `Result.Success(tenant)` | | Implicit from the value | `Result ok = 42;` | | Implicit from the error | `Result failed = "not a number";` | `Result.Success`/`Result.Failure` exist so a call site does not have to name the value type twice. The implicit conversions from `TValue` and `TError` are public contract, and they are also what lets a method body `return` a plain value or a non-union error. ## `ToString` `ToString()` is stable and is what the examples print: | State | Output | | --- | --- | | Success | `Success(value)` | | Failure | `Failure(error)` | | Uninitialized | `Uninitialized` | ## `IResultValue` `IResultValue` is the non-generic, read-only view used by infrastructure that cannot be generic over the value and error types — the ASP.NET Core endpoint filter, for example. | Member | Behaviour | | --- | --- | | `IsInitialized` | Whether the result was created at all | | `IsSuccess` | Whether the result represents success | | `SuccessValue` | The successful value, or `null` when the result is not a success | | `ErrorValue` | The error, or `null` when the result is not a failure | Its accessors **never throw**: the accessor that does not describe the current state returns `null`. That is what makes it safe for a framework component to inspect a result without knowing the two type arguments. ## Where extension methods live Value-style operations that do not belong on the result type itself — probing, fallbacks, side effects, constraints and the asynchronous combinators — live in `src/src/Results/Extensions/Purview/Results/ResultExtensions.cs`. Add new extension methods there rather than on `Result`, so the type's own surface stays the three states and the four core combinators. ## Related - [Combinators](../combinators/) — the operations built on these states. - [Union Errors](../union-errors/) — making `TError` a union. - [Guarantees and Limitations](../guarantees-and-limitations/) — the invariants that must not regress. --- # Combinators The combinators fold a result into a value or chain another operation, without ever inspecting a state that is not there. All of them throw `InvalidOperationException` (`"The result is uninitialized."`) for `default`; the throwing input check for a `null` delegate is `ArgumentNullException`. ## Transforming | Member | On success | On failure | On `default` | | --- | --- | --- | --- | | `Match(success, failure)` | `success(value)` | `failure(error)` | throws | | `Map(map)` | `Result.Success(map(value))` | the existing error, unchanged | throws | | `Bind(bind)` | the result `bind` returns | the existing error, unchanged | throws | | `MapError(map)` | the value, unchanged | `Result.Failure(map(error))` | throws | ```csharp Result name = GetTenant(tenantId).Map(tenant => tenant.Name); Result created = GetTenant(tenantId) .Bind(tenant => store.CreateTenant(new TenantId("newco"), tenant.Name)); Result renamed = GetTenant(tenantId) .MapError(error => error.ToString()); ``` `Map` and `Bind` preserve the error type; `MapError` preserves the value. `Bind` is the operation to reach for when the next step can fail with the **same** error type — otherwise `MapError` first or a `Match` is clearer. ## Constraining a success `Ensure(predicate, errorFactory)` turns a successful value that fails the predicate into a failure: ```csharp Result enabled = GetTenant(tenantId) .Ensure(tenant => tenant.Enabled, tenant => new TenantDisabled(tenant.Id)); ``` The `errorFactory` is invoked only when the predicate fails, so a satisfied constraint allocates no error. A failure and its error pass through unchanged. ## Probing `TryGetValue` and `TryGetError` never throw — not even for `default`: ```csharp if (result.TryGetValue(out Tenant? tenant)) Console.WriteLine(tenant.Name); if (result.TryGetError(out TenantError error)) Console.WriteLine(error); ``` ## Fallbacks and alternatives | Member | Purpose | | --- | --- | | `GetValueOrDefault()` | The successful value, or `default` | | `GetValueOrDefault(TValue fallback)` | The successful value, or `fallback` | | `GetValueOrDefault(Func fallbackFactory)` | The successful value, or a lazily produced fallback | | `GetErrorOrDefault(TError fallback)` | The error, or `fallback` | | `OrElse(Result fallback)` | This result when successful, otherwise `fallback` | Each of these still throws for `default`, because an uninitialized result is a bug rather than an ordinary failure. Use them after a state has been established (or after probing). ## Side effects | Member | Purpose | | --- | --- | | `Switch(Action success, Action failure)` | Invoke the matching action and discard the value | | `Tap(Action success)` | Observe a success and return the result unchanged | | `TapError(Action failure)` | Observe a failure and return the result unchanged | `Tap` and `TapError` leave the result untouched, which keeps them usable in the middle of a chain. ## Asynchronous composition ```csharp Task> dto = GetTenantAsync(tenantId) .MapAsync(tenant => _mapper.MapAsync(tenant)); Task> saved = GetTenantAsync(tenantId) .BindAsync(tenant => _store.SaveAsync(tenant)); ``` `MapAsync` and `BindAsync` behave exactly like their synchronous counterparts, and both throw for `default`. They are extension methods in `ResultExtensions`, not members on the result type. ## Related - [Core Concepts](../core-concepts/) — the state contract these operations obey. - [Union Errors](../union-errors/) — producing the failure value a combinator carries. --- # Union Errors A C# 15 union is a natural `TError`: the error cases stay strongly typed, and a caller still matches on one value. This page covers the modelling rules and the helper the source generator supplies so a case value can be returned where a result is expected. ## Declaring the union ```csharp using Purview.Results; [GenerateResult] public readonly union TenantError(TenantNotFound, TenantDisabled, TenantAlreadyExists); public readonly record struct TenantNotFound(TenantId TenantId); public readonly record struct TenantDisabled(TenantId TenantId); public readonly record struct TenantAlreadyExists(TenantId TenantId); ``` `[GenerateResult]` is generated by `Purview.Results.SourceGenerator` during post-initialization, so the consuming project does not declare it and needs no second package reference. Opt-in is explicit: a compilation that never applies the attribute gets no helpers. ## The generated helper For each case type the generator emits one overload in a `{Union}ResultExtensions` static class in the union's own namespace: ```csharp public static class TenantErrorResultExtensions { public static Result AsFailure(this TenantNotFound error) => Result.Failure(error); public static Result AsFailure(this TenantDisabled error) => Result.Failure(error); public static Result AsFailure(this TenantAlreadyExists error) => Result.Failure(error); } ``` ```csharp Result GetTenant(TenantId tenantId) => _tenants.TryGetValue(tenantId, out var tenant) ? Result.Success(tenant) : new TenantNotFound(tenantId).AsFailure(); ``` One overload is generated **per case type** rather than one for the union, because C# does not apply union conversions to an extension-method receiver (`CS1929`), so a helper declared on the union itself would never bind. ## Why there is no implicit conversion The shortest possible call site would be `return new TenantNotFound(id);`. C# blocks every route to a generated implicit conversion, and each rule is recorded as a compiler experiment in `src/tests/SourceGenerator.UnitTests/UnionCompilerBehaviourTests.cs`: | Rule | Diagnostic | | --- | --- | | A user-defined operator cannot be declared in a **static class** — the generated helper class is one | `CS0715` | | A conversion operator must be declared by the **source or the target type**; a helper class is neither | `CS0556` | | Conversion operators are **not permitted as extension members** | `CS9282` | | A conversion operator **cannot declare its own type parameters**, so `TValue` is out of scope for a non-generic case type | `CS0246` | | **Only one user-defined conversion** may participate in a sequence, so `case → union → Result<…>` can never compose | `CS0029` | The one shape the language accepts is a *generic* case type carrying the value type parameter — precisely the shape the generator rejects as `RSG1002`. The per-case helper is therefore the best ergonomics the language allows. ## The helper-free form A cast closes the `case → union` conversion so only the library's `union → result` conversion remains, which means the cast form needs no generated code: ```csharp Result GetTenant(TenantId tenantId) => (TenantError)new TenantNotFound(tenantId); ``` It is not prettier than `AsFailure()`, but it is a legitimate choice when a project does not want the generator applied to a union. ## The code fix Returning a bare case value where a result is expected is a compiler error (`CS0029`). The package ships `UnionCaseResultCodeFixProvider`, which offers the rewrite in the IDE from the lightbulb: ```csharp return new TenantNotFound(id); // CS0029 return new TenantNotFound(id).AsFailure(); // after the fix ``` A fix is offered only when the rewrite will bind: the converted type is `Purview.Results.Result`, `TError` is a union **and** opted in with `[GenerateResult]`, the expression's type is one of that union's case types, and the union is reachable by its simple name at the call site. ## 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 case type shared by two unions | Yes — `RSG1006` warning; the helper is generated once | | A generic union | No — `RSG1002` | | A case type that itself contains type parameters | No — `RSG1002` (the remaining cases still generate) | | `IUnionMembers` member providers | No — `RSG1007` | See [Diagnostics](../diagnostics/) for the full rules table and the blocking policy. ## Related - [Source Generator](../source-generator/) — activation, pipeline design and build properties. - [Diagnostics](../diagnostics/) — every rule and the `CA1815` suppression. - [ASP.NET Core Integration](../aspnetcore-integration/) — mapping each case onto a response. --- # Source Generator `Purview.Results.SourceGenerator` is an incremental Roslyn source generator that makes C# 15 **union** error cases ergonomic with `Result`. It generates the `[GenerateResult]` attribute, one `AsFailure()` helper per union case, and the diagnostics that keep unsupported shapes out of a build. ## Installation ```bash dotnet add package Purview.Results.SourceGenerator dotnet add package Purview.Results ``` The component targets `netstandard2.0` (with the SDK's Roslyn defaults) so any compiler host can load it. Requirements: a C# 15 compiler with union declaration support (the .NET 11 SDK or later) and `LangVersion=preview`. ## Activation and design - **Opt-in only.** Nothing is generated for a compilation that never applies `[GenerateResult]`; discovery runs through `ForAttributeWithMetadataName` and only the attribute source is produced for every compilation. - **The attribute is generated** during post-initialization (`GenerateResultAttribute.g.cs`), so consumers do not declare it and do not need another package reference. - **One analyzer, one generator, one diagnostics library.** The analyzer raises the per-target rules; the 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. ## The incremental pipeline The pipeline resolves Roslyn symbols during discovery and converts them into value-equatable models (`ResultUnionModel`, `ResultUnionCaseModel`, `ResultSourceLocation`, `EquatableArray`). No `ISymbol`, `Compilation`, `SemanticModel`, `IOperation`, `SyntaxNode`, `SyntaxTree` or `Location` ever reaches cached state, which is what stops an unrelated edit from regenerating output. `ResultsSourceGeneratorCacheTests` proves this stage by stage with `GenerateIncrementalAsync`: `New` on the first run, `Cached`/`Unchanged` on an identical rerun, and `Modified` only for the stages whose inputs actually changed. `CodeWriter` is created inside the `RegisterSourceOutput` callback, never stored in cached state. ## Deterministic output - Cases are ordered **ordinally by fully-qualified name**. - Unions are processed in **ordinal order**. - Hint names derive from the union's identity. - Nothing emits timestamps, GUIDs or machine paths, so the same inputs always produce byte-identical output. ## Accessibility and nullability Accessibility never widens: the generated class is `public` only when the union and every case type it references are visible; otherwise it is `internal`. Generated files start with `#nullable enable`, reference fully qualified names, and do not add `?` to non-nullable case types. ## Generated shape ```csharp namespace Test.Tenancy; public static class TenantErrorResultExtensions { public static Result AsFailure(this TenantNotFound error) => Result.Failure(error); public static Result AsFailure(this TenantDisabled error) => Result.Failure(error); } ``` The generated helpers are pure static methods that call the existing `Result.Failure` factory: there is no reflection, no `dynamic`, no runtime type discovery and no mutable static state. The generator does not change, wrap or replace `Result`. ## Build properties | Property | Default | Purpose | | --- | --- | --- | | `ResultsSourceGenerator_Disable` | `false` | Disables generation while still emitting the opt-in attribute | The switch reaches `PackageReference` consumers through `buildTransitive/Purview.Results.SourceGenerator.props`, so `-p:ResultsSourceGenerator_Disable=true` (or a `Directory.Build.props` setting) disables the helpers there too. Release tracking for the rules lives in `src/src/SourceGenerator/AnalyzerReleases.Unshipped.md`. ## Packaging The generator is never packed loose: the package ships its merged analyzer under `analyzers/dotnet/cs/` with no `lib/` folder and **no PDB** (`PurviewPackAnalyzerPdb=false`), because the packaged analyzer is the framework's ILRepack-merged assembly whose rewritten PDB carries no Roslyn compiler-flags record. The `CS0029` code fix ships beside it as a second analyzer assembly (`Purview.Results.SourceGenerator.CodeFixes.dll`) and is never IL-merged into the generator. ## Related - [Union Errors](../union-errors/) — the union modelling this generator targets. - [Diagnostics](../diagnostics/) — the rules, the blocking policy and the suppression. - [Testing](../testing/) — the generation, cache and compiler-experiment tests. --- # Diagnostics The union rules live in one shared library (`Diagnostics/DiagnosticLibrary.cs`, `Diagnostics/ResultUnionDiagnostics.cs`, `Diagnostics/ResultDiagnostic.cs`) that both hosts consume: - **`ResultsDiagnosticAnalyzer` reports the per-target rules** (`RSG1000`–`RSG1004`, `RSG1007`) in the IDE and in build output. - **The generator reports the compilation-wide rules** (`RSG1005`, `RSG1006`) that need every opted-in union in the compilation. - The generator never reports a rule the analyzer reports. It still runs the same shared analysis, so `DiagnosticLibrary.IsBlocking` is the single blocking policy, but a finding is never surfaced twice. ## Rules | 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. | | `RSG1002` | Error | Analyzer | Union: yes / case: no | A generic union, or a case type that contains type parameters. | | `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 case's helper is skipped) | A case type is shared with another union, so its helper is generated once to keep call sites unambiguous. | | `RSG1007` | Error | Analyzer | Yes | The union uses an `IUnionMembers` member provider, which the first implementation does not support. | Case-level findings never block the union: the remaining cases are still generated and the skipped case is reported. Blocking is decided per rule in `DiagnosticLibrary.IsBlocking`, not derived from severity, because a finding may be an error the consumer must fix while usable output can still be produced. Every rule is categorized as `Purview.Results.Usage` and tracked in `src/src/SourceGenerator/AnalyzerReleases.Unshipped.md`, which the compiler's RS2008 rule catalogue validates. ## Diagnostic suppressions A union declaration gets no value equality from the compiler, so every union raises `CA1815` ("Override equals and operator equals on value types") unless it is silenced by hand with a `#pragma warning disable CA1815` or a `[SuppressMessage]`. A `[GenerateResult]` union is the error type of a result and is read by matching its case type, not by comparing two unions by value, so the package answers that warning: | Suppression ID | Suppressed rule | Applies to | | --- | --- | --- | | `RSG2000` | `CA1815` | A declaration that is a union **and** is opted in with `[GenerateResult]` | The suppression is narrow on purpose: - A union without `[GenerateResult]` keeps the warning — the package only speaks for the unions it generates for. - The union's **case types** keep the warning, as does every other value type. A case declared as a plain `struct` still gets `CA1815`; a `record struct` case never gets it, because records synthesise equality. - Nothing is hidden: a suppressor can only suppress non-error, configurable diagnostics, and `RSG2000` is a suppression id rather than a rule, so `RSG1000`–`RSG1007` remain the only diagnostics the package reports. Every suppression is logged as an `Info` diagnostic against `RSG2000`, in the verbose build log and in an MSBuild binlog (and as a suppressed diagnostic in an `/errorlog` SARIF file), so a build can always be audited for what it suppressed. ### Keeping `CA1815` Add `RSG2000` to the compiler's warning suppressions, and the warning returns for every union declaration, including the opted-in ones: ```xml $(NoWarn);RSG2000 ``` Declaring equality on the union is the other way to satisfy `CA1815` — it is the code the warning asks for, and a union that has it never raises the warning, so the suppressor has nothing to do. ## Related - [Union Errors](../union-errors/) — the union shapes the rules describe. - [Source Generator](../source-generator/) — where each host runs and how output is produced. - [Testing](../testing/) — how the analyzer, suppressor and code fix are verified. --- # ASP.NET Core Integration `Purview.Results.AspNetCore` maps a `Result` onto an ASP.NET Core response, so an endpoint can return a result and let the host decide what each error case looks like on the wire. ## Installation ```bash dotnet add package Purview.Results.AspNetCore ``` ## Quick start ```csharp using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; var builder = WebApplication.CreateBuilder(args); builder.Services.AddResultsHttp(options => options .Map(error => TypedResults.NotFound()) .Map(error => TypedResults.Problem(statusCode: StatusCodes.Status403Forbidden)) .Map(error => TypedResults.Problem(statusCode: StatusCodes.Status409Conflict)) // every unmapped case .AddFallback((error, context) => error is null ? null : TypedResults.Problem()) ); var app = builder.Build(); app.MapGet("/tenants/{id:int}", (int id) => GetTenant(id)).WithResultsHttp(); ``` `AddResultsHttp` also registers problem-details services (`AddProblemDetails()`), so the unmapped-failure and uninitialized-result paths work without further host setup. It uses `TryAddSingleton` for `IResultsHttpMapper` and `ResultsEndpointFilter`, so a host can register its own mapper first and replace the defaults. `WithResultsHttp()` exists on both `RouteHandlerBuilder` and `RouteGroupBuilder`. The endpoint filter is non-invasive: a handler that returns something other than an `IResultValue` is left untouched. ## How a result becomes a response **Success** — the value is serialized with `SuccessStatusCode` (`200 OK` by default). A result whose successful value is itself an `IResult` is passed through untouched, and `SuccessMapper` overrides both when set. **Failure** — the error is resolved to its *case* value (the active case of a union error, or the error itself for a non-union error), then mapped in this order: 1. a mapping registered for the **case** type — `Map(...)` 2. a mapping registered for the **error** type — `Map(...)`, which handles every case without its own mapping 3. the **fallback stage**, in registration order: `AddFallback(...)` delegates and `AddFailureMapper()` mappers share one ordered list; a fallback or mapper returns `null` to defer to the next entry 4. a `ProblemDetails` response using `UnmappedStatusCode` (`500`), `UnmappedTitle`, and an `errorType` extension naming the unmapped case — or an `InvalidOperationException` when `ThrowOnUnmappedFailure` is set An **uninitialized** result (`default`) takes the same path and is logged, because an endpoint returning `default` is a host bug rather than a domain outcome. ## Options | Option | Default | Purpose | | --- | --- | --- | | `SuccessStatusCode` | `200` | Status code for a serialized successful value | | `SuccessMapper` | `null` | Replaces the default success handling entirely | | `UnmappedStatusCode` | `500` | Status code for a failure with no mapping | | `UnmappedTitle` | *"The operation failed with an error that is not mapped to an HTTP response."* | Title of the unmapped `ProblemDetails` | | `ThrowOnUnmappedFailure` | `false` | Throw instead of producing a problem response; useful during development | | `IncludeTraceId` | `true` | Whether problem responses this package writes itself carry the request trace identifier | | `Map(Func)` | — | Maps a case (or the error itself) to a response | | `Map(Func)` | — | Same, with access to the request | | `AddFallback(Func)` | — | Consulted in order for unmapped failures, with the case value | | `AddFailureMapper()` | — | Same stage, for a mapper class resolved from dependency injection | Registering the same type twice replaces the earlier mapping. ## Choosing an extension point | The rule needs… | Use | | --- | --- | | One answer per error or case **type** | `Map(...)` | | The **value** the failure carries — a validation code, a category, a field | `IResultsFailureMapper` via `AddFailureMapper()` | | A quick inline rule, with no dependencies | `AddFallback((error, context) => ...)` | | To replace the whole pipeline | Your own `IResultsHttpMapper` | A failure mapper is a **shape** rule, not a catch-all: ```csharp public sealed class BlankIdentifierMapper : IResultsFailureMapper { public IResult? Map(ResultsFailureContext context) => context.Case is ITenantFailure { TenantId.Value: var id } && string.IsNullOrWhiteSpace(id) ? TypedResults.Problem(statusCode: StatusCodes.Status400BadRequest, title: "An identifier is required.") : null; // defer: the case mappings and the other fallbacks still apply } builder.Services.AddSingleton(); builder.Services.AddResultsHttp(options => options .Map(_ => TypedResults.NotFound()) .AddFailureMapper()); ``` `ResultsFailureContext` carries the failure's **case** (the active case of a union error, or the error itself), the **error** the result carries, and the request. A mapper is resolved from the failing request's services the first time it is needed, so it may take its own dependencies in its constructor; register it before the first request. A failure that reaches an unregistered mapper throws an `InvalidOperationException` naming the registration that is missing. Answering every failure in a mapper — with a generic problem or a `202`, for example — turns a mapping gap in the host into a plausible-looking response, which is exactly what the unmapped-failure path exists to expose. Map the shapes you can name and return `null` for the rest. ## Converting a result by hand ```csharp app.MapGet("/tenants/{id:int}", (int id, HttpContext context) => GetTenant(id).ToHttpResult(context)); ``` `ToHttpResult(HttpContext)` resolves `IResultsHttpMapper` from the request services; the `ToHttpResult(IResultsHttpMapper, HttpContext)` overload takes one directly. ## Extensibility `IResultsHttpMapper` is registered with `AddResultsHttp` as `DefaultResultsHttpMapper` via `TryAddSingleton`, so a host can register its own implementation first to replace the defaults entirely. A host that replaces it also bypasses `ResultsHttpOptions` — including the failure mappers — so prefer `Map`, `AddFallback` and `AddFailureMapper` unless the pipeline itself has to change. ## Example responses Running `Examples.AspNetCore`: | Request | Response | | --- | --- | | `GET /tenants/acme` | `200 OK` with the tenant | | `GET /tenants/initech` | `404 Not Found` — the mapping for the `TenantNotFound` case | | `GET /tenants/globex/usage` | `403 Forbidden` — the mapping for the `TenantDisabled` case | | `POST /tenants/acme` | `409 Conflict` — the mapping for the `TenantError` error type | | `GET /tenants/broken` | `500` with an `errorType` extension, because an endpoint returning `default` is a host bug | ```bash dotnet run --project src/examples/Examples.AspNetCore --urls http://localhost:5215 ``` ## Related - [ZodSharp Problem Details](../zodsharp-problemdetails/) — validation-carrying failures rendered as `HttpValidationProblemDetails`. This package deliberately knows nothing about ZodSharp. - [Getting Started](../) — the end-to-end walkthrough. --- # ZodSharp Integration `Purview.Results.ZodSharp` bridges [ZodSharp](https://www.nuget.org/packages/Purview.ZodSharp) `ValidationResult` values into results, so validation outcomes flow through the same result pipeline as every other expected outcome instead of throwing. ## Installation ```bash dotnet add package Purview.Results.ZodSharp ``` ## Why a bridge ZodSharp validation never throws: `Validate` returns a `ValidationResult` carrying the validated value on success and every `ValidationError` on failure. That is already a result-shaped value, but it is not `Result` — the failure type is fixed rather than chosen by the caller. `ToResult` closes that gap. ## Quick start ```csharp return RepositoryReconciliationResultSchema .Validate(reconciliationResult) .ToResult(errors => new ReconciliationResultInvalid(reconciliationResult, errors) ); ``` `ToResult` names both type arguments explicitly — including the **error** type — because a union case does not carry the union type that contains it: ```csharp .ToResult(...) ``` ## When the success type differs from the validated type If the surrounding method's success value is not the validated value, produce the failure with the generated `AsFailure()` helper instead, because the validated value cannot be carried forward: ```csharp var validated = ProviderConnectionIdSchema.Validate(providerConnectionId); if (!validated.IsSuccess) return new ProviderConnectionInvalid(providerConnectionId, validated.Errors) .AsFailure(); return await ReconcileCoreAsync(validated.Value, repositories, cancellationToken); ``` ## Carrying validation errors in the error value `IValidationErrorCarrier` is implemented by an error value that carries ZodSharp validation errors, so an HTTP layer can turn it into a validation problem without knowing the error type: ```csharp public readonly record struct TenantInputInvalid(TenantInput Input, ImmutableArray Errors) : IValidationErrorCarrier { public ImmutableArray ValidationErrors => Errors; } ``` `IValidationErrorCarrier` exposes a single `ValidationErrors` member, preserving each `ValidationError`'s code, category, path and parameters. [ZodSharp Problem Details](../zodsharp-problemdetails/) consumes the interface; without it, a host would have to map every validation-carrying error individually. ## API | Member | Purpose | | --- | --- | | `ValidationResult.ToResult(Func, TError> onFailure)` | Success carries the validated value; failure carries the created error. `onFailure` runs only when validation failed, so a successful validation allocates no error | | `IValidationErrorCarrier` | Implemented by an error value that carries ZodSharp validation errors | A factory returning a **result** rather than an error is deliberately not offered: for a lambda returning `Result` the compiler prefers a `Func<..., TError>` parameter and would silently nest the results. Naming the error type explicitly keeps the intent unambiguous. ## Example ```bash dotnet run --project src/examples/Examples.Zod ``` `Examples.Zod` validates a `[ZodSchema] TenantInput` and turns the outcome into a `Result`, with the rejection carrying its reported `ValidationError`s. ## Related - [ZodSharp Problem Details](../zodsharp-problemdetails/) — the ASP.NET Core rendering of an `IValidationErrorCarrier` failure. - [Core Concepts](../core-concepts/) — the result states the validated value flows into. --- # ZodSharp Problem Details `Purview.Results.ZodSharp.AspNetCore` renders a result failure that carries ZodSharp validation errors as an ASP.NET Core `HttpValidationProblemDetails`, produced by the same mapper the ZodSharp exception handler uses — so a validation failure carried by a result and the same failure thrown as a `ZodException` produce identical responses. ## Installation ```bash dotnet add package Purview.Results.ZodSharp.AspNetCore ``` ## Quick start ```csharp using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; var builder = WebApplication.CreateBuilder(args); builder.Services.AddZodSharpProblemDetails(); builder.Services.AddResultsHttp(); // Every validation-carrying failure becomes one validation problem, except the codes and categories that a // rule answers with something else. builder.Services.AddResultsZodSharpHttp(options => options .MapCode("tenant_not_found", StatusCodes.Status404NotFound) .MapCategory("invalid_value", StatusCodes.Status422UnprocessableEntity) ); var app = builder.Build(); app.MapPost("/reconcile", (ReconciliationRequest request) => Reconcile(request)).WithResultsHttp(); ``` `AddResultsZodSharpHttp` registers the mapping as a **failure mapper**, so: - a mapping the host registered for a specific error **case** always wins, - a mapping registered for the **error** type wins, - and a host failure mapper registered **before** `AddResultsZodSharpHttp` wins too. Register it **last**, therefore, so everything the host declared earlier takes precedence. ## Answering by validation error code or category | Member | Purpose | | --- | --- | | `MapCode(string code, int statusCode)` | Renders the validation problem with a different default status | | `MapCode(string code, Func, HttpContext, IResult?>)` | Renders a response of your own | | `MapCategory(string category, int statusCode)` | The same, for a category that spans many codes | | `MapCategory(string category, Func, HttpContext, IResult?>)` | Renders a response of your own | A rule applies when **any** of the failure's errors carries its code or category, so a registered code rule is always reachable whatever else the schema reported. Matching walks the **code** rules first, then the **category** rules, each in registration order, and finally the default validation problem — a code is narrower than a category, so it wins however the two were registered. A factory that wants stricter semantics returns `null` to decline the failure, and matching continues: ```csharp options // A code rule that only answers a failure whose every error is that code. .MapCode("tenant_not_found", (errors, context) => errors.All(error => error.Code == "tenant_not_found") ? TypedResults.NotFound() : null) // A category rule that answers the failures the code rule declined. .MapCategory("invalid_value", StatusCodes.Status422UnprocessableEntity); ``` A factory receives the failure's **full error set**, so a rule never hides the other problems the caller has to fix, and the `int statusCode` overloads still render every error as an `HttpValidationProblemDetails`. A code or category registered twice with different behaviour is rejected at configuration time. A failure that does **not** carry validation errors is declined (`null`), so other mappers and fallbacks still apply — this mapper never answers a failure it does not understand. ## API | Member | Purpose | | --- | --- | | `AddResultsZodSharpHttp(Action? configure = null)` | Registers the ZodSharp options and adds the validation failure mapper to `ResultsHttpOptions` | | `ZodResultsHttpOptions.MapCode` / `.MapCategory` | The per-code and per-category rules described above | | `ZodResultsFailureMapper` | The mapper itself, for a host that wants to register or compose it by hand | | `IValidationErrorCarrier.ToValidationProblem(HttpContext, int statusCode = 400)` | Creates the validation problem for the errors the error value carries | | `ImmutableArray.ToValidationProblem(HttpContext, int statusCode = 400)` | Creates the validation problem for a set of errors | | `ZodValidationProblems.ToProblem(errors, options, defaultStatusCode = 400, traceId = null)` | The underlying mapper, for hosts that resolve the options themselves | The status code resolved by `ZodProblemDetailsOptions.StatusCodeSelector` wins; `statusCode` (or `defaultStatusCode`, or a rule's `statusCode`) is only used when the resolved error type does not define one. The trace identifier is included when `ResultsHttpOptions.IncludeTraceId` is `true` (the default). The mapper reuses `ZodValidationProblems.ToProblem` rather than reimplementing error-to-problem mapping, which is what keeps a result-carried validation failure and a thrown `ZodException` in agreement on status code, title, detail and the `issues` extension. ## Example ```bash dotnet run --project src/examples/Examples.AspNetCore.Zod --urls http://localhost:5216 ``` `Examples.AspNetCore.Zod` shows a `TenantInputInvalid` failure carrying ZodSharp errors becoming a `400` validation problem, while the host's own `TenantAlreadyExists` mapping still returns `409`. ## Related - [ZodSharp Integration](../zodsharp-integration/) — producing the results that carry validation errors. - [ASP.NET Core Integration](../aspnetcore-integration/) — the failure resolution order this mapper plugs into. --- # Guarantees and Limitations This page records what the suite guarantees, and what it deliberately does not do. Treat it as the contract a change must not break. ## Runtime guarantees - `Result` is a `readonly record struct` with exactly three observable states: `Uninitialized` (the `default` value), `Success` and `Failure`, each observable through `IsInitialized`, `IsSuccess` and `IsFailure`. - The throw-on-misuse contract holds: `Value` and `Error` throw `InvalidOperationException` in the wrong state, and `Match`, `Map`, `Bind` and `MapError` throw `"The result is uninitialized."` for `default`. An uninitialized result is never silently coerced into a success or a failure. - `ToString()` stays `Success(value)` / `Failure(error)` / `Uninitialized`. - `IResultValue` accessors never throw; the accessor that does not describe the current state returns `null`. - The implicit conversions from `TValue` and `TError`, and the `Result.Success`/`Result.Failure` and `Result.Success`/`.Failure` factories, are public contract. ## Dependency guarantees - `Purview.Results` is dependency-free. - The ZodSharp and ASP.NET Core packages depend on it, never the reverse. - `Purview.Results.AspNetCore` deliberately knows nothing about ZodSharp; validation handling lives in `Purview.Results.ZodSharp.AspNetCore`. - The runtime packages contain no reflection, no `dynamic` and no runtime type discovery. Union structure is inspected only by the source generator, through Roslyn symbols. ## Union support limits - `[GenerateResult]` is supported on union declarations only. Generic unions are unsupported (`RSG1002`), and so are `IUnionMembers` member providers (`RSG1007`). - Generated implicit conversions are not possible — see [Union Errors](../union-errors/) for the five compiler rules. The per-case `AsFailure()` helper and the IDE code fix are the ergonomics the language allows; the helper-free alternative is the `(TenantError)caseValue` cast. - Accessibility never widens: a union or case type that is not visible produces an `internal` generated class (`RSG1003` covers the case where generated code could not reference a type at all). ## HTTP mapping philosophy - An unmapped failure is a host mapping gap, not a domain outcome. It is answered with `UnmappedStatusCode` (`500`) and a `ProblemDetails` carrying the `errorType` extension, and it is logged. - `ThrowOnUnmappedFailure` exists for development and keeps throwing `InvalidOperationException`. - An uninitialized result (`default`) takes the unmapped path and is logged, because an endpoint returning `default` is a bug. - `IResultsFailureMapper` is the way in for rules keyed by a **value** rather than a type, and it must not become a catch-all that answers every failure — that hides exactly the gaps the unmapped-failure response exists to expose. ## ZodSharp mapping philosophy - Code rules are consulted before category rules (each in registration order), then the default validation problem, so a rule can only narrow what the host already gets. - A rule matches when **any** of the failure's errors carries its code or category: a rule a schema can silently never reach is the kind of gap this suite surfaces rather than hides. - A factory returns `null` to decline, and matching continues. Factories see the failure's whole error set. ## Not in scope - No exception-to-result conversion: exceptions remain for exceptional circumstances. - No runtime union inspection: matching an error case is ordinary C# pattern matching. - No replacement for `Result`: the generator only adds call-site ergonomics. - No `Task`/`ValueTask`-specific async combinators beyond `MapAsync` and `BindAsync`. ## Related - [Core Concepts](../core-concepts/) — the state contract in detail. - [Diagnostics](../diagnostics/) — the rules that enforce the union limits. - [ASP.NET Core Integration](../aspnetcore-integration/) — the mapping order described above. --- # Testing Tests are **TUnit on Microsoft.Testing.Platform** only — never xUnit, NUnit, MSTest, NSubstitute or FluentAssertions. `TUnit.Mocks` and `Bogus` are added to test projects automatically by `Purview.BuildSdk`, so they are versioned centrally and must not be added as `PackageReference`s. ## Running the tests ```bash just test-unit ``` That resolves to the whole solution's unit tests through the supported tree-node filter: ```bash dotnet test src/Results.slnx --no-build --treenode-filter "/*/*/*/*[Category=Unit]" ``` 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`. ## Test projects | Project | Covers | | --- | --- | | `src/tests/Results.UnitTests` | `Result` states, the throw-on-misuse contract, and the extension operations (`ResultsTests`, `ResultExtensionsTests`) | | `src/tests/SourceGenerator.UnitTests` | Generation, diagnostics, incremental caching, the compiler experiments, 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 | ## Conventions - Test naming is `{SubjectUnderTest}_{Scenario}_{Expectation}`, for example `DefaultResult_ShouldBeUninitialized`, `MapError_WhenFailure_ShouldTransformError`, `GenerateIncrementalAsync_GivenFirstRun_MarksEveryStageNew`. - One `{Class}Tests` class per class under test, in the same namespace. - `public async Task` with a `CancellationToken` whenever the API under test accepts one. - Mock with `TUnit.Mocks`; do not introduce a substitute framework. ## Source-generator tests Generator tests derive from `TUnitSourceGeneratorTestBase` with an options record that seeds the required namespaces and assemblies; `ResultsSourceGeneratorTestOptions` is the reference. Assert on generated code with the framework's `CodeQuery` and terminal assertion extensions (`result.Generated()`, `HasGeneratedSyntaxTree`, `HasGeneratedClass`) rather than raw strings. **Incremental pipelines need stage-by-stage cache tests.** `ResultsSourceGeneratorCacheTests` is the reference: it drives `GenerateIncrementalAsync`, asserts `New` on the first run, `Cached`/`Unchanged` on an identical rerun, and `Modified` only for the stages whose inputs changed. ## Compiler experiments Behaviour that motivates a design decision is recorded as a compiler experiment rather than an assumption. `UnionCompilerBehaviourTests` proves the `CS0029`/`CS1929` cases the generator exists to work around, and the `CS0715`/`CS0556`/`CS9282`/`CS0246` cases that rule out generated implicit conversions. When a language claim in these docs changes, the experiment is where it is verified. ## Code-fix tests A code fix that answers a **compiler** diagnostic cannot use `TUnitCodeFixTestBase`, because that base is driven by an analyzer's diagnostics. `UnionCodeFixTestHarness` instead runs the generator, applies the fix through an `AdhocWorkspace`, and recompiles the rewritten source so a broken fix cannot pass. A `Document` is an immutable snapshot, so the harness re-resolves it from the workspace's current solution after adding documents. ## Suppressor tests The real `CA1815` comes from the .NET analyzers the SDK loads, which a unit-test compilation cannot reference, so `UnionEqualityDiagnosticSuppressorTests` reports the same id at the same location from a test-only analyzer (`Ca1815ReporterAnalyzer`) and runs it beside the shipped suppressor through `CompilationWithAnalyzers`. The compilation under test is still real: it is produced by running the generator, so the union, the generated attribute and the generated helpers are all present. ## Related - [Source Generator](../source-generator/) — the pipeline the cache tests guard. - [Diagnostics](../diagnostics/) — the rules the analyzer tests assert. - [Contributing](../contributing/) — the day-to-day command set. --- # Agent Skills The packages ship **agent skills** to consumers. A repository that imports `Purview.BuildSdk` receives them in its own `.agents/` folder on the next restore or build, so AI agents working there get the guidance automatically. The content is authored in this repository under `src/src//Sdk/.agents/**` and packed as `.agents/**`; there is nothing to install by hand. ## Skills shipped by this repository | Content | Package | Covers | | --- | --- | --- | | `skills/purview-results-core` | `Purview.Results` | The result type, its three states, combinators and the throw-on-misuse contract | | `skills/purview-results-union-errors` | `Purview.Results.SourceGenerator` | Union modelling, the generated helpers, the diagnostics table, and the language rules that make the helper necessary | | `skills/purview-results-http-mapping` | `Purview.Results.AspNetCore` | Result-to-HTTP mapping, the failure resolution order, and unmapped-failure `500`s | | `skills/purview-results-zodsharp-validation` | `Purview.Results.ZodSharp` | ZodSharp validation flowing through results | | `skills/purview-results-zodsharp-problems` | `Purview.Results.ZodSharp.AspNetCore` | Rendering validation-carrying failures as ProblemDetails | The generator package also ships the `purview-results-union-author` agent and the `migrate-error-returns-to-result-unions` prompt. ## When to load which skill - Modelling or migrating to result error unions → `purview-results-union-errors` - The result type, its states or its combinators → `purview-results-core` - Result-to-HTTP mapping or unmapped-failure `500`s → `purview-results-http-mapping` - ZodSharp validation flowing through results → `purview-results-zodsharp-validation`, then `purview-results-zodsharp-problems` ## Upstream skills `Purview.BuildSdk` and the source-generator framework packages also deliver their own skills (project placement, SDK configuration, generator authoring and testing, TUnit authoring) into the same `.agents/` tree. Those are owned by the package that ships them — changes belong in the owning repository, not here. ## Related - [Contributing](../contributing/) — where the authored content lives in this repository. - [Source Generator](../source-generator/) — packaging and distribution of the component. --- # Release Flow Releases are driven by the shared [purview-dev/build](https://github.com/purview-dev/build) pipeline through the GitHub Actions workflows in `.github/workflows/`. Consuming repositories own configuration through `purview-build.json`, not pipeline source code. ## Versioning `package.json` is the authoritative release and package version; every package is versioned from it by `Purview.BuildSdk`. Never diverge a project's version by hand. The current line is `1.0.0-prerelease.1`; a prerelease uses a `MAJOR.MINOR.PATCH-prerelease.N` suffix. ## Workflows | Workflow | Trigger | Pipeline | | --- | --- | --- | | `.github/workflows/pr.yml` | pull requests against `main` (opened, synchronize, reopened, ready_for_review) | shared `purview-build.yml`, with `run-pack: true` and `validate-pack: true` | | `.github/workflows/release.yml` | push to `main` | shared `purview-release.yml` with `release-mode: NuGet` | Both workflows pin `dotnet-version` to the SDK in `global.json` (`11.0.100-rc.1.26425.128`) — 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. 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. ## `purview-build.json` | Key | Value | | --- | --- | | `Build:Solution` | `src/Results.slnx` | | `Build:TestRoot` | `src/tests` | | `Build:TestPatterns` | `*Tests.csproj` | | `Build:TestProjects` | `*UnitTests*` | | `Build:TestFilter` | `/*/*/*/*[Category=Unit]` | | `PackValidation:RequireSymbolPackage` | `false` | | `PackValidation:RequiredContent` | The exhaustive per-package content declaration | | `Release:Mode` | `None` (publishing is enabled only by the release workflow) | `RequireExplicitContent` defaults to `true`, so `RequiredContent` is exhaustive: a package with no rule, or an entry matching none of its globs, fails validation. Update it whenever package content changes — a new asset, a removed PDB, a renamed analyzer. ## Packed shapes that must not regress - `Purview.Results.SourceGenerator` ships its merged analyzer under `analyzers/dotnet/cs/` with **no `lib/` folder** and **no PDB** (`PurviewPackAnalyzerPdb=false`), because the packaged analyzer is the framework's ILRepack-merged assembly whose rewritten PDB carries no Roslyn compiler-flags record. The `CS0029` code fix ships beside it as `Purview.Results.SourceGenerator.CodeFixes.dll` and is never IL-merged into the generator. - The library packages ship `lib//.dll` plus the XML documentation file, and a symbol package. - Every package ships its `README.md`, its `.agents/**` content and `purview-logo-light.png`. The framework assembly must never appear loose beside the merged analyzer — `ForbiddenContent` rejects `analyzers/**/Purview.SourceGeneratorFramework.dll`. ## Analyzer release tracking `Purview.Results.SourceGenerator` ships public diagnostics (`RSG1000`–`RSG1007`), so it maintains the Roslyn release-tracking files. New or changed rules go in `src/src/SourceGenerator/AnalyzerReleases.Unshipped.md`, which the compiler's RS2008 catalogue validates during the build. `RSG2000` is a *suppression* id, not a reported rule, so it must not appear there. ## Commands ```bash just pipeline-pr # restore, build, lint, tests, pack, validate pack just pipeline-build # restore, build, lint (no tests, no release) just pipeline-pack-validate # restore, build, lint, tests, pack, validate contents just pipeline-release # pack, publish, GitHub release (NuGet mode) just pipeline-local-release # pack, publish to a local NuGet feed just version # the version package.json declares ``` `pipeline-pr` and the other pipeline recipes install the pinned `Purview.Build` tool into `.tools/purview-build` when it is missing. ## Testing the packed packages locally A `.nupkg` published under a **new** version always extracts fresh, so the supported flow is: bump `package.json`, publish to the local feed, then scrub the consuming repository. In this repository, publish the new version to the local feed: ```bash just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/ ``` Then, in the consuming repository: ```bash just scrub just build ``` `LOCAL_NUGET_FEED_PATH` is exported in the development environment, so the explicit switch is only needed when it is not. NuGet reuses an already-extracted package for the same id **and** version, so republishing the same version requires deleting `$(NUGET_PACKAGES)//` first — prefer a new version. ## Related - [Contributing](../contributing/) — the local commands and quality gates. - [Diagnostics](../diagnostics/) — the rules the release-tracking file lists. --- # Contributing Contributions are welcome. Open an issue or a pull request against [purview-dev/results](https://github.com/purview-dev/results). ## Repository layout ``` src/ Results.slnx Canonical solution for restore, build, test and pack src/ Results/ Result, Result factories, IResultValue SourceGenerator/ Roslyn incremental generator + analyzer + [GenerateResult] SourceGenerator.CodeFixes/ CS0029 code fix for returning a bare union case AspNetCore/ Result-to-response mapping, endpoint filter, DI registration ZodSharp/ ZodSharp ValidationResult bridge ZodSharp.AspNetCore/ Validation-problem mapping for validation-carrying failures Examples.Basic/ Runnable examples, one project per integration aspect Examples.Zod/ Examples.AspNetCore/ Examples.AspNetCore.Zod/ /Sdk/ Package-only assets: README.md and .agents/** skills tests/ TUnit unit tests (one *.UnitTests project per package) docs/wiki/ This documentation suite Directory.Packages.props Centrally managed NuGet versions purview-build.json Shared pipeline configuration package.json Authoritative repository and package version ``` ## Commands ```bash dotnet tool restore just restore just build just test-unit just lint-check just lint-fix just pack just pipeline-pr ``` Local CI-equivalent validation: ```bash dotnet restore src/Results.slnx dotnet build src/Results.slnx --no-restore dotnet test src/Results.slnx --no-build --treenode-filter "/*/*/*/*[Category=Unit]" dotnet csharpier check . bun run lint:slnx:check ``` `just lint-fix` runs CSharpier and the `.slnx` GUID cleanup. Formatting is enforced with CSharpier using the root `.editorconfig`. ## Working agreement 1. Read `AGENTS.md`, inspect the working tree, and locate the implementation, tests, documentation and existing patterns relevant to the change. 2. Confirm behaviour from code and tests rather than relying on memory or documentation alone. 3. Make the smallest coherent change; preserve public behaviour unless the task explicitly changes it. 4. Update tests for fixes and behaviour changes, and documentation when public behaviour changes. 5. Run the narrowest meaningful validation first, then broader validation in proportion to risk. 6. Review the diff for unrelated edits, generated noise, compatibility risks and missing docs or tests. ## Commit conventions Commits follow [Conventional Commits](https://www.conventionalcommits.org/), enforced by Lefthook and Commitlint (`.config/lefthook.yml`, `commitlint.config.mts`). Allowed types: `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, `test`. ## Quality gates - `just build` succeeds with no new warnings or errors. - The relevant tests pass (`just test-unit`). - `just lint-check` reports no formatting or `.slnx` changes. - Packed packages match `purview-build.json` `PackValidation` (`just pipeline-pack-validate`). - Generated code stays deterministic and reviewable. ## Documentation This wiki lives in `docs/wiki/`. The purview-dev site build pulls these files through `github-path` aggregation and rewrites relative `.md` links into site routes. Conventions: - `_Sidebar.md` declares page order; it is parsed by the sync and never rendered (as are `Home.md` and `index.md`, which the catalogue project excludes). - Every page starts with a single `# ` heading followed by a one-paragraph description. - Link to other pages with relative `.md` links: `[Getting Started](../)`. - GitHub alert blockquotes (`> [!NOTE]`, `> [!TIP]`, `> [!WARNING]`, `> [!CAUTION]`, `> [!IMPORTANT]`) become Starlight asides. - Relative repository-path links are rewritten to GitHub blob URLs automatically. - Keep `README.md`, each package's `Sdk/README.md`, `AGENTS.md` and this wiki aligned with actual behaviour. If a change alters diagnostics, build properties, defaults, resolution order or public API, change all of them in the same commit. ## 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 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`. ## Related - [Testing](../testing/) — the TUnit conventions and source-generator testing approach. - [Release Flow](../release-flow/) — versioning, workflows and pack validation. - [Agent Skills](../agent-skills/) — the guidance the packages ship to consumers.