From 8a38e0e9146aa5129bf33631fcf2529bc0dc1a0e Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 8 Oct 2026 20:59:42 +0100 Subject: [PATCH 01/13] feat: better ZodSharp and ValueObjects integration --- CHANGELOG.md | 20 +++ README.md | 2 +- docs/wiki/Value-Objects-Composition.md | 29 +++- docs/wiki/ZodSharp-Integration.md | 18 +++ package.json | 2 +- src/src/AspNetCore/ResultsHttpOptions.cs | 8 +- src/src/Examples.ValueObjects.Zod/TenantId.cs | 23 +-- src/src/Results/ResultTError.cs | 2 +- .../Diagnostics/ResultUnionDiagnostics.cs | 6 +- .../ZodResultsFailureMapper.cs | 2 +- .../ZodResultsHttpOptions.cs | 23 ++- .../ZodSharp.AspNetCore.csproj | 8 ++ .../SKILL.md | 19 ++- src/src/ZodSharp/Sdk/README.md | 18 ++- .../ZodSharp/ZodValidationResultExtensions.cs | 99 +++++++++++++ .../ResultsSourceGeneratorTests.cs | 5 +- .../ValueObjectRuleTestModels.cs | 28 +++- .../ZodResultsFailureMapperTests.cs | 133 ++++++++++++++++++ .../ZodValidationResultExtensionsTests.cs | 131 +++++++++++++++++ .../ZodValidationResultIntegrationTests.cs | 55 ++++++++ 20 files changed, 595 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af6b37c..6fcc880 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,18 @@ been published to NuGet. ### Added +- **Asynchronous validation folding in `Purview.Results.ZodSharp`.** `ToResultAsync` and `ToUnitResultAsync` accept + the `ValueTask>` that `ValidateAsync` returns — on the generated schema, the DI validator + adapter, or a `[ZodSchema(CustomValidationMethodName = ...)]` rule — so an async schema folds into the result + pipeline the same way the synchronous `Validate` does. As with the synchronous forms, the failure factory runs + only when validation failed. +- The `Examples.ValueObjects.Zod` example (and the matching end-to-end test fixture) now uses the **automatic + scalar form**, `[Scalar]`, so the composition is exercised against Purview.ValueObjects 1.0.1's + generator-declared property rather than only the manual `[Scalar]` form, whose schema Purview.ZodSharp 2.0.2+ + generates from the attribute itself. +- A test fixture covering a **nullable scalar** (`[Scalar(Nullable = true)]`) whose type-level rule is + ZodSharp's shipped null-tolerant `[NullOrNonWhiteSpace]`: `null` validates, whitespace-only is rejected, and the + rejection maps through the result pipeline by its code. - The value-less `Result` type: the same three-state `readonly record struct` contract as `Result`, but a success holds the stateless `Success` marker instead of a value. Comes with `Match`, `Switch`, `Tap`, `TapError`, `Ensure`, `Map`, `Bind`, `MapError`, `MapAsync`, `BindAsync`, `OrElse`, @@ -36,6 +48,9 @@ been published to NuGet. ### Changed +- `Purview.Results.ZodSharp.AspNetCore` references `Purview.ZodSharp` explicitly rather than relying on it + transitively through `Purview.ZodSharp.AspNetCore`, because the mapping uses `ZodException` and + `ValidationError` directly. No package content changes. - **Consuming these packages no longer forces preview features on your solution.** `EnablePreviewFeatures` was set repo-wide, which emitted `[assembly: RequiresPreviewFeatures]` into all four shipped assemblies. Because that attribute is assembly-wide, `CA2252` — an **error** by default — fired on every member a @@ -120,6 +135,11 @@ been published to NuGet. ### Fixed +- **Registering the same validation-error rule factory twice is idempotent again.** `ZodResultsHttpOptions` + compared two factories by delegate identity, and a method group allocates a new delegate on every use — so + `MapCode(code, NotAValidationError)` registered twice was rejected as a conflict even though it answers + identically. Factories are now compared by the method they bind (and its target), so the same registration twice + keeps one rule while two different lambdas still throw. - **An array-typed union case crashed the generator and discarded every other union's output.** The discovery code explicitly handles `IArrayTypeSymbol` when checking accessibility and type parameters, then cast the case to `INamedTypeSymbol` — which an array is not — throwing `InvalidCastException`. With no diff --git a/README.md b/README.md index c8999a9..2275055 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Tenancy domain the Quick start uses, so one error-union vocabulary drives every | [`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`, 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 | -| [`Examples.ValueObjects.Zod`](src/src/Examples.ValueObjects.Zod) | `Purview.Results.ZodSharp`, `Purview.ValueObjects` | A `[Scalar]` value object whose type-level `[ZodRule]` owns its code and origin, validated into a result failure the HTTP layer can answer by origin | +| [`Examples.ValueObjects.Zod`](src/src/Examples.ValueObjects.Zod) | `Purview.Results.ZodSharp`, `Purview.ValueObjects` | An automatic `[Scalar]` value object whose type-level `[ZodRule]` owns its code and origin, validated into a result failure the HTTP layer can answer by origin | ```bash dotnet run --project src/src/Examples.Basic diff --git a/docs/wiki/Value-Objects-Composition.md b/docs/wiki/Value-Objects-Composition.md index 826015a..f46ddff 100644 --- a/docs/wiki/Value-Objects-Composition.md +++ b/docs/wiki/Value-Objects-Composition.md @@ -68,6 +68,33 @@ public readonly partial record struct AssetId } ``` +### Automatic and manual scalar forms + +`[Scalar]` is the *manual* form — you declare the underlying property. The *automatic* form, `[Scalar]` +(or `[Scalar(typeof(Guid))]`), has the value-object generator declare `public Guid Value { get; init; }`, and +Purview.ZodSharp 2.0.2+ generates the schema from that attribute even though the member is not visible to it: + +```csharp +[Scalar] +[ZodSchema] +[NonEmpty(Code = "invalid_asset_id", Message = "AssetId must not be empty.")] +public readonly partial record struct AssetId; +``` + +The automatic form is what a generator-declared property requires anyway: a property-level DataAnnotation has no +member to attach to, so the invariant has to be a **type-level** rule. Keep the manual form when property-level +DataAnnotations are needed. The two forms are mutually exclusive — declaring the generator-owned property yourself +is `VO1022`. + +A **nullable** scalar (`[Scalar(Nullable = true)]`, or a `T?` value type) round-trips JSON `null`, so its +schema must be null-tolerant. Use ZodSharp's shipped `[NullOrNonWhiteSpace]`, which accepts `null` but rejects an +empty or whitespace-only value, where `[RequiredZod]` (which always rejects `null`) cannot express the check. + +> **An escape hatch, not the pipeline.** A `[ZodSchema]` value object's generated `Create` throws `ZodException` +> on failure, and `TryCreate` catches it and returns `false`. That is the right shape for a call site that only +> needs a boolean — but expected rejections belong in the result pipeline, where the errors stay structured and +> the HTTP layer can render them. + ## The rejection as a result failure The case that carries the errors implements `IValidationErrorCarrier`, so the HTTP layer can render it without @@ -118,7 +145,7 @@ registration-ordered; see [ZodSharp Problem Details](ZodSharp-ProblemDetails.md) ## Runnable example [`Examples.ValueObjects.Zod`](https://github.com/purview-dev/results/tree/main/src/src/Examples.ValueObjects.Zod) -runs the whole composition: a `[Scalar]` tenant identifier whose type-level `[ZodRule]` owns +runs the whole composition: an automatic `[Scalar]` tenant identifier whose type-level `[ZodRule]` owns `Code = "invalid_tenant_id"` and `Origin = "value_object"`, validated into a `Result` failure and answerable by `MapOrigin("value_object", …)`. diff --git a/docs/wiki/ZodSharp-Integration.md b/docs/wiki/ZodSharp-Integration.md index 6b7917a..036fd1b 100644 --- a/docs/wiki/ZodSharp-Integration.md +++ b/docs/wiki/ZodSharp-Integration.md @@ -63,6 +63,22 @@ Result Validate(TenantInput input) => Use `ToResult` when the success has to carry the validated value, and `ToUnitResult` when it does not. +## Async validation + +`ValidateAsync` — on the generated schema, the `{Type}SchemaValidator` DI adapter, or a +`[ZodSchema(CustomValidationMethodName = ...)]` rule — returns a `ValueTask>`. +`ToResultAsync` and `ToUnitResultAsync` fold that into a result exactly as the synchronous methods fold `Validate`: + +```csharp +async Task> Validate(TenantInput input, CancellationToken cancellationToken) => + await TenantInputSchemaValidator + .ValidateAsync(input, cancellationToken) + .ToUnitResultAsync(errors => new TenantInputInvalid(input, errors)); +``` + +The error type is named explicitly for the same reason as the synchronous forms, and the failure factory runs only +when validation failed. + ## Carrying validation errors in the error value `IValidationErrorCarrier` is implemented by an error value that carries ZodSharp validation errors, so an HTTP @@ -90,6 +106,8 @@ map every validation-carrying error individually. | --- | --- | | `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 | | `ValidationResult.ToUnitResult(Func, TError> onFailure)` | The value-discarding counterpart: success is a unit `Result`, failure carries the created error | +| `ValueTask>.ToResultAsync(Func, TError> onFailure)` | The asynchronous counterpart, for a schema whose validation awaits | +| `ValueTask>.ToUnitResultAsync(Func, TError> onFailure)` | The value-discarding asynchronous counterpart | | `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 diff --git a/package.json b/package.json index 58ceb30..a386408 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-results", - "version": "1.0.0.1", + "version": "1.0.1", "license": "MIT", "private": true, "keywords": [], diff --git a/src/src/AspNetCore/ResultsHttpOptions.cs b/src/src/AspNetCore/ResultsHttpOptions.cs index cfa874f..cd40ece 100644 --- a/src/src/AspNetCore/ResultsHttpOptions.cs +++ b/src/src/AspNetCore/ResultsHttpOptions.cs @@ -38,7 +38,6 @@ public sealed class ResultsHttpOptions // ImmutableArray is a struct, so storing it as one would box on every access — once per failure, on // the request path. FrozenDictionary>? _frozenMappers; - IReadOnlyList>? _frozenFallbacks; /// /// Gets or sets the status code used when a result succeeded. Defaults to 200 OK. @@ -118,8 +117,11 @@ public sealed class ResultsHttpOptions /// consults them is a singleton shared by every request. A fallback added after the first failure is /// handled is therefore ignored rather than mutating a list another thread is enumerating. /// - public IReadOnlyList> Fallbacks => - _frozenFallbacks ??= ImmutableArray.CreateRange(_fallbacks); + public IReadOnlyList> Fallbacks + { + get => field ??= ImmutableArray.CreateRange(_fallbacks); + private set; + } /// /// Maps a case (or the error itself, for a non-union error type) onto a response. diff --git a/src/src/Examples.ValueObjects.Zod/TenantId.cs b/src/src/Examples.ValueObjects.Zod/TenantId.cs index f1adeb9..f78bcb3 100644 --- a/src/src/Examples.ValueObjects.Zod/TenantId.cs +++ b/src/src/Examples.ValueObjects.Zod/TenantId.cs @@ -70,16 +70,19 @@ sealed class NonEmptyTenantIdAttribute : ValidationAttribute /// The identifier of a tenant, as a scalar value object. /// /// -/// [Scalar] makes the value-object generator emit Create/Hydrate, and [ZodSchema] -/// makes ZodSharp's generator emit TenantIdSchema from the type-level rule below, so the value object's -/// own invariant is expressed as schema rules rather than hand-written guards. Hydrate never runs them, -/// which is what keeps rehydrating persisted values replay-safe. +/// +/// [Scalar<Guid>] is the automatic form: the value-object generator declares +/// public Guid Value { get; init; } and emits Create/Hydrate, and [ZodSchema] makes +/// ZodSharp's generator emit TenantIdSchema from the type-level rule below, so the value object's own +/// invariant is expressed as schema rules rather than hand-written guards. Hydrate never runs them, which +/// is what keeps rehydrating persisted values replay-safe. +/// +/// +/// A generator-declared property has no member for a property-level DataAnnotation to attach to, which is why +/// the invariant is a type-level rule rather than an attribute on a member. +/// /// -[Scalar] +[Scalar] [ZodSchema] [NonEmptyTenantId(Code = "invalid_tenant_id", Message = "A tenant id must not be empty.")] -readonly partial record struct TenantId -{ - /// Gets the underlying identifier value. - public Guid Value { get; } -} +readonly partial record struct TenantId { } diff --git a/src/src/Results/ResultTError.cs b/src/src/Results/ResultTError.cs index 6b47e66..4d0c3c4 100644 --- a/src/src/Results/ResultTError.cs +++ b/src/src/Results/ResultTError.cs @@ -65,7 +65,7 @@ TReturn IResultValue.AcceptSuccess(IResultValueVisitor diff --git a/src/src/SourceGenerator/Diagnostics/ResultUnionDiagnostics.cs b/src/src/SourceGenerator/Diagnostics/ResultUnionDiagnostics.cs index df3c122..dbd4ede 100644 --- a/src/src/SourceGenerator/Diagnostics/ResultUnionDiagnostics.cs +++ b/src/src/SourceGenerator/Diagnostics/ResultUnionDiagnostics.cs @@ -382,11 +382,9 @@ static ImmutableArray AnalyzeIncludedCases( CancellationToken cancellationToken ) { - HashSet directCases = new(SymbolEqualityComparer.Default); - foreach (var caseType in caseTypes) - directCases.Add(caseType); + HashSet directCases = [with(SymbolEqualityComparer.Default), .. caseTypes]; - Dictionary> paths = new(SymbolEqualityComparer.Default); + Dictionary> paths = [with(SymbolEqualityComparer.Default)]; HashSet ambiguous = [with(SymbolEqualityComparer.Default)]; HashSet stack = [with(SymbolEqualityComparer.Default)]; diff --git a/src/src/ZodSharp.AspNetCore/ZodResultsFailureMapper.cs b/src/src/ZodSharp.AspNetCore/ZodResultsFailureMapper.cs index 7b50d4d..4f44169 100644 --- a/src/src/ZodSharp.AspNetCore/ZodResultsFailureMapper.cs +++ b/src/src/ZodSharp.AspNetCore/ZodResultsFailureMapper.cs @@ -84,7 +84,7 @@ IResult Render( } /// - /// The log messages this mapper emits, with stable s. + /// The log messages this mapper emits, with stable s. /// /// /// Source-generated, so the identifier is fixed and the message allocates nothing when Debug is diff --git a/src/src/ZodSharp.AspNetCore/ZodResultsHttpOptions.cs b/src/src/ZodSharp.AspNetCore/ZodResultsHttpOptions.cs index ef13f20..84e7324 100644 --- a/src/src/ZodSharp.AspNetCore/ZodResultsHttpOptions.cs +++ b/src/src/ZodSharp.AspNetCore/ZodResultsHttpOptions.cs @@ -203,7 +203,7 @@ ZodResultsHttpOptions Add(List rules, string value, string kind, Z continue; // The same rule twice is idempotent; two different answers for one value are ambiguous. - if (rule.Behaviour == behaviour) + if (IsSameBehaviour(rule.Behaviour, behaviour)) return this; // The same value with different behaviour is a configuration error. @@ -218,6 +218,27 @@ ZodResultsHttpOptions Add(List rules, string value, string kind, Z return this; } + /// + /// Answers whether two behaviours answer identically, comparing a factory by the method it binds rather than + /// by delegate identity. + /// + /// + /// A delegate comparison would treat the same method group registered twice as different registrations — + /// each occurrence allocates its own delegate — and so reject a configuration that answers one way. Two + /// different lambdas compile to different methods, so they are still rejected. + /// + static bool IsSameBehaviour(ZodErrorRuleBehaviour left, ZodErrorRuleBehaviour right) + { + if (left.StatusCode != right.StatusCode) + return false; + + if (left.Map is null || right.Map is null) + return left.Map is null && right.Map is null; + + // Compare the method and target of the factory, not the delegate itself, so the same method group is treated as the same behaviour. + return left.Map.Target == right.Map.Target && left.Map.Method.Equals(right.Map.Method); + } + /// /// Finds the rules whose value any of the failure's errors carries, in registration order. /// diff --git a/src/src/ZodSharp.AspNetCore/ZodSharp.AspNetCore.csproj b/src/src/ZodSharp.AspNetCore/ZodSharp.AspNetCore.csproj index 143f4c7..58be9e6 100644 --- a/src/src/ZodSharp.AspNetCore/ZodSharp.AspNetCore.csproj +++ b/src/src/ZodSharp.AspNetCore/ZodSharp.AspNetCore.csproj @@ -21,6 +21,14 @@ + + + + + diff --git a/src/src/ZodSharp/Sdk/.agents/skills/purview-results-zodsharp-validation/SKILL.md b/src/src/ZodSharp/Sdk/.agents/skills/purview-results-zodsharp-validation/SKILL.md index a20490a..83bc66c 100644 --- a/src/src/ZodSharp/Sdk/.agents/skills/purview-results-zodsharp-validation/SKILL.md +++ b/src/src/ZodSharp/Sdk/.agents/skills/purview-results-zodsharp-validation/SKILL.md @@ -67,6 +67,22 @@ Result Validate(TenantInput input) => Use `ToResult` when the success carries the validated value, `ToUnitResult` when it carries nothing, and the generated `AsFailure()` when the success carries something else. +## Async validation + +`ValidateAsync` returns a `ValueTask>`, and the async counterparts fold it in the same way: + +```csharp +async Task> Validate(TenantInput input, CancellationToken cancellationToken) => + await TenantInputSchemaValidator + .ValidateAsync(input, cancellationToken) + .ToUnitResultAsync(errors => new TenantInputInvalid(input, errors)); +``` + +- `ToResultAsync` carries the validated value on success; `ToUnitResultAsync` discards it. +- The error type is named explicitly for the same reason as the synchronous forms. +- Do not block on `ValidateAsync` to reuse the synchronous methods — that reintroduces the sync-over-async the + bridge exists to avoid. + ## Carrying the errors ```csharp @@ -97,7 +113,8 @@ results. Naming the error type explicitly keeps the intent unambiguous. ## Checklist 1. Validation outcomes are mapped, never thrown. -2. The error type is named explicitly (`ToResult(…)` or `ToUnitResult(…)`). +2. The error type is named explicitly (`ToResult(…)` or `ToUnitResult(…)`, and + the async counterparts for a `ValidateAsync` outcome). 3. The case that carries the errors implements `IValidationErrorCarrier`. 4. A method whose success type differs uses `AsFailure()`, or `AsFailure()` for a unit result, with the validation errors. diff --git a/src/src/ZodSharp/Sdk/README.md b/src/src/ZodSharp/Sdk/README.md index f2f2ff4..b80dc3e 100644 --- a/src/src/ZodSharp/Sdk/README.md +++ b/src/src/ZodSharp/Sdk/README.md @@ -47,17 +47,31 @@ return await ReconcileCoreAsync(validated.Value, repositories, cancellationToken | --- | --- | | `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 | | `ValidationResult.ToUnitResult(Func, TError> onFailure)` | The value-discarding counterpart: success is a unit `Result` and failure carries the created error. Use it when the method reports only why the input was rejected | +| `ValueTask>.ToResultAsync(Func, TError> onFailure)` | The asynchronous counterpart, for a schema whose validation awaits | +| `ValueTask>.ToUnitResultAsync(Func, TError> onFailure)` | The value-discarding asynchronous counterpart | | `IValidationErrorCarrier` | 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 | The error type must be named explicitly when it is a union, as in `ToResult(...)`, because a union case does not carry the -union type that contains it. `ToUnitResult` still names the validated value type for the same reason, even though -it discards it. +union type that contains it. `ToUnitResult` (and the async forms) still name the validated value type for the same +reason, even when the value is discarded. 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. +## Async validation + +`ValidateAsync` — on the generated schema, the DI validator adapter, or a +`[ZodSchema(CustomValidationMethodName = ...)]` rule — returns a `ValueTask>`, and +`ToResultAsync`/`ToUnitResultAsync` fold that into a result exactly as the synchronous methods fold `Validate`: + +```csharp +return await TenantInputSchemaValidator + .ValidateAsync(input, cancellationToken) + .ToUnitResultAsync(errors => new TenantInputInvalid(input, errors)); +``` + ## Examples [`src/src/Examples.Zod`](https://github.com/purview-dev/results/tree/main/src/src/Examples.Zod) is a diff --git a/src/src/ZodSharp/ZodValidationResultExtensions.cs b/src/src/ZodSharp/ZodValidationResultExtensions.cs index a7d55dd..2545bce 100644 --- a/src/src/ZodSharp/ZodValidationResultExtensions.cs +++ b/src/src/ZodSharp/ZodValidationResultExtensions.cs @@ -115,4 +115,103 @@ Func, TError> onFailure return validation.IsSuccess ? Result.Success() : Result.Failure(onFailure(validation.Errors)); } + + /// + /// Converts an asynchronous validation result into a result whose failure carries the error produced by + /// . + /// + /// The type of the validated value. + /// + /// The error type of the result. A union type must be named explicitly, as in + /// ToResultAsync<RepositoryReconciliationResult, ReconciliationError>(...), because a union case + /// does not carry the union type that contains it. + /// + /// The validation result to convert, such as the one ValidateAsync returns. + /// + /// Creates the error from the validation errors. Only invoked when validation failed, so a successful + /// validation allocates no error. + /// + /// + /// A successful result carrying the validated value, or a failed result carrying the created error. + /// + /// + /// + /// The asynchronous counterpart of , for a schema whose validation + /// awaits — a [ZodSchema(CustomValidationMethodName = ...)] rule, or the DI adapter's + /// ValidateAsync. The validated value is carried on success exactly as the synchronous form carries it. + /// + /// + /// Because the method is asynchronous, the for a null + /// surfaces when the returned task is awaited rather than at the call site. + /// + /// + /// + /// + /// return await RepositoryReconciliationResultSchemaValidator + /// .ValidateAsync(reconciliationResult, cancellationToken) + /// .ToResultAsync<RepositoryReconciliationResult, ReconciliationError>(errors => + /// new ReconciliationResultInvalid(reconciliationResult, errors) + /// ); + /// + /// + /// + /// Thrown when is null, when the returned task is awaited. + /// + public static async ValueTask> ToResultAsync( + this ValueTask> validation, + Func, TError> onFailure + ) + { + ArgumentNullException.ThrowIfNull(onFailure); + + var validated = await validation.ConfigureAwait(false); + + return validated.IsSuccess + ? Result.Success(validated.Value) + : Result.Failure(onFailure(validated.Errors)); + } + + /// + /// Converts an asynchronous validation result into a unit result whose failure carries the error produced by + /// , discarding the validated value. + /// + /// The type of the validated value. + /// + /// The error type of the result. A union type must be named explicitly, as in + /// ToUnitResultAsync<TenantInput, TenantError>(...), because a union case does not carry the + /// union type that contains it. + /// + /// The validation result to convert, such as the one ValidateAsync returns. + /// + /// Creates the error from the validation errors. Only invoked when validation failed, so a successful + /// validation allocates no error. + /// + /// + /// A successful unit result, or a failed unit result carrying the created error. + /// + /// + /// + /// The asynchronous counterpart of : the method reports only why an + /// input was rejected, and the validated value is deliberately dropped, which is why the value type still has + /// to be named. Use when the success has to carry it. + /// + /// + /// Because the method is asynchronous, the for a null + /// surfaces when the returned task is awaited rather than at the call site. + /// + /// + /// + /// Thrown when is null, when the returned task is awaited. + /// + public static async ValueTask> ToUnitResultAsync( + this ValueTask> validation, + Func, TError> onFailure + ) + { + ArgumentNullException.ThrowIfNull(onFailure); + + var validated = await validation.ConfigureAwait(false); + + return validated.IsSuccess ? Result.Success() : Result.Failure(onFailure(validated.Errors)); + } } diff --git a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs index 77608d1..eaf85b5 100644 --- a/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs +++ b/src/tests/SourceGenerator.UnitTests/ResultsSourceGeneratorTests.cs @@ -352,10 +352,7 @@ namespace Test // Act var result = await GenerateAsync( source, - new ResultsSourceGeneratorTestOptions - { - AnalyzerTypes = [typeof(Purview.Results.SourceGenerator.Analyzers.ResultsDiagnosticAnalyzer)], - }, + new ResultsSourceGeneratorTestOptions { AnalyzerTypes = [typeof(Analyzers.ResultsDiagnosticAnalyzer)] }, cancellationToken ); diff --git a/src/tests/ZodSharp.AspNetCore.UnitTests/ValueObjectRuleTestModels.cs b/src/tests/ZodSharp.AspNetCore.UnitTests/ValueObjectRuleTestModels.cs index e271e89..b9560ce 100644 --- a/src/tests/ZodSharp.AspNetCore.UnitTests/ValueObjectRuleTestModels.cs +++ b/src/tests/ZodSharp.AspNetCore.UnitTests/ValueObjectRuleTestModels.cs @@ -3,6 +3,7 @@ using Purview.ValueObjects.Serialization; using ZodSharp; using ZodSharp.Core; +using ZodSharp.Rules; namespace Purview.Results.ZodSharp.AspNetCore; @@ -66,11 +67,26 @@ sealed class NonEmptyValidatedIdAttribute : ValidationAttribute /// A Guid-backed scalar value object whose validation is driven entirely by a type-level custom rule, so its /// generated Create surfaces the rule's own code and origin while Hydrate stays replay-safe. /// -[Scalar] +/// +/// [Scalar<Guid>] is the automatic form — the value-object generator declares the underlying +/// property — and Purview.ZodSharp 2.0.2+ generates the schema from that attribute, so this exercises the +/// composition on the newest ValueObjects surface rather than the manual [Scalar] form. +/// +[Scalar] [ZodSchema] [NonEmptyValidatedId(Code = "invalid_tenant_id", Message = "A tenant id must not be empty.")] -readonly partial record struct ValidatedTenantId -{ - /// Gets the underlying identifier value. - public Guid Value { get; } -} +readonly partial record struct ValidatedTenantId { } + +/// +/// A nullable string scalar whose type-level rule is one of ZodSharp's built-in, generator-attributed rules, so +/// the schema accepts but rejects an empty or whitespace-only value. +/// +/// +/// A nullable scalar round-trips JSON , so its schema has to be null-tolerant — which is +/// what exists for, where [RequiredZod] (which always rejects +/// ) cannot express the check. +/// +[Scalar(Nullable = true)] +[ZodSchema] +[NullOrNonWhiteSpace] +readonly partial record struct NullableTenantAlias { } diff --git a/src/tests/ZodSharp.AspNetCore.UnitTests/ZodResultsFailureMapperTests.cs b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodResultsFailureMapperTests.cs index d8afde6..7db8da3 100644 --- a/src/tests/ZodSharp.AspNetCore.UnitTests/ZodResultsFailureMapperTests.cs +++ b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodResultsFailureMapperTests.cs @@ -16,6 +16,8 @@ public sealed class ZodResultsFailureMapperTests const string MissingCode = "missing"; const string InvalidValueCategory = "invalid_value"; const string ValueObjectOrigin = "value_object"; + const string InvalidTenantIdCode = "invalid_tenant_id"; + const string NullableAliasInvalidCode = "invalid_string"; [Test] public async Task Map_GivenCodeRuleWithStatus_ReturnsTheValidationProblemWithThatStatus() @@ -259,6 +261,61 @@ public async Task Map_GivenAFailureFromAValueObjectRule_AnswersByOrigin() await Assert.That(body).Contains(ValueObjectOrigin); } + [Test] + public async Task Map_GivenAFailureFromANullableScalarRule_AnswersByItsCode() + { + // Arrange + // A nullable scalar round-trips null, so its type-level rule is the null-tolerant one: null validates and + // whitespace-only is rejected. The code it reports is enough for a code rule to answer the failure. + await using var services = CreateServices(zod => + zod.MapCode(NullableAliasInvalidCode, StatusCodes.Status422UnprocessableEntity) + ); + var mapper = services.GetRequiredService(); + var context = CreateContext(services); + + await Assert.That(NullableTenantAliasSchema.Validate(NullableTenantAlias.Hydrate(null)).IsSuccess).IsTrue(); + + var validation = NullableTenantAliasSchema.Validate(NullableTenantAlias.Hydrate(" ")); + await Assert.That(validation.IsSuccess).IsFalse(); + + // Act + var result = validation.ToResult(errors => new InputRejected( + 7, + errors + )); + var (statusCode, body) = await ExecuteAsync(mapper.Map(result, context), context); + + // Assert + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status422UnprocessableEntity); + await Assert.That(body).Contains(NullableAliasInvalidCode); + } + + [Test] + public async Task Map_GivenAUnitResultFailureFromAValueObjectRule_AnswersTheValidationProblem() + { + // Arrange + // A command that reports only why it was rejected folds the validation outcome into a unit result; the + // value being discarded must not cost the failure its validation response. + await using var services = CreateServices(zod => + zod.MapCode(InvalidTenantIdCode, StatusCodes.Status422UnprocessableEntity) + ); + var mapper = services.GetRequiredService(); + var context = CreateContext(services); + + var validation = ValidatedTenantIdSchema.Validate(ValidatedTenantId.Hydrate(Guid.Empty)); + + // Act + var result = validation.ToUnitResult(errors => new InputRejected( + 7, + errors + )); + var (statusCode, body) = await ExecuteAsync(mapper.Map(result, context), context); + + // Assert + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status422UnprocessableEntity); + await Assert.That(body).Contains(InvalidTenantIdCode); + } + [Test] public async Task MapOrigin_GivenEmptyOrigin_Throws() { @@ -291,6 +348,56 @@ public async Task MapOrigin_GivenTheSameOriginTwiceWithDifferentBehaviour_Throws await Assert.That(exception!.Message).Contains(ValueObjectOrigin); } + [Test] + public async Task Map_GivenDecliningOriginRule_FallsThroughToTheDefaultValidationProblem() + { + // 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 + ) + ); + var mapper = services.GetRequiredService(); + var context = CreateContext(services); + + // Act + var (statusCode, body) = await ExecuteAsync( + mapper.Map( + new InputRejected( + 7, + CreateOriginErrors(ValueObjectOrigin, InvalidNameCode, MissingCode) + ).AsFailure(), + context + ), + context + ); + + // Assert + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status400BadRequest); + await Assert.That(body).Contains(MissingCode); + } + + [Test] + public async Task Map_GivenACarrierWithNoValidationErrors_ReturnsTheDefaultValidationProblem() + { + // Arrange + await using var services = CreateServices(zod => zod.MapCode(InvalidNameCode, StatusCodes.Status409Conflict)); + var mapper = services.GetRequiredService(); + var context = CreateContext(services); + + // Act + var (statusCode, body) = await ExecuteAsync( + mapper.Map(new InputRejected(7, []).AsFailure(), context), + context + ); + + // Assert + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status400BadRequest); + await Assert.That(body).DoesNotContain(InvalidNameCode); + } + [Test] public async Task Map_GivenNoMatchingRule_ReturnsTheDefaultValidationProblem() { @@ -479,6 +586,28 @@ public async Task MapCode_GivenTheSameCodeTwiceWithDifferentBehaviour_Throws() await Assert.That(exception!.Message).Contains(InvalidNameCode); } + [Test] + public async Task MapCode_GivenTheSameFactoryTwice_KeepsTheSingleRule() + { + // Arrange + // A method group allocates a new delegate on every use, so identity is not how "the same behaviour" is + // recognised: registering the same factory again is the same rule, not a conflicting one. + await using var services = CreateServices(zod => + zod.MapCode(InvalidNameCode, NotAValidationError).MapCode(InvalidNameCode, NotAValidationError) + ); + var mapper = services.GetRequiredService(); + var context = CreateContext(services); + + // Act + var (statusCode, _) = await ExecuteAsync( + mapper.Map(new InputRejected(7, CreateErrors()).AsFailure(), context), + context + ); + + // Assert + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status404NotFound); + } + [Test] public async Task MapCode_GivenTheSameCodeTwiceWithTheSameBehaviour_KeepsTheSingleRule() { @@ -570,6 +699,10 @@ static ImmutableArray CreateErrorsWithCategoryAndOrigin(string } } + /// The factory the same-factory-twice test registers, named so it is one method group. + static IResult NotAValidationError(ImmutableArray errors, HttpContext context) => + TypedResults.NotFound(); + static DefaultHttpContext CreateContext(IServiceProvider services) => new() { Response = { Body = new MemoryStream() }, RequestServices = services }; diff --git a/src/tests/ZodSharp.UnitTests/ZodValidationResultExtensionsTests.cs b/src/tests/ZodSharp.UnitTests/ZodValidationResultExtensionsTests.cs index 396600f..77220a9 100644 --- a/src/tests/ZodSharp.UnitTests/ZodValidationResultExtensionsTests.cs +++ b/src/tests/ZodSharp.UnitTests/ZodValidationResultExtensionsTests.cs @@ -162,4 +162,135 @@ public async Task ToUnitResult_GivenNullFailureFactory_ThrowsArgumentNullExcepti // Act & Assert await Assert.That(() => validation.ToUnitResult(null!)).Throws(); } + + [Test] + public async Task ToResultAsync_GivenSuccessfulValidation_ReturnsTheValidatedValueWithoutInvokingTheFactory() + { + // Arrange + var validation = ValueTask.FromResult(ValidationResult.Success(42)); + var factoryInvocations = 0; + + // Act + var result = await validation.ToResultAsync(errors => + { + factoryInvocations++; + return errors.Length; + }); + + // Assert + await Assert.That(result.IsInitialized).IsTrue(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo(42); + await Assert.That(factoryInvocations).IsEqualTo(0); + } + + [Test] + public async Task ToResultAsync_GivenFailedValidation_MapsTheErrorValue() + { + // Arrange + var validation = ValueTask.FromResult( + ValidationResult.Failure(ValidationError.Create("too_small", "Value is too short.", ["Value"])) + ); + + // Act + var result = await validation.ToResultAsync(errors => errors.Single()); + + // Assert + await Assert.That(result.IsInitialized).IsTrue(); + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error.Code).IsEqualTo("too_small"); + await Assert.That(result.Error.Message).IsEqualTo("Value is too short."); + await Assert.That(result.Error.Path).IsEquivalentTo(["Value"]); + } + + [Test] + public async Task ToResultAsync_GivenFailedValidation_KeepsEveryError() + { + // Arrange + var errorsSeenByFactory = 0; + var validation = ValueTask.FromResult( + ValidationResult.Failure([ + ValidationError.Create("first", "The first failure.", []), + ValidationError.Create("second", "The second failure.", []), + ]) + ); + + // Act + var result = await validation.ToResultAsync(errors => + { + errorsSeenByFactory = errors.Length; + return errors.Length; + }); + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error).IsEqualTo(2); + await Assert.That(errorsSeenByFactory).IsEqualTo(2); + } + + [Test] + public async Task ToResultAsync_GivenNullFailureFactory_ThrowsArgumentNullException() + { + // Arrange + var validation = ValueTask.FromResult(ValidationResult.Success(1)); + + // Act & Assert + await Assert + .That(async () => await validation.ToResultAsync(null!)) + .Throws(); + } + + [Test] + public async Task ToUnitResultAsync_GivenSuccessfulValidation_ReturnsAUnitSuccessWithoutInvokingTheFactory() + { + // Arrange + var validation = ValueTask.FromResult(ValidationResult.Success(42)); + var factoryInvocations = 0; + + // Act + var result = await validation.ToUnitResultAsync(errors => + { + factoryInvocations++; + return new OperationRejected(new Operation(), errors); + }); + + // Assert + await Assert.That(result.IsInitialized).IsTrue(); + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(factoryInvocations).IsEqualTo(0); + } + + [Test] + public async Task ToUnitResultAsync_GivenFailedValidation_ReturnsAUnitFailureCarryingTheCreatedError() + { + // Arrange + var validation = ValueTask.FromResult( + ValidationResult.Failure(ValidationError.Create("too_small", "Value is too short.", ["Value"])) + ); + + // Act + var result = await validation.ToUnitResultAsync(errors => new OperationRejected( + new Operation(), + errors + )); + + // Assert + await Assert.That(result.IsInitialized).IsTrue(); + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.IsSuccess).IsFalse(); + await Assert.That(result.Error is OperationRejected).IsTrue(); + } + + [Test] + public async Task ToUnitResultAsync_GivenNullFailureFactory_ThrowsArgumentNullException() + { + // Arrange + var validation = ValueTask.FromResult(ValidationResult.Success(1)); + + // Act & Assert + await Assert + .That(async () => await validation.ToUnitResultAsync(null!)) + .Throws(); + } } diff --git a/src/tests/ZodSharp.UnitTests/ZodValidationResultIntegrationTests.cs b/src/tests/ZodSharp.UnitTests/ZodValidationResultIntegrationTests.cs index 1b7b62f..6648da6 100644 --- a/src/tests/ZodSharp.UnitTests/ZodValidationResultIntegrationTests.cs +++ b/src/tests/ZodSharp.UnitTests/ZodValidationResultIntegrationTests.cs @@ -188,4 +188,59 @@ public async Task ToResult_GivenConsistentAggregate_SucceedsWithTheValidatedAggr await Assert.That(result.IsSuccess).IsTrue(); await Assert.That(result.Value).IsEqualTo(operationResult); } + + [Test] + public async Task ToResultAsync_GivenTheAsyncValidator_ProducesTheSameRejectionAsTheSynchronousSchema( + CancellationToken cancellationToken + ) + { + // Arrange + // The generated validator adapter is the DI-friendly shape, whose ValidateAsync is what a hosted + // service resolves; its outcome must fold into a result exactly like the synchronous schema's. + var operation = Operation.Create(null); + + // Act + var result = await new OperationSchemaValidator() + .ValidateAsync(operation, cancellationToken) + .ToResultAsync(errors => new OperationRejected(operation, errors)); + + // Assert + await Assert.That(result.IsInitialized).IsTrue(); + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.IsSuccess).IsFalse(); + + OperationRejected? rejected = result.Error switch + { + OperationRejected value => value, + _ => null, + }; + + 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)); + } + + [Test] + public async Task ToResultAsync_GivenTheAsyncValidatorAndAValidOperation_SucceedsWithoutInvokingTheFactory( + CancellationToken cancellationToken + ) + { + // Arrange + var operation = Operation.Create("reconcile"); + var factoryInvocations = 0; + + // Act + var result = await new OperationSchemaValidator() + .ValidateAsync(operation, cancellationToken) + .ToResultAsync(errors => + { + factoryInvocations++; + return new OperationRejected(operation, errors); + }); + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo(operation); + await Assert.That(factoryInvocations).IsEqualTo(0); + } } From e4bf25440376cd14a50dd35dc4e6faff5be0c45b Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 12 Oct 2026 20:09:24 +0100 Subject: [PATCH 02/13] chore: version bumps --- Directory.Packages.props | 8 ++++---- global.json | 2 +- package.json | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index bb6bdd1..6dc07fc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,15 +14,15 @@ 10.0.12 - 2.0.2 - 1.0.1 + 2.1.0 + 1.1.0 - 1.0.0 + 1.1.0 5.9.0 - 1.68.17 + 1.74.0 diff --git a/global.json b/global.json index 047b1b2..e23264a 100644 --- a/global.json +++ b/global.json @@ -5,7 +5,7 @@ "allowPrerelease": true }, "msbuild-sdks": { - "Purview.BuildSdk": "1.0.4" + "Purview.BuildSdk": "1.0.5" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/package.json b/package.json index a386408..3b13717 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "purview-results", - "version": "1.0.1", + "name": "results", + "version": "1.0.2", "license": "MIT", "private": true, "keywords": [], From 4e92b146e2c1027c766ef22f210216e13bca1025 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Mon, 12 Oct 2026 23:03:58 +0100 Subject: [PATCH 03/13] build: install Purview.Build as a .NET local tool Add Purview.Build 0.5.2 to .config/dotnet-tools.json and replace the hand-rolled dotnet tool install --tool-path .tools/purview-build in the Justfile with dotnet tool restore, running the pipeline as dotnet purview-build. The version is now pinned in the tool manifest alongside CSharpier, so it is restored by the same command as every other tool and needs no bespoke install step, no --add-source feed argument and no platform-specific executable suffix. The dead pipeline_feed variable is removed, and the docs describing the old .tools/purview-build path are updated to match. --- .config/dotnet-tools.json | 7 +++++++ AGENTS.md | 2 +- Justfile | 19 ++++++++----------- README.md | 2 +- docs/wiki/Release-Flow.md | 4 ++-- 5 files changed, 19 insertions(+), 15 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index fb05763..922c948 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -15,6 +15,13 @@ "dotnet-inspect" ], "rollForward": false + }, + "purview.build": { + "version": "0.5.2", + "commands": [ + "purview-build" + ], + "rollForward": false } } } \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 1f6ec9c..323da09 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -402,7 +402,7 @@ just build repository's `Justfile`; the equivalent direct invocation (after `just ensure-pipeline-tool`) is: ```text -.tools/purview-build/purview-build --Release:Mode=LocalNuGet --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/ +dotnet purview-build --Release:Mode=LocalNuGet --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/ ``` `LOCAL_NUGET_FEED_PATH` is exported in this environment, so the explicit `--PublishLocalNuGet:LocalFeedPath` diff --git a/Justfile b/Justfile index 9cd068a..f1084b5 100644 --- a/Justfile +++ b/Justfile @@ -11,8 +11,7 @@ build_configuration := "Debug" artifacts_folder := "./artifacts" default_test_filter := "/*/*/*/*" -pipeline_feed := "https://api.nuget.org/v3/index.json" -pipeline_tool := ".tools/purview-build/purview-build" +pipeline_tool := "dotnet purview-build" current_version := `bun -p "require('./package.json').version"` @@ -20,26 +19,24 @@ current_version := `bun -p "require('./package.json').version"` default: just --list -# Install the shared Purview.Build tool (authenticated to the Purview-Dev feed) if not present +# Restore the repository's .NET local tools (.config/dotnet-tools.json) [private] ensure-pipeline-tool: - if [ ! -x "{{ pipeline_tool }}" ]; then \ - dotnet tool install Purview.Build --tool-path .tools/purview-build --add-source "{{ pipeline_feed }}"; \ - fi + dotnet tool restore # Run the PR pipeline (restore, build, lint, tests) [group('Pipeline')] pipeline-pr *args: just ensure-pipeline-tool echo "Running PR pipeline..." - "{{ pipeline_tool }}" {{ args }} + {{ pipeline_tool }} {{ args }} # Run the build pipeline (restore, build, lint) [group('Pipeline')] pipeline-build *args: just ensure-pipeline-tool echo "Running build pipeline..." - "{{ pipeline_tool }}" --Build:RunTests=false --Release:Mode=None {{ args }} + {{ pipeline_tool }} --Build:RunTests=false --Release:Mode=None {{ args }} # Run the release pipeline (restore, build, lint, tests, pack, publish, GitHub release) [group('Pipeline')] @@ -47,7 +44,7 @@ pipeline-release *args: just ensure-pipeline-tool just lint-fix echo "Running release pipeline..." - "{{ pipeline_tool }}" --Release:Mode=NuGet {{ args }} + {{ pipeline_tool }} --Release:Mode=NuGet {{ args }} # Run the release pipeline (restore, build, lint, tests, pack, local nuget publish) # Note: `just` runs recipes through the shell, which strips backslashes from unquoted arguments. @@ -58,14 +55,14 @@ pipeline-local-release *args: just ensure-pipeline-tool just lint-fix echo "Running local release pipeline..." - "{{ pipeline_tool }}" --Release:Mode=LocalNuGet {{ args }} + {{ pipeline_tool }} --Release:Mode=LocalNuGet {{ args }} # Run the pipeline through pack + validate (restore, build, lint, tests, pack, validate pack contents) without publishing/releasing [group('Pipeline')] pipeline-pack-validate *args: just ensure-pipeline-tool echo "Running pack + validate pipeline..." - "{{ pipeline_tool }}" --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None {{ args }} + {{ pipeline_tool }} --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None {{ args }} # Displays the current version from package.json [group('Build and Test')] diff --git a/README.md b/README.md index 2275055..79a8f8c 100644 --- a/README.md +++ b/README.md @@ -322,7 +322,7 @@ just pack # pack all four packages to ./artifacts just pipeline-pr # the full PR pipeline (restore, build, lint, test, pack) ``` -`just pipeline-pr` installs the pinned `Purview.Build` tool to `.tools/purview-build` when it is missing. +`just pipeline-pr` restores the repository's .NET local tools, which include the pinned `Purview.Build`. Local CI-equivalent validation: ```bash diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index 494ac87..3a3d0cf 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -72,8 +72,8 @@ 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. +`pipeline-pr` and the other pipeline recipes run `dotnet tool restore`, which installs the pinned +`Purview.Build` from `.config/dotnet-tools.json`. ## Testing the packed packages locally From c008c46353063c654ea08470f1d4c6a1f7b119fb Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 13 Oct 2026 08:42:10 +0100 Subject: [PATCH 04/13] build: declare RequiredCompilerFlags explicitly Purview.Build 0.5.3 defaults RequiredCompilerFlags to empty. Earlier versions forced the optimization=release check on and could not be configured off, because binding an empty JSON array contributes no configuration keys and so never overwrote the tool's non-empty code default. These packages have therefore always been validated against that flag. Declaring it keeps that behaviour rather than silently losing the check on the move to 0.5.3. Verified with just pipeline-pack-validate: all packages still valid. --- .config/purview-build.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.config/purview-build.json b/.config/purview-build.json index 1eea3a4..10ba01d 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -7,6 +7,10 @@ "TestFilter": "/*/*/*/*[Category=Unit]" }, "PackValidation": { + // Explicit because Purview.Build 0.5.3 defaults RequiredCompilerFlags to empty. Earlier + // versions forced this check on and could not be configured off, so these packages have + // always been validated against it; declaring it keeps that behaviour. + "RequiredCompilerFlags": ["optimization=release"], // Every package is a library package that produces a symbol package, so RequireSymbolFiles and the // assembly checks below all apply to them. Purview.Results additionally carries the merged source // generator and its code fix under analyzers/dotnet/cs (no PDB, no lib folder for those), so the From 6090c4bd079b676308a3ab0bf43f2b9ebe0fc2c8 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 13 Oct 2026 09:15:54 +0100 Subject: [PATCH 05/13] build: take Purview.Build 0.5.3 Picks up the fix that makes RequiredCompilerFlags configurable: earlier versions forced the compiler-flag check on because binding an empty JSON array contributes no configuration keys, so the tool's non-empty code default could never be overridden. This repository declares "RequiredCompilerFlags": ["optimization=release"] explicitly, so the check it has always run under is preserved. Verified with just pipeline-pack-validate against 0.5.3. --- .config/dotnet-tools.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 922c948..42b8dea 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -17,7 +17,7 @@ "rollForward": false }, "purview.build": { - "version": "0.5.2", + "version": "0.5.3", "commands": [ "purview-build" ], From 16a05cb07515d306a0e2b383652ff1a29b542496 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 15 Oct 2026 08:36:41 +0100 Subject: [PATCH 06/13] build: take .NET SDK 11.0.100-rc.2.26504.105 --- global.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/global.json b/global.json index e23264a..b9a3e8b 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "11.0.100-rc.1.26425.128", + "version": "11.0.100-rc.2.26504.105", "rollForward": "latestMajor", "allowPrerelease": true }, From acb3bbe49eafaab67ee35427fddae391dd652616 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 15 Oct 2026 08:36:42 +0100 Subject: [PATCH 07/13] chore: move authored skills to repository-root skills folder The skills CLI only discovers skills at a root skills/ folder, and .agents/ is gitignored here, so it is absent from a git clone. Per-package delivery is unchanged: each project names the subset it ships via PurviewSkillsToPack. --- AGENTS.md | 38 +++++++--- .../purview-results-core/SKILL.md | 0 .../purview-results-http-mapping/SKILL.md | 0 .../purview-results-union-errors/SKILL.md | 0 .../SKILL.md | 0 .../SKILL.md | 0 src/Directory.Build.targets | 74 +++++++++++++++++++ src/src/AspNetCore/AspNetCore.csproj | 10 ++- src/src/Results/Results.csproj | 11 ++- .../ZodSharp.AspNetCore.csproj | 11 ++- src/src/ZodSharp/ZodSharp.csproj | 10 ++- 11 files changed, 139 insertions(+), 15 deletions(-) rename {src/src/Results/Sdk/.agents/skills => skills}/purview-results-core/SKILL.md (100%) rename {src/src/AspNetCore/Sdk/.agents/skills => skills}/purview-results-http-mapping/SKILL.md (100%) rename {src/src/Results/Sdk/.agents/skills => skills}/purview-results-union-errors/SKILL.md (100%) rename {src/src/ZodSharp.AspNetCore/Sdk/.agents/skills => skills}/purview-results-zodsharp-problems/SKILL.md (100%) rename {src/src/ZodSharp/Sdk/.agents/skills => skills}/purview-results-zodsharp-validation/SKILL.md (100%) diff --git a/AGENTS.md b/AGENTS.md index 323da09..793fc4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,8 @@ generator that makes C# 15 union error cases ergonomic), and the ZodSharp and AS | `src/src/AspNetCore` | Result-to-response mapping, endpoint filter, DI registration | | `src/src/ZodSharp` | ZodSharp `ValidationResult` bridge | | `src/src/ZodSharp.AspNetCore` | Validation-problem mapping for validation-carrying failures | -| `src/src//Sdk` | Package-only assets. `Sdk/README.md` is packed as the package README; `Sdk/.agents/**` would ship agent skills with the package | +| `src/src//Sdk` | Package-only assets. `Sdk/README.md` is packed as the package README; `Sdk/.agents/**` ships sub-agent definitions and prompts with the package | +| `skills` | Authored agent skills, packed into each package's `.agents/skills/` and installable with `npx skills` (see [Agent skills](#agent-skills)) | | `src/tests` | TUnit unit tests, including source-generation and incremental-cache tests | | `src/src/Examples.*` | Runnable, non-packable examples: one project per integration aspect on a shared Tenant* domain | | `docs/wiki` | User-facing documentation suite, aggregated by the purview-dev website | @@ -311,24 +312,41 @@ result and the HTTP mapping). ## Agent skills The repository-root `.agents/**` tree is **delivered by NuGet packages**; this repository also **authors** its -own agent content under `src/src//Sdk/.agents/**`, which the packages ship to consumers. +own agent content, which the packages ship to consumers. `Purview.BuildSdk` mirrors the `.agents` folder of every restored package into the repository root before build and records what it copied in `.purview/agent-sync.cache`, so the root tree is complete even though it is not maintained here. +**Authored skills live in the repository-root `skills/` folder, not under `Sdk/.agents/skills/`.** The skills +CLI (`npx skills`) only discovers skills at a root `skills/` folder, and `.agents/` is gitignored here — so it +is absent from a git clone and cannot be the published source. Sub-agent definitions and prompts are unaffected +and stay under `src/src//Sdk/.agents/`. + ### Authored here (shipped by this repository's packages) -| Content | Package | Path | -| --- | --- | --- | -| `skills/purview-results-core`, `skills/purview-results-union-errors`, `agents/purview-results-union-author.agent.md`, `prompts/migrate-error-returns-to-result-unions.prompt.md` | `Purview.Results` | `src/src/Results/Sdk/.agents` | -| `skills/purview-results-http-mapping` | `Purview.Results.AspNetCore` | `src/src/AspNetCore/Sdk/.agents` | -| `skills/purview-results-zodsharp-validation` | `Purview.Results.ZodSharp` | `src/src/ZodSharp/Sdk/.agents` | -| `skills/purview-results-zodsharp-problems` | `Purview.Results.ZodSharp.AspNetCore` | `src/src/ZodSharp.AspNetCore/Sdk/.agents` | +Each project names the subset it ships through `PurviewSkillsToPack`, so a skill reaches only the package whose +library it describes. The authored copy still lives once, at the repository root. -- These files **are tracked source** and are packed as `.agents/**` (the SDK also generates the per-skill - `.gitignore` inside the package). Update them like any other package asset, and keep each skill's guidance in +| Skill (in `skills/`) | Package | Declared in | +| --- | --- | --- | +| `purview-results-core`, `purview-results-union-errors` | `Purview.Results` | `src/src/Results/Results.csproj` | +| `purview-results-http-mapping` | `Purview.Results.AspNetCore` | `src/src/AspNetCore/AspNetCore.csproj` | +| `purview-results-zodsharp-validation` | `Purview.Results.ZodSharp` | `src/src/ZodSharp/ZodSharp.csproj` | +| `purview-results-zodsharp-problems` | `Purview.Results.ZodSharp.AspNetCore` | `src/src/ZodSharp.AspNetCore/ZodSharp.AspNetCore.csproj` | + +Sub-agent definitions and prompts remain under `src/src/Results/Sdk/.agents/` (`agents/purview-results-union-author.agent.md`, +`prompts/migrate-error-returns-to-result-unions.prompt.md`). + +- Packing is performed by the `IncludeRepositorySkillsInPackage` target in `src/Directory.Build.targets`. That + is an **interim shim**: `global.json` pins `Purview.BuildSdk` 1.0.5, which does not yet provide the SDK-side + equivalent. It implements the same `PurviewSkillsToPack` property the SDK uses, so adopting the newer SDK + means setting `PurviewPackRepositorySkills=true` on the same projects and deleting the shim. +- These files **are tracked source** and are packed as `.agents/skills/**` (the per-skill `.gitignore` is + generated into the package too). Update them like any other package asset, and keep each skill's guidance in step with the code it documents. +- Adding a skill: create `skills//SKILL.md`, then add the name to the owning project's + `PurviewSkillsToPack`. A skill that is not named there is installable from git but ships in no package. - A consuming repository that imports `Purview.BuildSdk` receives them in its own `.agents/` folder on the next restore/build, so consumer-facing guidance is written for consumers (package-facing, not repository-facing). - Load the relevant skill before doing the work it covers: diff --git a/src/src/Results/Sdk/.agents/skills/purview-results-core/SKILL.md b/skills/purview-results-core/SKILL.md similarity index 100% rename from src/src/Results/Sdk/.agents/skills/purview-results-core/SKILL.md rename to skills/purview-results-core/SKILL.md diff --git a/src/src/AspNetCore/Sdk/.agents/skills/purview-results-http-mapping/SKILL.md b/skills/purview-results-http-mapping/SKILL.md similarity index 100% rename from src/src/AspNetCore/Sdk/.agents/skills/purview-results-http-mapping/SKILL.md rename to skills/purview-results-http-mapping/SKILL.md diff --git a/src/src/Results/Sdk/.agents/skills/purview-results-union-errors/SKILL.md b/skills/purview-results-union-errors/SKILL.md similarity index 100% rename from src/src/Results/Sdk/.agents/skills/purview-results-union-errors/SKILL.md rename to skills/purview-results-union-errors/SKILL.md diff --git a/src/src/ZodSharp.AspNetCore/Sdk/.agents/skills/purview-results-zodsharp-problems/SKILL.md b/skills/purview-results-zodsharp-problems/SKILL.md similarity index 100% rename from src/src/ZodSharp.AspNetCore/Sdk/.agents/skills/purview-results-zodsharp-problems/SKILL.md rename to skills/purview-results-zodsharp-problems/SKILL.md diff --git a/src/src/ZodSharp/Sdk/.agents/skills/purview-results-zodsharp-validation/SKILL.md b/skills/purview-results-zodsharp-validation/SKILL.md similarity index 100% rename from src/src/ZodSharp/Sdk/.agents/skills/purview-results-zodsharp-validation/SKILL.md rename to skills/purview-results-zodsharp-validation/SKILL.md diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index ab68169..77afe53 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -1,3 +1,77 @@ + + + $([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)', '..', 'skills')) + $(TargetsForTfmSpecificContentInPackage);IncludeRepositorySkillsInPackage + + + + + <_RepositorySkillToPack Include="$(PurviewSkillsToPack)" /> + <_RepositorySkillFile Include="$(PurviewSkillsSourceFolder)%(_RepositorySkillToPack.Identity)/**/*"> + %(_RepositorySkillToPack.Identity) + + + + + + + + <_RepositorySkillPackageFile Include="@(_RepositorySkillFile)"> + .agents/skills/%(SkillName)/$([System.String]::Copy('%(RecursiveDir)').Replace("\", "/"))%(Filename)%(Extension) + + + + + + + <_RepositorySkillGitIgnoreRoot>$([MSBuild]::NormalizeDirectory('$(IntermediateOutputPath)', '_PurviewSkillGitIgnores')) + + + + <_RepositorySkillGitIgnoreFile Include="$(_RepositorySkillGitIgnoreRoot)%(_RepositorySkillToPack.Identity)/.gitignore"> + .agents/skills/%(_RepositorySkillToPack.Identity)/.gitignore + + + + diff --git a/src/src/AspNetCore/AspNetCore.csproj b/src/src/AspNetCore/AspNetCore.csproj index 11bb6f2..449360a 100644 --- a/src/src/AspNetCore/AspNetCore.csproj +++ b/src/src/AspNetCore/AspNetCore.csproj @@ -1,4 +1,11 @@ + + + purview-results-http-mapping + + + true Purview Results ASP.NET Core @@ -26,5 +33,6 @@ + - + \ No newline at end of file diff --git a/src/src/Results/Results.csproj b/src/src/Results/Results.csproj index 9bf6fcf..c4b6a32 100644 --- a/src/src/Results/Results.csproj +++ b/src/src/Results/Results.csproj @@ -1,4 +1,11 @@ - + + + + purview-results-core;purview-results-union-errors + + + + purview-results-zodsharp-problems + + + true Purview Results ZodSharp ASP.NET Core @@ -31,6 +38,8 @@ + + - + \ No newline at end of file diff --git a/src/src/ZodSharp/ZodSharp.csproj b/src/src/ZodSharp/ZodSharp.csproj index bcef141..ec05b8c 100644 --- a/src/src/ZodSharp/ZodSharp.csproj +++ b/src/src/ZodSharp/ZodSharp.csproj @@ -1,4 +1,11 @@ + + + purview-results-zodsharp-validation + + + 11.0.0-rc.1.26425.128 - 10.0.12 + 10.0.13 2.1.0 1.1.0 - 1.1.0 + 1.1.1 5.9.0 - 1.74.0 + 1.75.0 @@ -47,7 +47,7 @@ - + From 5deda5687fb3910d1374cbe71ac764c77295aa00 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 15 Oct 2026 22:37:16 +0100 Subject: [PATCH 12/13] feat: migrate agent skills to the root skills/ folder and the skills CLI Purview.BuildSdk 1.0.6 removes skill packaging and the build-time .agents/ mirror, so the skills CLI cloning the git repository is the only delivery route. Skills are authored at skills//SKILL.md and nothing packs them. global.json already pinned 1.0.6, which is why pack validation had started failing: the leftover Sdk/.agents content stopped being flattened to .agents/** and packed at its literal path instead, as undeclared content. - delete the PurviewSkillsToPack shim in src/Directory.Build.targets, the property from all four packable projects, and the .agents/**/* entries in .config/purview-build.json - delete the Sdk/.agents agents and prompts; the task-shaped prompt becomes references/migrating-error-returns.md inside the skill it belongs to, and the agent wrapper only routed to purview-results-union-errors and purview-results-core - validate the skills on every test run with Purview.Skills.Testing 1.0.6, through a Repository.UnitTests project whose [InheritsTests] attribute is load-bearing - raise the TUnit floor to 1.75.0, which Purview.Skills.Testing depends on and central transitive pinning would otherwise pin back down - rewrite the AGENTS.md, README.md and docs/wiki/Agent-Skills.md sections that described the mirror-and-pack model No release version change: package.json is untouched. Verified: just test 520/520, just pipeline-pack-validate 8/8 packages valid, just lint-check clean, and Purview.Results.nupkg no longer carries .agents/**. --- AGENTS.md | 101 +++++++----------- Directory.Packages.props | 6 ++ README.md | 2 +- docs/wiki/Agent-Skills.md | 36 ++++--- skills/purview-results-union-errors/SKILL.md | 4 + .../references/migrating-error-returns.md | 67 ++++++++++++ src/Directory.Build.targets | 74 ------------- src/Results.slnx | 1 + src/src/AspNetCore/AspNetCore.csproj | 6 -- src/src/Results/Results.csproj | 6 -- .../purview-results-union-author.agent.md | 77 ------------- ...e-error-returns-to-result-unions.prompt.md | 67 ------------ .../ZodSharp.AspNetCore.csproj | 6 -- src/src/ZodSharp/ZodSharp.csproj | 6 -- .../Repository.UnitTests.csproj | 16 +++ src/tests/Repository.UnitTests/SkillTests.cs | 60 +++++++++++ 16 files changed, 210 insertions(+), 325 deletions(-) create mode 100644 skills/purview-results-union-errors/references/migrating-error-returns.md delete mode 100644 src/src/Results/Sdk/.agents/agents/purview-results-union-author.agent.md delete mode 100644 src/src/Results/Sdk/.agents/prompts/migrate-error-returns-to-result-unions.prompt.md create mode 100644 src/tests/Repository.UnitTests/Repository.UnitTests.csproj create mode 100644 src/tests/Repository.UnitTests/SkillTests.cs diff --git a/AGENTS.md b/AGENTS.md index 793fc4d..2faf879 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,8 +25,8 @@ generator that makes C# 15 union error cases ergonomic), and the ZodSharp and AS | `src/src/AspNetCore` | Result-to-response mapping, endpoint filter, DI registration | | `src/src/ZodSharp` | ZodSharp `ValidationResult` bridge | | `src/src/ZodSharp.AspNetCore` | Validation-problem mapping for validation-carrying failures | -| `src/src//Sdk` | Package-only assets. `Sdk/README.md` is packed as the package README; `Sdk/.agents/**` ships sub-agent definitions and prompts with the package | -| `skills` | Authored agent skills, packed into each package's `.agents/skills/` and installable with `npx skills` (see [Agent skills](#agent-skills)) | +| `src/src//Sdk` | Package-only assets. `Sdk/README.md` is packed as the package README | +| `skills` | Authored agent skills, installable with the skills CLI (see [Agent skills](#agent-skills)) | | `src/tests` | TUnit unit tests, including source-generation and incremental-cache tests | | `src/src/Examples.*` | Runnable, non-packable examples: one project per integration aspect on a shared Tenant* domain | | `docs/wiki` | User-facing documentation suite, aggregated by the purview-dev website | @@ -36,8 +36,6 @@ generator that makes C# 15 union error cases ergonomic), and the ZodSharp and AS | `package.json` | Authoritative repository and package version | | `purview-build.json` | Shared `Purview.Build` pipeline configuration (solution, test discovery, test filter) | | `Justfile` | Supported local workflow commands | -| `.agents` | Package-delivered skills, agents and prompts (see [Agent skills](#agent-skills)) | -| `.purview/agent-sync.cache` | SDK-managed manifest recording which `.agents` files are already mirrored | ## Standard workflow @@ -311,68 +309,41 @@ result and the HTTP mapping). ## Agent skills -The repository-root `.agents/**` tree is **delivered by NuGet packages**; this repository also **authors** its -own agent content, which the packages ship to consumers. - -`Purview.BuildSdk` mirrors the `.agents` folder of every restored package into the repository root before build -and records what it copied in `.purview/agent-sync.cache`, so the root tree is complete even though it is not -maintained here. - -**Authored skills live in the repository-root `skills/` folder, not under `Sdk/.agents/skills/`.** The skills -CLI (`npx skills`) only discovers skills at a root `skills/` folder, and `.agents/` is gitignored here — so it -is absent from a git clone and cannot be the published source. Sub-agent definitions and prompts are unaffected -and stay under `src/src//Sdk/.agents/`. - -### Authored here (shipped by this repository's packages) - -Each project names the subset it ships through `PurviewSkillsToPack`, so a skill reaches only the package whose -library it describes. The authored copy still lives once, at the repository root. - -| Skill (in `skills/`) | Package | Declared in | -| --- | --- | --- | -| `purview-results-core`, `purview-results-union-errors` | `Purview.Results` | `src/src/Results/Results.csproj` | -| `purview-results-http-mapping` | `Purview.Results.AspNetCore` | `src/src/AspNetCore/AspNetCore.csproj` | -| `purview-results-zodsharp-validation` | `Purview.Results.ZodSharp` | `src/src/ZodSharp/ZodSharp.csproj` | -| `purview-results-zodsharp-problems` | `Purview.Results.ZodSharp.AspNetCore` | `src/src/ZodSharp.AspNetCore/ZodSharp.AspNetCore.csproj` | - -Sub-agent definitions and prompts remain under `src/src/Results/Sdk/.agents/` (`agents/purview-results-union-author.agent.md`, -`prompts/migrate-error-returns-to-result-unions.prompt.md`). - -- Packing is performed by the `IncludeRepositorySkillsInPackage` target in `src/Directory.Build.targets`. That - is an **interim shim**: `global.json` pins `Purview.BuildSdk` 1.0.5, which does not yet provide the SDK-side - equivalent. It implements the same `PurviewSkillsToPack` property the SDK uses, so adopting the newer SDK - means setting `PurviewPackRepositorySkills=true` on the same projects and deleting the shim. -- These files **are tracked source** and are packed as `.agents/skills/**` (the per-skill `.gitignore` is - generated into the package too). Update them like any other package asset, and keep each skill's guidance in - step with the code it documents. -- Adding a skill: create `skills//SKILL.md`, then add the name to the owning project's - `PurviewSkillsToPack`. A skill that is not named there is installable from git but ships in no package. -- A consuming repository that imports `Purview.BuildSdk` receives them in its own `.agents/` folder on the next - restore/build, so consumer-facing guidance is written for consumers (package-facing, not repository-facing). -- Load the relevant skill before doing the work it covers: - - 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` - -### Delivered by upstream packages (re-synced, do not edit) - -| Content | Source package | +Authored skills live in the repository-root `skills/` folder. **Nothing packs them into a NuGet package.** +`Purview.BuildSdk` 1.0.6 removed skill packaging outright — the skills CLI clones the *git* repository and only +discovers a root `skills/` folder, so that is the one delivery route. `PurviewSkillsToPack`, the +`IncludeRepositorySkillsInPackage` shim in `src/Directory.Build.targets`, the `Sdk/.agents/**` sub-agents and +prompts, and the `.agents/**/*` entries in `.config/purview-build.json` went with it. + +`.agents/skills/` is the CLI's install location for skills from **other** repositories, and is gitignored. This +repository's own skills are never installed into it — that would leave two copies to drift apart. Read them at +their authored path, `skills//SKILL.md`. + +| Skill | Answers | | --- | --- | -| `skills/project-placement-defaults`, `skills/sdk-configuration-reference`, `skills/sdk-project-behavior-and-detection`, `agents/sdk-consumer-setup.md`, `prompts/sdk-diagnose-agent-folder-copy.md` | `Purview.BuildSdk` | -| `skills/source-generator-codewriter-modernization`, `agents/source-generator-framework-writer.agent.md`, `prompts/refactor-source-generator-to-codewriter.prompt.md` | `Purview.SourceGeneratorFramework` | -| `skills/source-generator-testing` | `Purview.SourceGeneratorFramework.Testing` | -| `skills/tunit-test-authoring`, `agents/test-author-writer.agent.md`, `prompts/modernize-test-to-codequery-tunit.prompt.md` | `Purview.SourceGeneratorFramework.Testing.TUnit` | - -- **Upstream skills are not tracked by git.** Each `.agents/skills//` folder carries an SDK-generated - `.gitignore` that ignores everything except itself, so only those stub files appear in `git status`. Treat that - content as read-only: do not edit it here and do not force-add it to git. Changes belong in the owning package - repository (`build-sdk`, `sourcegenerator-framework`). -- Upstream agents and prompts under `.agents/agents/**` and `.agents/prompts/**` are tracked, but they are still - package-owned: editing them locally is overwritten by the next restore or build. -- If `.agents` content looks missing or stale after a package bump, run a build and inspect - `.purview/agent-sync.cache`; deleting an entry (or the manifest) forces a fresh copy. +| `skills/purview-results-core/` | the result type, its three states, the throw-on-misuse contract and the combinators | +| `skills/purview-results-union-errors/` | modelling failures as a `[GenerateResult]` union, the generated helpers, the `RSG1000`–`RSG1008` diagnostics, and migrating ad-hoc failure signalling | +| `skills/purview-results-http-mapping/` | result-to-HTTP mapping, failure resolution order and unmapped-failure `500`s | +| `skills/purview-results-zodsharp-validation/` | ZodSharp validation flowing through a result | +| `skills/purview-results-zodsharp-problems/` | validation problems from result failures | + +Every skill carries the `purview-results` prefix. Skills install into one flat directory per agent, shared with +every skill a developer has installed from anywhere, so an unprefixed name collides and the loser is whichever +installed second. + +To add a skill: create `skills//SKILL.md` with spec-legal frontmatter, and nothing else. There is no +per-project registration any more. Deep, task-shaped detail belongs in that skill's own `references/` folder — +`skills/purview-results-union-errors/references/migrating-error-returns.md` is the worked example. + +A relative link from one `SKILL.md` into another skill's folder is a rule violation: only a skill's own folder is +installed with it, so such a link works here and nowhere else. Name the other skill in prose instead. + +`src/tests/Repository.UnitTests/SkillTests.cs` validates every skill on every test run, through +`Purview.Skills.Testing`'s rule set (the Agent Skills specification: frontmatter fields, name and description +constraints, bundled links, body size). The `[InheritsTests]` attribute on that class is load-bearing — TUnit does +not walk base types during discovery, so without it the run is green and nothing is checked. That project pins +`net10.0` rather than taking the repository's testing set, because it asserts something about the repository +rather than about the runtime's reach. ## Build, format, test and pack commands diff --git a/Directory.Packages.props b/Directory.Packages.props index 8c215f6..b6fada4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -22,7 +22,10 @@ 5.9.0 + 1.75.0 + 1.0.6 @@ -52,7 +55,10 @@ + + + diff --git a/README.md b/README.md index 79a8f8c..3ae63cd 100644 --- a/README.md +++ b/README.md @@ -300,7 +300,7 @@ The full documentation suite lives in [`docs/wiki`](docs/wiki/Getting-Started.md | `src/src/AspNetCore` | Result-to-response mapping, endpoint filter and DI registration | | `src/src/ZodSharp` | ZodSharp `ValidationResult` bridge | | `src/src/ZodSharp.AspNetCore` | `HttpValidationProblemDetails` mapping for validation-carrying failures | -| `src/src//Sdk` | Package-only assets: `README.md` (packed as the package README) and any `Sdk/.agents/**` skills | +| `src/src//Sdk` | Package-only assets: `README.md` (packed as the package README) | | `src/tests` | TUnit unit tests, including source-generation and incremental-cache tests | | `src/src/Examples.*` | Runnable, non-packable examples: one project per integration aspect, built on the Tenant* domain | | `docs/wiki` | User-facing documentation suite, aggregated by the purview-dev website | diff --git a/docs/wiki/Agent-Skills.md b/docs/wiki/Agent-Skills.md index d06087b..a0be5be 100644 --- a/docs/wiki/Agent-Skills.md +++ b/docs/wiki/Agent-Skills.md @@ -1,22 +1,22 @@ # 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. +This repository authors **agent skills** so AI agents working with `Purview.Results` get the guidance +automatically. They are authored at `skills//SKILL.md` and installed with the skills CLI, which clones +this git repository — **no package ships them**. `Purview.BuildSdk` 1.0.6 removed skill packaging and the +build-time `.agents/` mirror, so the CLI is the only delivery route. -## Skills shipped by this repository +## Skills published 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` | 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 | +| Skill | Covers | +| --- | --- | +| `skills/purview-results-core` | The result type, its three states, combinators and the throw-on-misuse contract | +| `skills/purview-results-union-errors` | Union modelling, the generated helpers, the diagnostics table, and the language rules that make the helper necessary | +| `skills/purview-results-http-mapping` | Result-to-HTTP mapping, the failure resolution order, and unmapped-failure `500`s | +| `skills/purview-results-zodsharp-validation` | ZodSharp validation flowing through results | +| `skills/purview-results-zodsharp-problems` | Rendering validation-carrying failures as ProblemDetails | -The `Purview.Results` package also ships the `purview-results-union-author` agent and the -`migrate-error-returns-to-result-unions` prompt. +Task-shaped detail lives in a skill's own `references/` folder — migrating existing code onto error unions is +`skills/purview-results-union-errors/references/migrating-error-returns.md`. ## When to load which skill @@ -28,9 +28,11 @@ The `Purview.Results` package also ships the `purview-results-union-author` agen ## 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. +`Purview.BuildSdk` and the source-generator framework repositories publish their own skills (project placement, +SDK configuration, generator authoring and testing, TUnit authoring) through the same CLI. Those are owned by +the repository that publishes them — changes belong there, not here. The CLI installs them into `.agents/skills/`, +which is gitignored; this repository's own skills are never installed into it, because two copies would drift +apart. ## Related diff --git a/skills/purview-results-union-errors/SKILL.md b/skills/purview-results-union-errors/SKILL.md index 988dcfe..ce6ea9a 100644 --- a/skills/purview-results-union-errors/SKILL.md +++ b/skills/purview-results-union-errors/SKILL.md @@ -8,6 +8,10 @@ description: "Use when modelling expected failures as a C# 15 union and converti Use this skill when a codebase models expected failures as a **union** and returns `Result` or the value-less `Result` from the `Purview.Results` package. +Replacing thrown domain exceptions, `bool`/`out` pairs or error enums in existing code? The +step-by-step playbook is in +[references/migrating-error-returns.md](references/migrating-error-returns.md). + ## The shape ```csharp diff --git a/skills/purview-results-union-errors/references/migrating-error-returns.md b/skills/purview-results-union-errors/references/migrating-error-returns.md new file mode 100644 index 0000000..b0405f4 --- /dev/null +++ b/skills/purview-results-union-errors/references/migrating-error-returns.md @@ -0,0 +1,67 @@ +# Migrating ad-hoc failure signalling to an error union + +A playbook for replacing thrown domain exceptions, `bool`/`out` pairs, sentinel return values and +error-code enums with a C# 15 `union` of case types returned as `Result`. + +## Before you start, collect + +- The file(s) being migrated, and the operation family they belong to — a union is per family, not + per method, so methods can come and go without changing the error contract. +- A name for the union (for example `TenantError`). +- The failure modes to model as cases, or the decision to infer them from the current code. +- Whether genuinely exceptional exits stay throwing. They normally should: a lost connection or a + violated invariant is not a domain outcome. + +## Requirements + +1. Declare one `readonly record struct` per failure mode, carrying the payload a caller needs to + react, and declare the union once: + + ```csharp + [GenerateResult] + public readonly union TenantError(TenantNotFound, TenantDisabled, TenantAlreadyExists); + ``` + +2. Change the affected signatures to `Result` — or the value-less + `Result` when the member has nothing to return on success — and produce failures with + `TenantError.Failure(case)` / `TenantError.Failure(case)`, or the per-case + `new Case(...).AsFailure()` / `AsFailure()` helpers. +3. Leave genuinely exceptional exits throwing. +4. Do **not** attempt implicit conversions from a case to a result, and do not add operators, + extension conversions or wrapper types: `CS0715`, `CS0556`, `CS9282`, `CS0246` and `CS0029` each + block that route. Use the generated factory or helper, or `(TenantError)new TenantNotFound(id)` + when a cast reads better. Prefer the union-receiver factory when a leaf case type is shared with + another union (`RSG1006`), because it names the union. +5. Remove the replaced mechanism — thrown domain exceptions, error enums, `out` error parameters, + sentinel values — rather than leaving both paths in place. +6. Keep the union free of transport concerns: no `IResult`, no status codes, no HTTP types. +7. One case type per meaning. A case carrying a `string Reason` or an `int Code` invites string or + int matching and defeats the point. +8. Compose unions rather than repeating leaf cases. A case type that is itself a union is an + *included union*: the including union's factory also covers its cases and builds the nested value, + and sharing a union-typed case across unions is not reported. This is how to avoid `RSG1006`, + which applies only to a shared **leaf** case. + +## Migration strategy + +- Inventory the failure modes and the call sites of each affected member. +- Introduce the cases and the union before changing signatures, so each edit compiles in isolation. +- Update call sites to `Match` / `Map` / `Bind` where a value has to come out of the result, rather + than `if (IsSuccess)`. +- Update or add tests: one per case, asserting the case the caller receives. +- Map the union once in the host — see the `purview-results-http-mapping` skill — instead of deciding + status codes in the domain. + +## Verification + +- Build the solution and confirm no `RSG1000`–`RSG1008` diagnostics. +- Run the affected tests. +- Confirm no remaining `throw` for an outcome the caller is expected to handle. +- Confirm the union appears in exactly one mapper, not scattered through handlers. +- Confirm no method can fall off the end of its body: `default` is a bug, not a failure. +- Reflect public API changes in the package documentation. + +## Report + +When the migration is done, state: the files changed, the union and its cases, the failure modes that +stayed exceptional and why, and the verification performed. diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index 77afe53..ab68169 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -1,77 +1,3 @@ - - - $([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)', '..', 'skills')) - $(TargetsForTfmSpecificContentInPackage);IncludeRepositorySkillsInPackage - - - - - <_RepositorySkillToPack Include="$(PurviewSkillsToPack)" /> - <_RepositorySkillFile Include="$(PurviewSkillsSourceFolder)%(_RepositorySkillToPack.Identity)/**/*"> - %(_RepositorySkillToPack.Identity) - - - - - - - - <_RepositorySkillPackageFile Include="@(_RepositorySkillFile)"> - .agents/skills/%(SkillName)/$([System.String]::Copy('%(RecursiveDir)').Replace("\", "/"))%(Filename)%(Extension) - - - - - - - <_RepositorySkillGitIgnoreRoot>$([MSBuild]::NormalizeDirectory('$(IntermediateOutputPath)', '_PurviewSkillGitIgnores')) - - - - <_RepositorySkillGitIgnoreFile Include="$(_RepositorySkillGitIgnoreRoot)%(_RepositorySkillToPack.Identity)/.gitignore"> - .agents/skills/%(_RepositorySkillToPack.Identity)/.gitignore - - - - diff --git a/src/Results.slnx b/src/Results.slnx index 78ac03a..90584fb 100644 --- a/src/Results.slnx +++ b/src/Results.slnx @@ -27,6 +27,7 @@ + diff --git a/src/src/AspNetCore/AspNetCore.csproj b/src/src/AspNetCore/AspNetCore.csproj index 9cc5f75..11bb6f2 100644 --- a/src/src/AspNetCore/AspNetCore.csproj +++ b/src/src/AspNetCore/AspNetCore.csproj @@ -1,10 +1,4 @@ - - - purview-results-http-mapping - - true Purview Results ASP.NET Core diff --git a/src/src/Results/Results.csproj b/src/src/Results/Results.csproj index a2f5599..0b35675 100644 --- a/src/src/Results/Results.csproj +++ b/src/src/Results/Results.csproj @@ -1,10 +1,4 @@ - - - purview-results-core;purview-results-union-errors - - - purview-results-zodsharp-problems - - true Purview Results ZodSharp ASP.NET Core diff --git a/src/src/ZodSharp/ZodSharp.csproj b/src/src/ZodSharp/ZodSharp.csproj index 2d9cce9..bcef141 100644 --- a/src/src/ZodSharp/ZodSharp.csproj +++ b/src/src/ZodSharp/ZodSharp.csproj @@ -1,10 +1,4 @@ - - - purview-results-zodsharp-validation - - + net10.0 + false + + + + + + + diff --git a/src/tests/Repository.UnitTests/SkillTests.cs b/src/tests/Repository.UnitTests/SkillTests.cs new file mode 100644 index 0000000..8c7f585 --- /dev/null +++ b/src/tests/Repository.UnitTests/SkillTests.cs @@ -0,0 +1,60 @@ +using Purview.Skills.Testing; + +namespace Purview.Results; + +/// +/// Runs the Agent Skills conformance rule set against this repository's authored skills in +/// skills/. +/// +/// +/// +/// [InheritsTests] is load-bearing. TUnit does not walk base types when it discovers +/// tests, so without it this class compiles, the run is green, and nothing is validated. +/// +/// +/// There is no RepositoryRoot override: the default is the SDK's repository-root marker walk, +/// the same resolution InstallPurviewSkills uses, so an ordinary test run exercises it. +/// is what catches a walk that landed elsewhere - +/// every inherited rule passes vacuously against a catalogue that was never found. +/// +/// +[InheritsTests] +public sealed class SkillTests : PurviewSkillTests +{ + [Test] + public async Task SkillsFolder_ResolvesToThisRepository() + { + var catalogue = Evaluator.Catalogue; + + await Assert + .That(catalogue.FolderExists) + .IsTrue() + .Because($"the marker walk resolved '{catalogue.RepositoryRoot}', whose skills folder does not exist"); + + await Assert + .That(File.Exists(Path.Combine(catalogue.RepositoryRoot, "AGENTS.md"))) + .IsTrue() + .Because($"'{catalogue.RepositoryRoot}' does not look like this repository"); + + await Assert.That(catalogue.Skills).IsNotEmpty(); + } + + [Test] + public async Task EverySkill_IsNamedForThisProduct() + { + // Skills install into one flat directory per agent, shared with every skill a developer has + // installed from anywhere. An unprefixed name collides with somebody else's, and the loser is + // whichever installed second. + var unprefixed = Evaluator + .Catalogue.Skills.Select(skill => skill.FolderName) + .Where(name => !name.StartsWith("purview-results", StringComparison.Ordinal)) + .ToArray(); + + await Assert + .That(unprefixed) + .IsEmpty() + .Because( + "every skill this repository publishes must carry the product prefix: " + string.Join(", ", unprefixed) + ); + } +} From a41310382ea249846badeaff1db1ab7d03c52d52 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Fri, 16 Oct 2026 09:57:40 +0100 Subject: [PATCH 13/13] chore(release): name the 1.0.2 release and cover the net10.0 mapping path Rename the CHANGELOG's `Unreleased` heading to `1.0.2` so a merge to main cannot publish an unnamed release, and add an `Upgrade notes` section covering the `[GenerateResult]` namespace move, the new `IResultValue.AcceptSuccess` member and the `ConfigureAwait(false)` change. Each note names the old form, the new form and the edit to make, replacing the inline "breaking" markers. Cover the net10.0 mapping path. AspNetCore.UnitTests is pinned to net11.0 because it declares a union, so the net10.0 branch of DefaultResultsHttpMapper.ResolveCases could not be executed by the suite at all - the only coverage was the end-to-end packed-consumer check. Two tests now assert the equivalence that branch relies on: a non-union error resolves as its own single case, both when it has a mapping and when it falls through to the unmapped-failure response. That is what net10.0 always returns, so unwrapping that stopped treating a non-union error as a single case now fails here instead of silently breaking net10.0 consumers. Enable package validation against the 1.0.0.1 baseline so an unintended public API change fails `dotnet pack`. All four packages validate clean with no suppressions. Also document the pre-merge release checklist and correct four stale facts in the release-flow page: a hardcoded SDK version, the RSG1000-RSG1008 rule range, a claim that packages ship `.agents/**` content (BuildSdk 1.0.6 removed skill packaging), and the purview-build.json path, which is under .config/. --- CHANGELOG.md | 50 ++++++++++++--- docs/wiki/Release-Flow.md | 38 +++++++++--- src/Directory.Build.targets | 18 ++++++ .../DefaultResultsHttpMapperTests.cs | 61 +++++++++++++++++++ 4 files changed, 150 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fcc880..68971eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,31 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `package.json` is the authoritative version; see [Release Flow](docs/wiki/Release-Flow.md). -Released versions correspond to `v` GitHub releases. Entries below the `Unreleased` heading have not -been published to NuGet. +Released versions correspond to `v` GitHub releases. -## Unreleased +## 1.0.2 + +Consolidates the `1.0.0` and `1.0.0.1` releases that followed `1.0.0-prerelease.2`: upgrading from the +prerelease goes straight to `1.0.2`, and the notes below cover that whole arc. + +> **Upgrading?** Start with [Upgrade notes](#upgrade-notes). Two APIs moved or changed shape along the way; +> each note names the old form, the new form, and the edit to make. + +### Upgrade notes + +- **The `[GenerateResult]` attribute now lives in `Purview.Results`.** + - *Previously:* the generated opt-in attribute was emitted into `Purview.Results.SourceGenerator`. + - *Now:* it is `Purview.Results.GenerateResultAttribute`. + - *To upgrade:* remove any `using Purview.Results.SourceGenerator;` that was added only for the attribute. + `Purview.Results` is already imported wherever the result types are used, so most files need no change. +- **`IResultValue` gained `AcceptSuccess`.** See + [`IResultValueVisitor`](#added--iresultvaluevisitortstate-treturn) for why. + - *To upgrade:* nothing, in the normal case — the two result types implement `IResultValue` in-box, and + external implementations are not expected. A hand-written implementation needs the new member. +- **Async combinators now await with `ConfigureAwait(false)`**, so they no longer resume on a captured + synchronization context. + - *To upgrade:* nothing for an ASP.NET Core or console host. Code that relied on continuing back onto a UI or + legacy ASP.NET context must marshal back itself. ### Added @@ -48,6 +69,9 @@ been published to NuGet. ### Changed +- **NuGet package validation.** All four packages now enable `EnablePackageValidation` against the previous + stable release (`1.0.0.1`), so an unintended change to the public API fails `dotnet pack` rather than + shipping. All four validate clean with no suppressions. - `Purview.Results.ZodSharp.AspNetCore` references `Purview.ZodSharp` explicitly rather than relying on it transitively through `Purview.ZodSharp.AspNetCore`, because the mapping uses `ZodException` and `ValidationError` directly. No package content changes. @@ -65,8 +89,9 @@ been published to NuGet. - Declaring your own `[GenerateResult]` union needs `LangVersion=preview` in **that** project. Both paths are verified against the packed package, the first with `TreatWarningsAsErrors=true`. -- **Breaking:** the generated `[GenerateResult]` opt-in attribute moved from `Purview.Results.SourceGenerator` - to `Purview.Results`. Remove any `using Purview.Results.SourceGenerator;` added for the attribute. +- The generated `[GenerateResult]` opt-in attribute moved from `Purview.Results.SourceGenerator` to + `Purview.Results`, so it sits alongside the types it opts into. Listed under + [Upgrade notes](#upgrade-notes). - **All four packages are now marked `IsAotCompatible`**, enabling `IsTrimmable` and both the trim and AOT analyzers, and all four are verified clean. For `Purview.Results` and `Purview.Results.ZodSharp` this independently confirms the "no reflection, no dynamic, no runtime type discovery" guarantee. @@ -110,6 +135,13 @@ been published to NuGet. pinned to `net11.0`. - `Results.UnitTests` multi-targets, so the core is now exercised on both runtimes — the suite went from 342 to 492 executions. +- **The `net10.0` mapping behaviour is now pinned by a test.** `AspNetCore.UnitTests` is itself pinned to + `net11.0` (it declares a union, which cannot compile earlier), so the `net10.0` branch of + `DefaultResultsHttpMapper.ResolveCases` could not be executed by the suite at all — the only coverage was the + end-to-end packed-consumer check. Two tests now assert the equivalence that branch relies on: a **non-union** + error type resolves as its own single case, both when it has a mapping and when it falls through to the + unmapped-failure response. That is precisely what `net10.0` always returns, so unwrapping that stopped + treating a non-union error as a single case now fails here instead of silently breaking `net10.0` consumers. - **Runtime async is scoped to `net11.0`.** `Features=runtime-async=on` was set repo-wide. On `net10.0` it lowers `await` to `System.Runtime.CompilerServices.AsyncHelpers`, which is evaluation-only and fails the build with `SYSLIB5007` — the API is explicitly subject to change or removal. A `net10.0` consumer @@ -127,11 +159,11 @@ been published to NuGet. - A new visitor interface and `IResultValue.AcceptSuccess` hand a result's successful value to infrastructure **with its static type intact**, without reflection. `SuccessValue` remains the right shape for inspection; use `AcceptSuccess` whenever the type matters, such as serialization. -- **Breaking for anyone implementing `IResultValue` themselves** (the two result types implement it in-box; - external implementations are not expected). +- The two result types implement `IResultValue` in-box, so this reaches a consumer only through a + hand-written implementation, which is not an expected shape. Listed under + [Upgrade notes](#upgrade-notes). - Async combinators now await with `ConfigureAwait(false)`, so they no longer resume on a captured - synchronization context. This affects consumers that relied on continuation back onto a UI or legacy - ASP.NET context. + synchronization context. Listed under [Upgrade notes](#upgrade-notes). ### Fixed diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index 3a3d0cf..be7e7fd 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -10,6 +10,25 @@ GitHub Actions workflows in `.github/workflows/`. Consuming repositories own con `Purview.BuildSdk`. Never diverge a project's version by hand. Read the current line from `package.json` (`just version`) rather than from this page; a prerelease uses a `MAJOR.MINOR.PATCH-prerelease.N` suffix. +## Releasing a version + +A push to `main` publishes, so everything below has to be true *before* the merge. + +1. **Rename the changelog heading to the version being released.** `CHANGELOG.md`'s newest `## ` + heading is the one that ships; there is no `Unreleased` heading, precisely so a merge cannot publish an + unnamed release. Anything a consumer has to act on goes under that version's `### Upgrade notes`, each note + naming the old form, the new form and the edit to make. +2. **Move new diagnostics into a shipped block**, as [Analyzer release tracking](#analyzer-release-tracking) + describes. A rule stays recorded under the release that *introduced* it, so earlier blocks are never + rewritten. +3. **Bump the package-validation baseline.** `src/Directory.Build.targets` sets + `PackageValidationBaselineVersion` to the previous stable release; it must name the version currently on + NuGet, not the one being released. The build then fails `dotnet pack` on an unintended public-API change. An + intended one needs a `CompatibilitySuppressions.xml` beside the owning project + (`dotnet pack /p:GenerateCompatibilitySuppressionFile=true`) *and* a matching upgrade note — the two are each + other's evidence. +4. **Run `just pipeline-pr`**, which covers restore, build, lint, tests, pack and pack validation in one pass. + ## Workflows | Workflow | Trigger | Pipeline | @@ -17,14 +36,14 @@ GitHub Actions workflows in `.github/workflows/`. Consuming repositories own con | `.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. +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. 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` +## `.config/purview-build.json` | Key | Value | | --- | --- | @@ -49,17 +68,20 @@ removed PDB, a renamed analyzer. `Purview.Results.SourceGenerator.CodeFixes.dll` and is never IL-merged into the generator. The generator is not published as a separate package. - 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`. +- Every package ships its `README.md` and `purview-logo-light.png`. Agent skills are **not** packed — + `Purview.BuildSdk` 1.0.6 removed skill packaging, and they are delivered from the repository-root `skills/` + folder by the skills CLI instead. The framework assembly must never appear loose beside the merged analyzer — `ForbiddenContent` rejects `analyzers/**/Purview.SourceGeneratorFramework.dll`. ## Analyzer release tracking -The generator bundled in `Purview.Results` ships public diagnostics (`RSG1000`–`RSG1008`), so it maintains the -Roslyn release-tracking files. New or changed rules go in +The generator bundled in `Purview.Results` ships public diagnostics (`RSG1000`–`RSG1009` and `RSG9000`), 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. +the build, and move into a `## Release ` block in `AnalyzerReleases.Shipped.md` when that version ships. +`RSG2000` is a *suppression* id, not a reported rule, so it must not appear in either file. ## Commands diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index ab68169..f8b63df 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -1,3 +1,21 @@ + + + + true + 1.0.0.1 + diff --git a/src/tests/AspNetCore.UnitTests/DefaultResultsHttpMapperTests.cs b/src/tests/AspNetCore.UnitTests/DefaultResultsHttpMapperTests.cs index c4b9729..7979a51 100644 --- a/src/tests/AspNetCore.UnitTests/DefaultResultsHttpMapperTests.cs +++ b/src/tests/AspNetCore.UnitTests/DefaultResultsHttpMapperTests.cs @@ -150,6 +150,67 @@ public async Task Map_GivenMapperForTheErrorType_MapsEveryCaseWithoutItsOwnMappi await Assert.That(response.Body).Contains("already exists"); } + /// + /// Pins the equivalence that lets Purview.Results.AspNetCore ship net10.0: union unwrapping in + /// DefaultResultsHttpMapper.ResolveCases is compiled for net11.0 only, and on net10.0 an + /// error is always its own single case. A non-union error must therefore resolve to exactly one case here too, + /// so both targets answer identically for every error a net10.0 consumer is able to declare. + /// + /// + /// This project is pinned to net11.0 because it declares a union, so the net10.0 branch cannot be + /// executed directly. Asserting the equivalence on net11.0 is what keeps the two in step: a change to + /// the unwrapping that stopped treating a non-union error as a single case would break net10.0 + /// consumers silently, and fails here instead. + /// + [Test] + public async Task Map_GivenNonUnionErrorType_ResolvesTheErrorAsItsOwnSingleCase() + { + // Arrange + var mapper = ResultsHttpTestFactory.CreateMapper(options => + options.Map(error => + TypedResults.Problem(statusCode: StatusCodes.Status418ImATeapot, title: error.Reason) + ) + ); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(Result.Failure(new NotAUnionError("brewing")), context), + context + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status418ImATeapot); + await Assert.That(response.Body).Contains("brewing"); + } + + /// + /// A non-union error with no mapping of its own still reaches the unmapped-failure response rather than being + /// mistaken for a case of something else — the other half of the net10.0 equivalence above. + /// + [Test] + public async Task Map_GivenUnmappedNonUnionErrorType_ReturnsTheUnmappedFailureResponse() + { + // Arrange + var mapper = ResultsHttpTestFactory.CreateMapper(); + var context = ResultsHttpTestFactory.CreateContext(); + + // Act + var response = await ResultsHttpTestFactory.ExecuteAsync( + mapper.Map(Result.Failure(new NotAUnionError("brewing")), context), + context + ); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(StatusCodes.Status500InternalServerError); + } + + /// + /// A plain error type, deliberately not a union and not a case of one: this is the only error shape a + /// net10.0 consumer can declare, because unions need .NET 11. + /// + sealed record NotAUnionError(string Reason); + [Test] public async Task Map_GivenCaseMappingAndErrorTypeMapping_PrefersTheCaseMapping() {