diff --git a/.agents/skills/purview-architecture-testing/SKILL.md b/.agents/skills/purview-architecture-testing/SKILL.md new file mode 100644 index 0000000..0619225 --- /dev/null +++ b/.agents/skills/purview-architecture-testing/SKILL.md @@ -0,0 +1,183 @@ +--- +name: purview-architecture-testing +description: >- + Use when adding, changing or debugging architecture tests in a .NET solution - dependency-direction + rules, forbidden framework references, project naming checks, cycle detection - or when a boundary + keeps being broken in review and should fail the build instead. Adopts Purview.Architecture.Testing + and a single policy file rather than hand-maintained lists. +license: MIT +metadata: + author: Purview-Dev + version: "2.0" +--- + +# Purview architecture testing + +A boundary that only review enforces is a boundary that erodes. The job of this skill is to turn a +rule a reviewer keeps repeating into a test that fails the build, **without** producing a wall of +brittle assertions that everyone disables six months later. + +Prerequisite: know what the projects are *for*. See `purview-architecture`. + +## 1. Only test a rule that is real + +Before writing anything, answer: **has this rule been broken, or is it certain to be?** + +A test for a dependency nobody would plausibly add is cost with no benefit. Good candidates: + +- A direction that has already been violated once. +- A boundary that is load-bearing - Core staying free of EF Core, Composition being referenced by + nothing. +- A rule a newcomer cannot infer from the code. + +Bad candidates: anything the compiler already enforces, and style preferences dressed up as +architecture. + +## 2. Adopt the package; define the policy once + +The failure mode of architecture tests is a dependency matrix written twice - once in a wiki table and +once in assertions - which then disagree. The prose is the copy that drifts, because nothing fails +when it is wrong. + +So do not write the rule engine. `Purview.Architecture.Testing` ships it, so the matrix, naming, +sibling-independence, composition, forbidden-package, cycle, test-reference and waiver rules are +defined once and shared. Adoption is two files. + +**A policy** at `.config/architecture-policy.json`, pointing at the published schema so an editor +validates it as you type: + +```json +{ + "$schema": "https://raw.githubusercontent.com/purview-dev/architests/main/architecture-policy.schema.json", + "version": 2, + "sourceRoots": ["src"], + "responsibilities": [ + { + "name": "Core", + "aliases": ["Domain"], + "category": "domain", + "purpose": "The domain model, its invariants and its rules.", + "mayDependOn": ["Shared"], + "mustNotDependOn": ["Application", "Persistence", "Composition"], + "forbiddenPackagePrefixes": ["Microsoft.EntityFrameworkCore", "Microsoft.AspNetCore"] + } + ], + "waivers": [] +} +``` + +**One derived test class:** + +```csharp +[InheritsTests] +public sealed class ArchitectureTests : PurviewArchitectureTests; +``` + +That is the whole of the adoption. Each rule is a separate test, so a failure names the rule rather +than collapsing the set into one assertion, and the package validates the policy against its schema +first - a policy that does not match the schema may be silently missing a rule. + +The build-sdk ships a skill for the package itself, `purview-buildsdk-architecture-tests`, which is +authoritative on its API and rule ids. Read it rather than this page when you are extending or +debugging the package. + +Then add a test that the documentation matches the policy, so the table in the wiki is falsifiable +too. Full worked example: +[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Project-Responsibilities.md). + +## 3. Read project files, and assemblies + +Two sources, because they catch different things: + +| Source | Catches | Misses | +| -- | -- | -- | +| `.csproj` `ProjectReference` | The reference a reviewer actually added | A dependency arriving transitively | +| Compiled assembly references | A dependency that arrived indirectly | Intent — it cannot tell direct from transitive | + +The package's rules read the **project files**, because a declared `ProjectReference` is what a +reviewer adds and what the policy governs. Add a small hand-written test that reads the **compiled +assemblies** for the leak a project file cannot show - a namespace from the wrong layer reaching a +public type. + +Classify a project by the **first segment** of its name, so `Persistence.SqlServer` and `Persistence` +are both Persistence - qualification is a naming choice, never a different rule. The package does +this for you; a hand-written assembly test must do it too. + +## 4. The rules worth having + +| Rule | What it catches | +| -- | -- | +| Dependency matrix | Core referencing Persistence; Application referencing a concrete provider | +| Nothing references Composition | A circular implementation choice | +| Sibling independence | `Providers.GitHub` reaching into `Providers.AzureDevOps` | +| Forbidden package prefixes | EF Core in Core; ASP.NET hosting in a domain project | +| Cycle detection | A graph that MSBuild rejects with an unreadable message | +| Catch-all names | `Infrastructure`, `Services`, `Helpers` appearing as project names | +| No production → test reference | A test helper leaking into shipped code | + +The package provides all of these. Do not re-implement one; change the policy instead. + +## 5. Write failures someone can act on + +This is what decides whether the suite survives. A failure must name: + +1. The offending project. +2. The rule violated. +3. The specific reference that broke it. +4. The direction the dependency *should* run. +5. The likely fix. + +```text +'Application' (Application) references 'Persistence.SqlServer' (Persistence), but Application +must not depend on Persistence. Invert it: Persistence may depend on Application, or introduce +an abstraction that Application owns and Persistence implements. +``` + +Not `Expected collection to be empty`. Collect every violation and report them as a numbered list - +one test run should show all of them, not the first. The package already reports this way; keep the +same standard in any rule you add. + +## 6. Waivers, not suppressions + +A repository that predates the policy will violate it. Deleting the test or adding a blanket +`#pragma` makes the policy a lie. Record the departure in the policy instead: + +```json +{ + "project": "Infrastructure", + "mapsTo": "Adapters", + "rules": ["naming"], + "reason": "Holds one adapter, predates the catalogue; renaming churns the wiki and docs for no gain.", + "reviewedOn": "2026-10-15" +} +``` + +The package tests the waivers themselves: each must name a project that still exists, a rule, and a +substantive reason. A waiver for a deleted project is dead weight that hides the next one. + +Critically, a waiver excuses **the narrowest thing possible** - usually the name. `mapsTo` keeps the +project classified so every dependency rule still applies to it. Prefer a waiver to +`SkipRule`, which removes a rule for the whole repository with no record. + +## 7. Conventions + +- TUnit, matching the repository's other tests, adopted through `PurviewArchitectureTests` with + `[InheritsTests]`. +- Arrange/Act/Assert, one behaviour per test. +- Let the package find the repository root - it probes upward for the policy and stops at the `.git` + boundary. Override `RepositoryRoot` only when the policy does not sit above the test binary. +- Keep the policy file free of anything a rule cannot read; a field no rule consults is a rule that + looks enforced and is not. + +## Checks before you answer + +- Is every rule you added one that has been, or will be, broken? +- Is the matrix defined once, in the policy, with the documentation checked against it? +- Does each failure name the fix, not just the fact? +- Did you record a waiver rather than weaken a rule for existing code? + +## See also + +- `purview-architecture` — what the projects are for, and how they are named +- `purview-buildsdk-architecture-tests` — the package's own skill, authoritative on its API +- [Architecture as Tests](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Architecture-As-Tests.md) diff --git a/.agents/skills/purview-architecture/SKILL.md b/.agents/skills/purview-architecture/SKILL.md new file mode 100644 index 0000000..c04f3d6 --- /dev/null +++ b/.agents/skills/purview-architecture/SKILL.md @@ -0,0 +1,176 @@ +--- +name: purview-architecture +description: >- + Use when deciding the shape of a .NET solution that uses Purview libraries — how many projects, + whether to add a repository or CQRS layer, where validation lives, how an API and its clients share + a contract (Contracts, OpenAPI or Hybrid) and when a BFF is earned — or before generating + substantial implementation code. Establishes project context first, then applies DRY, KISS and + architecture proportional to the actual requirements rather than to an enterprise reference diagram. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" +--- + +# Purview architecture + +The job of this skill is to stop two failures that cost far more than they look: + +- **Architecture bought in advance.** Four projects, a repository interface and a mapping layer for an + application that has one table and one caller. +- **A recommendation made without context.** Advice about database-side GUID generation given to a + stateless CLI, or a Clean Architecture split proposed for a 300-line utility. + +So: establish context, then choose proportionally. Both steps are mandatory, and the first one is the +one that gets skipped. + +## 1. Establish context before deciding + +Before recommending a structure, adding a project, or generating substantial code, you must know four +things. **Read them out of the repository first** — asking a user a question the code already answers +is the most common way this skill is applied badly. + +| Dimension | Read it from | Ask only if | +| -- | -- | -- | +| Complexity (small / medium / large) | Number of functional areas, integrations and external consumers — not file count | The requirements are genuinely not written down anywhere | +| Project type | SDK attribute, `OutputType`, `Microsoft.NET.Sdk.Web`, an AppHost project, a `Main` | The repository is empty | +| Persistence | `PackageReference` to an EF Core provider, `DbContext`, a connection string, a `Purview.Containers` resource | Persistence is being introduced now | +| Telemetry | `Purview.Telemetry.SourceGenerator`, `ILogger` injection, `ActivitySource`, an OTLP exporter | Nothing is configured and you are about to add it | + +Identifier generation is a **conditional** dimension: resolve it only when persistence exists *and* +GUID keys are in play. See `purview-persistence`. + +Judge complexity on requirements, not size. A 2,000-line CLI with one output format is small. A +900-line service that reconciles three upstream systems and owns money movement is not. + +**Do not re-ask.** A dimension answered by repository configuration, existing source, a project file, +an earlier turn in the conversation, or a recorded architectural decision is answered. Ask concise, +targeted questions only for the unknowns that change your recommendation — and if nothing changes, +ask nothing. + +Full decision procedure: `references/project-context-discovery.md`. + +## 2. Choose proportionally + +| Scale | Shape | What you still do not add | +| -- | -- | -- | +| Small | One project, or two when a test project needs a non-trivial subject. Direct implementations. | Repository over `DbContext`, CQRS, a mapping layer, speculative interfaces | +| Medium | Explicit boundaries where complexity already hurts — usually a domain project and a host | Event sourcing, a generic infrastructure framework, a second infrastructure implementation nobody asked for | +| Large | Modularity, independent infrastructure implementations, enforceable boundaries (see the playbook's Architecture-As-Tests page) | Patterns adopted for symmetry rather than for a named problem | + +Architecture is allowed to arrive late. A boundary added when the second caller appears is cheap; a +boundary added in anticipation of a second caller that never appears is permanent. + +**Never add a project for symmetry.** "Domain, Application, Infrastructure, API" is a response to a +problem, not a starting position. If you cannot name the failure a layer prevents in this codebase, +do not add the layer. + +Full guidance and the per-pattern justification tests: `references/proportional-architecture.md`. + +## 3. Name what you do create + +A project name states a **responsibility**, optionally qualified by an implementation: + +```text +[.] +``` + +The responsibility decides the rules; the qualifier never changes them. Three rules people usually +get wrong: + +- **Do not qualify a single implementation.** `Persistence` is correct even when it uses SQL Server. + Rename when a second provider arrives, not in anticipation. +- **Do not create an empty parent.** `Persistence.SqlServer` + `Persistence.PostgreSql` with nothing + shared need no `Persistence` project. +- **Multiple implementations do not require multiple assemblies.** Two small adapters with the same + dependencies belong in one project. + +Never use a *category* as a project name — `Infrastructure`, `Services`, `Managers`, `Models`, +`Helpers`, `Common`. Nothing can be excluded from a category, so it grows without limit. And never +rename an established project just to match canonical terminology; `Domain`, `UseCases` and +`Integrations` are recognised aliases. + +Responsibility catalogue, dependency matrix and the naming decision tree: +`references/project-responsibilities.md`. + +## 4. Check the direction + +`A → B` means **A depends on B at compile time** — not a runtime call, a DI registration, an HTTP +request or an Aspire orchestration edge. Confusing those is the most common source of a wrong answer +about whether a dependency is allowed. + +The rules that matter most: + +- Core depends on nothing but `Shared`. +- Application never references a concrete `Persistence`, `Providers` or `Adapters` project. +- Composition references implementations; **nothing references Composition** except a host. +- Sibling implementations (`Providers.GitHub`, `Providers.AzureDevOps`) stay independent. +- Persistence owns migration *definitions*; `Migrator` owns their *execution*. Production API startup + does not apply schema migrations. + +When a boundary is worth having, make it fail the build rather than the review — see +`purview-architecture-testing`. + +## 5. Choose how an API and its clients share a contract + +When there is an HTTP API and at least one client, choose **Contracts**, **OpenAPI** or **Hybrid** +from the clients that **exist today**. A planned client is noted; it does not count. + +| Clients today | Strategy | Failure it prevents | +| -- | -- | -- | +| All .NET, same solution, shipped in lockstep | **Contracts** — a library of request/response types on the domain's value objects, referenced by the server and every .NET client | Rules drifting, and a weaker second copy of them in the UI | +| At least one non-.NET client, or one that ships independently | **Hybrid** — Contracts is the source; the OpenAPI document and its schemas are generated from it | Non-.NET clients hand-writing shapes, and .NET clients losing their value objects to a generated client | +| No .NET client — partners or the public only | **OpenAPI**, generated from the server's types; no Contracts project | A project with no consumer but the server | + +The rules that do not bend: + +- **OpenAPI is an output, never an input**, while Contracts exists. Never regenerate a C# client from + your own API's document when that client is in the same solution. +- **Never hand-maintain a parallel set of models**, and **the server always validates** — a shared + validator is for user experience, not trust. +- **Contracts is browser-safe** when a WebAssembly client references it: architecture-policy bans, + the trim and AOT analyzers and the browser platform analyzer check it, not a reviewer. +- **A BFF is earned by keeping tokens out of the browser.** It holds the tokens, gives the browser an + `HttpOnly` cookie, forwards with the user's token, and requires an anti-forgery header on every + forwarded call. In Blazor it is the host that serves the WebAssembly client, and a BFF that only + forwards does not reference Contracts. The policy does not define a BFF yet, so these are review + obligations until a future Build SDK release adds Blazor and BFF support. + +Full procedure, version-skew rules, BFF guidance and worked examples: `references/contract-strategy.md`. + +## 6. DRY, applied where it pays + +Reuse the Purview libraries when they remove a real source of truth, not for coverage: + +| Concern | Library | Use it when | +| -- | -- | -- | +| Operation outcomes without exceptions for control flow | `Purview.Results` | Failures are expected and the caller must handle them | +| Primitive obsession, validated identity types | `Purview.ValueObjects` | A value has rules that would otherwise be re-checked at each boundary | +| Schema shared with a TypeScript client | `Purview.ZodSharp` | A contract is duplicated between C# and TypeScript today | +| Local dependencies for dev and integration tests | `Purview.Containers` | Tests need a real database, queue or emulator | +| Logging, tracing and metrics from one declaration | `Purview.Telemetry.SourceGenerator` | Always — see `purview-telemetry` | + +Duplication that matters: validation repeated per entry point, a domain rule restated in the UI, +configuration bound in two places, two types that both claim to be the truth. Duplication that does +not: two similar-looking methods that answer to different owners. + +## 7. Telemetry is not an advanced topic + +Recommend instrumentation from the first commit, at every project size. A small application gets +small telemetry — it does not get none. See `purview-telemetry`. + +## Checks before you answer + +- Did you read the four context dimensions rather than ask for them? +- Can you name, for each project and each abstraction you are proposing, the failure it prevents here? +- Is anything in your proposal there only because a reference architecture has it? +- If there is an API with clients, did you choose its contract strategy from the clients that exist + today? +- Did you include telemetry? + +## See also + +- `purview-telemetry` — instrumentation at every scale +- `purview-persistence` — storage and GUID-key guidance, only when persistence is real +- [Engineering Principles](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Engineering-Principles.md) +- [Adopting This At Your Scale](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Adopting-This-At-Your-Scale.md) diff --git a/.agents/skills/purview-architecture/references/contract-strategy.md b/.agents/skills/purview-architecture/references/contract-strategy.md new file mode 100644 index 0000000..e281a41 --- /dev/null +++ b/.agents/skills/purview-architecture/references/contract-strategy.md @@ -0,0 +1,236 @@ +# Contract strategy: Contracts, OpenAPI or Hybrid + +When a solution has an HTTP API and one or more clients, something has to define the shapes that cross +between them. There are three ways to do it, and the wrong one fails in one of two directions: + +- **Rules drift.** The client holds a second, weaker copy of the domain rules, and a request the UI + accepted is rejected by the server — or a break only surfaces at runtime. +- **Decoupling bought in advance.** A .NET client in the same solution regenerates its models from + the API's own OpenAPI document, and throws away every value object and rule to buy an independence + the solution does not have. + +The strategy is chosen from the clients that **exist today**. A planned client is noted; it does not +count. "Later" and "in case" do not justify a strategy any more than they justify a project — see +`proportional-architecture.md`. + +## The three strategies + +### Contracts + +A .NET class library holding the request and response types, built on the domain's value objects and +carrying `[ZodSchema]`. The server references it, and so does every .NET client — including a Blazor +WebAssembly client. + +There is one copy of every rule, error code and normalisation, and both sides run the same generated +validators. + +### OpenAPI + +The server publishes an OpenAPI document, and clients generate their models and calls from it. It is +language-neutral and versions independently. + +Generated models are primitives. Value objects, custom ZodSharp rules, error codes, and trimming and +casing are lost; only what JSON Schema can express survives. + +### Hybrid + +Contracts is the **single source of truth**. The OpenAPI document is **generated from it**, with its +component schemas taken from ZodSharp's export — the generated `{T}Schema.BuildJsonSchema()` is built +from the same metadata as the validator, so the document and the validators cannot drift. + +.NET clients reference Contracts. Non-.NET clients generate from the document and take their +validation rules from the exported JSON Schema — for TypeScript, `z.fromJSONSchema` (Zod 4) reads it +back as Zod schemas. The `purview-zodsharp-json` and `purview-zodsharp-source-generator` skills are +authoritative on the export. + +Three facts about the export that a Hybrid design has to plan for: + +- **ZodSharp ships no OpenAPI integration.** It produces JSON Schema; putting that into the OpenAPI + document — for example with a schema transformer in `Microsoft.AspNetCore.OpenApi` — is the API + host's job. Without it, ASP.NET Core describes a value object from its CLR shape, not from its rules, + and the document and the validators drift after all. +- **A non-.NET client still gets only what JSON Schema can express.** A value object exports as its + underlying value, and a rule the exporter cannot represent is listed under `x-zodsharp-unsupported` + rather than dropped. The contract test should fail when that list grows. +- **Exported objects set `additionalProperties: false`.** A client that validates a *response* against + that schema rejects a field the server has added — so "additive changes only" is not safe until + clients tolerate unknown response fields. Validate requests strictly and responses leniently, or the + versioning rule below protects nobody. (The reverse also holds: System.Text.Json ignores unknown + members, so the server accepts a request with an extra key that the schema rejects.) + +## Decision procedure + +1. **List the clients that exist today.** For each, record: + - its language and runtime; + - who owns it; + - whether it lives in the same repository and solution; + - whether it ships **in lockstep** with the server or on its own cadence. + + A planned client is noted, but it does not count yet. +2. **Every client is .NET, in the same solution, and ships in lockstep → Contracts.** This prevents: + - rules drifting between client and server; + - a second, weaker copy of the domain rules in the UI; + - contract breaks that only surface at runtime. + + Publish OpenAPI anyway if documentation or tooling wants it, but generate it from Contracts. It is + an output, never an input. +3. **At least one client is not .NET, or ships independently → Hybrid.** This prevents: + - non-.NET clients hand-writing shapes and rules; + - the .NET clients losing their value objects and rules to a generated client. + + It requires the independent-versioning rules under [Version skew](#version-skew). +4. **No .NET client exists, or the API is a surface whose clients the team does not own (public or + partner) → OpenAPI**, still generated from the server's types. A separate Contracts project is not + earned when the server is its only consumer. +5. **A .NET client in another repository with its own release cadence** prefers Contracts shipped as + a package, if that team accepts the dependency. Otherwise treat it as step 3. + +```text +flowchart TD + A["List the clients that exist today"] --> C{"Every client .NET,
same solution, lockstep?"} + C -- yes --> K["Contracts
OpenAPI only as an output"] + C -- no --> B{"Any .NET client
at all?"} + B -- no --> O["OpenAPI
generated from the server's types"] + B -- yes --> D{"The only other clients are .NET
in another repository,
and they accept a package?"} + D -- yes --> P["Contracts, shipped as a package"] + D -- no --> H["Hybrid
Contracts is the source, OpenAPI generated from it"] +``` + +Steps 3 and 4 do not overlap: a .NET client in the solution alongside a partner or non-.NET client is +step 3, because the .NET client still has value objects and rules to lose. + +## Rules that follow + +- **Never regenerate a C# client from your own API's OpenAPI document when that client is in the same + solution.** It throws away value objects and rules to buy decoupling the solution does not have. +- **Never hand-maintain a parallel set of models** on either side. Two definitions of one wire shape + disagree eventually, and nothing fails when they start to. +- **The server always validates.** Sharing validators with a client is for user experience and + consistency. It is never a substitute for validating on the server, because the client is untrusted. +- **Moving from Contracts to Hybrid must be additive.** Because Contracts is already the source, adding + a non-.NET client means turning on generation of the OpenAPI document and a contract test. Nothing in + Contracts changes. Design for that, but do not build it until the client exists. + +## Contracts project constraints + +- **References only the domain library (value objects) and the runtime Purview packages** — + `Purview.ValueObjects`, `Purview.ZodSharp`, `Purview.Results`. Never the server, persistence, HTTP + clients or hosting, and never an ASP.NET Core bridge such as `Purview.ZodSharp.AspNetCore`; those + belong to the API host. A Contracts project that references the server is a server that every client + now ships. +- **Browser-safe when a WebAssembly client references it.** No file, network or process I/O, safe to + trim, and no reflection-dependent behaviour. The same applies transitively to the domain library, + because the client downloads it too. Make the build enforce it, not a reviewer — three mechanisms, + each catching what the others cannot: + - **Your repository's architecture policy** forbids `Microsoft.AspNetCore` packages + (`forbiddenPackagePrefixes`) and the `Microsoft.AspNetCore.App` framework reference + (`forbiddenFrameworkReferences`) in `Contracts` and `Core`. The playbook's own policy forbids the + packages in `Core` only; the rest arrives with the Build SDK's planned Blazor support, so add it + yourself until then. See `purview-architecture-testing`. + - **`true`** on both projects turns on the trim and AOT analyzers, + so reflection a trimmed client would break on is a warning at build time. `Purview.ValueObjects` + and `Purview.ZodSharp` already set it. + - **``** makes the platform-compatibility analyzer (`CA1416`) + flag an API that is unsupported in the browser, such as starting a process. + + The policy rules fail a test run. The two analyzers produce warnings, and the SDK does not turn on + `TreatWarningsAsErrors` for an ordinary project — set it on `Contracts` and `Core`, or the "clean + build has no warnings" rule is the only thing standing behind them. No analyzer proves the absence of + every kind of I/O; what they miss is a review obligation, and the client's own tests running in the + browser are the last check. +- **Deserialisation mode is a deliberate choice per direction.** The server reads requests through + `Create` (strict), because the client is untrusted. Record what the client uses for responses, and + why — strict catches a server that has drifted, and `Hydrate` trusts a server the client cannot + verify. + +## Version skew + +| Shipping | Strategy | Failure it prevents | What to do | +| -- | -- | -- | -- | +| Lockstep | Contracts, internal apps | A silent failure that looks like a bug, when an old cached client talks to a new server | The server stamps its build on responses; the client prompts the user to reload when the stamps differ | +| Independent | Hybrid, OpenAPI | A deployed client breaking without warning | Additive changes only; a breaking change gets a new route version; a contract test compares the generated document against the last published one and fails on a breaking difference | + +## Backend-for-Frontend + +A BFF is a host, not a layer, and it is held to the same justification test as anything else. +Microsoft's [Blazor Web App with OIDC and the BFF pattern](https://learn.microsoft.com/aspnet/core/blazor/security/blazor-web-app-with-oidc?pivots=with-bff-pattern) +is the authority on the mechanics; what follows is the rules and the failures behind them. + +### What earns it + +- **Keeping tokens out of the browser.** The BFF signs the user in (OpenID Connect), holds the access + and refresh tokens, and gives the browser only an authentication cookie. Script in the page — a + compromised dependency, an XSS hole — cannot read a token that never reached it. This prevents tokens + being stolen from browser storage. +- **It forwards with the user's access token, never its own identity.** The API validates that token + exactly as it would any other, so authorisation and the audit trail stay per-user. A BFF calling with + a client-credentials token turns every action into the BFF's and moves authorisation into the BFF. + +### In a Blazor solution, the BFF is the host + +The project that serves the WebAssembly client is the natural BFF: it already runs on the server, at +the client's origin. Do not add a separate BFF in front of it. + +- **It references the client project** (`Bff → WebApp`) because it serves the client's assets. The + client never references it; they talk over HTTP with the cookie. +- **Same origin is the point.** The client calls the BFF's own routes, the BFF forwards them to the API + (with YARP's `MapForwarder`, for example), and there is no CORS configuration and no third-party + cookie. +- **The client shares Contracts with the API directly.** The WebAssembly client references Contracts, + sends Contracts types, and runs the generated validators. The BFF in the middle passes the bytes + through untouched. + +### Rules that come with a cookie + +Moving the credential into a cookie trades token theft for a different attack, and the BFF has to +close it: + +- **Every forwarded call requires an anti-forgery signal.** The browser attaches the cookie to any + request to the BFF's origin, including one a hostile page triggers. Require a custom header the + client always sends, which a cross-site form cannot set, and reject calls without it — Duende.BFF's + `X-CSRF: 1` is the established form. `SameSite` narrows the attack; it does not replace the check. + This prevents cross-site request forgery. +- **The cookie is `HttpOnly`, `Secure` and `SameSite=Strict` or `Lax`.** `HttpOnly` is what keeps + script from reading it; without it the BFF protects nothing. +- **Data Protection keys are persisted and shared** when the BFF runs on more than one instance or + survives a restart. The cookie is encrypted with them, so per-instance keys sign users out at random. +- **Refresh happens on the server.** The BFF refreshes the access token before it expires and signs + the user out when refresh fails. The client never sees either token. +- **A large session moves to a server-side store.** Tokens kept inside the cookie make it large enough + to be split across several cookies, and the session cannot be revoked server-side. Use a ticket store + when either matters. + +### What it may reference + +- **A BFF that only forwards does not reference Contracts** and never reads payloads. Reading a payload + it does not own makes it a second consumer that has to change in lockstep for no benefit. +- **Prerendering is the common exception.** If the BFF prerenders client components that load data, + it must run those data calls on the server, and it references Contracts to do so. That is a real + reason; turning prerendering off for those components is the alternative, and the choice should be + written down. +- **A BFF that combines several API calls into one response owns its own contract** for those + responses, chosen with this same procedure. That is a separate justification. The forwarding-only + test — "a service layer that forwards" is not earned — still applies to everything else it does. +- **One BFF per client kind, not one per screen.** Web and mobile may each get one when their sign-in + or response needs differ. + +**None of this is enforced yet.** The architecture policy has no BFF responsibility; Blazor and BFF +support is planned for a future Build SDK release. Until then every rule in this section is a review +obligation, and a repository that adds a BFF project records it in its own policy — see +`project-responsibilities.md`. Even once it is in the policy, "Contracts only when it prerenders or +combines" stays a review obligation, because a dependency matrix cannot express a condition. + +## Worked examples + +| Clients today | Strategy | +| -- | -- | +| Blazor WebAssembly app, same solution, lockstep, signed in through a BFF | Contracts, referenced by the API and the WebAssembly client. The BFF is the host that serves the client, holds the tokens and forwards with the user's token; it references Contracts only if it prerenders data. OpenAPI generated for documentation. | +| Blazor WebAssembly app plus a React Native mobile app on its own release cadence | Hybrid. Mobile generates from the document and takes Zod rules from the exported schema. Additive-only versioning. A mobile BFF if sign-in differs. | +| Partner integrations only | OpenAPI, generated from the server's types. No Contracts project. | + +## See also + +- [Contracts and Data Exchange](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Contracts-And-Data-Exchange.md) + — `Create` and `Hydrate`, and the schema as the source of truth for a wire shape +- [zodsharp](https://github.com/purview-dev/zodsharp) — authoritative on `[ZodSchema]` and JSON Schema + export diff --git a/.agents/skills/purview-architecture/references/project-context-discovery.md b/.agents/skills/purview-architecture/references/project-context-discovery.md new file mode 100644 index 0000000..d40569a --- /dev/null +++ b/.agents/skills/purview-architecture/references/project-context-discovery.md @@ -0,0 +1,115 @@ +# Project context discovery + +The procedure for establishing enough context to make an architectural recommendation, and — just as +importantly — for knowing when to stop asking. + +## The principle + +Every question you ask a user is a question they have usually already answered in the repository. +Reading is free and silent; asking is slow and, when the answer was already in a project file, it +reads as not having looked. So the order is fixed: + +1. Read the repository. +2. Read the conversation so far. +3. Ask only about what remains *and* changes your answer. + +A dimension whose answer would not change your recommendation is not worth a question. If a project +is a stateless CLI, the database GUID strategy is not an unknown — it is irrelevant, and raising it +is noise. + +## Dimension 1 — complexity + +Assess from requirements, never from line or project count. + +| Signal | Suggests | +| -- | -- | +| One functional area, one consumer, no integrations | Small | +| A handful of features, one or two integrations, a single team | Medium | +| Several bounded areas, multiple services or teams, operational obligations (SLOs, auditability, money) | Large | + +Where to look: `README.md`, the wiki, issue or ticket references, the solution file's project list, +`PackageReference` entries naming external systems, the number of distinct hosts. + +Traps: + +- **A big repository is not a complex one.** A monorepo of ten tiny tools is ten small projects. +- **A small repository can be complex.** Few files, hard invariants. +- **Generated code inflates everything.** Discount it. + +## Dimension 2 — project type + +Read it; do not ask it. + +| Evidence | Type | +| -- | -- | +| `Microsoft.NET.Sdk.Web` | Web application or HTTP API — distinguish by whether views/pages or endpoints dominate | +| `OutputType` of `Exe` plus an argument parser | CLI | +| `IsPackable`, no entry point | Reusable library | +| `IHostedService` / `BackgroundService`, `Worker` SDK | Background worker | +| An AppHost project, `Aspire.Hosting` | Distributed application | +| WPF/WinForms/Avalonia/MAUI SDK or `UseWPF` | Desktop application | + +A reusable library carries the strongest constraint on the rest of your advice: it must not configure +global telemetry providers, must not read configuration from the ambient environment, and must not +decide a persistence technology for its consumer. + +## Dimension 3 — persistence + +| Question | Read it from | +| -- | -- | +| Is persistence required at all? | Any data-access `PackageReference`, a `DbContext`, a connection string, a repository type, an Aspire resource | +| Which technology? | The EF Core provider package (`Microsoft.EntityFrameworkCore.SqlServer`, `Npgsql.EntityFrameworkCore.PostgreSQL`), the client package, or the `Purview.Containers` resource in the AppHost | +| Is more than one implementation required? | Two providers referenced, a provider abstraction already present, or an explicitly stated requirement | +| EF Core or something else? | Presence of `Microsoft.EntityFrameworkCore` versus Dapper, a raw client, or a document store SDK | + +"No persistence" is a complete and common answer. When it is the answer, every storage-shaped +recommendation — repositories, migrations, GUID strategies, connection resilience — is out of scope. + +## Dimension 4 — telemetry + +| Question | Read it from | +| -- | -- | +| Is telemetry configured? | `ILogger` injection, `ActivitySource`/`Meter` construction, `AddOpenTelemetry`, a `ServiceDefaults` project | +| Is Purview Telemetry available? | A `Purview.Telemetry.SourceGenerator` `PackageReference`, or interfaces attributed `[Logger]`, `[ActivitySource]`, `[Meter]` | +| Which signals are relevant? | Logs always; traces once a call crosses a process or a dependency; metrics once someone would alert on a rate or a duration | +| Is there an exporter? | An OTLP endpoint in configuration, a console exporter, an Aspire dashboard, an APM package | + +Existing instrumentation is a constraint, not a starting point to replace. Preserve it, extend it, +and never register the same source twice. + +## Conditional dimension — identifier generation + +Resolve **only** when persistence exists and GUIDs are, or are about to be, primary keys. + +- Are GUIDs primary keys, or just surface identifiers? +- Is the value generated in the application or by the database? +- Which provider and version? (This changes the answer — see `purview-persistence`.) +- Does index locality matter at the expected write volume? +- Must the identifier exist before the entity is persisted (outbox, correlation, client-side graph)? + +The last question usually decides it: if the application needs the value before `SaveChanges`, the +database cannot be the generator. + +## Asking well + +When you must ask, ask few and ask concretely. Batch the unknowns into one short set, offer the +likely answers, and state what you will do if the user does not care. + +Good: + +> Two things I can't tell from the repository: is this service expected to own its own database, or +> read from the existing one? And should failures be returned to the caller or thrown? I'll assume +> its own database and `Purview.Results` for expected failures unless you say otherwise. + +Bad: + +> What is the project complexity? What is the project type? Is persistence required? Which database? +> Is telemetry configured? Do you use GUID primary keys? + +The second asks six questions, five of which the repository answers, and one of which is irrelevant. + +## Recording the answer + +When a dimension is resolved by conversation rather than by the repository, that is a decision the +next agent will not be able to read. Write it down — in `AGENTS.md`, a wiki page, or an ADR — so the +question is asked once and never again. diff --git a/.agents/skills/purview-architecture/references/project-responsibilities.md b/.agents/skills/purview-architecture/references/project-responsibilities.md new file mode 100644 index 0000000..d74d2cf --- /dev/null +++ b/.agents/skills/purview-architecture/references/project-responsibilities.md @@ -0,0 +1,119 @@ +# Project responsibilities, naming and dependency direction + +The catalogue in brief. The authoritative definition is +[`.config/architecture-policy.json`](https://github.com/purview-dev/engineering-playbook/blob/main/.config/architecture-policy.json), +and the full page — with diagrams — is +[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Project-Responsibilities.md). +Do not restate either here or in another skill; link to them. + +## The catalogue + +`A → B` means A depends on B **at compile time**. + +| Responsibility | Also known as | Owns | May depend on | +| -- | -- | -- | -- | +| `Core` | `Domain` | Entities, value objects, invariants, domain events and errors | Shared | +| `Abstractions` | — | Cross-boundary capability contracts | Core, Shared | +| `Application` | `UseCases` | Use cases, workflows, orchestration | Core, Abstractions, Contracts, Shared | +| `Contracts` | — | Externally exposed request/response types; browser-safe when a WebAssembly client references it | Core, Shared | +| `Persistence` | — | DbContext, mappings, migration *definitions* | Core, Abstractions, Application, Shared | +| `Providers` | `Integrations` | Integration with a *named external platform* | Core, Abstractions, Application, Shared | +| `Adapters` | — | An *application-defined capability* via one technology | Core, Abstractions, Application, Shared | +| `Composition` | — | DI registration and implementation selection | Everything above | +| `API` | — | HTTP host | Composition, Contracts, ServiceDefaults, … | +| `Worker` | — | Background-processing host | Composition, ServiceDefaults, … | +| `Migrator` | — | *Executes* migrations and controlled seeding | Persistence, Composition, ServiceDefaults | +| `AppHost` | — | Aspire orchestration | The hosts it orchestrates | +| `ServiceDefaults` | — | Telemetry, health, resilience, discovery defaults | Shared | +| `WebApp` | — | Frontend, including a Blazor WebAssembly client that references Contracts | Contracts, ServiceDefaults, Shared | +| `Shared` | — | Small domain-independent primitives | *nothing* | + +**None of these is mandatory.** A small application with `Core` and one host has a complete and +correct architecture. The catalogue names responsibilities so you can tell when splitting one out +buys something — it is not a list of projects to create. + +### Backend-for-Frontend: guidance, not yet policy + +A BFF is a host responsibility that the policy does **not** define yet; Blazor and BFF support is +planned for a future Build SDK release. Until then it is guidance, and every rule below is a review +obligation: + +| Owns | May depend on | Split it out when | Leave it combined when | +| -- | -- | -- | -- | +| Sign-in and token custody for a browser or mobile client; forwards with the user's token. In Blazor, the host that serves the WebAssembly client | The WebAssembly client project, ServiceDefaults, Shared — and Contracts only when it prerenders data or combines responses | Tokens must be kept out of the browser, or several API calls must become one response | It would only forward calls that the client could make with a token it already holds safely | + +A repository that adds a BFF project today needs its own responsibility entry or a waiver in its +policy, or the architecture tests reject the project as unclassified. See `contract-strategy.md`. + +## When to split + +| Responsibility | Split it out when | Leave it combined when | +| -- | -- | -- | +| `Core` | A domain model exists | The app is a thin utility | +| `Abstractions` | Two implementations, or the consumer must not see the implementation | One implementation — keep the interface beside it | +| `Application` | Workflows are reused by more than one host | It would only forward CRUD | +| `Contracts` | The contract strategy is Contracts or Hybrid — see `contract-strategy.md` | The API host is the only consumer, which is the OpenAPI strategy || `Persistence` | Persistence exists at all | A single-project utility writing a file | +| `Providers` | Two platforms with different SDKs or lifecycles | One platform | +| `Adapters` | One capability, several technologies | One capability, one technology | +| `Composition` | Two hosts compose the same implementations | One host — compose in the host | +| `Migrator` | Schema changes are a deliberate deployment step | Development-only startup migration | +| `Shared` | A primitive is used by two or more responsibilities | Almost always | + +## Naming + +```text +[.] +``` + +| Situation | Name | +| -- | -- | +| One implementation | `Persistence` — do **not** qualify it just because it is SQL Server | +| Several, same dependencies and lifecycle | `Persistence` — one project, several classes | +| Several, genuinely different | `Persistence.SqlServer`, `Persistence.PostgreSql` | +| Several, with shared code | Add `Persistence` alongside the qualified projects | +| Several, nothing shared | Qualified projects only — no empty parent | + +Discouraged as project names, because each names a category rather than a responsibility: +`Infrastructure`, `Services`, `Managers`, `Models`, `Helpers`, `Common`, `Utils`, `Utilities`. + +`Infrastructure` is the common case. It is a useful *category* — covering Persistence, Providers and +Adapters — and a poor *project*, because every technical class qualifies for it. + +**Do not rename an established project** merely to match canonical terminology. A rename costs more +than the consistency is worth, and `Domain`/`UseCases`/`Integrations` are recognised aliases. + +## Providers versus Adapters + +Not a vendor distinction: + +- **Provider** — the requirement names the platform: "sync issues from GitHub". Swapping it changes + the product. +- **Adapter** — the requirement names the capability: "send an email". Swapping the technology + changes nothing the user sees. + +Azure Blob behind `IFileStore` is an Adapter. The Azure DevOps work-item API in a product that +integrates Azure DevOps is a Provider. + +## Dependency rules that catch people out + +1. **Composition is referenced by nothing** except a host. It selects implementations, so it depends + on them; the reverse would make the choice circular. +2. **Application never references a concrete infrastructure project.** It depends on an abstraction + and receives an implementation it cannot name. +3. **Sibling implementations stay independent.** If `Providers.GitHub` needs something from + `Providers.AzureDevOps`, that shared thing belongs in the unqualified project or an abstraction. +4. **Persistence owns migration definitions; Migrator executes them.** Production API startup does + not mutate a schema. +5. **Runtime is not compile time.** DI registration, an HTTP call and Aspire orchestration are not + assembly references. A Worker and an API can share Application without referencing each other. + +## Recommending this to a repository that predates it + +Do not propose a mass rename. Suggest, in order: + +1. Fix dependency *directions* that are actually wrong — those are bugs. +2. Split a project only where a named problem justifies it. +3. Record a waiver for an established name rather than renaming it, so the rule stays enforceable + without churn. + +A waiver excuses the name only; the project still classifies, so every dependency rule still applies. diff --git a/.agents/skills/purview-architecture/references/proportional-architecture.md b/.agents/skills/purview-architecture/references/proportional-architecture.md new file mode 100644 index 0000000..3ed7f55 --- /dev/null +++ b/.agents/skills/purview-architecture/references/proportional-architecture.md @@ -0,0 +1,92 @@ +# Proportional architecture + +Structure is a cost paid now against a failure expected later. Proportional architecture is the +practice of only paying when the failure is named, present, and more expensive than the structure. + +## The justification test + +Before adding a project, an interface, a layer or a pattern, answer in one sentence: + +> **What failure does this prevent in *this* codebase, and has it happened or is it certain to?** + +If the sentence needs the words "in case", "later", "when we scale", or "it's good practice", the +structure is not yet justified. Write the simpler thing. The simpler thing is also the thing that is +cheapest to change when the failure finally does arrive. + +This is not an argument against structure. It is an argument against *unfalsifiable* structure. + +## By scale + +### Small — a utility, CLI, prototype, simple API, limited-scope application + +- One project. A second only when there is a test project, or when a genuinely reusable library + falls out. +- Call the database, the API or the file system directly from the code that needs it. +- Validate at the edge, once. +- Logging from the first commit; traces and metrics when there is something to correlate. + +Do not add: a repository interface over `DbContext`, a service layer that forwards, a mapping layer +between two near-identical shapes, an interface with one implementation and no test double need +(`Purview.Containers` and TUnit.Mocks remove most of that need), a `Domain`/`Application`/ +`Infrastructure` split. + +### Medium — several functional areas, some integrations, moderate domain complexity + +- Introduce a boundary where you can name what it separates. Usually that is domain rules from the + host, so a domain project and an application host. +- An abstraction over an external dependency earns its place when the dependency is genuinely + swappable, or when testing against it is otherwise painful. "Swappable in principle" is not + swappable. +- Dependency instrumentation, trace propagation across the integrations, metrics on the operations + someone would be paged about. + +Do not add: CQRS unless reads and writes have actually diverged in shape, lifetime or scaling; +event sourcing unless the history *is* the product; a second infrastructure implementation that has +no second consumer. + +### Large — complex domain, multiple services or teams, substantial operational requirements + +- Modularity along ownership lines, because the real constraint is people, not code. +- Independent infrastructure implementations where they are genuinely independent. +- Boundaries made enforceable rather than documented — a dependency-direction rule that a wrong + `ProjectReference` fails the build. See the playbook's Architecture-As-Tests page. +- End-to-end tracing, standardised resource attributes, SLIs, retention and sampling policy. + +Even here, the justification test holds per decision. "Large" licenses the *option* to add +structure, not the obligation to add all of it on day one. + +## Patterns and the conditions that earn them + +| Pattern | Earned when | Not earned by | +| -- | -- | -- | +| Repository over EF Core | You must swap the store, or the `DbContext` API genuinely leaks into domain decisions | Testability — EF Core's in-memory and container-backed testing already covers it | +| CQRS | Read and write models have diverged in shape or scaling needs | A desire for symmetry with a reference architecture | +| Event sourcing | The sequence of changes is the product, or audit is a hard requirement | "We might want history" | +| Mediator / in-process bus | Many handlers, genuinely decoupled registration | Three call sites that could be method calls | +| Mapping layer | Two shapes that must diverge and are owned separately | Two shapes that are identical today | +| Extra DI indirection | A real second implementation, or a composition root that must vary | Inversion for its own sake | +| Generic infrastructure framework | Three or more concrete uses exist now | One use and an expectation of more | + +## Evolving incrementally + +The honest sequence is: write the direct implementation; let the second caller arrive; extract the +boundary the second caller revealed. Each step is small, reversible and informed. + +The dishonest sequence is: predict the second caller; build the boundary; never get the second +caller; maintain the boundary forever. The cost is invisible because it is spread across every +future change. + +When you do extract, extract along the seam the code showed you — not the seam the diagram +predicted. + +## What this does not license + +Proportional is not an excuse for: + +- Skipping tests, or skipping telemetry. +- Leaving validation duplicated across entry points. +- Letting domain rules live in the UI or in the database. +- Two sources of truth for the same fact. + +Those are not structure bought in advance; they are defects. Simplicity means fewer moving parts, +not fewer guarantees. diff --git a/.agents/skills/purview-buildsdk-adoption/SKILL.md b/.agents/skills/purview-buildsdk-adoption/SKILL.md new file mode 100644 index 0000000..b1f222c --- /dev/null +++ b/.agents/skills/purview-buildsdk-adoption/SKILL.md @@ -0,0 +1,114 @@ +--- +name: purview-buildsdk-adoption +description: Adopt Purview.BuildSdk in a repository, or troubleshoot an adoption that is not behaving. Use when wiring the SDK into Directory.Build.props for the first time, when a property seems to have no effect, when the resolved version or namespace is wrong, or when a consuming repository builds but the SDK's automatic behaviour is not appearing. Covers the import chain, pre-import property ordering, and version detection from package.json. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Adopting Purview.BuildSdk + +The goal is a working adoption that does not break existing build behaviour. Prefer minimal, +targeted property changes over rewriting a repository's `Directory.Build.props`. + +For the full property list use `purview-buildsdk-configuration`; for auditing an existing +repository's shape, `purview-buildsdk-repository-review`. + +## 1. The import chain + +```xml + + + + + Acme.Sales + + + + +``` + +```xml + + + + +``` + +Both halves are required. A repository with only `Sdk.props` imported gets the evaluation-time +defaults — naming, identity, framework selection — but none of the targets, so validation, +packaging behaviour and the generated files never run. That combination usually presents as "the +properties look right but nothing happens". + +The SDK version comes from `global.json`, which the SDK itself bootstraps. + +## 2. Pre-import ordering is the first thing to check + +**A property that must affect `Sdk.props` evaluation has to be set before the import.** Setting it +in a `.csproj`, or below the import, is too late and silently does nothing — no error, no warning. +This is the single most common adoption failure. + +Set before the import: + +- `NamespacePrefix` +- `UsePackageJsonVersion`, `RootPackageJson` +- `EnableAssemblyNameGeneration` +- `AutoIncludeUsings` +- `PurviewSharedTestingOutputType`, `PurviewTestContextNoWarn` +- any repo-wide testing-framework selection you want every project to inherit + +Everything else is read in `Sdk.targets` and can be set in a project file. + +When a property appears to have no effect, confirm what MSBuild actually resolved before changing +anything else: + +```bash +dotnet msbuild -getProperty:NamespacePrefix -getProperty:RootNamespace +``` + +## 3. Version detection + +Versions come from `package.json`. Resolution order: + +1. An explicit `RootPackageJson`. +2. CI workspace variables. +3. The `.git` root. +4. A nearby `package.json`. + +On success both `Version` and `PackageVersion` are set from the `version` field. + +If the version looks wrong, do not guess at the discovery — make it fail loudly instead: + +```xml +Strict +``` + +`Strict` fails the build when discovery or resolution cannot succeed, rather than silently skipping +it and leaving the default `1.0.0`. Set `VersionDetectionLogEnabled=true` to see what it found. + +## 4. Naming and placement + +Most of the SDK's value is inferred from project names and locations, so an adoption that keeps a +bespoke structure gets correspondingly little. Confirm the repository follows the conventions rather +than introducing new structure to work around them — see `purview-buildsdk-project-layout`, and +`purview-buildsdk-repository-review` for auditing an existing layout. + +The highest-value single check: every test project is named `.Tests`. **The dot is +required.** A project named plainly `UnitTests` is not detected as a test project at all, and +silently gets no runner, no coverage, no `InternalsVisibleTo` and no category. + +## 5. Agent skills are a separate, explicit step + +The SDK does not install agent skills, and nothing it does during a build writes to an agent +directory. It reads which skills each referenced package ships and recommends the command that +installs them; the skills CLI does the installing. See `purview-buildsdk-skill-installation`. + +## Constraints + +- Prefer minimal, targeted property changes over broad `Directory.Build.props` rewrites. +- Do not disable `PurviewAutoSdkPack` unless the consumer explicitly asks to opt out. +- Do not restate an SDK default in a project file. Hand-configuration that merely repeats a default + is pure maintenance cost, and it hides the fact that the default later changed. +- Re-run the build — or the repository's canonical build command — after each change, and confirm + the fix before moving to the next one. diff --git a/.agents/skills/purview-buildsdk-configuration/SKILL.md b/.agents/skills/purview-buildsdk-configuration/SKILL.md new file mode 100644 index 0000000..d82768b --- /dev/null +++ b/.agents/skills/purview-buildsdk-configuration/SKILL.md @@ -0,0 +1,115 @@ +--- +name: purview-buildsdk-configuration +description: Configure Purview.BuildSdk from Directory.Build.props or a .csproj. Use when setting NamespacePrefix, choosing a testing or mocking framework, turning telemetry or SourceLink off, controlling version detection from package.json, tuning the repository bootstrap, making a library browser-safe for a Blazor WebAssembly client, configuring a BFF host, or working out whether a property must be set before the SDK import. Also use when the SDK "did something automatically" and you need the property that controls it. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Purview.BuildSdk configuration + +Use this skill when the question is "what property controls this, and where do I set it?" + +For *why* the SDK classified a project a particular way, use `purview-buildsdk-project-detection` +instead. For where projects and tests should live, use `purview-buildsdk-project-layout`. + +## First rule: know where a property must be set + +A property that changes `Sdk.props` evaluation must be set **before** the SDK import — in +`Directory.Build.props`, above the import. Setting it in a `.csproj` is too late and silently has no +effect. + +Pre-import properties include: + +- `NamespacePrefix` +- `UsePackageJsonVersion`, `RootPackageJson` +- `EnableAssemblyNameGeneration` +- `PurviewSharedTestingOutputType`, `PurviewTestContextNoWarn` +- `AutoIncludeUsings` +- `DisablePurviewBlazorWebAssemblyDefaults` +- repo-wide testing-framework selection you want every project to inherit + +Everything else is read in `Sdk.targets` and can be set in a project file. Prefer repo-wide defaults +in `Directory.Build.props` unless the setting is deliberately project-specific. + +## The properties you will reach for most + +| Property | Default | What it does | +| -- | -- | -- | +| `NamespacePrefix` | *(required)* | The root identity. Required unless `DisableNamespacePrefixCheck=true` | +| `TestingFramework` | `TUnit` | `TUnit`, `Xunit` or `None` | +| `SubstituteFramework` | `TUnitMocks` | `TUnitMocks`, `NSubstitute` or `None` | +| `TestDataFramework` | `Bogus` | `Bogus` or `None` | +| `TargetFramework` | `net10.0` | `netstandard2.0` when `IsRoslynComponent=true` | +| `IsPackable` | `false` | Opts a project into the packable defaults | +| `UsePackageJsonVersion` | `true` | `true`, `false` or `Strict` — version from `package.json` | +| `ExcludePurviewTelemetry` | `false` | Drops the telemetry source generator | +| `DisableSourceLink` | `false` | Drops SourceLink from packable projects | +| `EnableAssemblyNameGeneration` | `true` | `AssemblyName`/`PackageId` follow the resolved `RootNamespace` | +| `PurviewBrowserSafe` | *(unset)* | On a class library a WebAssembly client downloads: trim, AOT and browser-unsupported APIs fail the build | +| `IsBffProject` | name ends `Bff` | Classifies a BFF host; `false` opts out | +| `PurviewBrowserSafeReferenceCheckAsError` | `false` | Makes `PRBLZ0001` (a client reference that is not browser-safe) an error | + +The full set, grouped by area and with the behaviour each one changes, is in +[references/properties.md](references/properties.md). Read it rather than guessing a property name — +several of these have near-miss names that do nothing. + +## Derived properties to inspect, not re-derive + +When explaining what the SDK did, read the value it computed rather than reasoning from the folder +name: + +`PurviewLogicalProjectName`, `PurviewNamespacePrefix`, `PurviewProjectShortName`, `PurviewTestType`, +`RootNamespace`, `AssemblyName`, `PackageVersion`, `TestingType`, `TargetProjectName`, `RepoRoot`, +`RootPackageJson`, `IsBlazorHostProject`, `IsBffProject`, `PurviewBffExposesApi`, `IsAotCompatible`, +`WarningsAsErrors`. + +Ask MSBuild for one directly: + +```bash +dotnet msbuild -getProperty:RootNamespace +dotnet msbuild -getProperty:TestingType -getProperty:TargetProjectName +``` + +## Compiler-visible properties + +The SDK exports its properties to analyzers and generators as `build_property.`. When +authoring an analyzer, read the exported property instead of re-deriving SDK behaviour — the export +is the SDK's answer, and it stays correct when the rules change. The exported set is listed in +[references/properties.md](references/properties.md). + +## Version detection + +1. If `RootPackageJson` is set, that file is used. +2. Otherwise the repo root is discovered from CI variables, `.git`, or a nearby `package.json`. +3. On success, both `Version` and `PackageVersion` come from the `version` field. +4. `UsePackageJsonVersion=Strict` turns a failed discovery into a build failure instead of a silent + fallback. + +## Repository bootstrap + +The SDK bootstraps two repository-wide files from the package: `.editorconfig` and `global.json`. + +- `PurviewRepoBootstrapMode` — `IfMissing` (default), `Always`, `WarnOnDrift`, `Never` +- `DisableAutoCopySdkFiles` — the master off switch +- `PurviewRepoBootstrapCopyRetries` / `...RetryDelayMilliseconds` / `...FailureAsError` + +Every project in a solution writes those same destinations, so the write is race-tolerant by +design: an existing file is never overwritten unless asked, each write is staged into a temporary +file and renamed into place, retries are quiet, and only a failure that survives every retry is +reported. + +**The SDK writes nothing into an agent directory.** It used to mirror a package's `.agents/**` into +the repository; that is gone, along with `EnableAgentFolderInPackage`, `AgentPackDestinationFolder` +and the `PurviewAgentFolder*` properties. Skills have exactly one installer, the skills CLI — see +`purview-buildsdk-skill-installation`. + +## Guidance when changing SDK configuration + +1. Preserve existing defaults unless the task explicitly changes product behaviour. +2. Keep `README.md`, the property declaration, its validation and the shipped skills aligned — a + property documented in one place and absent from another is the failure mode this repository + keeps hitting. +3. A new property that affects import-time behaviour must say so in its documentation. +4. Add the property to the `CompilerVisibleProperty` list if an analyzer would benefit from it. diff --git a/.agents/skills/purview-buildsdk-configuration/references/properties.md b/.agents/skills/purview-buildsdk-configuration/references/properties.md new file mode 100644 index 0000000..1fb1f81 --- /dev/null +++ b/.agents/skills/purview-buildsdk-configuration/references/properties.md @@ -0,0 +1,168 @@ +# Purview.BuildSdk property reference + +Every user-facing property, grouped by what it changes. Defaults are what the SDK applies when the +consuming project supplies nothing. + +## Identity and naming + +| Property | Default | Behaviour | +| -- | -- | -- | +| `NamespacePrefix` | *(required)* | The root identity source. Required unless the check is disabled. Set before the SDK import | +| `DisableNamespacePrefixCheck` | `false` | Stops the build failing when `NamespacePrefix` is unset | +| `EnableAssemblyNameGeneration` | `true` | `AssemblyName` and the default `PackageId` follow the fully evaluated `RootNamespace`, or the full logical project name when suffix-stripping removed a segment (`Shared`, `ServiceDefaults`). Set `false` before the SDK import for standard project-name behaviour | +| `AssemblyIdentifierClassName` | *(derived)* | Renames the generated marker class | +| `DisableGenerateAssemblyIdentifierClass` | `false` (forced `true` for test and shared-testing projects) | Drops the empty marker class (`Assembly`, global namespace) that architecture tests use to pin an assembly by `typeof` rather than by name | +| `DisableGenerateAssemblyInfoClass` | `false` | Drops the generated `AssemblyInfo` | +| `DisableAutoInternalsVisibleTo` | `false` | Drops automatic friend-assembly generation | +| `DisableProjectFileNamingConventionCheck` | `false` | Stops validating that the `.csproj` filename matches its directory | +| `AutoIncludeUsings` | `true` | Controls the SDK-added global usings. Set before the SDK import | + +## Target framework and language + +| Property | Default | Behaviour | +| -- | -- | -- | +| `TargetFramework` | `net10.0` | Applied when neither `TargetFramework` nor `TargetFrameworks` is set. A project that explicitly declares `IsRoslynComponent=true` defaults to `netstandard2.0` | +| `IsRoslynComponent` | *(unset)* | When explicitly `true`: a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, SourceLink with `EmbedUntrackedSources=true`, no dependency file, telemetry exclusion, and `PrivateAssets=all` on `Microsoft.CodeAnalysis.*` references | +| `IsRoslynComponentOnly` | `true` for Roslyn components | Analyzer-only package: `IncludeBuildOutput=false`, `IncludeSymbols=false`, portable PDB packed beside the analyzer under `analyzers/dotnet/cs/`. Set `false` for a dual-role component using normal library symbol packaging | +| `DisableRoslynCompilerDefaultsValidation` | `false` | Opts out of `ValidateRoslynComponentCompilerSettings`, which fails the pack of a packable Roslyn component missing `LangVersion`, `Nullable`, `TreatWarningsAsErrors` or `EnforceExtendedAnalyzerRules` | +| `PackProjectReferencedSourceGenerators` | `true` | Packable projects include analyzer `ProjectReference` outputs under `analyzers/dotnet/cs/`. Use `Pack="false"` on one reference to opt that one out | + +## Style policy + +| Property | Default | Behaviour | +| -- | -- | -- | +| `DisablePurviewStylePolicyValidation` | `false` | Stops the build failing (`PRSGD0006`–`PRSGD0009`) when the repository `.editorconfig` overrides the modifier policy — `dotnet_style_require_accessibility_modifiers` other than `omit_if_default`, hiding `IDE0040`/`IDE1006`, disabling the Style category in bulk, weakening the `_camelCase` private-instance-field rule, or adding the accessibility rules to `NoWarn`. Entries the SDK injects itself are ignored | +| `PurviewTestContextNoWarn` | `CA1002;CA1012;CA1034;CA1047;CA1050;CA1051;CA1062;CA1064;CA1515;CA1707` | The production API-surface rules exempted in test and shared-testing projects. Test projects keep the strict style contract (`IDE0040`, field naming, formatting, `IDE1006`) but are context aware: public test classes, `Method_Scenario_Expectation` names, exposed fields and unvalidated helper parameters are allowed. Set before the SDK import | +| `DisablePurviewTestContextRuleSet` | `false` | Makes test and shared-testing projects enforce the production API-surface rules too | +| `PurviewPolicyExemptNoWarn` | *(derived)* | The SDK-injected `NoWarn` entries `ValidatePurviewStylePolicy` accepts | + +## Testing + +| Property | Default | Behaviour | +| -- | -- | -- | +| `TestingFramework` | `TUnit` | `TUnit`, `Xunit`, `None` | +| `SubstituteFramework` | `TUnitMocks` | `TUnitMocks`, `NSubstitute`, `None` | +| `TestDataFramework` | `Bogus` | `Bogus`, `None` | +| `CollectCoverage` | `true` for detected test projects | | +| `EnableStaticNativeInstrumentation` | `false` for test projects | | +| `EnableDynamicNativeInstrumentation` | `false` for test projects | | +| `TestingPlatformDotnetTestSupport`, `UseMicrosoftTestingPlatformRunner`, `EnableMicrosoftTestingPlatform` | enabled for TUnit test projects | Microsoft.Testing.Platform wiring | +| `PurviewSharedTestingOutputType` | `Library` | Forced onto `IsSharedTestingProject` projects, with `IsTestProject`/`IsTestingPlatformApplication` cleared, because the test packages otherwise flip them into an executable test host. Set `Exe` before the SDK import to keep the package-driven shape | + +## Packaging + +| Property | Default | Behaviour | +| -- | -- | -- | +| `IsPackable` | `false` | | +| `PurviewAutoSdkPack` | `true` | Packs `Sdk/**/*` into the package at the right root-level paths | +| `PurviewSkillsSourceFolder` | `/skills` | Where authored skills live | + +Packable-project defaults, applied only when the project supplied nothing: +`GenerateDocumentationFile=true`, `IncludeSymbols=true`, `SymbolPackageFormat=snupkg`, +`PublishRepositoryUrl=true`, `EmbedUntrackedSources=true`, `DebugType=portable`. Portable PDBs ship +in the `.snupkg`; the `.nupkg` gets none unless the project opts in. Roslyn-component-only packages +instead default `IncludeSymbols=false` and ship their PDB inside `analyzers/dotnet/cs/`. + +When the repository root is discoverable, the root `README.md` is packed automatically and +registered as `PackageReadmeFile`, unless one is configured already. + +Non-packable projects, including web apps, default `WarnOnPackingNonPackableProject=false` so a +solution-wide pack skips them silently. + +`ContinuousIntegrationBuild` is set only by real CI environment variables. Packability alone never +forces SourceLink's CI-mode dirty-repository checks. + +## Telemetry and SourceLink + +| Property | Default | Behaviour | +| -- | -- | -- | +| `ExcludePurviewTelemetry` | `false` | Removes `Purview.Telemetry.SourceGenerator` | +| `ExcludeMSTelemetryExtension` | `false` | Removes `Microsoft.Extensions.Telemetry.Abstractions`. Only relevant when `ExcludePurviewTelemetry` is also `false` — the whole group is skipped otherwise | +| `SourceLinkPackageName` | `Microsoft.SourceLink.GitHub` | | +| `DisableSourceLink` | `false` | | + +## Blazor WebAssembly, browser-safe libraries and BFF hosts + +| Property | Default | Behaviour | +| -- | -- | -- | +| `IsBffProject` | `true` for a web SDK project named `*Bff`, else `false` | Classifies a BFF host. `false` opts a `*Bff` project out; `true` opts another web project in | +| `IsBlazorHostProject` | *(computed)* | `true` for a web SDK project that references a WebAssembly client. Set `false` to opt out. Never implies `IsBffProject` | +| `PurviewAutoBlazorClientReference` | `true` | A host named `` references its `.Client` WebAssembly client automatically. Skipped when the reference is already declared | +| `DisablePurviewBlazorWebAssemblyDefaults` | `false` | Keeps the generic defaults on a WebAssembly client: packable when asked, Dockerfile container detection, the automatic `Shared` reference. Set before the SDK import | +| `PurviewBrowserSafe` | *(unset)* | `true` on a class library applies `IsAotCompatible`, `SupportedPlatform browser` and the browser-safe `WarningsAsErrors`, each only where the project set nothing | +| `PurviewBrowserSafeWarningsAsErrors` | `true` | `false` keeps the trim and AOT diagnostics as warnings; `CA1416` then falls back to the repository's severity (`suggestion` in the shipped `.editorconfig`) | +| `PurviewBrowserSafeWarningCodes` | `CA1416`, `IL2001`-`IL2128`, `IL3000`-`IL3005`, `IL3050`-`IL3059` | The ids a browser-safe library adds to `WarningsAsErrors` | +| `DisablePurviewBrowserSafeReferenceCheck` | `false` | Turns off `PRBLZ0001`, the check that every project a client (or browser-safe library) references sets `PurviewBrowserSafe` | +| `PurviewBrowserSafeReferenceCheckAsError` | `false` | Reports `PRBLZ0001` as an error | +| `PurviewBffExposesApi` | `true` when the BFF references `Microsoft.AspNetCore.OpenApi`, else `false` | Whether a BFF keeps the `Microsoft.AspNetCore.OpenApi.Generated` interceptor namespace | + +`PurviewBrowserSafe="true"` is also accepted as `ProjectReference` metadata, recording that a +reference was judged browser-safe when it cannot say so itself. + +## Version detection + +| Property | Default | Behaviour | +| -- | -- | -- | +| `UsePackageJsonVersion` | `true` | `true`, `false`, `Strict` | +| `RootPackageJson` | *(discovered)* | Explicit path to the `package.json` to read | +| `EnableVersionDetectionCache` | `true` | | +| `VersionDetectionCacheFile` | *(derived)* | | +| `VersionDetectionLogEnabled` | `false` | Logs the detected version | + +## Repository bootstrap + +| Property | Default | Behaviour | +| -- | -- | -- | +| `DisableAutoCopySdkFiles` | `false` | Master switch for SDK-managed repository file writes | +| `PurviewRepoBootstrapMode` | `IfMissing` | Also `Always`, `WarnOnDrift`, `Never` | +| `BootstrapEditorConfigToRepoRoot` | `true` | | +| `RepositoryEditorConfigFilePath` | *(derived)* | Overrides the destination `.editorconfig` | +| `BootstrapGlobalJsonToRepoRoot` | `true` | | +| `RepositoryGlobalJsonFilePath` | *(derived)* | Overrides the destination `global.json` | +| `PurviewBuildSdkVersionForGlobalJson` | restored package version, else the version it was packed as | | +| `PurviewRepoBootstrapCopyRetries` | `3` | | +| `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | `500` | | +| `PurviewRepoBootstrapCopyFailureAsError` | `true` | `false` downgrades a persistent write failure to a warning | +| `PurviewSuppressCopyRetryWarnings` | `true` | Demotes the built-in copy task's `MSB3026` retry notice to a message | +| `PurviewRepositoryRootMarkers` | `.git/;.github/;.gitattributes;LICENSE.md;AGENTS.md;.agents/;CLAUDE.md;.claude/;package.json` | Ordered markers identifying a repository root, most authoritative first | + +There is no agent-folder mirror. `EnableAgentFolderInPackage`, `AgentPackDestinationFolder`, +`PurviewAgentFolderSourcePath`, `PurviewAgentFolderCopy*`, `PurviewAgentSyncManifestPath` and +`DisableMirroredFileTrackingCheck` were removed: skills are installed by the skills CLI, and +nothing else was left for the mirror to deliver. + +## Skills + +Covered in full by `purview-buildsdk-skill-installation`: `PurviewSkillsRunner`, +`PurviewSkillsRunnerCandidates`, `PurviewSkillsCommand`, `PurviewSkillsPackage`, +`PurviewSkillsAgents`, `PurviewSkillsSource`, `PurviewSkillsInstallRoot`, `PurviewSkillsRepoRoot`, +`EnablePurviewSkillRecommendations`, `PurviewSkillRecommendationsInCI`. + +## Compiler-visible properties + +Exported to analyzers and generators as `build_property.`: + +- `UsePackageJsonVersion`, `RootPackageJson`, `RepoRoot`, `Version`, `PackageVersion` +- `NamespacePrefix`, `DisableNamespacePrefixCheck` +- `TestingFramework`, `SubstituteFramework`, `TestDataFramework` +- `ExcludePurviewTelemetry`, `ExcludeMSTelemetryExtension` +- `EnableAssemblyNameGeneration`, `DisableAutoInternalsVisibleTo`, `DisableGenerateAssemblyInfoClass`, + `DisableGenerateAssemblyIdentifierClass`, `AssemblyIdentifierClassName` +- `IsCSharpProject`, `IsTestProject`, `IsSharedTestingProject`, `IsSharedProject` +- `TestingType`, `TargetProjectName` +- `IsContainerProject`, `IsSdkProject`, `SdkProjectName`, `IsWebProject`, `IsWebSdkProject`, + `IsWorkerSdkProject`, `IsAspireHostProject`, `IsCLIProject` +- `IsBlazorWebAssemblyProject`, `IsRazorClassLibraryProject`, `IsBlazorHostProject`, `IsBffProject` +- `PurviewBrowserSafe`, `PurviewBrowserSafeWarningsAsErrors`, `DisablePurviewBrowserSafeReferenceCheck`, + `PurviewBrowserSafeReferenceCheckAsError`, `DisablePurviewBlazorWebAssemblyDefaults`, + `PurviewAutoBlazorClientReference`, `PurviewBffExposesApi` +- `EditorConfigFilePath`, `RepositoryEditorConfigFilePath`, `BootstrapEditorConfigToRepoRoot` +- `RepositoryGlobalJsonFilePath`, `BootstrapGlobalJsonToRepoRoot`, `DisableAutoCopySdkFiles` +- `PurviewRepoBootstrapMode`, `PurviewRepoBootstrapCopyRetries`, + `PurviewRepoBootstrapCopyRetryDelayMilliseconds`, `PurviewRepoBootstrapCopyFailureAsError` +- `PurviewSuppressCopyRetryWarnings` +- `PurviewSkillsSourceFolder`, + `PurviewSkillsAgents`, `PurviewSkillsInstallRoot`, `PurviewRepositoryRootMarkers`, + `PurviewSkillsRunner` +- `PurviewBuildSdkVersionForGlobalJson`, `CurrentYear`, `AutoGeneratedAssemblyInfoFile`, + `AutoGeneratedAssemblyIdentifierFile` diff --git a/.agents/skills/purview-buildsdk-project-detection/SKILL.md b/.agents/skills/purview-buildsdk-project-detection/SKILL.md new file mode 100644 index 0000000..8573ca4 --- /dev/null +++ b/.agents/skills/purview-buildsdk-project-detection/SKILL.md @@ -0,0 +1,157 @@ +--- +name: purview-buildsdk-project-detection +description: Explain why Purview.BuildSdk classified a project as test, shared, shared-testing, CLI, web, worker, Aspire host, container, Roslyn component, Blazor WebAssembly client, Razor class library, Blazor host or BFF, and why it added a package, a project reference, a namespace or an analyzer automatically. Use when SDK behaviour looks surprising, when a test project gets no runner or no InternalsVisibleTo, or when reasoning about RootNamespace, AssemblyName and TargetProjectName. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Why Purview.BuildSdk did that + +Use this skill when the question is **why** the SDK applied something automatically. + +For where a project should live, use `purview-buildsdk-project-layout`. For the property that turns +a behaviour off, use `purview-buildsdk-configuration`. + +## Diagnose from computed properties, never from the folder name + +Read what the SDK actually computed: + +```bash +dotnet msbuild -getProperty:PurviewLogicalProjectName -getProperty:RootNamespace \ + -getProperty:TestingType -getProperty:TargetProjectName -getProperty:IsTestProject +``` + +The values worth inspecting first: `MSBuildProjectName`, `NamespacePrefix`, +`PurviewLogicalProjectName`, `RootNamespace`, `TestingType`, `TargetProjectName`, `SdkProjectName`, +`IsTestProject`, `IsSharedProject`, `IsSharedTestingProject`, `IsContainerProject`, `IsCLIProject`, +`IsWebSdkProject`, `IsAspireHostProject`, `IsBlazorWebAssemblyProject`, `IsRazorClassLibraryProject`, +`IsBlazorHostProject`, `IsBffProject`. + +## Detection rules + +### Test projects + +Name matches `^.*\.([\w|_]\w*)Tests?$` — a target name, a dot, then any word before `Test`/`Tests`. +The captured word becomes `TestingType`. + +This is a pattern, **not** a lookup. `MyProject.GoldenTests` is detected exactly as readily as +`MyProject.UnitTests`, with `TestingType=Golden` and `Category=Golden`, even though `Golden` is not a +name the SDK ships. + +**The dot is required.** A project named plainly `UnitTests` does not match, and silently gets no +runner, no coverage, no `InternalsVisibleTo` and no category. This is the single most common +surprise. + +Derived: `IsTestProject=true`, `TestingType=`, `PurviewTestType=Tests`, +`TargetProjectName=`. + +The SDK's explicit `TestType` item group governs **`InternalsVisibleTo` generation**, not detection: +a non-test project grants access to `{AssemblyName}.{TestType}Tests` for each name in the group. A +suffix outside it still builds, runs and gets a category — it just cannot see `internal` members. +The 53 names are in [references/test-types.md](references/test-types.md). + +### Shared and shared-testing projects + +Recognised by **exact name**, never by substring. The two lists are in +[references/detection.md](references/detection.md). Derived: `IsSharedProject`, +`IsSharedTestingProject`. + +### SDK and content based + +| Signal | Result | +| -- | -- | +| the project's `Sdk="..."` declaration | `IsSdkProject`, `SdkProjectName` | +| `Microsoft.NET.Sdk.Web` | `IsWebSdkProject=true` | +| `Microsoft.NET.Sdk.Worker` | `IsWorkerSdkProject=true` | +| SDK starts with `Aspire.Sdk.Host` or `Aspire.AppHost.Sdk` | `IsAspireHostProject=true` | +| `Dockerfile`, `dockerfile` or `Dockerfile.dev` in the project directory | `IsContainerProject=true` | +| name ends with `CLI`, `Console`, `CommandLine`, `QuickStart`, `QuickStarts` | `IsCLIProject=true` | +| `Microsoft.NET.Sdk.BlazorWebAssembly` | `IsBlazorWebAssemblyProject=true` | +| `Microsoft.NET.Sdk.Razor` | `IsRazorClassLibraryProject=true` | +| a web SDK project whose name ends with `Bff` (case-sensitive, like `CLI`), or that sets `IsBffProject=true` | `IsBffProject=true` | +| a web SDK project that references an `IsBlazorWebAssemblyProject` project | `IsBlazorHostProject=true` | + +`IsBffProject=false` opts a `*Bff` project out. `IsBlazorHostProject` is **never** inferred into +`IsBffProject`: a host that keeps no tokens is not a BFF, and BFF rules do not describe it. + +`IsBlazorHostProject` is known at evaluation for the ``/`.Client` pairing. A host +referencing a client under any other name is resolved during the build, so ask for it with the +target that resolves it: + +```bash +dotnet msbuild -t:ResolvePurviewBlazorHostProject -getProperty:IsBlazorHostProject +``` + +What each Blazor and BFF classification applies - and the browser-safe library check - is in +[references/blazor.md](references/blazor.md). + +## Namespace and identity + +1. `PurviewLogicalProjectName` is `NamespacePrefix` plus the project name, de-duplicated when the + project name already starts with the prefix's tail. +2. `RootNamespace` defaults to `PurviewLogicalProjectName`. +3. Known suffixes are stripped from `RootNamespace` — shared and shared-testing names, and segments + such as `Core`, `EF`, `Shared`, `ClientShared`, `ServiceDefaults`. +4. Test suffixes are stripped too, so `Acme.Api.UnitTests` maps back to `Acme.Api`. +5. `AssemblyName` and `PackageId` default to the fully evaluated `RootNamespace` — except where + stripping removed a segment, where they use the full `PurviewLogicalProjectName` so the artifact + stays distinct from its parent. Test and shared-testing projects keep their detected suffix. + Explicit `AssemblyName`/`PackageId`/`RootNamespace` always win. +6. The naming defaults are applied during `Sdk.props`, before the Microsoft SDK computes + `TargetName`, so the compiled output name always matches `AssemblyName`. + +## Automatic packages and references + +- **Non-test C# projects**: SourceLink (unless `DisableSourceLink`), the Purview telemetry packages + (unless `ExcludePurviewTelemetry`), `GenerateDocumentationFile=true`, and `InternalsVisibleTo` + generation (unless `DisableAutoInternalsVisibleTo`). +- **Test and shared-testing projects**: test-friendly `NoWarn` defaults, not packable or + publishable, and the packages selected by `TestingFramework`, `SubstituteFramework` and + `TestDataFramework`. TUnit test projects get Microsoft.Testing.Platform wiring automatically. + Shared-testing projects skip the runnable test package. +- **Packable projects**, **Roslyn components** and **special project types** (CLI, container, web, + Aspire host): see [references/detection.md](references/detection.md). +- **Blazor WebAssembly clients, Blazor hosts, BFFs and browser-safe libraries**: see + [references/blazor.md](references/blazor.md). + +Project-reference probing is layout-driven and covered by `purview-buildsdk-project-layout`. + +## The shipped analyzers + +The SDK adds `Purview.BuildSdk.Analyzers` as an `` item to every C# project, so its rules +surface in command-line builds and in Visual Studio. The code-fix assembly ships beside it. + +| Rule | What it enforces | +| -- | -- | +| `PDS0002` | Extensions classes live under `Extensions//` | +| `PDS0003` | Explicit types with target-typed `new()` | +| `PDS0004` | Correct acronym capitalisation | +| `PDS0008` | A BFF's authentication cookie is not set `HttpOnly = false` | +| `PDS0009` | A BFF's authentication cookie is not set `SameSite = None` | + +`PDS0004` follows .NET naming guidance: well-known framework spellings (`Sql`, `Guid`, `Uuid`, +`Url`, `Dns`, `Tcp`, `Http`, `Xml`, `Db`, …) are exempt — `Db` among them, so `DbContext`, +`DbConnection` and `DbSet` are never flagged. A small set is still uppercased: `Api`, `Ai`, `Ui`, +`Io`, `Os`, `Cpu`, `Gpu`, `Cli`, `Gui`, `Ram`, `Ssh`. Members mandated by a contract — interface +implementations and base-class overrides — are never renamed. + +Customise through `dotnet_analyzer_configuration.pds0004.allowed_words` (exempt segments), +`.acronym_map` (segment renames, e.g. `Sql:SQL`, or `Db:DB` to re-enable it) and +`.allowed_identifiers` (brand names, exact or word-boundary prefix, first match wins — `CosmosDb` +covers `CosmosDbServer`). All three merge with and override the shipped defaults. + +The shipped `.editorconfig` marks generated content (`Migrations/`, `*.g.cs`, `Generated/`, +`*.Designer.cs`, `obj/`, `bin/`) as `generated_code = true`, and suppresses namespace-conflict +diagnostics under `Extensions/`, so no `#pragma` suppressions are needed there. + +Two Visual Studio refactorings ship with them, both described in +[references/detection.md](references/detection.md). + +## When changing a detection rule + +1. Keep the `.csproj` filename equal to its directory name. +2. Update `README.md` and the shipped skills in the same change — a rule true in code and false in + prose is this repository's recurring failure. +3. Add or update the integration test in `src/tests/BuildSdk.IntegrationTests/` that pins it. diff --git a/.agents/skills/purview-buildsdk-project-detection/references/blazor.md b/.agents/skills/purview-buildsdk-project-detection/references/blazor.md new file mode 100644 index 0000000..c65d153 --- /dev/null +++ b/.agents/skills/purview-buildsdk-project-detection/references/blazor.md @@ -0,0 +1,105 @@ +# Blazor WebAssembly, browser-safe libraries and BFF hosts + +Every behaviour below applies only to the project type it names, and each one names the failure it +prevents. Nothing here changes a project that is not a WebAssembly client, a Blazor host, a BFF or a +`PurviewBrowserSafe` library. + +## Blazor WebAssembly client (`IsBlazorWebAssemblyProject`) + +| Applied | Failure it prevents | Opt out | +| -- | -- | -- | +| `IsPackable=false` | packing an app's `wwwroot` as a package nobody can consume | `IsPackable=true` in the project | +| `IsContainerProject=false`, so no `PublishAot`, `InvariantGlobalization` or Docker defaults | `PublishAot` on a client is a publish that fails, and the Blazor SDK owns globalisation | `DisablePurviewBlazorWebAssemblyDefaults=true` before the SDK import | +| no automatic `../Shared*/Shared*.csproj` reference | a server-side library joining the browser download because of a folder name | `DisablePurviewBlazorWebAssemblyDefaults=true` before the SDK import | +| the browser-safe reference check (`PRBLZ0001`) | a library joining the download without anyone deciding it was safe there | `DisablePurviewBrowserSafeReferenceCheck=true` | + +Globalisation, trimming mode and the Blazor runtime settings are deliberately left alone: the Blazor +SDK owns them, and a second default would compete with it. + +### The automatic packages + +The SDK adds `Purview.Telemetry.SourceGenerator` and `Microsoft.Extensions.Telemetry.Abstractions` +to every non-test project, a client included. Both were checked rather than assumed: + +- the generator is analyzer-only (`PrivateAssets=all`), so nothing of it is downloaded; +- its generated `ActivitySource`, `ILogger` and `Meter` code, and `Telemetry.Abstractions` itself, + publish with no trim warnings and run on the WebAssembly runtime + (`BlazorWebAppTests.AutomaticTelemetryPackages_RunInsideTheWebAssemblyRuntime` boots it); +- the SDK adds no exporter. An OTLP exporter cannot work from a browser, which is exactly why the SDK + never adds one - that is the application's choice. + +Nothing is therefore excluded from a client by default. `ExcludePurviewTelemetry` and +`ExcludeMSTelemetryExtension` still remove them. + +## Blazor host (`IsBlazorHostProject`) + +A web SDK project named `` beside a WebAssembly client named `.Client` is the Blazor Web +App pairing. The host gets an automatic `ProjectReference` to the client - probed the same way a test +project's target is, and skipped when the project already references it - so the client's framework +files are served. Opt out with `PurviewAutoBlazorClientReference=false`. + +`IsBlazorHostProject` says nothing about `IsBffProject`. In a Blazor Web App the host is the BFF only +if it keeps the tokens. + +## BFF host (`IsBffProject`) + +- **No packages are added.** YARP, OpenID Connect and any BFF library are the application's choice, + and Duende.BFF carries a commercial licence. +- The `Microsoft.AspNetCore.OpenApi.Generated` interceptor namespace every other web project gets is + removed, because a BFF that only forwards has no API document to generate. A BFF referencing + `Microsoft.AspNetCore.OpenApi` keeps it; `PurviewBffExposesApi` decides explicitly. +- `PDS0008` reports `HttpOnly = false` on the `CookieAuthenticationOptions` cookie (script could read + the session). `PDS0009` reports `SameSite = None` on it (cross-site requests would carry it). Only a + constant assigned to that cookie is reported - never the antiforgery cookie, which script may + legitimately read, nor the OpenID Connect correlation and nonce cookies, which need + `SameSite=None`. + +Deliberately **not** checked, because a build cannot check them precisely: + +- **a persisted Data Protection key ring.** The call that persists it (`PersistKeysTo*`) is routinely + made in a shared ServiceDefaults project, which the BFF's compilation cannot see, so the rule would + fire on correctly configured BFFs - and a noisy rule gets suppressed wholesale. +- **anti-forgery on forwarded routes.** It stays a review obligation. + +## Browser-safe library (`PurviewBrowserSafe=true`) + +Applies to class libraries only, and only what the project has not set: + +- `IsAotCompatible=true` (on a TFM that supports it), which turns on `IsTrimmable` and the trim, + single-file and AOT analyzers; +- ``, so `CA1416` reports an API the browser does not support; +- `WarningsAsErrors` for every trim (`IL2001`-`IL2128`), single-file (`IL3000`-`IL3005`) and AOT + (`IL3050`-`IL3059`) diagnostic and `CA1416`, listed in `PurviewBrowserSafeWarningCodes`. + `TreatWarningsAsErrors` is not touched. + +`CA1416` is `suggestion` in the shipped `.editorconfig`. The compiler's `/warnaserror+:CA1416` +outranks an `.editorconfig` severity, so the error still fires without the repository's setting +changing. The consequence of `PurviewBrowserSafeWarningsAsErrors=false` is that the trim and AOT +diagnostics stay warnings while `CA1416` falls back to the repository's configured severity. + +What it cannot catch: file and network I/O. `File` works against the browser's in-memory file system +and `HttpClient` works through `fetch`, so neither is annotated as unsupported, and no analyzer reports +them. Reviewing for them stays a human obligation. + +### The reference check (`PRBLZ0001`) + +A WebAssembly client asks each `ProjectReference` - through an MSBuild call on the referenced project, +never by guessing from its name - for its `PurviewBrowserSafe` value, and warns for each one that does +not set it. A browser-safe library runs the same check over its own references, so the guarantee +covers the whole download, not only the first level. + +- `PurviewBrowserSafe="true"` metadata on the `ProjectReference` records the decision for a project + that does not use this SDK. +- References with `ReferenceOutputAssembly="false"` or an `OutputItemType` are not downloaded and are + not checked. Package references are not checked either. +- `PurviewBrowserSafeReferenceCheckAsError=true` makes it an error. + +### Purview packages a Contracts library builds on + +Checked against a `PurviewBrowserSafe` library, then run on the WebAssembly runtime: + +| Package | Result | +| -- | -- | +| `Purview.Results` 1.0.4 | clean | +| `Purview.ZodSharp` 2.4.2 | clean, including `[ZodSchema]` | +| `Purview.ValueObjects` 1.2.3 | the runtime assembly is AOT-compatible, but the JSON converter the generator emits for a scalar calls the reflection-based `JsonSerializer.Serialize`/`Deserialize(..., options)` overloads, which is `IL2026` and `IL3050` - errors in a browser-safe library. Set `GenerateJsonConverter = false` on the scalar until the generator writes the value directly. | diff --git a/.agents/skills/purview-buildsdk-project-detection/references/detection.md b/.agents/skills/purview-buildsdk-project-detection/references/detection.md new file mode 100644 index 0000000..85fde09 --- /dev/null +++ b/.agents/skills/purview-buildsdk-project-detection/references/detection.md @@ -0,0 +1,83 @@ +# Detection detail + +## Shared project names (exact) + +`Shared`, `SharedFramework`, `SharedInfrastructure`, `SharedInfra`, `SharedUtilities`, +`SharedUtils`, `SharedLibrary`, `SharedLib`, `SharedHelpers` + +## Shared testing project names (exact) + +`SharedTestingFramework`, `SharedTestingInfrastructure`, `SharedTestingInfra`, +`SharedTestingUtilities`, `SharedTestingUtils`, `SharedTestingLibrary`, `SharedTestingLib`, +`SharedTestingHelpers` + +These are matched whole, not as substrings. `SharedModels` is not a shared project. + +A shared-testing project is forced to `PurviewSharedTestingOutputType` (default `Library`), with +`IsTestProject` and `IsTestingPlatformApplication` cleared, because the test packages otherwise flip +it into an executable test host. Set `PurviewSharedTestingOutputType=Exe` before the SDK import to +keep that package-driven shape. + +## Packable projects + +Defaults applied only where the project supplied nothing: `GenerateDocumentationFile`, +`IncludeSymbols`, `SymbolPackageFormat=snupkg`, `PublishRepositoryUrl`, `EmbedUntrackedSources`, +`IncludeSource`, `DebugType=portable`. + +- Portable PDBs ship in the `.snupkg`. The `.nupkg` receives none unless the project opts in. +- The repository-root `README.md` is packed and registered as `PackageReadmeFile` when it exists and + nothing else is already being packed as one. +- Non-packable projects, web apps included, default `WarnOnPackingNonPackableProject=false`, so a + solution-wide `dotnet pack` skips them silently. +- `ContinuousIntegrationBuild` is set only by real CI environment variables. Packability alone never + forces SourceLink's CI-mode dirty-repository checks. + +## Roslyn components + +A project that explicitly declares `IsRoslynComponent=true` gets: + +- a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, + `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules +- SourceLink with `EmbedUntrackedSources=true`, no dependency file, telemetry excluded +- compiler-generated output under the framework-specific intermediate directory +- `PrivateAssets=all` on `Microsoft.CodeAnalysis.*` and `Microsoft.CodeAnalysis.Analyzers`, so they + never leak into the packed nuspec + +`IsRoslynComponentOnly` defaults to `true`: `IncludeBuildOutput=false`, `IncludeSymbols=false`, no +`.symbols.nupkg` or `.snupkg`, and the analyzer PDB shipped inside the main `.nupkg` under +`analyzers/dotnet/cs/` beside the assembly (`PurviewPackAnalyzerPdb=true`). Set it to `false` for a +dual-role component that wants normal library symbol packaging. + +`ValidateRoslynComponentCompilerSettings` fails the pack of a packable Roslyn component missing +`LangVersion`, `Nullable`, `TreatWarningsAsErrors` or `EnforceExtendedAnalyzerRules`. Opt out with +`DisableRoslynCompilerDefaultsValidation=true`. + +`PackProjectReferencedSourceGenerators` (default `true`) makes a packable project include analyzer +`ProjectReference` outputs and their runtime dependencies under `analyzers/dotnet/cs/`. Set it +`false` globally, or `Pack="false"` on one analyzer reference. + +## Special project types + +| Type | Applied | +| -- | -- | +| CLI | `OutputType=Exe`, `appsettings*.json` included as content | +| Container | `InvariantGlobalization`, `PublishAot`, Linux Docker defaults, container tooling packages | +| Web SDK | `Microsoft.AspNetCore.OpenApi.Generated` added to `InterceptorsNamespaces`, unless marked as a separate web-project mode | +| Aspire host | `OutputType=Exe` | +| Blazor WebAssembly client | not packable, no container defaults, no automatic `Shared` reference, the browser-safe reference check - see [blazor.md](blazor.md) | +| Blazor host | an automatic reference to its `.Client` - see [blazor.md](blazor.md) | +| BFF | no packages, no OpenAPI interceptor unless it exposes an API, `PDS0008`/`PDS0009` - see [blazor.md](blazor.md) | +| `PurviewBrowserSafe` library | `IsAotCompatible`, `SupportedPlatform browser`, trim/AOT/`CA1416` as errors - see [blazor.md](blazor.md) | + +## The Visual Studio refactorings + +Two ship with the code-fix assembly, both serving the `PDS0002` convention: + +- **Split extensions class into one class per receiver type** — offered on a static extensions class + targeting several receiver types. It produces one `Extensions` class per receiver + (a generic `this TBuilder where TBuilder : IHostApplicationBuilder` receiver becomes + `HostApplicationBuilderExtensions`) and places each under `Extensions//`. +- **Move extensions class to conventional location** — offered on a misplaced single-receiver + extensions class. It re-paths the file to `Extensions//`, fixes the namespace + to the receiver's, and updates `using` directives in other referencing documents so the move + compiles. diff --git a/.agents/skills/purview-buildsdk-project-detection/references/test-types.md b/.agents/skills/purview-buildsdk-project-detection/references/test-types.md new file mode 100644 index 0000000..dace44f --- /dev/null +++ b/.agents/skills/purview-buildsdk-project-detection/references/test-types.md @@ -0,0 +1,35 @@ +# The shipped `TestType` group + +These 53 names drive **`InternalsVisibleTo` generation**, not test detection. A non-test project +grants friend access to `{AssemblyName}.{TestType}Tests` for each name here. + +Detection is the pattern `^.*\.([\w|_]\w*)Tests?$`, so a suffix outside this group is still detected, +still runs and still gets its category — it simply cannot reach the target project's `internal` +members. Prefer a name from this group unless the test type genuinely needs its own word. + +```text +Unit Integration E2E EndToEnd Acceptance +Functional Performance Load Smoke Stress +Regression Security Chaos Scenario System +Threat BlackBox WhiteBox GrayBox GreyBox +Accessibility Usability Visual Interactive Localization +Globalization Environment Configuration Deployment Architecture +Contract Component Sanity Property Fuzz +Mutation Snapshot Approval Benchmark Capacity +Concurrency Endurance Soak Spike Failover +Recovery Resilience Compatibility Interoperability Migration +Upgrade Penetration Compliance +``` + +## The five to reach for first + +`Unit`, `Integration`, `E2E`, `Functional`, `Contract`. Everything else is available, but a reader +has to ask what it means — use one only when the test type itself carries operational weight. + +## What a suffix buys you + +| | detected as a test project | gets a category | gets a runner and coverage | sees `internal` members | +| -- | -- | -- | -- | -- | +| `Api.UnitTests` | yes | `Unit` | yes | yes | +| `Api.GoldenTests` | yes | `Golden` | yes | **no** | +| `UnitTests` *(no dot)* | **no** | no | **no** | no | diff --git a/.agents/skills/purview-buildsdk-project-layout/SKILL.md b/.agents/skills/purview-buildsdk-project-layout/SKILL.md new file mode 100644 index 0000000..6c19c3d --- /dev/null +++ b/.agents/skills/purview-buildsdk-project-layout/SKILL.md @@ -0,0 +1,161 @@ +--- +name: purview-buildsdk-project-layout +description: Decide where a project goes and what to call it in a repository that uses Purview.BuildSdk. Use when creating, moving, splitting or renaming a project or test project, choosing a test suffix, aligning namespaces and assembly identity with NamespacePrefix, laying out a Blazor Web App host, WebAssembly client and browser-safe Contracts library, or relying on the SDK's automatic project references. Also use when rationalising a repository's shape so the SDK's conventions work without overrides. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Project layout and naming for Purview.BuildSdk + +Use this skill when physically adding, moving, splitting or renaming a project. + +For *why* the SDK classified an existing project a particular way, use +`purview-buildsdk-project-detection`. For the property that changes a behaviour, use +`purview-buildsdk-configuration`. + +## First principle + +**Treat naming and placement as configuration, not decoration.** + +The SDK infers namespaces, assembly identity, test categories, package wiring and project references +from a small set of conventions. The closer a repository sits to them, the fewer overrides it needs. + +The corollary matters just as much: **preserve the host repository's existing layout first.** Only +introduce new structure when no established pattern exists. + +## Canonical layout + +```text +/ + package.json version source + .editorconfig bootstrapped by the SDK + skills/ authored agent skills, if any + src/ + .slnx + src//.csproj + tests/.Tests/.Tests.csproj +``` + +- The `.csproj` filename must equal its containing directory name, unless + `DisableProjectFileNamingConventionCheck=true`. +- Source projects under `src/`, test projects under `tests/`. +- Place a new project beside similar ones — same language, layer and test type. +- One test type per project by default. + +A repository that already uses a different shape keeps it; add to it rather than converting it. + +## Naming and identity + +`NamespacePrefix` is the root identity source. Keep project names short and let the SDK apply the +prefix: + +| `NamespacePrefix` | Project | `RootNamespace` | +| -- | -- | -- | +| `Aspire` | `Hosting` | `Aspire.Hosting` | +| `Acme.Sales.RegionalPipeline` | `Identity.API` | `Acme.Sales.RegionalPipeline.Identity.API` | +| `Acme.Sales.RegionalPipeline` | `Identity.Core` | `Acme.Sales.RegionalPipeline.Identity` | + +`AssemblyName` and `PackageId` follow the resolved `RootNamespace` — except where suffix-stripping +removed a segment (`Core`, `Shared`, `ServiceDefaults`), in which case they keep the fuller logical +name so the artifacts stay distinct from their parent. Explicit values always win, and +`EnableAssemblyNameGeneration=false` (before the SDK import) restores standard behaviour. + +Do not hand-author an alternate namespace scheme when one is already in use. + +## Test project naming + +Test detection is a **pattern**, not a list: `^.*\.([\w|_]\w*)Tests?$`. A target name, a dot, then +any word before `Test` or `Tests`. The captured word becomes `TestingType` and the baseline category. + +**The dot is required.** A project named plainly `UnitTests`, with no target in front of it, is not +detected as a test project at all — no runner, no coverage, no `InternalsVisibleTo`, no category. +Always name a test project `.Tests`. + +Prefer these by default: `UnitTests`, `IntegrationTests`, `E2ETests`, `FunctionalTests`, +`ContractTests`. Use another suffix only when the test type itself carries operational meaning. + +A suffix outside the SDK's shipped `TestType` group still builds, runs and gets its category — it +just cannot reach the target project's `internal` members, because that group drives +`InternalsVisibleTo` generation. The full list is in `purview-buildsdk-project-detection`. + +## Shared projects: exact names only + +The SDK recognises these names exactly — a near miss gets none of the behaviour. + +Shared: `Shared`, `SharedFramework`, `SharedInfrastructure`, `SharedInfra`, `SharedUtilities`, +`SharedUtils`, `SharedLibrary`, `SharedLib`, `SharedHelpers`. + +Shared testing: `SharedTestingFramework`, `SharedTestingInfrastructure`, `SharedTestingInfra`, +`SharedTestingUtilities`, `SharedTestingUtils`, `SharedTestingLibrary`, `SharedTestingLib`, +`SharedTestingHelpers`. + +## Automatic project references to preserve + +Layout is what makes these work. Move a project off the convention and you own the reference. + +Test projects probe for their target, in order: + +- `../$(TargetProjectName)/$(TargetProjectName).csproj` +- `../../$(TargetProjectName)/$(TargetProjectName).csproj` +- `../src/$(TargetProjectName)/$(TargetProjectName).csproj` +- `../../src/$(TargetProjectName)/$(TargetProjectName).csproj` + +Non-test projects pick up sibling shared projects via `../Shared*/Shared*.csproj`; test projects +pick up `../SharedTesting*/SharedTesting*.csproj`. A Blazor WebAssembly client is the exception: it +does not pick up `Shared*`, because everything it references is downloaded to the browser. + +## Blazor Web App: host and client + +```text +src/src/ + Shop/Shop.csproj Microsoft.NET.Sdk.Web - the host + Shop.Client/Shop.Client.csproj Microsoft.NET.Sdk.BlazorWebAssembly - the client + Shop.ClientShared/Shop.ClientShared.csproj a PurviewBrowserSafe library both use + Shop.Bff/Shop.Bff.csproj a BFF, when tokens are kept server-side in a separate host +src/tests/ + Shop.Client.UnitTests/ TargetProjectName = Shop.Client +``` + +- A web host named `` beside a WebAssembly client named `.Client` is the pairing the SDK + recognises: the host gets `IsBlazorHostProject=true` and a reference to the client automatically + (skipped when declared; `PurviewAutoBlazorClientReference=false` opts out). +- Identities with `NamespacePrefix=Acme`: `Shop.Client` is `Acme.Shop.Client`; `Shop.Bff` is + `Acme.Shop.Bff`; `ClientShared` is a stripped segment, so `Shop.ClientShared` has namespace + `Acme.Shop` and assembly `Acme.Shop.ClientShared`. +- Every library the client references must set `true`, or the + build reports `PRBLZ0001`. Keep server-only code (file, process, database access) out of anything + the client references. +- Name a BFF host `*Bff` (case-sensitive) for `IsBffProject=true`. Being the client's host does not + make a project a BFF. + +## Creating a new project + +1. Match the SDK and style of sibling projects. +2. Use Central Package Management if the repository has it. +3. Add only the dependencies the project's scope needs — the SDK supplies the testing stack, + SourceLink and telemetry by default. +4. Add the project to the solution. +5. Keep target frameworks, nullability, analyzers and warning settings consistent with neighbours. + +## Moving or splitting + +1. Create the destination using the established layout. +2. Move the files. +3. Update namespaces, usings and references for the destination. +4. Verify the destination still produces the intended `TestingType`, `TargetProjectName` and + `RootNamespace` — ask MSBuild rather than assuming: + `dotnet msbuild -getProperty:RootNamespace -getProperty:TargetProjectName`. +5. Remove dependencies the source no longer needs. +6. Update solution membership and project references. +7. Build, then run the affected tests. + +## Guardrails + +- Prefer minimal, targeted diffs; avoid cross-cutting renames unrelated to the move. +- Keep test intent unchanged while relocating. +- When the structure is ambiguous, infer from the nearest sibling projects and say which assumption + you made. +- When in doubt, preserve compatibility with the SDK's automatic naming, namespace and + project-reference behaviour. diff --git a/.agents/skills/purview-buildsdk-repository-review/SKILL.md b/.agents/skills/purview-buildsdk-repository-review/SKILL.md new file mode 100644 index 0000000..ce1fd29 --- /dev/null +++ b/.agents/skills/purview-buildsdk-repository-review/SKILL.md @@ -0,0 +1,111 @@ +--- +name: purview-buildsdk-repository-review +description: Review a repository that uses Purview.BuildSdk for naming, placement and test-structure alignment, and report what the SDK can and cannot do automatically as a result. Use when asked to audit, rationalise or health-check a repo against the SDK's conventions, when onboarding an existing codebase onto Purview.BuildSdk, or when deciding whether a layout quirk is worth changing. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Reviewing a repository against the SDK's conventions + +This is an audit, not a refactor. The deliverable is a prioritised list of findings and the cost of +each, so somebody else can decide what to change. + +For the conventions themselves use `purview-buildsdk-project-layout`; for why one project was +classified as it was, `purview-buildsdk-project-detection`. + +## The governing judgement + +**Deviation is only a finding when it costs the repository something.** The SDK infers behaviour +from naming and placement, so a deviation is interesting precisely to the extent that it turns an +automatic behaviour off. A layout that differs from the canonical one but still gets every automatic +behaviour is not debt, and reporting it as such wastes the reader's attention. + +Sort every finding into one of three buckets, and say which: + +| Bucket | Meaning | +| -- | -- | +| **Acceptable variance** | Differs from the canonical layout, costs nothing. Preserve it. | +| **Structural debt** | Blocks an automatic behaviour the repository would benefit from. | +| **Incremental opportunity** | Low-risk tidying worth doing when the area is touched anyway. | + +## 1. Read before concluding + +Inspect, in this order, before forming any opinion: + +- `Directory.Build.props` and `Directory.Build.targets` — where the SDK is imported, and what is set + before the import +- the solution entry point, for what is actually in the build +- `Directory.Packages.props`, for Central Package Management +- the project layout on disk + +## 2. Check the structural signals + +- Source projects under `src/`, test projects under `tests/`. +- `.csproj` filename equals its containing directory name — unless + `DisableProjectFileNamingConventionCheck=true`, which is itself a finding worth surfacing. +- Short project names, with `NamespacePrefix` carrying the repository identity. +- Test project names match `^.*\.([\w|_]\w*)Tests?$`. **The dot is required**; a project named + plainly `UnitTests` is not detected at all and silently gets no runner, no coverage, no + `InternalsVisibleTo` and no category. This is the single highest-value thing to check. +- Test suffixes come from the SDK's shipped `TestType` group where `internal` access is wanted — a + suffix outside it still runs, it just cannot see `internal` members. +- Shared and shared-testing projects use the **exact** recognised names. A near miss gets none of + the behaviour. + +## 3. Verify with MSBuild, do not infer from folder names + +A review that reasons from directory names will be wrong about at least one project. Ask: + +```bash +dotnet msbuild -getProperty:RootNamespace -getProperty:AssemblyName \ + -getProperty:IsTestProject -getProperty:TestingType -getProperty:TargetProjectName +``` + +Quote the computed values in the findings. "`Api.Tests` resolves `TestingType=` empty, so it has no +runner" is actionable; "`Api.Tests` looks like it might not be detected" is not. + +## 4. Review test readability + +- Subject-based suites: `{SubjectName}Tests`, with + `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}` methods. +- Non-subject-based suites — build, generator, workflow, package, full-system — named clearly for + their broader role. A forced `{Subject}Tests` on one of these is worse, not better. +- TUnit categories and display names used where a suite is large enough to need navigating. + +See `purview-buildsdk-testing`. + +## 5. Check the test dependencies are earned + +The SDK already supplies `TUnit`, `TUnit.Mocks`, `Bogus` and the Microsoft.Testing.Platform wiring +to every detected test project. Anything beyond that was a deliberate choice, and deliberate choices +go stale: + +- `TUnit.Aspire` and `Purview.Containers` are **not** universal defaults. Confirm each reference is + genuinely needed by that project rather than copied from a sibling. +- Where a project references `Testcontainers` directly, check whether a `Purview.Containers` typed + module now covers it — the module set grows, and a direct reference that was once necessary often + is not any more. +- A package the SDK already adds, restated in a project file, is maintenance cost with no effect. + +## 6. Explain consequences, not just mismatches + +For each finding, name the specific automatic behaviour that is lost: `RootNamespace`, +`AssemblyName`, `PackageId`, `TargetProjectName`, the automatic target-project reference, the +sibling `Shared*` reference, `InternalsVisibleTo` generation, the test runner and coverage wiring. + +"Project is not under `src/`" is not a finding. "Project is not under `src/`, so the test project's +four probe paths all miss and the `ProjectReference` has to be maintained by hand" is. + +## Reporting the result + +1. One sentence on whether the repository broadly fits the SDK's conventions. +2. The findings, ordered by impact, each with its bucket and the behaviour it costs. +3. The low-risk rationalisation steps, as concrete renames or moves. +4. An explicit split of what is **already automatic** versus what this repository has configured by + hand — that list is usually the most useful part of the review, because hand-configuration that + merely restates a default is pure maintenance cost. + +Do not propose a cross-cutting rename as a first step. Name the one change with the best +cost-to-benefit ratio and say what it unlocks. diff --git a/.agents/skills/purview-buildsdk-skill-installation/SKILL.md b/.agents/skills/purview-buildsdk-skill-installation/SKILL.md new file mode 100644 index 0000000..66c20a3 --- /dev/null +++ b/.agents/skills/purview-buildsdk-skill-installation/SKILL.md @@ -0,0 +1,166 @@ +--- +name: purview-buildsdk-skill-installation +description: Install, list and diagnose the agent skills a Purview.BuildSdk package ships. Use for the InstallPurviewSkills, ListPurviewSkills and RecommendPurviewSkills MSBuild targets, for any PRSKL0001-PRSKL0014 diagnostic, for choosing a package runner (npx, pnpm dlx, bunx, yarn dlx, deno), for skills-lock.json, or when a package ships skills but no agent can see them. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Installing the skills a package ships + +For *authoring* skills, use `purview-buildsdk-skill-tests`. + +## The one thing to understand first + +**The SDK writes nothing into an agent directory.** A package *ships* `.agents/skills//`, but +as metadata — it is what tells the SDK which skills the package offers and at which commit. Nothing +copies it out. + +Skills are installed by the skills CLI and by nothing else. That is a deliberate single-writer rule. +Earlier versions of this SDK mirrored a package's whole `.agents/**` folder into the consuming +repository, and the CLI's own universal install location is `.agents/skills/` — the same paths. The +mirror would silently overwrite a pinned install on the next build, while `PRSKL0011` compared a +lockfile ref against content the mirror had already replaced. The mirror was removed outright rather +than fenced off, because the CLI covers the whole job. + +Installing is therefore a separate, deliberately **explicit** step. It is never a side effect of a +restore, a build, a design-time build or a test run. + +```bash +dotnet msbuild -t:ListPurviewSkills # diagnostics only, installs nothing +dotnet msbuild -t:InstallPurviewSkills # delegates to the skills CLI +``` + +Neither target has `BeforeTargets` or `AfterTargets`. If skills are appearing or changing on their +own, something has been wired up that should not be. + +## How a skill is resolved, and why the version is always right + +There is deliberately **no custom manifest**. A package that ships skills already carries both +halves of the mapping, and `ResolvePurviewSkillSources` reads them: + +- `.agents/skills//` in the restored package says *which* skills it ships. +- The nuspec's ``, written by SourceLink for every package this + SDK builds, says which git repository and which **exact commit** produced them. + +So the resolved ref is the commit the *restored package version* was built from. An older +`PackageReference` gets that version's skills, not whatever is on the default branch. Compatibility +is exact by construction rather than by convention. + +## Installing from an explicit source + +For a skill you want but have no `PackageReference` for: + +```bash +dotnet msbuild -t:InstallPurviewSkills -p:PurviewSkillsSource=purview-dev/build-sdk +``` + +The caller owns the ref here — append `#` to pin it. This path needs no restore, so +`PRSKL0001` does not apply. + +## Diagnostics + +| Code | Meaning | Fix | +| -- | -- | -- | +| `PRSKL0001` | `project.assets.json` is missing | Restore. Resolution reads the restore graph and deliberately does not trigger a restore of its own | +| `PRSKL0002` | The package's nuspec has no GitHub repository URL, or no commit | The package cannot be pinned. It is reported rather than installed from a guessed branch | +| `PRSKL0003` | No repository root resolved | Pass `-p:PurviewSkillsInstallRoot=` | +| `PRSKL0004` | No skills CLI and no package runner found | Install Node, or set `PurviewSkillsCommand` | +| `PRSKL0005` | An authored skill is malformed | See `purview-buildsdk-skill-tests` | +| `PRSKL0010` | A skill a referenced package ships is missing | Warning. `npx skills add ...` | +| `PRSKL0011` | An installed skill is pinned to a different ref than the referenced package | Warning. Install the package's own commit | +| `PRSKL0012` | A skill cannot be verified | Message | +| `PRSKL0013` | An orphaned lockfile entry | Message | +| `PRSKL0014` | Recorded in the lockfile but never restored | Message | + +## Recommendations run during an ordinary build + +This is the **one** skill feature that does. `RecommendPurviewSkillsOnBuild` runs after `Build` and +delegates to `RecommendPurviewSkills`, which can also be invoked by name: + +```bash +dotnet msbuild -t:RecommendPurviewSkills +``` + +`EvaluatePurviewSkillState` joins the skills each restored package ships against the CLI's own +`skills-lock.json`. Both inputs are local files, so it stays offline and spawns no process. + +Four things keep it quiet, and all of them are load-bearing: + +- Design-time builds, CI builds, non-default TFM passes and + `EnablePurviewSkillRecommendations=false` are skipped outright. + `PurviewSkillRecommendationsInCI=true` re-enables CI. +- A fingerprint stamp at `/.purview/skill-recommendations.cache` means a given state is + reported once per repository, not once per project and again on every rebuild. The explicit target + ignores the stamp. +- Missing and drifted are **warnings** (fixable in one command); unverifiable and orphaned are + **messages**. No amount of skill advice can break a build using `TreatWarningsAsErrors`. +- A correctly installed set produces no output at all. + +Orphan detection is **owner-scoped on purpose**: a lockfile cannot record who asked for an entry, so +only an entry under an owner the project references — but from a repository it no longer references +— is reported. A skill from any other owner is the developer's own, and is never mentioned. + +### Drift never recommends `npx skills update` + +`update` installs the *newest* published skill, which is only correct when the newest skill happens +to describe the referenced package version. `PRSKL0011` names the referenced package's own commit in +a pinned `npx skills add` instead. + +## Package runners + +The CLI is published to npm, and every runner spells "run this npm package" differently. +`PurviewSkillsRunner` selects one, defaulting to `npx` because npx ships with Node itself. + +| Runner | Invocation | +| -- | -- | +| `npx` | `npx -y skills@1` | +| `bunx` | `bunx skills@1` | +| `bun` | `bun x skills@1` | +| `pnpm` | `pnpm dlx skills@1` | +| `yarn` | `yarn dlx skills@1` — Yarn 2+ only | +| `deno` | `deno run -A npm:skills@1` | + +An unknown runner falls through to ` $(PurviewSkillsPackage)`, so it still works. + +`DetectPurviewSkillsCli` probes `PATH` for `skills` first, then `$(PurviewSkillsRunner)`, then +`$(PurviewSkillsRunnerCandidates)`, honouring `PATHEXT` on Windows where these are `.cmd` shims. +`PATH` is read directly rather than by spawning a version check: deterministic, no process, and it +cannot hang on a cold download. **The runner actually found wins over the configured preference.** +`PurviewSkillsCommand` overrides the launcher entirely and skips detection. + +`ListPurviewSkills` reports runner availability but **never** errors on its absence — resolving and +reporting needs no Node tooling at all. + +## Always the repository root + +`skills-lock.json` and the agent directories belong to the repository, not to a project, so every +skill target runs from one resolved root: + +1. `PurviewSkillsInstallRoot` — an explicit override +2. `PurviewSkillsRepoRoot` — only when the **user** set it +3. the `PurviewRepositoryRootMarkers` walk +4. `PurviewSkillsRepoRoot` / `RepoRoot` as the SDK guessed them +5. CI workspace variables +6. SourceLink's git `SourceRoot` + +There is deliberately **no project-directory fallback**. That would scatter a lockfile and an agent +directory through the tree, one per project. An unresolved root is `PRSKL0003` on install, and +silence for recommendations — a build never fails over advice. + +The marker walk is described in [references/repository-root.md](references/repository-root.md); its +two-pass behaviour and its home-directory bound are both load-bearing. + +## Why the CLI rather than a reimplementation + +The CLI owns the agent-specific destinations and their discovery conventions, which differ per agent +and change independently of this SDK. It also owns `skills-lock.json`, so a repository can restore +the same set later with `npx skills experimental_install`. + +`PurviewSkillsAgents` (default `*`) narrows the target agents. + +The lockfile is read **directly** rather than by parsing `npx skills list --json`, because that +listing omits the `ref` — and the ref is the whole compatibility question. Reading a file also needs +no Node tooling, which is what keeps recommendations working on a machine that can build .NET but +has no Node. There is deliberately no competing lockfile. diff --git a/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md b/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md new file mode 100644 index 0000000..e90b695 --- /dev/null +++ b/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md @@ -0,0 +1,55 @@ +# The repository-root marker walk + +`FindPurviewRepositoryRoot` is an inline MSBuild task driven by `PurviewRepositoryRootMarkers`: + +```text +.git/;.github/;.gitattributes;LICENSE.md;AGENTS.md;.agents/;CLAUDE.md;.claude/;package.json +``` + +A trailing slash marks a directory. A directory marker still matches a **file** of that name, +because `.git` is a directory in a clone but a file in a worktree or a submodule, and both identify +a repository root. + +## Three things that are load-bearing + +### It cannot be an MSBuild intrinsic + +`GetDirectoryNameOfFileAbove` matches **files only** and returns nothing for `.git/`, `.github/`, +`.agents/` or `.claude/` — four of the nine markers. Verified, not assumed. + +### Two passes + +- **Pass 1** — the first marker in the list is *definitive* and is searched all the way up. `.git/` + *is* the repository root, so a monorepo sub-package with its own `package.json` does not win. +- **Pass 2** — every other marker resolves nearest-ancestor-first, with list order breaking ties + inside a single directory. Searching them all to the top would let a stray `.claude/` in a + developer's home directory outrank the real root. + +### The home directory is never a candidate + +The home directory and everything above it are excluded. Without that bound, a project outside any +repository resolves to `~` and installs into it — and a `.claude/` or a `.github/` very commonly +sits there. A repository *below* the home directory is unaffected, because it is below it. + +## Why the marker walk outranks the evaluation-time guess + +`PurviewSkillsRepoRoot` is computed during evaluation from `RepoRoot` and an `AGENTS.md` walk-up. +An `AGENTS.md` can legitimately sit in a subdirectory, and resolving to it would put a lockfile and +an agent directory somewhere that is not the repository. + +`_PurviewSkillsRepoRootWasSpecified` is captured in `Sdk.props` *before* defaulting, so a value the +**user** set still outranks the walk while a value the SDK guessed does not. + +## A target's Condition is evaluated before its DependsOnTargets + +A property a dependency computes is still empty in the depending target's `Condition`. That is why +the unresolved-root guard sits on the `EvaluatePurviewSkillState` task rather than on +`RecommendPurviewSkills` — and equally why the build hook's CI and design-time gating works at all. + +## The managed port + +`Purview.Skills.Testing` ships `SkillRepositoryRoot`, a managed port of the same walk with the same +default markers, so a test that validates a repository's skills resolves the same root the build +installs them into. The two are kept in step by `SkillRepositoryRootTests`, including an assertion +on the default marker list itself — both are defaults nobody sets, so nothing else would notice them +drifting apart. diff --git a/.agents/skills/purview-buildsdk-skill-tests/SKILL.md b/.agents/skills/purview-buildsdk-skill-tests/SKILL.md new file mode 100644 index 0000000..4043ac4 --- /dev/null +++ b/.agents/skills/purview-buildsdk-skill-tests/SKILL.md @@ -0,0 +1,131 @@ +--- +name: purview-buildsdk-skill-tests +description: Author agent skills in a repository that uses Purview.BuildSdk, and validate them with Purview.Skills.Testing or the SDK's pack-time ValidatePurviewSkills target. Use when adding or renaming a skill, writing SKILL.md frontmatter, splitting content into references/ or scripts/, fixing a PRSKL0005 pack failure or a skill-name, skill-description or skill-bundled-resources test failure, or deciding where skills belong in a repository. +license: MIT +metadata: + package: Purview.Skills.Testing + repository: purview-dev/build-sdk +--- + +# Authoring and validating agent skills + +For installing skills a package ships, use `purview-buildsdk-skill-installation`. + +## Where skills live + +**At the repository root, in `skills//SKILL.md`.** Always. + +The skills CLI only discovers skills at a root `skills/` folder, and it reaches a repository by +The skills CLI only discovers skills at a root `skills/` folder, and it reaches a repository by +cloning it, so nothing else is visible to it. Nothing is packed into a NuGet package — a package +carries .NET artifacts, and skills are delivered by the CLI from git. + +```text +skills/ + my-skill/ + SKILL.md required + references/ loaded on demand + scripts/ executable, run on demand + assets/ templates and static resources +``` + +Link to a skill by its **authored** path, `./skills//SKILL.md`. A link into `.agents/skills/` +points at a gitignored file that does not exist in a fresh clone. + +## `SKILL.md` format + +```markdown +--- +name: my-skill +description: What it does, and when to use it. Include the words a task would contain. +license: MIT +metadata: + package: My.Package +--- + +# My skill + +Instructions. +``` + +| Field | Required | Constraint | +| -- | -- | -- | +| `name` | yes | 1–64 chars, lowercase `a-z0-9` and single hyphens, no leading, trailing or consecutive hyphen, **must equal the folder name** | +| `description` | yes | 1–1024 chars, non-empty | +| `license` | no | A licence name, or the name of a bundled licence file | +| `compatibility` | no | ≤500 chars, only when the skill has real environment requirements | +| `metadata` | no | A map of string to string. Anything not defined above belongs here | +| `allowed-tools` | no | Space-separated pre-approved tools. Experimental | + +**There are no other top-level fields.** `category`, `roles` and `tags` are common inventions and a +strict client rejects them; put them under `metadata`. + +The full specification is at . +[references/authoring.md](references/authoring.md) covers description writing, naming for a shared +namespace, and progressive disclosure. + +## Progressive disclosure + +An agent loads a skill in three stages: the `name` and `description` at startup, the whole +`SKILL.md` body once the skill activates, and `references/`, `scripts/` or `assets/` only when the +task reaches them. + +So: keep `SKILL.md` **under 500 lines**, and move detail into `references/`. The body is a cost paid +on every activation; a reference file is paid only when it is needed. + +Keep file references one level deep from `SKILL.md`, and relative to the skill root. A path that +climbs out of the skill folder is broken everywhere except the authoring repository, because only +the skill's own folder is installed. + +## Validating + +### In tests — `Purview.Skills.Testing` + +```csharp +[InheritsTests] +public sealed class SkillTests : PurviewSkillTests; +``` + +**`[InheritsTests]` is load-bearing.** TUnit does not walk base types during discovery, so without +it the class compiles, the run is green and nothing is validated. TUnit reports `TUnit0030`; treat +it as an error. + +| Rule id | What it checks | +| -- | -- | +| `skills-discovery` | The folder, when present, holds at least one skill | +| `skill-structure` | Every folder has a `SKILL.md` whose frontmatter parses | +| `skill-name` | Spec-legal, unique, and equal to the folder name | +| `skill-description` | Present, non-empty, within 1024 characters | +| `skill-optional-fields` | Only spec-defined fields, each within its limit | +| `skill-bundled-resources` | Every linked file exists, inside the skill folder | +| `skill-body-size` | `SKILL.md` stays under the recommended 500 lines | + +A repository with **no** `skills/` folder passes every rule — authoring skills is optional. An +**empty** folder fails `skills-discovery`, because every other rule is vacuous against an empty +catalogue. + +Overrides: `RepositoryRoot` (default: the SDK's repository-root marker walk), +`SkillsFolderName` (default `skills`), and `SkipRule(string)`. + +The evaluator takes a `SkillCatalogue`, so a rule can be exercised against a fixture folder rather +than a real repository — the same three-layer shape this repository uses throughout: a reader, an +evaluator over an in-memory model, and a thin test base class. + +### At pack time — `ValidatePurviewSkills` + +It runs `BeforeTargets="Pack"`, so a malformed skill fails in the repository that owns it rather +than reaching the CLI, which would silently skip it. + +```bash +dotnet msbuild -t:ValidatePurviewSkills +``` + +Failures are reported as `PRSKL0005`, one per problem. Every authored skill is checked. + +The two checks overlap deliberately: the pack-time one runs without a test host, the test one +covers more of the specification. + +## What is deliberately not checked + +Prose quality, and whether an agent actually obeys the skill. A build can only check that a skill is +well formed. Treat a green run as "installable", not "good". diff --git a/.agents/skills/purview-buildsdk-skill-tests/references/authoring.md b/.agents/skills/purview-buildsdk-skill-tests/references/authoring.md new file mode 100644 index 0000000..69d4ee4 --- /dev/null +++ b/.agents/skills/purview-buildsdk-skill-tests/references/authoring.md @@ -0,0 +1,79 @@ +# Authoring a skill that fires, and keeps firing + +## The description is the whole activation decision + +`name` and `description` are the only things loaded at startup. Everything else — the body, the +references, the scripts — is invisible until an agent has already decided to activate the skill on +the strength of those two fields. + +So write the description for a *matcher*, not for a reader: + +- Say **what it does** and **when to use it**, in that order. +- Include the words a real task would contain: the tool names, the file names, the error codes, the + property names, the phrases a person would type. +- Name the thing the skill is *not*, when a neighbouring skill could be confused with it. + +| | | +| -- | -- | +| Poor | `Helps with the build SDK.` | +| Better | `Configure Purview.BuildSdk from Directory.Build.props or a .csproj. Use when setting NamespacePrefix, choosing a testing framework, or working out whether a property must be set before the SDK import.` | + +The limit is 1024 characters. Most of that budget is better spent on trigger words than on prose. + +## Naming for a shared namespace + +Skills install into one flat directory per agent, shared with every other skill a developer has +installed from anywhere. A generic name is a collision waiting to happen, and the loser is whichever +one installed second. + +- Prefix with the vendor and the product: `purview-buildsdk-configuration`, not `sdk-configuration`. +- `sdk-*`, `project-*`, `test-*`, `build-*` are all too common to use unprefixed. +- The name must be 1–64 characters, lowercase `a-z0-9` with single hyphens, no leading, trailing or + consecutive hyphen — and it must equal the folder name, because the CLI installs by the + frontmatter name while a human reads the folder. + +Renaming a skill means renaming the folder and the frontmatter together, and re-running the install. + +## Progressive disclosure in practice + +| Stage | Loaded | Budget | +| -- | -- | -- | +| Metadata | `name`, `description` | ~100 tokens, always | +| Instructions | the whole `SKILL.md` body | under 5000 tokens recommended; keep the file under 500 lines | +| Resources | `references/`, `scripts/`, `assets/` | only when the task reaches them | + +What belongs where: + +- **`SKILL.md`** — the decisions, the rules, the things that are wrong by default, and a pointer to + everything else. If a reader needs it to make the *first* correct move, it goes here. +- **`references/`** — long tables, full option lists, grammars, enumerations. Keep each file + focused; an agent loads the whole file. +- **`scripts/`** — executable code. Self-contained, or documenting its dependencies clearly, with + helpful error messages. +- **`assets/`** — templates, schemas, images, lookup data. + +Keep references one level deep. Avoid a chain where one reference sends an agent to another. + +## Links + +Use relative paths from the skill root: + +```markdown +See [the reference](references/REFERENCE.md) for detail. +Run `scripts/extract.py`. +``` + +Only the skill's own folder is installed, so a link that climbs out of it (`../other-skill/...`) is +broken on every machine except the one that authored it. To point at another skill, name it in prose +and let the agent find it by name. + +## Keeping a skill true + +A skill that describes a product goes stale the moment the product changes, and nothing about a +stale skill looks broken — it just gives confidently wrong advice. + +- Change the skill in the **same commit** as the behaviour it documents. +- Prefer stating the property name, the diagnostic code or the target name over paraphrasing the + behaviour; those are greppable when the code changes. +- Where a number or a list is authoritative somewhere else — a default, an enumeration — say where + it comes from, so a reader can check it. diff --git a/.agents/skills/purview-buildsdk-testing/SKILL.md b/.agents/skills/purview-buildsdk-testing/SKILL.md new file mode 100644 index 0000000..5a5a7d5 --- /dev/null +++ b/.agents/skills/purview-buildsdk-testing/SKILL.md @@ -0,0 +1,121 @@ +--- +name: purview-buildsdk-testing +description: Write and run tests in a repository that uses Purview.BuildSdk. Use when authoring or refactoring TUnit tests (AAA structure, Subject_Scenario_Expectation naming, awaited assertions, CancellationToken), when choosing a test project's framework packages, and when running or filtering tests - the SDK wires Microsoft.Testing.Platform, so `dotnet test --filter` reports "Zero tests ran" and `--treenode-filter` is the one that works. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Testing in a Purview.BuildSdk repository + +Two things to know before anything else: + +1. A test project is detected by **name**, and detection is what wires the runner. See + `purview-buildsdk-project-layout` if the project is new or being moved. +2. The runner is **Microsoft.Testing.Platform (MTP)**, not VSTest. `--filter` does nothing useful. + +## What a test project gets automatically + +With the defaults (`TestingFramework=TUnit`, `SubstituteFramework=TUnitMocks`, +`TestDataFramework=Bogus`), a detected test project receives: + +- `TUnit`, `TUnit.Mocks`, `Bogus` +- Microsoft.Testing.Platform integration +- `CollectCoverage=true` +- test-friendly `NoWarn` defaults (`PurviewTestContextNoWarn`), while keeping the strict style + contract — `IDE0040`, field naming, formatting, `IDE1006` +- `IsPackable=false`, `IsPublishable=false` + +Add nothing else unless the project's purpose demands it. Two that stay explicit: + +- `TUnit.Aspire` — Aspire lifecycle and AppHost-backed integration tests +- `Purview.Containers` — container-backed integration tests. One API over Microsoft WSL Containers + (no Docker install needed on Windows) or Docker, with typed modules for SQL Server, PostgreSQL, + Redis, MySQL, RabbitMQ, Azurite and NATS. Experimental and prerelease, so pin the version. + Reference `Testcontainers` directly only where no typed module exists yet. + +## Authoring rules + +- Use TUnit (`TUnit.Core`, `TUnit.Assertions`). Do not introduce xUnit, NUnit, MSTest or + FluentAssertions unless explicitly asked. +- Every test method carries `[Test]`. +- **Every assertion is awaited**: `await Assert.That(actual).IsEqualTo(expected);`. An un-awaited + assertion silently passes. +- Arrange / Act / Assert, with the `// Arrange`, `// Act`, `// Assert` comments present. +- Test classes are `{SubjectName}Tests`, in the **same namespace** as the subject. +- Test methods are `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}`. Use `ctor` as the subject + for constructor tests. Methods, constructors, properties, operators, conversions and validation + hooks are all valid subjects. +- When any method called in the test body accepts a `CancellationToken`, take + `CancellationToken cancellationToken` as the **last parameter** and pass it through. + +```csharp +[Test] +public async Task ProcessAsync_GivenAnEmptyBatch_ReturnsZero(CancellationToken cancellationToken) +{ + // Arrange + var sut = new BatchProcessor(); + + // Act + var result = await sut.ProcessAsync([], cancellationToken); + + // Assert + await Assert.That(result).IsEqualTo(0); +} +``` + +For a suite that is not subject-based — a build, generator, workflow, package or full-system suite — +a broader, more truthful class name is better than a forced `{Subject}Tests`. Use TUnit display +names and categories to keep it navigable. + +When refactoring existing tests, bring them into compliance with all of the above while preserving +test intent. + +## Categories + +The detected test type becomes the baseline category automatically — `Api.IntegrationTests` yields +`Category=Integration`. Add further categories when they make a large suite easier to navigate; +they are what `--treenode-filter` property predicates select on. + +## Running tests + +```bash +dotnet test # the whole suite +dotnet test --treenode-filter "/*/*/LoginTests/*" # one class +dotnet test --treenode-filter "/*/*/*/*[Category=Unit]" +``` + +Prefer the repository's own task (`just test`, or equivalent) for a full run. + +The filter path is `////`, with `*` wildcards, `=`/`!=` property +predicates, `&`/`|` inside a single bracket group, and `**` only at the end. The full grammar, the +`--` separator rules across SDK versions, and the zero-tests checklist are in +[references/treenode-filter.md](references/treenode-filter.md). + +### "Zero tests ran" + +In order: + +1. Are you using `--treenode-filter` and not `--filter`? This is the cause most of the time. +2. Try `dotnet test --treenode-filter "..."`; on an older SDK use `dotnet test -- --treenode-filter "..."`. +3. Broaden to `/*/*/*/*`, then narrow one segment at a time. +4. Does the project actually contain `[Test]` methods? +5. Is the project **detected** as a test project? A name without the dot — `UnitTests` rather than + `Api.UnitTests` — gets no runner at all. Check with + `dotnet msbuild -getProperty:IsTestProject`. +6. Are all property predicates inside one bracket group, `[(A=1)&(B=2)]`? + +## Cross-platform requirement + +CI runs the full suite on Linux, so a test that only passes on Windows is a failing change. + +- Never hardcode a platform-specific path — no `C:\...`, no backslash-only separators, no + case-insensitive path assumptions. +- Build paths with `Path.Combine`, `Path.GetTempPath()`, `Path.DirectorySeparatorChar`, + `Path.GetFullPath()`. +- Feed production path-math code only native-format paths. Normalise a fixed Windows-style literal + to the current platform before passing it in. +- Do not rely on a case-insensitive filesystem, a `C:` drive, or trailing-separator behaviour. +- Keep `SyntaxTree` file paths and `build_property.*` values consistent and native when writing + analyzer tests. diff --git a/.agents/skills/purview-buildsdk-testing/references/treenode-filter.md b/.agents/skills/purview-buildsdk-testing/references/treenode-filter.md new file mode 100644 index 0000000..35d032b --- /dev/null +++ b/.agents/skills/purview-buildsdk-testing/references/treenode-filter.md @@ -0,0 +1,107 @@ +# `--treenode-filter` reference + +TUnit runs on Microsoft.Testing.Platform, not VSTest, so the VSTest `--filter` syntax does not +apply. Narrowing is done with `--treenode-filter`. + +## The path + +```text +//// +``` + +Each segment maps to one level of the tree: + +| Position | Matches | +| -- | -- | +| 1 | Assembly | +| 2 | Namespace | +| 3 | Class | +| 4 | Test name | + +```text +/*/*/LoginTests/* every test in LoginTests +/*/*/*/AcceptCookiesTest one test by name +/*/MyProject.Tests.Integration/*/* every test in a namespace +/*/*/*/* everything +``` + +## Operators + +### `*` — wildcard + +Matches a whole segment or part of one. + +```text +/*/*/LoginTests*/* +/*/*/MyProject.Tests.Api*/* +``` + +### `=` and `!=` — property predicates + +In the last segment, inside `[...]`. + +```text +/*/*/*/*[Category=Unit] +/*/*/*/*[Category!=Slow] +/*/*/*/*[Owner=*Team-Backend*] +``` + +### `&` and `|` — combining + +Inside a **single** bracket group, or a single parenthesised segment. + +```text +/*/*/*/*[(Category=Unit)&(Priority=High)] +/**[(Category=Unit)|(Priority=High)] +/*/*/(LoginTests)|(SignupTests)/* +``` + +### `**` — match all + +Any depth, but **only at the end** of the path. + +```text +/** +/MyAssembly/** +``` + +`/**/Path` is not valid. + +## Rules that catch people out + +- Only **one** property group `[...]` per path segment. `[Category=Smoke]|[Priority=High]` is not + valid; write `[(Category=Smoke)|(Priority=High)]`. +- `**` must be last. +- A segment left empty is not a wildcard — write `*`. + +## The `--` separator + +```bash +dotnet test --treenode-filter "/*/*/*/*[Category=Unit]" # preferred +dotnet test -- --treenode-filter "/*/*/*/*[Category=Unit]" # older SDKs +``` + +Newer .NET SDKs forward unrecognised arguments to the MTP runner directly. On an older SDK the `--` +separator is required, and omitting it produces an argument error rather than a filter. + +## Common selections + +| Intent | Filter | +| -- | -- | +| Everything | `/*/*/*/*` | +| All unit tests | `/*/*/*/*[Category=Unit]` | +| High-priority unit tests | `/*/*/*/*[(Category=Unit)&(Priority=High)]` | +| One class | `/*/*/MyFeatureTests/*` | +| A name pattern | `/*/*/*/MyScenario*` | +| Critical integration tests | `/*/MyProject.Tests.Integration/*/*[Priority=Critical]` | + +## Zero tests ran + +1. `--treenode-filter`, not `--filter`. +2. Try without the `--` separator first, then with it. +3. Broaden to `/*/*/*/*`, then narrow one segment at a time. +4. Confirm the project has `[Test]` methods. +5. Confirm the project is **detected** as a test project — + `dotnet msbuild -getProperty:IsTestProject`. A name without the dot, such as + `UnitTests` rather than `Api.UnitTests`, is not detected and gets no runner. +6. Confirm every property predicate sits in one bracket group. diff --git a/.agents/skills/purview-engineering-principles/SKILL.md b/.agents/skills/purview-engineering-principles/SKILL.md new file mode 100644 index 0000000..ba4dd9b --- /dev/null +++ b/.agents/skills/purview-engineering-principles/SKILL.md @@ -0,0 +1,171 @@ +--- +name: purview-engineering-principles +description: >- + Use when setting up, reviewing or rationalising a .NET repository against the Purview engineering + conventions - convention over configuration, derived identity, architecture as tests, warnings as + errors, proving consumer-facing claims, documentation ownership, test data builders, agent + legibility, and tiering verification by risk. Each rule names the failure it prevents and whether a + build or a reviewer enforces it. +metadata: + product: Purview engineering playbook + repository: purview-dev/engineering-playbook +--- + +# Purview engineering principles + +Ten rules, each with the failure it prevents and the mechanism that makes it true. The distinction +that matters throughout: **a convention a build can check is checked by the build; a convention only +a reviewer can check is labelled a review obligation and never described as enforced.** A rule that +claims enforcement it does not have is worse than no rule, because people stop looking. + +## 1. Convention over configuration + +A repository declares as little as possible. One MSBuild SDK supplies the defaults, and a project +file is short because the SDK already knows what a project of that shape needs. + +Configuration copied between repositories diverges. When the rule lives in one package, fixing it +once fixes it everywhere; when it lives in eleven project files, it is fixed eleven times — or, more +often, once. + +*Enforced by:* `` in a single +`Directory.Build.props`. The SDK detects project type from the filename and the `Sdk` attribute. + +*At your scale:* a single-project repository still benefits. The import is two lines and removes the +target framework, language version, nullable context, analyzer set and package metadata. + +## 2. Identity is derived, never repeated + +`RootNamespace` comes from `$(NamespacePrefix).$(ProjectName)`, and `AssemblyName` follows the +resolved `RootNamespace`. A name appears once — in the folder and file name — and everything else is +computed. + +A hand-typed namespace drifts from the assembly it lives in, and the drift is invisible until +something reflects over types: an architecture test, a serializer, a DI scan. + +*Enforced by:* the SDK during `.props` evaluation, plus a build-time check +(`PurviewProjectFileNameMismatch`) when a `.csproj` filename does not match its parent directory. + +*At your scale:* always. It costs nothing and removes a class of bug. + +## 3. Architecture is a test, not a diagram + +The dependencies a layer may have are asserted by tests that read the compiled assemblies. A +`ProjectReference` pointing the wrong way fails the build. + +A diagram is a claim about the past. Dependency direction erodes one convenient reference at a time, +and every such reference looks reasonable in isolation. + +*Enforced by:* a test project that references every layer and inspects their metadata references. + +*At your scale:* small repositories can skip it. Adopt it the moment a **second layer** exists — +that is when the wrong direction first becomes possible. + +## 4. The build is the only authority + +Style is enforced by analyzers and `EnforceCodeStyleInBuild`, and the SDK's own style policy is +re-validated on every compile, so a repository cannot quietly downgrade a rule. + +*At your scale:* always. If a rule matters and cannot be checked, say so plainly. + +## 5. Warnings are not acceptable output + +A clean build has no warnings. One that will never be fixed is either fixed, suppressed *where it +occurs* with a stated justification, or turned off deliberately. + +Warnings are a budget. A build that emits twenty is a build where the twenty-first is invisible — and +the twenty-first is the one that mattered. + +*Enforced by:* severity, partly. The SDK's shipped `.editorconfig` sets many rules to `error`. The +rest is review, backed by the pipeline running `dotnet build` with the repository's configuration. + +*At your scale:* a small repository can tolerate `warning`; a large one cannot. Decide deliberately +rather than by accumulation. + +## 6. Every claim about a consumer is proved by a consumer + +When a package documents how it must be used — a target framework, an asset, a guard, a workaround — +something in the repository builds that documented consumer and asserts the documented outcome. + +Consumer-facing documentation is the part of a library least likely to be exercised by its own tests, +because **the library's own build is not a consumer**. It drifts silently and fails in somebody +else's repository. + +*Enforced by:* a verification script that packs, then builds throwaway consumer projects against the +produced packages, asserting the happy path *and* the documented guard errors. `purview-dev/containers` +is the worked example (`just verify-consumers`), deliberately excluded from the default unit filter +because it restores from nuget.org. + +*At your scale:* only once a package is consumed outside its own repository. Before that, the +repository's own tests already are the consumer. + +## 7. Documentation is a build artefact of the repository + +The wiki lives in the repository it describes, in `docs/wiki`, and the repository owns its +`mkdocs.yml`, its navigation and its `edit_uri`. + +Documentation that lives elsewhere is updated elsewhere. A page with no owner and no review path is +wrong within one release. + +*Enforced by:* review, plus the navigation file — a page absent from `_Sidebar.md` is not published. + +*At your scale:* always. Even a repository with one page should own that page. + +## 8. Everything a test needs is built by one builder + +Domain content in a test comes from `TestBuilder`. A test never constructs a domain object inline, +and it overrides **only** the value it is proving. + +Inline construction encodes a type's shape into every test that touches it, so one constructor change +becomes a repository-wide edit. It also hides which values the test actually depends on, because +every value looks deliberate. + +*Enforced by:* review. + +*At your scale:* adopt from the first test. Retrofitting is a mechanical edit across every test file, +and exactly the kind of edit that is postponed forever. + +## 9. An agent is a reader with no memory + +A repository states its purpose, its layout and its conventions in `AGENTS.md`. **Nothing important +is discoverable only by reading the code.** + +A coding agent has the repository and nothing else — no colleague to ask, no institutional memory, no +intuition about which of two plausible patterns is intended. It will confidently generalise from +whatever it finds first. That is also every new-joiner failure mode; the agent just makes the cost +visible sooner, because an agent does not ask. + +*Enforced by:* review, plus publishing skills so agent-facing guidance tracks the library version +rather than a repository's memory of it. + +*At your scale:* always. `AGENTS.md` is cheaper than the questions it prevents. + +## 10. A change is verified in proportion to its risk + +Checks are tiered, and a repository declares which tiers its pipeline runs. A pull request runs the +fast tier; the slow tier runs deliberately, locally or on a dedicated runner. + +A pipeline that runs everything on every pull request gets made optional. One that runs too little +gets ignored. Tiering is what keeps the gate credible. + +*Enforced by:* the pipeline's `TestFilter` and the test project's own **name** — the SDK categorises +a `*UnitTests` project as `Unit`, so the tier is a property of the project rather than of a +hand-written attribute. + +*At your scale:* one tier is fine until the slow tests exist. Then split by project name, not by +attribute. + +## Applying these in review + +Three questions catch most of it: + +1. **Is this configuration, or is it a default the SDK should own?** If two repositories would copy + it, it belongs in the SDK. +2. **Is this rule enforced, or merely stated?** If stated, say so where it is stated. +3. **Could an agent — or a new joiner — find this without being told?** If not, it goes in + `AGENTS.md` or the wiki. + +## Related skills + +`purview-architecture` for project-shape decisions, `purview-architecture-testing` for turning a +boundary into a failing build, `purview-telemetry` and `purview-persistence` for the per-concern +guidance. diff --git a/.agents/skills/purview-persistence/SKILL.md b/.agents/skills/purview-persistence/SKILL.md new file mode 100644 index 0000000..4667fa2 --- /dev/null +++ b/.agents/skills/purview-persistence/SKILL.md @@ -0,0 +1,106 @@ +--- +name: purview-persistence +description: >- + Use when a .NET project actually persists data and you are choosing a storage approach, deciding + whether to abstract EF Core, or selecting a GUID primary-key strategy. Gives provider-specific + guidance — SQL Server and PostgreSQL differ and must not be treated alike — and stays silent for + projects that do not persist anything. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" +--- + +# Purview persistence + +**Activation condition:** this skill applies only when persistence is real. If the project is a +stateless CLI, a pure library, or a service that calls an API and stores nothing, stop here — none of +this is relevant, and raising a database GUID strategy with that project is noise. + +Confirm persistence from the repository before proceeding (see `purview-architecture`): a data-access +`PackageReference`, a `DbContext`, a connection string, or an explicit requirement to store state. + +## 1. Shape the data access proportionally + +| Scale | Shape | +| -- | -- | +| Small | `DbContext` used directly from the code that needs it. No repository, no unit-of-work wrapper — `DbContext` already is both. | +| Medium | A boundary when domain rules must not know about EF Core, or when a query is complex enough to deserve a name. Often that is a few focused query types, not a generic `IRepository`. | +| Large | Independent infrastructure implementations where there genuinely are several, with the dependency direction enforced by a test. | + +**Do not wrap EF Core in a repository for testability.** `Purview.Containers` gives you a real +database in tests, which is both more faithful and less code than an abstraction built to be mocked. +A repository earns its place when you must swap the store or when `DbContext` is genuinely leaking +into domain decisions — not before. + +## 2. Local dependencies + +`Purview.Containers` provides Aspire resource integrations for the dependencies you need in +development and integration tests — including `MsSql`, `PostgreSql`, `MySql`, `Redis`, `Nats`, +`RabbitMq`, `Azurite` and an Azure Key Vault emulator. Prefer a real container over an in-memory +provider: the in-memory provider does not enforce the constraints that break you in production. + +## 3. GUID keys — only if GUIDs are keys + +Resolve these before recommending anything; most are answered by the repository: + +1. Are GUIDs primary keys, or merely external identifiers over an integer key? +2. Is the value generated by the application or by the database? +3. Which provider, and which version? +4. Does index locality matter at the expected write volume? +5. Must the identifier exist before the row is persisted? + +Question 5 usually decides it. If the application needs the identifier before `SaveChanges` — for an +outbox row, a correlation value, or to build an object graph client-side — the database cannot be the +generator, and the remaining questions are about *which* client-side strategy. + +### The rule that matters most + +**SQL Server and PostgreSQL do not benefit from the same sequential GUID implementation.** Their +index structures and their GUID comparison semantics differ, and a "sequential GUID" that helps one +can be no better than random on the other. Never carry a strategy from one to the other without +re-deriving it. + +- SQL Server specifics: `references/sqlserver.md` +- PostgreSQL specifics: `references/postgresql.md` +- Choosing between them, and when a custom generator is justified: `references/guid-generation.md` + +### Prefer built-in capability + +Use the framework or database capability when it is sufficient — `Guid.CreateVersion7()` on .NET 9+, +`NEWSEQUENTIALID()`, EF Core's own value generators, a provider's native UUIDv7 function. A custom +generator is a maintenance obligation and a correctness risk; introduce one only against a measured +requirement. + +**There is no Purview GUID generation package.** Do not reference one, and do not create one without +first establishing a concrete, measured need that the built-in options cannot meet. If such a +capability is added later, this skill is where the guidance for recommending it belongs. + +## 4. Value objects as keys + +`Purview.ValueObjects` gives strongly-typed identifiers that stop an `OrderId` being passed where a +`CustomerId` belongs. They need an EF Core value converter; configure it once in the model rather +than per property. + +This is about type safety at the boundary, not about the generation strategy — the two decisions are +independent. + +## 5. Migrations and schema + +- Migrations are code: reviewed, tested against a real container, and applied by a deliberate step — + not by the application at startup in production. +- Expect the schema to be ahead of or behind the code during a deployment; design changes to be + compatible in both directions. +- Keep the connection string out of source. Keep it out of telemetry too. + +## Checks before you answer + +- Have you confirmed persistence is actually required? +- If you are recommending a GUID strategy, do you know the provider *and* its version? +- Are you proposing an abstraction you can justify by something other than testability? +- Have you preferred a built-in capability over a custom generator? + +## See also + +- `purview-architecture` — context discovery and proportional structure +- `purview-telemetry` — instrument the data access too; it is where the latency is diff --git a/.agents/skills/purview-persistence/references/guid-generation.md b/.agents/skills/purview-persistence/references/guid-generation.md new file mode 100644 index 0000000..432053a --- /dev/null +++ b/.agents/skills/purview-persistence/references/guid-generation.md @@ -0,0 +1,93 @@ +# Choosing a GUID generation strategy + +The cross-provider decision procedure. Provider detail lives in `sqlserver.md` and `postgresql.md`. + +## Step 0 — is this question in scope? + +Ask nothing and recommend nothing unless **both** are true: + +- The project persists data, and +- GUIDs are (or are about to be) primary keys. + +A stateless CLI, a pure library, a service with an integer key, or a project using GUIDs only as +external correlation identifiers does not need this decision. Raising it anyway is the failure this +page is written to prevent. + +## Step 1 — must the identifier exist before persistence? + +This question decides more than any other. + +**Yes** — the application needs the value before `SaveChanges` for an outbox row, a correlation +identifier, an object graph built client-side, or an identifier returned before commit. The database +cannot be the generator. Go to step 2 with client-side options only. + +**No** — the database may generate it, and database generation is available to you. + +## Step 2 — which provider, and which version? + +Do not skip the version. It changes the available options materially: + +| Provider | Version matters because | +| -- | -- | +| SQL Server | `NEWSEQUENTIALID()` is long-standing; the comparison semantics that make byte-ordered UUIDs useless have never changed | +| PostgreSQL | Native `uuidv7()` exists only from 18; `gen_random_uuid()` is built in from 13 | +| .NET | `Guid.CreateVersion7()` exists from .NET 9 | + +**The two databases do not share an answer.** SQL Server compares `uniqueidentifier` with the last +six bytes most significant, so a byte-ordered UUIDv7 is effectively random to its clustered index. +PostgreSQL compares `uuid` in byte order, so UUIDv7 is genuinely sequential there. A strategy that is +optimal on one is unremarkable or wrong on the other. + +## Step 3 — does locality actually matter? + +Sequential keys exist to reduce index fragmentation and page splits under write pressure. If the +table takes tens of inserts a minute, this is not your problem and a random GUID is the simpler, +more private choice. + +Reach for a sequential strategy when you can point at write volume, measured fragmentation, page +splits, or buffer-pool pressure. "It's best practice" is not a measurement. + +Note also that on SQL Server the problem is often better solved by making the clustered index +something other than the GUID key — see `sqlserver.md`. + +## Step 4 — is unpredictability required? + +Sequential and time-ordered identifiers are guessable, and UUIDv7 discloses creation time to the +millisecond. Where an identifier appears in a URL, is handed to a third party, or acts as a +capability, use a random UUID — or keep the ordered value internal and expose a random surrogate. + +Never use `NEWSEQUENTIALID()` output as a secret. + +## Decision summary + +| Provider | Needs value before insert | Locality matters | Recommendation | +| -- | -- | -- | -- | +| SQL Server | No | Yes | `NEWSEQUENTIALID()` (EF Core's default for `Guid` keys) | +| SQL Server | Yes | Yes | EF Core `SequentialGuidValueGenerator` | +| SQL Server | Either | No | `Guid.NewGuid()` | +| PostgreSQL | Yes | Yes | `Guid.CreateVersion7()` with `ValueGeneratedNever()` | +| PostgreSQL | No | Yes | `uuidv7()` on 18+, otherwise generate v7 in the application | +| PostgreSQL | Either | No | `Guid.NewGuid()` or `gen_random_uuid()` | +| Any | Either | Either, but must be unpredictable | Random v4 | + +## Prefer built-in capability + +In order of preference: + +1. A framework capability — `Guid.CreateVersion7()`, EF Core's value generators. +2. A database capability — `NEWSEQUENTIALID()`, `uuidv7()`, `gen_random_uuid()`. +3. A reviewed, tested implementation of a published standard, when neither of the above is available + on your target versions. +4. A bespoke generator — only against a measured requirement the first three cannot meet. + +A hand-written sequential GUID generator is easy to get subtly wrong — endianness, clock regression, +counter rollover within a millisecond, and thread safety are all live hazards, and the failure mode +is duplicate or badly-ordered keys found in production. + +## There is no Purview GUID package + +No Purview library provides GUID generation today. Do not reference one and do not invent one. + +If a concrete need emerges that the built-in options cannot meet — established by measurement, not by +anticipation — that is the point at which creating one is worth discussing. Until then this guidance +is the deliverable, and this page is where a recommendation for such a package would be documented. diff --git a/.agents/skills/purview-persistence/references/postgresql.md b/.agents/skills/purview-persistence/references/postgresql.md new file mode 100644 index 0000000..cd557ec --- /dev/null +++ b/.agents/skills/purview-persistence/references/postgresql.md @@ -0,0 +1,98 @@ +# PostgreSQL UUID keys + +Read this only when the provider is PostgreSQL and UUIDs are primary keys. The SQL Server answer is +different — see `sqlserver.md`. + +## Why PostgreSQL differs from SQL Server + +Two differences drive everything on this page: + +1. **PostgreSQL has no clustered index.** Tables are heaps; the primary key is a separate B-tree. A + random UUID therefore does not cause row-level page splits the way it does on a clustered SQL + Server table. The cost is in the index: random inserts touch scattered index pages, which hurts + cache locality and WAL volume, but it is a smaller and differently-shaped problem. +2. **`uuid` compares in byte order.** Unlike SQL Server, PostgreSQL orders UUIDs by their bytes. A + UUID that is sequential in byte layout *is* sequential to the index. + +The consequence: **UUIDv7 works here.** It is the natural answer on PostgreSQL and the wrong answer +on SQL Server. Never carry a sequential-GUID choice across the two. + +## The options + +### `Guid.CreateVersion7()` — .NET 9+, application-generated + +The default recommendation for new PostgreSQL schemas with UUID keys. + +```csharp +public sealed class Order +{ + public Guid Id { get; init; } = Guid.CreateVersion7(); +} +``` + +- Time-ordered in byte layout, so index locality is good. +- The value exists before persistence, so outbox rows, correlation and client-side graphs all work. +- Standard (RFC 9562), so it is portable and readable by other systems. +- Embeds a millisecond timestamp — which is also its disclosure caveat. + +Tell EF Core the application supplies it: + +```csharp +builder.Property(o => o.Id).ValueGeneratedNever(); +``` + +Without that, EF Core may treat the key as store-generated and skip your value. + +### Database-generated UUIDv7 — PostgreSQL 18+ + +PostgreSQL 18 added a native `uuidv7()` function. Earlier versions do not have it; on 13–17 you have +`gen_random_uuid()` (v4, from the built-in pgcrypto-derived function) and nothing native for v7. + +**Check the server version before recommending `uuidv7()`.** It is the single most common way this +guidance goes wrong — the function simply does not exist on the version most deployments are running. + +```sql +-- PostgreSQL 18 and later only +ALTER TABLE orders ALTER COLUMN id SET DEFAULT uuidv7(); +``` + +Prefer application-side generation anyway unless rows are inserted by paths that bypass the +application, because the value-before-persistence property is worth more than the marginal +difference. + +### `gen_random_uuid()` — database-generated v4 + +Available without an extension from PostgreSQL 13. Random, so index locality is poor, but +unpredictable and simple. + +### `Guid.NewGuid()` — application-generated v4 + +Random and unpredictable. Fine at low to moderate write volume, which covers most applications. + +## Npgsql and EF Core notes + +- `Guid` maps to `uuid` natively. Do not store UUIDs as `text` or `varchar` — it costs space, loses + index efficiency and defeats comparison. +- Npgsql's `uuid` mapping is byte-order faithful, so a v7 value sorts in the index as you expect. +- If the application generates the key, set `ValueGeneratedNever()` so EF Core does not fetch a + store-generated value that does not exist. +- A legacy database may have UUIDs in `text` columns. Migrating to `uuid` is usually worth it, but it + is a data migration, not a model change. + +## Choosing + +| Situation | Use | +| -- | -- | +| New schema, .NET 9+, UUID keys | `Guid.CreateVersion7()`, `ValueGeneratedNever()` | +| .NET 8 or earlier, high write volume | A reviewed UUIDv7 implementation, or accept v4 after measuring | +| Rows inserted outside the application, PostgreSQL 18+ | `uuidv7()` as a column default | +| Rows inserted outside the application, PostgreSQL 13–17 | `gen_random_uuid()` | +| Key must be unpredictable | `Guid.NewGuid()` or `gen_random_uuid()` — v7 leaks creation time | +| Low or moderate volume | v4 — the locality difference will not be your bottleneck | + +## The timestamp caveat + +A UUIDv7 exposes when the row was created, to the millisecond. Where identifiers are public, that can +leak signup times, order rates or activity patterns — and it makes identifiers partially guessable by +time window. Where that matters, use a random UUID for the public identifier and keep the v7 value +internal, or use a surrogate. diff --git a/.agents/skills/purview-persistence/references/sqlserver.md b/.agents/skills/purview-persistence/references/sqlserver.md new file mode 100644 index 0000000..c728295 --- /dev/null +++ b/.agents/skills/purview-persistence/references/sqlserver.md @@ -0,0 +1,101 @@ +# SQL Server GUID keys + +Read this only when the provider is SQL Server and GUIDs are primary keys. The PostgreSQL answer is +different — see `postgresql.md`. + +## Why SQL Server is a special case + +A clustered index determines physical row order. A `uniqueidentifier` primary key is clustered by +default, so random GUIDs insert into the middle of the index, causing page splits, fragmentation and +a larger buffer-pool working set. On a write-heavy table this is measurable. + +The complication is that **SQL Server does not compare `uniqueidentifier` values the way the bytes +are laid out.** Its ordering weights the last six bytes (node field) most significantly, then earlier +groups. So a GUID that is sequential in big-endian byte order — including a standard UUIDv7 — is +**not** sequential under SQL Server's comparison. This is precisely why the SQL Server and PostgreSQL +answers diverge. + +## The options + +### `NEWSEQUENTIALID()` — database-generated + +A column default that produces values ascending in SQL Server's own ordering. Best index locality +available. + +```sql +ALTER TABLE [Orders] ADD CONSTRAINT [DF_Orders_Id] + DEFAULT NEWSEQUENTIALID() FOR [Id]; +``` + +EF Core, for a `Guid` key on SQL Server, configures this by default — `ValueGeneratedOnAdd` maps to +`NEWSEQUENTIALID()` through the default value-generation strategy. + +Trade-offs: + +- The value does not exist until the row is inserted. EF Core retrieves it, but you cannot build an + object graph around the key beforehand. +- Values are **not** unpredictable: the sequence restarts on service restart but is otherwise + guessable. Never use one as a security token or capability URL. +- It reveals rough insert ordering, which can be an information-disclosure concern on a public + surface. + +### EF Core's `SequentialGuidValueGenerator` — client-generated + +EF Core ships a value generator that produces GUIDs ordered for SQL Server's comparison semantics, +generated in the application. + +```csharp +builder.Property(o => o.Id) + .HasValueGenerator() + .ValueGeneratedOnAdd(); +``` + +Use this when the identifier must exist before `SaveChanges`. It keeps most of the index locality +while letting the application own the value. + +### `Guid.NewGuid()` — random + +Correct, portable and unpredictable; poor index locality. + +Appropriate when write volume is low (the fragmentation never matters), when the key is +non-clustered, or when unpredictability is a requirement. Do not dismiss it by default — for most +applications it is fine, and "most applications" includes more than people expect. + +### `Guid.CreateVersion7()` — .NET 9+ + +Ordered in byte layout, but **not** ordered under SQL Server's comparison. On SQL Server it behaves +roughly like a random GUID for clustered-index purposes. It is the right answer on PostgreSQL and the +wrong answer here, which is the trap this page exists to prevent. + +Use it on SQL Server only when you want the embedded timestamp for application reasons and accept +random-like insert locality. + +## Decoupling the key from the index + +Often the best answer is not a different GUID but a different index. Keep the GUID as the primary key +and make the clustered index something naturally ascending: + +```csharp +builder.HasKey(o => o.Id).IsClustered(false); +builder.HasIndex(o => o.CreatedAt).IsClustered(); +``` + +This removes the page-split problem entirely and leaves the key unpredictable. Consider it before +reaching for a sequential generator. + +## Choosing + +| Situation | Use | +| -- | -- | +| High insert volume, key may be database-generated | `NEWSEQUENTIALID()` | +| High insert volume, identifier needed before persisting | EF Core `SequentialGuidValueGenerator` | +| Key must be unpredictable (public surface, security boundary) | `Guid.NewGuid()`, with a non-clustered PK | +| Low or moderate volume | `Guid.NewGuid()` — do not optimise what does not hurt | +| You want a sortable timestamp in the key and accept the locality cost | `Guid.CreateVersion7()` | + +## Measure before optimising + +Index fragmentation from random GUIDs is real but frequently irrelevant. Before changing a key +strategy, confirm it matters: check page splits, fragmentation and buffer-pool pressure on the actual +table at the actual volume. Changing a primary key strategy on a live table is expensive; do it +against evidence. diff --git a/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md b/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md new file mode 100644 index 0000000..ee363c9 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md @@ -0,0 +1,334 @@ +--- +name: purview-sourcegenerator-codewriter +description: >- + Use when implementing, reviewing or refactoring C# source generators and analysers built on + Purview.SourceGeneratorFramework. Covers CodeWriter and XmlCommentWriter emission, scope safety, + incremental pipeline design, value equality, deterministic output, and migrating a legacy + string/StringBuilder emitter to CodeWriter. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Source generator CodeWriter modernization + +Use this skill for any work involving C# source generators, analysers, or generated output in `Purview.SourceGeneratorFramework`. It combines CodeWriter/XmlCommentWriter emission guidance with the incremental-source-generator and analyser best practices that ship with the framework. + +Refactoring an existing emitter off `string`/`StringBuilder`? The step-by-step playbook is in +[references/legacy-emitter-migration.md](references/legacy-emitter-migration.md). + +## Required implementation pattern + +When creating source output, favor this shape: + +1. Build immutable, value-equatable pipeline values first. +2. Register source output. +3. Inside the callback: `var writer = generationContext.CreateCodeWriter();` +4. Write header/usings/namespace. +5. Write structured types and members using declaration options records. +6. Add source once per output artifact. + +### CodeWriter lifetime: created in the output callback, never in pipeline state + +The line between "fine" and "forbidden" is the **incremental-cache boundary**, not the act of +creating a writer or handing it to a helper. + +**Fine — output-scoped use inside a `RegisterSourceOutput` callback.** Create the writer inside the +callback and pass it to any emitter/helper methods called from that same callback. It may be held in +local variables, passed as a parameter, or wrapped in a short-lived output context: + +```csharp +context.RegisterSourceOutput( + targets.CombineWithContext(contextProvider), + static (spc, pair) => + { + var (model, generationContext) = pair; + var writer = generationContext.CreateCodeWriter(); + EmitHeader(writer, model); + EmitType(writer, model); + spc.AddSource($"{model.Name}.g.cs", writer.ToString()); + } +); +``` + +**Forbidden — persisting the writer across the incremental-cache boundary.** Do not create it earlier +in the pipeline and pass it down, and do not store it anywhere Roslyn caches or another callback can +observe it: + +- Do not create a `CodeWriter` in a provider stage and carry it through the pipeline. +- Do not store a `CodeWriter` as a property or field on `GenerationContext` or a custom context. +- Do not return a `CodeWriter` (or an object holding one) from an incremental provider. +- Do not cache or reuse a writer for a later callback or a different output. + +A cached writer can retain previously written source, mix output from concurrently running +callbacks, and defeat scope tracking. A writer created in the callback and used only within that +callback is safe and expected. + +## Source generator & analyser best practices + +Apply the following rules to every generator, analyser, and refactor. + +### 1. Core principles + +- **Analyser for validation; generator for generation.** +- **Syntax for syntax, symbols for declarations, operations for executable semantics.** +- **Use `ForAttributeWithMetadataName` for attribute-driven generators.** +- **Remove Roslyn objects from the incremental pipeline as early as possible.** +- **Every value crossing a pipeline boundary must have meaningful value equality.** +- **Prefer many small incremental stages over one large transform.** +- **Keep broad inputs such as `Compilation` away from downstream generation.** +- **Generate deterministic output.** +- **Compile against the oldest Roslyn API version containing the functionality you need.** +- **Test caching, not just generated text.** + +The guiding principle for an incremental generator is: + +> Extract semantic information once, convert it into a small value model, and make everything downstream operate only on that value model. + +### 2. Analyser vs source generator + +Use a `DiagnosticAnalyser` when the question is: + +> Is the source code valid according to this library's rules? + +Use an `IIncrementalGenerator` when the question is: + +> Given valid source code, what source should be generated? + +| Requirement | Prefer | +| --- | --- | +| Require a class to be `partial` | Analyser | +| Require an attribute on a declaration | Analyser | +| Validate a method signature | Analyser | +| Reject unsupported property types | Analyser | +| Detect invalid attribute arguments | Analyser | +| Detect unsupported API usage | Analyser | +| Offer an automatic fix | Analyser + `CodeFixProvider` | +| Generate members for a marked class | Incremental generator | +| Generate serializers/validators/mappers | Incremental generator | +| Generate a registry from discovered types | Incremental generator | +| Read a schema file and generate C# | Incremental generator | +| Internal generation failure | Generator diagnostic | + +### 3. Choosing an analyser action + +Use the narrowest API that represents the concept being analysed: + +- `RegisterSyntaxNodeAction` — exact source syntax (e.g., modifier presence). +- `RegisterSymbolAction` — declaration semantics (e.g., attributes, interfaces, accessibility). +- `RegisterOperationAction` — executable behaviour (e.g., invocation, assignment, object creation). +- `RegisterOperationBlockStart/EndAction` — stateful method analysis. +- `RegisterSymbolStart/EndAction` — type-wide analysis across members. +- `RegisterCompilationStartAction` — resolve known framework symbols once. +- `RegisterAdditionalFileAction` — analyse `AdditionalFiles`. +- Avoid `RegisterSyntaxTreeAction`, `RegisterSemanticModelAction`, and compilation-end actions unless genuinely necessary. + +### 4. Syntax vs symbol vs operation + +Decision tree: + +1. Does exact source spelling/structure matter? → **Syntax** +2. Otherwise, is it a declaration? → **Symbol** +3. Otherwise, is it executable behaviour? → **Operation** + +Use `SymbolEqualityComparer.Default.Equals(...)` when comparing symbols. + +### 5. Analyser best practices + +- Enable concurrent execution with `context.EnableConcurrentExecution()`. +- Explicitly configure generated-code analysis with `context.ConfigureGeneratedCodeAnalysis(...)`. +- Resolve known framework/library symbols once in a `RegisterCompilationStartAction`. +- Prefer narrow registrations over scanning entire syntax trees or compilations. +- Treat diagnostic IDs as public contracts and maintain release tracking files when publishing public diagnostics. + +### 6. Incremental generator golden rules + +Implement `IIncrementalGenerator`. Simply implementing it is not enough; the pipeline must be incremental. + +> **Pipeline values must be immutable and value-equatable.** + +Never keep these in persistent pipeline models: + +| Type | Verdict | +| --- | --- | +| `ISymbol` / `INamedTypeSymbol` / `IMethodSymbol` / `IPropertySymbol` | Never retain | +| `Compilation` | Do not propagate | +| `SemanticModel` | Do not propagate | +| `IOperation` | Do not propagate | +| `SyntaxTree` | Do not propagate | +| `SyntaxNode` | Remove ASAP | +| `Location` | Remove ASAP | +| `AdditionalText` | Project immediately | +| `T[]` / `List` | Avoid | +| `ImmutableArray` | Wrap with sequence equality | + +Use immutable records and `EquatableArray` (sequence equality) for collection members. + +### 7. Designing the pipeline + +Pipeline shape: + +```text +Roslyn Input + ↓ +Cheap discovery + ↓ +Semantic extraction + ↓ +Small equatable model + ↓ +Validation/transformation + ↓ +Generation model + ↓ +Source output +``` + +Guidelines: + +- Project the semantic transform as the boundary where Roslyn objects disappear. +- Prefer `static` callbacks to avoid capturing generator state. +- Honour cancellation tokens. +- Split transformations into many small stages. +- Keep syntax predicates cheap. +- Avoid indirect discovery (every interface implementation, every subclass, entire-compilation scans). + +### 8. Syntax discovery + +- Prefer `context.SyntaxProvider.ForAttributeWithMetadataName(...)` for attribute-driven generators. +- Use `context.SyntaxProvider.CreateSyntaxProvider(...)` only when syntax itself is the trigger and there is no marker attribute. +- The predicate must be cheap; do not walk the tree or do semantic work in it. + +### 9. `Collect`, `Combine`, and invalidation + +- `Collect()` turns per-item outputs into one aggregate. Changing any item invalidates the aggregate. +- Use `Collect()` only for genuinely global output: registries, lookups, duplicate detection, aggregate switches. +- Prefer per-item `RegisterSourceOutput`. +- `Combine()` is correct when the output depends on two providers. +- Avoid `models.Combine(context.CompilationProvider)` — project the compilation to a tiny capability fact first. +- Use `.WithComparer(...)` only when logical equality differs from the default. + +### 10. Diagnostics + +- Prefer a separate `DiagnosticAnalyser` for normal user validation. +- Use generator diagnostics only for malformed additional files, generator-only configuration, conflicting output, or failures that cannot be expressed by an analyser. +- Report diagnostics on the most useful user-authored `Location`. +- Do not keep `Location` in long-lived pipeline models. + +### 11. Output generation + +- Output must be deterministic: no timestamps, random GUIDs, process IDs, machine paths, culture-dependent output, or unordered dictionary output. +- Hint names must be deterministic, unique, and stable. +- Prefer text generation or `CodeWriter` over building Roslyn syntax trees just to stringify them. +- Use `RegisterPostInitializationOutput` for constant source such as marker attributes. +- Add generated source once per output artifact. + +### 12. Testing incrementally + +- Snapshot-testing generated source is not enough. +- Test first execution, cached second execution, unrelated changes remaining cached, per-target invalidation, deletion, renaming, global options, additional files, and global registry invalidation. +- Use `GeneratorDriverOptions` with `trackIncrementalGeneratorSteps: true` and inspect reasons: `New`, `Modified`, `Unchanged`, `Cached`, `Removed`. + +### 13. Roslyn version compatibility and packaging + +- The `Microsoft.CodeAnalysis.*` version used to compile the analyser/generator sets the minimum compiler-host requirement. +- The consumer's `TargetFramework` does not determine analyser compatibility. +- Choose the oldest Roslyn version that contains the APIs you need. +- Common baselines: Roslyn 4.8 for VS 17.8 / .NET 8, 4.12 for VS 17.12 / .NET 9, 5.0 for VS 2026 18.0 / .NET 10. +- Ship one `netstandard2.0` analyser/generator binary unless you have a deliberate multi-version strategy. +- Do not mistake multi-targeting for automatic analyser asset selection. +- Use `PrivateAssets="all"` for Roslyn development dependencies. +- Enable `EnforceExtendedAnalyzerRules` and investigate `RSxxxx` diagnostics before suppressing them. + +### 14. Recommended project configuration + +A generator project should normally include: + +```xml + + netstandard2.0 + latest + enable + true + false + true + true + + + + + + + + + + +``` + +## Preferred API map for CodeWriter + +### File and namespace + +- `AutoGeneratedHeader(...)` +- `Using(...)` +- `FileScopedNamespace(...)` or `BlockNamespace(...)` +- `OpenPragmasScope(...)` for warning suppression scopes + +### Types and members + +- Types: `Class`, `Struct`, `RecordClass`, `RecordStruct`, `Interface`, `Enum`, `Delegate` +- Members: `Method`, `MethodScope`, `Property`, `Field`, `Constructor` +- Attributes: `AttributeDeclarationOptions`, `AttributeArgumentOptions` +- Type syntax: `TypeReference` (nullable/generic/array/pointer-safe composition) + +### XML documentation + +Use `XmlCommentWriter` extension methods on `CodeWriter`: + +- `XmlSummary(...)`, `XmlParam(...)`, `XmlTypeParam(...)`, `XmlReturn(...)`, `XmlRemarks(...)`, `XmlExample(...)` +- `XmlCode(...)` / `XmlCodeBlock(...)` +- `XmlList(...)`, `XmlSeeAlso(...)`, `XmlException(...)` + +Static helpers: `CodeWriter.XmlInlineCode(...)`, `CodeWriter.XmlSee(...)`, `CodeWriter.XmlParamRef(...)`, `CodeWriter.XmlText(...)`. + +## Refactoring guide: string/StringBuilder -> CodeWriter + +Apply this checklist in order: + +1. **Move emission boundaries** — replace giant string assembly with phases: header, namespace, type, members. +2. **Replace manual braces/indentation** — use `using` scopes (`ClassScope`, `MethodScope`, `OpenBlockScope`, `IndentedScope`). +3. **Replace handwritten signatures** — use declaration option records. +4. **Replace raw XML lines** — use XML extension methods (`XmlSummary`, `XmlParam`, etc.). +5. **Normalize type strings** — use `TypeReference`. +6. **Preserve semantics and ordering** — generated members and diagnostics must remain equivalent. +7. **Validate scope safety** — keep or enable `PurviewSourceGeneratorFrameworkValidateCodeWriterScopes` for tests/dev. + +## Anti-patterns to remove during refactors + +- `StringBuilder.AppendLine("public class ...")` for declarations that can be structured. +- Manually writing `{` / `}` around methods and types where scope APIs exist. +- Hard-coded nullable type suffixes and generic syntax in arbitrary strings when `TypeReference` is available. +- Raw XML tag string composition when XML extension methods can enforce consistency. +- Creating a `CodeWriter` before the output callback and passing it through the pipeline. +- Storing a `CodeWriter` on `GenerationContext`, a custom context, or any incremental pipeline model. +- Sharing one `CodeWriter` across multiple generated outputs or callbacks. +- Keeping Roslyn objects, `CodeWriter`, or mutable state in incremental pipeline models. + +## Review checklist for pull requests + +- Generated declarations use structured APIs for types and members. +- XML docs use XML extension methods rather than raw `///` fragments. +- `CodeWriter` is created inside each output callback and never persists in pipeline state; passing it + to helper methods within that callback is expected. +- Header and generated attributes are deterministic and consistent. +- Existing diagnostics, generated member names, and public behavior are preserved. +- Roslyn objects are removed from pipeline models; `EquatableArray` is used for collections. +- Per-target output is preferred over collected global output unless global knowledge is required. +- `CompilationProvider` is not casually combined into output. +- Deterministic hint names and source text are used. +- Incremental caching behavior is tested, not just generated text. + +## See also + +- `agents/source-generator-framework-writer.agent.md` — specialist agent for `Purview.SourceGeneratorFramework` emitter authoring. +- `prompts/refactor-source-generator-to-codewriter.prompt.md` — prompt template for legacy-emitter refactor tasks. diff --git a/.agents/skills/purview-sourcegenerator-codewriter/references/legacy-emitter-migration.md b/.agents/skills/purview-sourcegenerator-codewriter/references/legacy-emitter-migration.md new file mode 100644 index 0000000..54efd09 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-codewriter/references/legacy-emitter-migration.md @@ -0,0 +1,58 @@ +# Migrating a legacy emitter to CodeWriter + +A playbook for moving a source generator's emission from manual `string` / `StringBuilder` +construction to `CodeWriter` plus the `XmlCommentWriter` documentation extensions, with behaviour +parity. + +## Before you start, collect + +- The emitter file(s) being changed. +- The generator type name, and the version string it stamps into generated attributes or headers. +- Whether the output must stay **byte-identical**. If it must, every whitespace decision below is a + constraint rather than a preference, and the diff of the generated snapshots is the acceptance + test. + +## Requirements + +1. Use the structured declaration APIs rather than hand-written declaration text: + `Class` / `Struct` / `RecordClass` / `Interface` / `Enum`, and `Method`, `Property`, `Field`, + `Constructor`. +2. Use the XML helper extensions instead of composing `///` lines: `XmlSummary`, `XmlParam`, + `XmlReturn`, `XmlRemarks`, `XmlCode` / `XmlCodeBlock`. +3. Use `TypeReference` once the type text gets complex — nullability, generics, arrays. +4. Keep the writer **output-scoped**: `generationContext.CreateCodeWriter()` *inside* the output + callback. Never create it earlier in the pipeline, and never store it in incremental provider + state or in a custom context. +5. Preserve behaviour, diagnostics and generated names. +6. Keep the change minimal. Do not reformat logic the migration does not touch. + +## Strategy + +Work phase by phase, because each phase has a different mechanical replacement: + +| Emitter phase | Replace with | +| --- | --- | +| header | the header helper, not a literal banner string | +| namespace | the namespace scope API | +| type declarations | declaration option records, not signature strings | +| member declarations | `Method` / `Property` / `Field` / `Constructor` | +| indentation and braces | scoped APIs — never manual `{`/`}` and `\t` | +| `///` comment composition | `XmlSummary`, `XmlParam`, `XmlReturn`, `XmlRemarks`, `XmlCode` | + +Call out any intentional semantic delta explicitly; a migration that quietly changes emitted names or +diagnostics is the failure mode this playbook exists to avoid. + +## Verification + +- Run the impacted tests. +- Confirm the generated files still compile. +- Confirm there are **no scope leaks**: `OpenScopeCount == 0` when the writer is materialized. Enable + CodeWriter scope validation in the test run. +- Where byte-identical output was required, diff the generated snapshots. + +## Report + +1. Files changed. +2. Why each change was necessary. +3. Risks and behaviour differences, if any. +4. The verification that actually ran. diff --git a/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md b/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md new file mode 100644 index 0000000..21d9522 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md @@ -0,0 +1,142 @@ +--- +name: purview-sourcegenerator-diagnostics +description: >- + Use when a Purview.SourceGeneratorFramework build reports a PSGFR, PSGF, PRSGD or TLB diagnostic, + when choosing which code fix applies, or when a source generator, diagnostic analyzer or code fix + provider is silently not running at all. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Purview.SourceGeneratorFramework diagnostics + +`Purview.SourceGeneratorFramework` ships `Purview.SourceGeneratorFramework.Analyzers` and +`Purview.SourceGeneratorFramework.CodeFixers` as analyzer assets inside the package, under +`analyzers/dotnet/cs/`. They are enabled automatically in any project that references the framework — +there is no separate package to add. + +Two compiler-analyzer families, plus a set of MSBuild-time diagnostics: + +- **Incremental generator best practice** — `PSGFR11`–`PSGFR33`: pipeline design, `CodeWriter` usage, + Roslyn component discovery, extension-class conventions. +- **C# 14 extension-member conventions** — `PSGFR34`–`PSGFR38`, with + `ReorganizeExtensionClassCodeFixProvider` and `ConvertToExtensionBlockCodeFixProvider`. +- **Merge and packaging** — `PSGFR39`–`PSGFR43`, where `PSGFR43` is the merge tool's blocked + public-surface code. + +## Start here when nothing runs + +A compiler host loads a generator, analyzer or code fix provider only when **three** conditions hold. +Missing any one means the component is **silently ignored** — no error, no output, a green build: + +1. **The type is public** (`PSGFR27`). +2. **The type is decorated** — `[Generator]` (`PSGFR26`), `[DiagnosticAnalyzer]` (`PSGFR25`), or + `[ExportCodeFixProvider]` (`PSGFR24`). +3. **The assembly is loaded as an analyzer** — packed under `analyzers/dotnet/cs/` in a package, or + referenced with `OutputItemType="Analyzer"` on a `ProjectReference`. + +A code fix provider additionally appears only when a diagnostic ID in its `FixableDiagnosticIds` is +actually produced by an analyzer loaded alongside it (`PSGFR28`). Visual Studio MEF-composes fix +providers when the analyzer set loads, so after adding or updating a fixer assembly you must restart +Visual Studio or reload the project before the fixes show up. + +## Build-time validation diagnostics + +These are MSBuild diagnostics, not compiler analyzers, so they are **not** tracked in +`AnalyzerReleases.*.md`: + +| Code | Raised by | Summary | +| --- | --- | --- | +| `PSGF0001` | `Purview.BuildSdk` | A Roslyn component did not produce (or declare) a source-generator analyzer file. | +| `PSGF0003` | `Purview.SourceGeneratorFramework` | A `PurviewGeneratorVisibleProperty` is not compiler-visible in the declaring project or its `Sdk/build`/`Sdk/buildTransitive` assets, so consumers cannot read `build_property.`. | +| `PRSGD0005` | `Purview.BuildSdk` | A file in the returned analyzer closure references an assembly that is neither in the closure nor a compiler-host assembly. | +| `PSGFR41` | merge tool | A component's public surface exposes framework types the merge internalizes. Reported by the analyzer at design time, and by the merge pass as `message` (default), `warning` or `error`. | +| `PSGFR43` | merge tool | The merged analyzer still exposes a public framework type, so it is not self-contained. Always an error; the merge fails with exit code 5. | + +Opt out with `PurviewSourceGeneratorFrameworkAnalyzerValidation=false` (`PRSGD0005`) or +`PurviewSourceGeneratorFrameworkGeneratorPropertyValidation=false` (`PSGF0003`). + +## Incremental pipeline rules + +| Rule | Summary | +| --- | --- | +| `PSGFR11` | Prefer `SyntaxProvider.ForAttributeWithMetadataName` over `CreateSyntaxProvider` for attribute-based detection. | +| `PSGFR12` | Use `IIncrementalGenerator` / `RegisterSourceOutput` instead of `ISourceGenerator`. | +| `PSGFR14` | Avoid `RegisterImplementationSourceOutput` unless implementation-only output is required. | +| `PSGFR15` | Pipeline model collection members should use sequence equality (`EquatableArray`). | +| `PSGFR30` | Prefer `static` lambdas in pipeline methods, so the compiler never allocates a closure on the per-item hot path. | +| `PSGFR31` | Prefer `GeneratorAttributeSyntaxContext.TargetSymbol` over `SemanticModel.GetDeclaredSymbol(ctx.TargetNode)`. | +| `PSGFR33` | Pipeline models must not retain Roslyn objects (`ISymbol`, `SyntaxNode`, `Location`, …); extract the information into value types. | + +## CodeWriter rules + +| Rule | Summary | +| --- | --- | +| `PSGFR16` | Prefer the nullable-context `Nullable()`/`MakeNullable()` overload, so annotations honour the target compilation. | +| `PSGFR17` | Consume scope-returning methods (`…Scope`, `IndentedScope`) with `using`. | +| `PSGFR18` | Prefer structured declaration APIs (`Class`, `Method`, `Property`, `Field`) over raw declaration text. | +| `PSGFR19` | Prefer structured statement APIs (`Return`, `MethodCall`, `Throw`, `Assignment`, `Using`, `Comment`) over raw statement text. | +| `PSGFR20` | Prefer the minimal `CodeWriter` overloads over hand-constructing `*DeclarationOptions` values. | +| `PSGFR21` | Prefer `HashDefines`/`HashDefinesScope` for `#if`/`#endif`. | +| `PSGFR22` | Prefer `PragmaDisable`/`OpenPragmasScope` for `#pragma warning`. | +| `PSGFR23` | Prefer structured `IfBlock`/`ElseIf`/`Else` over raw `if` block text. | +| `PSGFR29` | Do not embed a `CodeWriter` in a string; use `XmlCommentWriter.XmlInlineCode`. | +| `PSGFR32` | Avoid `NormalizeWhitespace` when generating source; use an indented text writer such as `CodeWriter`. | + +## Component, extension and merge rules + +| Rule | Summary | +| --- | --- | +| `PSGFR24` | `CodeFixProvider` is not marked `[ExportCodeFixProvider]`; Visual Studio will never discover it. | +| `PSGFR25` | `DiagnosticAnalyzer` is not marked `[DiagnosticAnalyzer]`; it will never run. | +| `PSGFR26` | A generator type is not marked `[Generator]`; it will never run. | +| `PSGFR27` | A Roslyn component type is not public; the compiler host cannot instantiate it. | +| `PSGFR28` | `FixableDiagnosticIds` names a diagnostic no analyzer in the compilation produces; the fix will never be shown. | +| `PSGFR34` | Prefer C# 14 `extension(Receiver)` blocks over classic static `this`-parameter extension methods. | +| `PSGFR35` | Extension class name must match the extended type (`{Receiver}Extensions`). | +| `PSGFR36` | Extension classes belong in the extended type's namespace, under an `Extensions` folder. | +| `PSGFR37` | One extension class per receiver type; split a class that extends several. | +| `PSGFR38` | Extension classes should carry `[EditorBrowsable(EditorBrowsableState.Never)]`. | +| `PSGFR39` | A non-packable Roslyn component opted out of the default self-contained analyzer output (`PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles=false`) while embedding the framework; otherwise the package embeds the loose framework DLL under `analyzers/`. | +| `PSGFR40` | In a Roslyn component (`IsRoslynComponent=true`), reference SGF types as inline code (`Type`) rather than a `cref`: copied documentation must not depend on cref resolution. | +| `PSGFR41` | A public member (or generic constraint) whose signature references an SGF type in a component whose framework implementation is merged. The merge internalizes every SGF type, so the signature ends up referring to an internal type. Make the member, or its declaring type, non-public. | +| `PSGFR42` | A disposable Testing-harness result (`DriverRunResult`) is produced and never disposed, leaking the collectible `AssemblyLoadContext` and `MetadataLoadContext` of a compiled run. `warning` when the run syntactically compiles to an assembly (a `Compile` options member, or `CompileToAssembly = true`), otherwise `info`. Awaiting an assertion, or calling a helper handed the result — an extension on it, or a method taking it as a parameter — is not a production: both hand the same instance back, so only a task-like await or a genuine producer is reported. | + +## Type-library and attribute-model diagnostics + +The bundled generators carry their own families: `TypeLibraryValidationAnalyzer` +(`TLB0001`–`TLB0019`, plus `TLB0021`) and the attribute-data-model validation analyzers. They are +documented on their own wiki pages, `Type-Library.md` and `Attribute-Data-Models.md`. + +## Code fixes + +Most rules above have a fix in `Purview.SourceGeneratorFramework.CodeFixers`: + +| Fix provider | Rule | +| --- | --- | +| `AddGeneratorAttributeCodeFixProvider` | `PSGFR26` | +| `AddDiagnosticAnalyzerAttributeCodeFixProvider` | `PSGFR25` | +| `AddExportCodeFixProviderAttributeCodeFixProvider` | `PSGFR24` | +| `MakeRoslynComponentPublicCodeFixProvider` | `PSGFR27` | +| `RemoveOrphanedFixableDiagnosticIdCodeFixProvider` | `PSGFR28` | +| `PreferTargetSymbolCodeFixProvider` | `PSGFR31` | +| `PreferStaticLambdaCodeFixProvider` | `PSGFR30` | +| `PreferNullableContextOverloadCodeFixProvider` (project-wide Fix All) | `PSGFR16` | +| `PipelineModelReferenceEqualityCollectionCodeFixProvider` | `PSGFR15` | +| `PreferStructuredCodeWriterIfBlockCodeFixProvider` | `PSGFR23` | +| `CodeWriterToStringCodeFixProvider` | `PSGFR29` | +| `PreferInlineCodeForFrameworkCrefCodeFixProvider` | `PSGFR40` | +| `MakeComponentSurfaceNonPublicCodeFixProvider` | `PSGFR41` | +| `DisposeTestResultCodeFixProvider` | `PSGFR42` | +| `ReorganizeExtensionClassCodeFixProvider` (renames, splits, moves, updates referencing files) | `PSGFR35`, `PSGFR36`, `PSGFR37` | +| `ConvertToExtensionBlockCodeFixProvider` | `PSGFR34` | +| `AddExtensionClassMetadataCodeFixProvider` | `PSGFR38` | +| `MakeTypeLibrarySpecNonPublicCodeFixProvider` | `TLB0021` | +| `AttributeDataModelSymbolPropertyCodeFixProvider` | attribute-data-model symbol properties | +| `TypeLibraryMemberAccessibilityCodeFixProvider`, `TypeLibraryMarkerDefaultInitializerCodeFixProvider`, `MakeTypeLibrarySpecPartialCodeFixProvider`, `RenameTypeLibrarySpecCodeFixProvider`, `TypeLibraryMemberTypeCodeFixProvider` | type-library rules | + +## Related skills + +`purview-sourcegenerator-codewriter` for the emission patterns the `CodeWriter` rules push you +towards, and `purview-sourcegenerator-testing` for proving a pipeline actually caches. diff --git a/.agents/skills/purview-sourcegenerator-packaging/SKILL.md b/.agents/skills/purview-sourcegenerator-packaging/SKILL.md new file mode 100644 index 0000000..92350db --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-packaging/SKILL.md @@ -0,0 +1,245 @@ +--- +name: purview-sourcegenerator-packaging +description: >- + Use when packaging or referencing a source generator built on Purview.SourceGeneratorFramework - + the self-contained merged analyzer, GetSourceGeneratorAnalyzerFiles, embedding a generator in + another package, PurviewGeneratorVisibleProperty, analyzer-closure validation, Roslyn version + compatibility, and errors such as CS0433, CS8784 or MSB3073 from a loaded analyzer. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Packaging a source generator + +The rule everything else follows from: **a shipped Roslyn component carries its own copy of the +framework and references no loose `Purview.SourceGeneratorFramework.dll`.** Two generators built +against different framework versions must be able to load into one compiler host. A shared DLL under +`analyzers/` cannot give you that — whichever version loads first wins, and the other fails with +`MissingMethodException` or `CS0433`. + +The framework delivers this by IL-merging and internalizing its implementation into each component's +**intermediate** output, leaving the component's `bin` unmerged. + +## Referencing the framework from a component + +```xml + + true + + + + + +``` + +And from a consuming project, as an analyzer: + +```xml + +``` + +`Purview.BuildSdk` invokes `GetSourceGeneratorAnalyzerFiles` automatically — naming +`Targets="GetSourceGeneratorAnalyzerFiles"` explicitly still works but is not required. + +## The three consumption paths + +| Path | Trigger | What you get | +| --- | --- | --- | +| `GetSourceGeneratorAnalyzerFiles` (default) | a consuming project references the component as an analyzer | the **merged** component, from the content-addressed `purview-merged//`. `bin` stays unmerged, so an in-process test harness keeps shared framework type identity and `InternalsVisibleTo` access without `CS0433`. | +| `GetSourceGeneratorAnalyzerFiles` with `PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles=false` | opt-out | unmerged component **plus** the loose framework DLL from the package `lib/`. | +| `GetPurviewMergedAnalyzerFile` | the framework's own bundled-component pack, or a third-party package embedding the generator | the merged component; `bin` is never overwritten. | +| `GetPurviewMergedAnalyzerFileForPack` (pack-time, via `TargetsForTfmSpecificContentInPackage`) | packing a standalone packable generator | the merged DLL and PDB contributed straight to `analyzers/dotnet/cs`. `bin` is never mutated, so its shape does not depend on whether `build` or `pack` ran last. | + +Two properties exist and both should normally be left alone: + +- `PurviewEmbedSourceGeneratorFramework=false` — only for a project that *is* the framework + compile-time library. A published generator package must never disable embedding. +- `PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles=false` — only when the analyzer-files + consumer must keep the unmerged + loose-DLL shape (a generator shipped into one compiler process + alongside an incompatible framework version). + +`PSGFR39` errors when a **non-packable** component opts out while embedding the framework, because +that shape, packed through the GASF path, forces the loose DLL back under `analyzers/`. + +## A component that references another component + +A code-fix or analyzer component can reference the generator component normally (`ProjectReference`, +`ReferenceOutputAssembly` **not** `false`) when it needs the generator's internal diagnostic +identity. Both are `IsRoslynComponent`; only the generator references the framework. + +- The referenced component is returned as **its own merged assembly** and is never IL-merged into the + dependent one — that would duplicate its types, including `InternalsVisibleTo`-visible internals, + inside a second analyzer in the same host. +- The framework assembly is declared as copy-to-output content beside the generator's `bin`, and + content with `CopyToOutputDirectory` flows transitively through `ProjectReference`. That is what + lets Visual Studio load the dependent code-fix provider from its own `bin` without a + `FileNotFoundException` for the framework assembly. +- Visual Studio's project system resolves an `OutputItemType=Analyzer` project reference to the + referenced project's **unmerged** `bin` assembly and adds it to the compiler's analyzers — the + command-line build does not. `Purview.BuildSdk` removes that item before adding the resolved + closure, so Roslyn only ever receives the merged artifact. Without that removal the unmerged copy + drags `Purview.SourceGeneratorFramework` into the compiler host + (`CS8784 FileNotFoundException: Could not load file or assembly 'Purview.SourceGeneratorFramework'`) + and every generator registers twice. + +## Analyzer-closure validation + +`Purview.BuildSdk` validates the whole closure `GetSourceGeneratorAnalyzerFiles` returns: every +file's PE `AssemblyRef` must resolve to another file in the returned set, or to a compiler-host +assembly (`Microsoft.CodeAnalysis*`, `System.Composition.*`, `System.*`, `netstandard`, …). A missing +component artifact fails the build with `PRSGD0005`. + +Extend the permitted set with `` or the +`PurviewAnalyzerClosurePermittedReferences` property. Opt out with +`PurviewSourceGeneratorFrameworkAnalyzerValidation=false`. + +## Generator-read MSBuild properties + +A generator reading `build_property.` is only correct if `` is a +`CompilerVisibleProperty` **wherever the generator runs** — including in a consumer's project. +Declare what the component reads: + +```xml + + + +``` + +`PSGF0003` fails the build when a declared property is neither a `CompilerVisibleProperty` in the +project nor declared by the project's own `Sdk/build` or `Sdk/buildTransitive` assets — which is what +consumers actually receive. Opt out with +`PurviewSourceGeneratorFrameworkGeneratorPropertyValidation=false`. + +NuGet imports `buildTransitive` assets for **PackageReference** consumers only. Inside the package +repository every project uses `ProjectReference`, so `Purview.BuildSdk`'s +`ImportProjectReferencedBuildTransitiveAssets` registers the referenced project's +`Sdk/buildTransitive/*.props|*.targets` `CompilerVisibleProperty` items for in-repo consumers. Opt +out with `PurviewImportProjectReferenceBuildTransitive=false`. + +## Embedding a generator in another package + +Pack the **merged** assembly, so the outer package ships no loose framework DLL: + +```xml + + false + $(TargetsForTfmSpecificContentInPackage);PackMyGenerator + + + + + + + + + analyzers/dotnet/cs/ + + + +``` + +If the generator is also embedded at compile time for the outer package's consumers, the outer +package must make the framework's compiler-visible properties visible to them — build assets from +`Purview.SourceGeneratorFramework` are **not** copied into the outer package. Ship a `.props` that +declares the property and its `CompilerVisibleProperty` entry, packed under the outer package's own +ID so NuGet imports it: + +```xml + +``` + +## `IsExternalInit` + +The framework assembly defines `System.Runtime.CompilerServices.IsExternalInit` **publicly**, so the +bundled generators can emit `init`-based attribute types into any consumer compilation and the merge +has a single marker definition to internalize. + +**Do not declare your own.** A generator-local marker gives calls to the framework's `init` setters a +different required-custom-modifier identity from the setter definitions. The merge tool normalizes +those modifiers in a temporary copy for compatibility with legacy tooling, but removing the redundant +marker is the preferred configuration. + +## Roslyn version compatibility + +> The version of `Microsoft.CodeAnalysis.*` you compile the component against sets a **minimum +> compiler-host API requirement**. The consumer's `` has nothing to do with it — +> analyzer code runs inside the compiler/IDE host. + +| Roslyn package | Minimum Visual Studio | Language / .NET generation | +| ---: | --- | --- | +| 4.8 | VS 2022 17.8 | C# 12 / .NET 8 | +| 4.12 | VS 2022 17.12 | C# 13 / .NET 9 | +| 5.0 | VS 2026 18.0 | C# 14 / .NET 10 | + +**This framework is built against Roslyn 5.0**, so a host loading it must be Roslyn 5.0 or later +(.NET 10 SDK / Visual Studio 2026 18.0). The testing packages multi-target `net8.0`–`net10.0`, and +Roslyn 5.x ships `net8.0`/`net9.0` assets, so those test targets still load the runner. + +## A generator project to start from + +```xml + + + netstandard2.0 + latest + enable + true + false + true + true + + + + + + + + + + + + +``` + +`Purview.BuildSdk` already defaults most of this for a project it classifies as a Roslyn component, +so in an SDK repository the project file is usually far shorter. The exact Roslyn baseline is a +product-support decision. + +## Release gates + +Acceptance criteria for self-contained packaging — check metadata, never "the sample compiled": + +1. **No framework reference, no public framework types.** Every `analyzers/dotnet/cs/*.dll` has no + assembly reference to `Purview.SourceGeneratorFramework` and exposes no public type in a + framework-owned namespace (including the generated `Generators` attribute set and the + framework-emitted `Microsoft.CodeAnalysis.EmbeddedAttribute`). The one exception is the + component's own Roslyn entry points, which must stay public — Roslyn only instantiates public + components (`PSGFR27`). +2. **No loose framework DLL in any package**, and no `*.pdb` in the `.nupkg` — symbols ship only + through the `.snupkg`. +3. **Merged entry points survive**: each merged DLL still exposes its `IIncrementalGenerator`, + `DiagnosticAnalyzer` or `CodeFixProvider`. +4. **Installed-package consumer test**: a generator project referencing the framework by + `PackageReference` packs one self-contained DLL under `analyzers/dotnet/cs`, and a consumer that + installs it builds with the generator producing output. +5. **Two-version coexistence test**: build Generator A against the current framework and Generator B + against an intentionally binary-incompatible version, install both into one consumer, and build + with both package-reference orders. Both must run against their own embedded copy. + +## Going deeper + +The merge pass, the content-addressed artifact, framework type internalization and the properties +that tune it are in [references/merge-internals.md](references/merge-internals.md) — read it when a +merge fails, when a type leaks, or when you hit `MSB3073` / `CS0433` / `CS8784` from a loaded +analyzer. diff --git a/.agents/skills/purview-sourcegenerator-packaging/references/merge-internals.md b/.agents/skills/purview-sourcegenerator-packaging/references/merge-internals.md new file mode 100644 index 0000000..5d8b6c9 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-packaging/references/merge-internals.md @@ -0,0 +1,139 @@ +# The merge pass and the self-contained analyzer + +## Where the merged artifact lives + +`_PurviewMergeSourceGeneratorFramework` only ever writes to the component's **intermediate** output. +`GetSourceGeneratorAnalyzerFiles` returns that result by substituting the merged path into +`TargetPathWithTargetPlatformMoniker` immediately before its body runs, leaving `GetTargetPath` — +which resolves assembly references — pointing at the unmerged `bin`. That split is what keeps the +in-repo test harness working while shipped assemblies stay self-contained. + +The artifact is **content-addressed**: + +```text +$(IntermediateOutputPath)purview-merged//$(TargetFileName) +``` + +`` is a SHA-256 over the component, the framework assembly and the merge tool (the tool +computes it in its `--tag` mode). The tag is in the **directory**, never the file name, because +ILRepack derives the merged assembly's simple name from the output file name and an analyzer must +keep `.dll`. + +Two properties follow, and both matter: + +- A rebuild with identical inputs reuses the existing file and **skips the merge**, so + `_PurviewMergeSourceGeneratorFramework` is idempotent across the per-consumer — and potentially + parallel — invocations of `GetSourceGeneratorAnalyzerFiles`. +- Changed inputs select a **new directory**, so the merge never overwrites an assembly a compiler + host (csc, `VBCSCompiler`, Visual Studio) already has loaded. Overwriting such a file fails on + Windows with a sharing violation, which surfaced as `MSB3073` (merge tool exit code + `-532462766`) and left consumers loading a stale or partially written analyzer — the root cause of + `CS8784 FileNotFoundException: Could not load file or assembly 'Purview.SourceGeneratorFramework'`. + +The tool merges into a per-process `.staging-` directory and publishes it with a +non-overwriting directory rename. A concurrent invocation that loses the race simply observes the +published artifact rather than failing. + +Packaging needs the stable `.dll` name, so the pack paths +(`GetPurviewMergedAnalyzerFile`, `GetPurviewMergedAnalyzerFileForPack`) copy the content-addressed +artifact into `$(IntermediateOutputPath)purview-pack/` and pack that plainly named staging copy. + +## Framework type internalization + +ILRepack's `Internalize` is best-effort, and two gaps matter: + +- a framework type that reaches the merged component's public API surface stays public; and +- the types the framework's own generators emit **into** the component — the + `Purview.SourceGeneratorFramework.Generators` attribute set, generated type libraries, marker + attributes — are not part of the merged framework assembly at all, so ILRepack never sees them. + +Either gap leaks framework types out of what must be a self-contained analyzer, and any project +loading that analyzer alongside the real framework assembly fails with `CS0433` ambiguity for every +leaked type. + +So the merge tool runs a deterministic internalization pass (`FrameworkTypeInternalizer`) after +ILRepack finishes: + +- Every type in a framework-owned namespace (`Purview.SourceGeneratorFramework` and its children, + including the generated `Generators` attribute set) becomes non-public. Ownership is evaluated + through the **declaring chain**, because Mono.Cecil reports an empty namespace for a nested type: + the framework's nested containers — generated type-library namespace classes, nested operator/enum + groups, `CodeWriter` scopes — are only reachable through their declaring type. +- Types the framework's generators emitted into the component, recognized by the tool name on their + `System.CodeDom.Compiler.GeneratedCodeAttribute` (`TypeLibraryGenerator`, + `AttributeDataModelGenerator`), become non-public too, so a generated type library can never leak a + `TypeIdentity`/`TypeReference` member as public API. +- `Microsoft.CodeAnalysis.EmbeddedAttribute`, the framework-emitted marker, becomes non-public. +- **Roslyn component entry points are never internalized.** A generator, analyzer, code fix provider + or refactoring provider reachable from the framework namespace stays public, because Roslyn only + instantiates public components (`PSGFR27`). This is what keeps the framework's own bundled + analyzers working after the pass. +- The pass reports the result. Leftover public framework types fail the merge with **exit code 5** + (`PSGFR43`). Public component members whose signature exposes a framework type are logged + (`PSGFR41`) so the author can make them, or their declaring type, non-public. Visibility is + followed through the declaring chain here too: a public nested type inside an internal type — for + example the compiler-synthesised `$`/`$` extension containers emitted for an extension class + — is not reachable from outside the component, so it is not part of the public surface and is not + reported. + +Ownership is seeded with the framework assembly's own type identities, so a framework type living +*outside* the framework namespace (`Microsoft.CodeAnalysis.*Extensions`, `System.StringExtensions`) +is internalized as well. The artifact's assembly-level `InternalsVisibleTo` / +`IgnoresAccessChecksTo` grants are removed, because a shipped analyzer is not the component's own +assembly. + +## Tuning the pass + +| Property | Default | Effect | +| --- | --- | --- | +| `PurviewMergeOwnedNamespaces` | empty | Extra namespace prefixes (semicolon-separated) treated as framework-owned. | +| `PurviewMergeOwnedTypeFullNames` | empty | Extra type full names (semicolon-separated) treated as framework-owned. | +| `PurviewMergePublicSurfaceSeverity` | `message` | How a finding is reported: `message` (plain log text), `warning`/`error` (MSBuild diagnostics with code `PSGFR41`), or `none`. | +| `PurviewMergePublicSurfaceValidation` | `true` | `false` sets the severity to `none`. | +| `PurviewMergePublicSurfaceOrigin` | the merged component assembly | The origin reported with an MSBuild finding; point it at a source or project path for IDE navigation. | + +All five participate in the content tag, so changing one re-runs the merge — and re-emits its +findings — rather than reusing a cached artifact. The compiler analyzer with the same id (`PSGFR41`) +reports the same condition while you edit, so the surface is usually fixed before the merge runs. + +## Generated accessibility: bin versus merged artifact + +The component's own generated types — the type library, the attribute data models — keep their +generated accessibility in the component's **bin output**. `TLB0015` requires a hand-written partial +to be `public static partial` so it can merge with the generated library, and in-repo consumers such +as code fixers and sibling assemblies compile against it. + +Only the **merged analyzer artifact** internalizes them. Self-containment is therefore enforced at +the merge boundary without rewriting the accessibility an in-repo consumer compiles against, and that +split is safe precisely because a merged artifact is never referenced as a compile-time dependency. + +> A merged component is an analyzer artifact. Never reference it as a compile-time dependency. Tests +> that need to run a *packaged* generator load it out of band. + +## How the framework package itself is assembled + +`Purview.SourceGeneratorFramework` is dual-role: the framework assembly ships in `lib/` so generator +projects compile against it, and `analyzers/` carries self-contained generator, analyzer and +code-fixer assemblies. The bundled projects are: + +- `SourceGeneratorFramework.Generators` — `AttributeDataModelGenerator`, `TypeLibraryGenerator`; +- `SourceGeneratorFramework.Analyzers` — the `PSGFR*` and `TLB*` analyzers; +- `SourceGeneratorFramework.CodeFixers` — the code fix providers. + +Each has the framework implementation merged and internalized into it. The package deliberately does +**not** put `Purview.SourceGeneratorFramework.dll` under `analyzers/dotnet/cs`, so generators built +against different framework versions never ask Roslyn to load competing versions of a same-named +dependency. + +The shared models and helpers that once shipped as a separate +`Purview.SourceGeneratorFramework.Shared.dll` are compiled directly into the framework assembly +(`SourceGeneratorShared.Link.targets` links the sources). Consumers get one +`Purview.SourceGeneratorFramework.dll`, which removes the version-skew hazard a separately bundled +Shared assembly caused — generator packages loading a different Shared version in-process failed with +binary-incompatibility errors, such as removed `PurviewTypeLibrary` fields. + +Those projects are `IsRoslynComponent = true` and are **not** packable on their own. The +`SourceGeneratorFramework` project builds them via `GetPurviewMergedAnalyzerFile` and packs them +under `analyzers/dotnet/cs/` in `BuildAndPackBundledAnalyzerAssemblies`. They were previously +consumed as analyzer project references, but they now reference the framework assembly for the shared +types, which would form a project-reference cycle. diff --git a/.agents/skills/purview-sourcegenerator-pipeline/SKILL.md b/.agents/skills/purview-sourcegenerator-pipeline/SKILL.md new file mode 100644 index 0000000..373942f --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-pipeline/SKILL.md @@ -0,0 +1,231 @@ +--- +name: purview-sourcegenerator-pipeline +description: >- + Use when composing an incremental pipeline with Purview.SourceGeneratorFramework - + IncrementalPipeline helpers, GenerationContext and custom contexts, CreateCodeWriter lifetime, + GeneratorResult and blocking versus non-blocking diagnostics, disabling a generator from MSBuild, + scope validation, test logging, and stage tracking names. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Incremental pipeline composition + +`IncrementalPipeline` composes `IncrementalValueProvider` and `IncrementalValuesProvider`: +attribute-based discovery, generation-context creation, disable-property checks, and thin +source-output registration. + +Everything here follows from one rule: **pipeline values must be immutable and value-equatable.** +Roslyn caches the complete value a provider publishes, and offers no way to exclude one property of +it from caching. + +## `GenerationContext` + +A base execution-services context carrying the Roslyn `Compilation`, immutable `GenerationSettings`, +an optional `ISourceGenLogger`, and a factory for independently owned `CodeWriter` instances. + +```csharp +var contextProvider = IncrementalPipeline.DefaultGenerationContextValueProvider(context); +``` + +### Custom contexts + +A custom context does not need to accept or read build properties itself: + +```csharp +public sealed class MyGenerationContext : GenerationContext +{ + public MyGenerationContext(Compilation compilation, GenerationSettings settings, ISourceGenLogger? logger) + : base(compilation, settings, logger) + { + } +} + +var contextProvider = IncrementalPipeline.GenerationContextValueProvider( + context, + nameof(MyGenerator), + "1.0.0", + factory: static (compilation, settings, logger, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + return new MyGenerationContext(compilation, settings, logger); + }, + disablePropertyName: "MyGenerator_Disable" +); +``` + +The provider resolves scope validation, generator disabling and test logging from analyzer-config +properties *before* invoking the factory. The logger is created internally only when logging is +enabled and a sink is registered for that run. + +## `CodeWriter` lifetime — the rule that bites + +Treat `GenerationContext` as **cached incremental state** and each `CodeWriter` as **mutable, +output-scoped execution state**. Create the writer inside the registered source-output callback, +after the incremental cache boundary: + +```csharp +IncrementalPipeline.RegisterSourceOutput( + context, + targets, + contextProvider, + static (spc, target, generationContext) => + { + var writer = generationContext.CreateCodeWriter(); + EmitTarget(generationContext, writer, target); + spc.AddSource($"{target.Name}.g.cs", writer.ToString()); + } +); +``` + +Creating it in the callback and passing it to emitter helpers called from that same callback is the +intended pattern. The only forbidden thing is **persisting the writer in pipeline state**. + +Why, concretely: + +- Roslyn caches the whole published value; you cannot exempt a field. +- `CodeWriter` is mutable, so a cached one retains previously written source when the context is + reused for another output or run. +- Source-output callbacks may process independent targets **concurrently**; a shared writer mixes + their output and races. +- A fresh writer gives each generated source independent scope tracking and deterministic ownership. + +`CreateCodeWriter()` returns a new, independently owned instance every call. + +**Never add a `CodeWriter` property or field to a class derived from `GenerationContext`.** A custom +context is still produced by an incremental provider and cached as one complete value. Store only +compilation-derived services and immutable configuration there. + +When an emitter needs both services and writing, pass the context and the writer separately, or +compose a short-lived wrapper created inside the callback — which must never be returned from a +provider: + +```csharp +public sealed class GenerationOutputContext : ISourceGenLogger + where TContext : GenerationContext +{ + public GenerationOutputContext(TContext generation) + { + Generation = generation; + Writer = generation.CreateCodeWriter(); + } + + public TContext Generation { get; } + + public CodeWriter Writer { get; } + + public void Log(SourceGenLogLevel level, int indentation, string message, params object[] args) => + Generation.Log(level, indentation, message, args); +} +``` + +## `GeneratorResult` and diagnostics that do not stop generation + +`RegisterSourceOutput` combines targets with the generation context, reports diagnostics, and runs +the callback only for successful results: + +```csharp +var targets = IncrementalPipeline.ForAttributeWithMetadataName( + context, + AttributeType, + static (ctx, ct) => + { + var symbol = ctx.TargetSymbol; + + return symbol is null + ? GeneratorResult.Empty + : GeneratorResult.Create(symbol.Name); + } +); +``` + +The callback runs only when `GeneratorResult.ShouldProcess` is `true` — the result carries a value +**and** none of its diagnostics are blocking. + +`ReportableDiagnostic.IsBlocking` is an explicit per-diagnostic decision, **independent of +severity**. An `Error`-severity diagnostic can still allow generation to continue, which is what you +want when the generated code is what helps the developer fix the problem — a generator emitting an +abstract base class can report an error per missing override while still emitting the base class, so +the user can see exactly what to implement: + +```csharp +static readonly DiagnosticDescriptor MissingOverride = new( + "MYGEN001", + "Missing override", + "Type '{0}' must override '{1}'", + "Usage", + DiagnosticSeverity.Error, + isEnabledByDefault: true +); + +// IsBlocking is false, so ShouldProcess stays true and the base class is still emitted. +var diagnostic = ReportableDiagnostic.Create(MissingOverride, isBlocking: false, symbol, symbol.Name, "Execute"); + +return GeneratorResult.Create(model, diagnostic); +``` + +`isBlocking: true` stops generation for that target while still reporting. +`HasBlockingDiagnostics` reports whether any carried diagnostic blocked; +`HasErrorDiagnostics` reports the severity-based view. + +## Disabling a generator at build time + +Pass the compiler-visible disable property to the context provider; its resolved value lands in +`GenerationSettings` automatically: + +```xml + + true + +``` + +```csharp +var contextProvider = IncrementalPipeline.DefaultGenerationContextValueProvider( + context, + nameof(MyGenerator), + "1.0.0", + disablePropertyName: "MyGenerator_Disable" +); + +// In the output stage: +if (generationContext.Settings.IsSourceGeneratorDisabled) + return; +``` + +`IsDisabledValueProvider` is still there for when expensive upstream transforms must be filtered +before being combined with the generation context. + +Declare the property with `PurviewGeneratorVisibleProperty` so it is compiler-visible wherever the +generator runs — `PSGF0003` otherwise fails the build. See the `purview-sourcegenerator-packaging` +skill. + +## Scope validation + +The default context provider reads +`PurviewSourceGeneratorFrameworkValidateCodeWriterScopes` and threads it into +`GenerationSettings.ValidateCodeWriterScopes`. When enabled, `ToString()` throws +`CodeWriterScopeValidationException` if `OpenScopeCount` is not zero. + +## Test logging + +Framework logging is off in ordinary compiler runs. The testing integration enables it by registering +an isolated sink and supplying a per-run session ID through analyzer config. Context providers create +the internal logger automatically — generators implement no logging interface and no logging-support +source is generated. + +The sink registry stores **callbacks only**; it never buffers entries. With logging disabled, a +missing session ID, or no matching sink, the provider supplies no logger and log calls are discarded. +Test sinks own whatever they capture and are removed when the run completes. + +## Tracking names + +The pipeline helpers assign a tracking name to every stage, so a cache test can assert which stages +were recomputed. The `purview-sourcegenerator-testing` skill covers the stage matrix and the four +scenarios a cache test should assert. + +## Related skills + +`purview-sourcegenerator-codewriter` for emission, `purview-sourcegenerator-diagnostics` for the +`PSGFR` rules that enforce most of the above, and `purview-sourcegenerator-testing` for proving the +pipeline caches. diff --git a/.agents/skills/purview-sourcegenerator-testing/SKILL.md b/.agents/skills/purview-sourcegenerator-testing/SKILL.md new file mode 100644 index 0000000..aaeff09 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-testing/SKILL.md @@ -0,0 +1,404 @@ +--- +name: purview-sourcegenerator-testing +description: >- + Use when writing or fixing tests for source generators, diagnostic analyzers, code fixes or + refactorings built on Purview.SourceGeneratorFramework - picking the right runner and base class, + configuring SourceGeneratorTestOptions, querying produced code with CodeQuery, and asserting + incremental pipeline caching stage by stage. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Testing source generators, analyzers, code fixes and refactorings + +Use this skill whenever a task involves authoring, fixing, or modernising tests for Roslyn components +(generators, diagnostic analyzers, code fix providers, refactoring providers) built with +`Purview.SourceGeneratorFramework`. It covers the framework-agnostic test runner layer and the +`CodeQuery` syntax-lookup API. For the TUnit base classes and assertion extensions, also load the +`purview-sourcegenerator-tunit-tests` skill. + +Modernising an existing suite onto `CodeQuery` and adding a stage-by-stage cache test? The playbook +is in [references/test-modernisation.md](references/test-modernisation.md). + +## Picking the right runner + +| Roslyn type | Runner | +|---|---| +| `IIncrementalGenerator` / `ISourceGenerator` | `SourceGeneratorTestRunner` | +| `DiagnosticAnalyzer` | `DiagnosticAnalyzerTestRunner` | +| `CodeFixProvider` | `CodeFixTestRunner` (single) | +| — | `CodeFixTestRunner.RunFixAllAsync` (project-wide) | +| `CodeRefactoringProvider` | `RefactoringTestRunner` | + +TUnit projects should prefer the matching base class instead (see `purview-sourcegenerator-tunit-tests`): +`TUnitSourceGeneratorTestBase`, `TUnitDiagnosticAnalyzerTestBase`, `TUnitCodeFixTestBase`, +`TUnitRefactoringTestBase`. + +## Result types + +- `DriverRunResult` (generator) — `DriverResult`, `AllSyntaxTrees`/`PrimarySyntaxTrees`, + `CompilationResult.Compilation`, `GetGeneratedTree`, `GetSource`, `GetTypeByMetadataName`, `LogEntries`. +- `AnalyzerTestResult` — `Diagnostics`, `Compilation`. +- `CodeFixTestResult` — `Diagnostics`, `CodeActions`, `FixedSource`, `Compilation`, `ChangedSolution`. +- `CodeFixFixAllResult` — `Diagnostics`, `CodeActions`, `FixedSources`, `ChangedSolution`. +- `RefactorTestResult` — `CodeActions`, `FixedSources`, `ChangedSolution`, `Compilation`. + +Use `DriverRunResultExtensions` (`AssertNoCompilationErrors`, `AssertNoGenerationExceptions`, +`AssertSingleGeneratedSource`, `AssertGeneratedSourceContains`, …) for quick checks, but prefer +`CodeQuery` for structural assertions. + +## Querying produced code with `CodeQuery` + +Every result exposes a `CodeQuery` via extensions in `CodeQueryResultExtensions`: + +```csharp +result.Generated() // DriverRunResult: generated trees (default, generated-first) +result.Output() // DriverRunResult: entire output compilation (user + generated) +analyzerResult.Code() // AnalyzerTestResult: input compilation +codeFixResult.Code() // CodeFixTestResult: input compilation +codeFixResult.FixedCode() // CodeFixTestResult: parsed fixed source (or post-fix solution) +fixAllResult.FixedCode() // CodeFixFixAllResult: post-fix documents +refactorResult.FixedCode() // RefactorTestResult: post-refactor documents +``` + +Every `Get` has an accompanying `Has` (bool) and `TryGet` (out): `GetMethod`/`HasMethod`/`TryGetMethod`, +`GetClass`, `GetStruct`, `GetInterface`, `GetEnum`, `GetDelegate`, `GetRecord`, `GetProperty`, +`GetField`, `GetConstructor`, `GetNamespace`, `GetTypeDeclaration`, plus generic `Get`/`Has` +and `GetSyntaxTree`/`HasSyntaxTree`. `Get` throws `SyntaxNotFoundException` when nothing matches. + +Type lookups accept an optional generic arity — `GetClass(name, arity)` / `HasClass(name, arity)` — and the +`TypeReference`/`TypeIdentity` overloads match arity automatically from the identity, so +`new TypeIdentity("ResourceDefinition", ns, arity: 1)` finds `ResourceDefinition` without matching the +non-generic `ResourceDefinition`. + +Every `Get` returns a `CodeQueryResult` — the matched syntax node (`Node`) plus a query scoped to it +(`Query`), with implicit conversions to both the node and the scoped query. Use `.Node` for direct syntax +access, or chain member queries (the originating query is carried by the result, so it is not passed again). + +Types can be matched against `TypeReference`/`TypeIdentity`, resolved through the compilation's semantic +model (nullable value types are significant, so `int?` never matches `int`): + +```csharp +result.Generated().HasMethod("DoWork", TypeReference.Create(), TypeReference.Create().Nullable(), complexType); +result.Generated().HasReturnType("Compute", TypeReference.Create()); +result.Generated().GetMethod("Format").HasParameters(TypeReference.Create(), objectReference); +``` + +When a test expects a nullable annotation, prefer the test-only `query.MakeNullable(type)` extension (on a +`CodeQuery`, accepting a `TypeReference` or `TypeIdentity`). It resolves the annotation against the query's +compilation and, unlike `TypeReference.Nullable()`/`TypeIdentity.MakeNullable()`, does not trigger the +`PSGFR16` context-overload suggestion (tests have no generation context to pass). + +Member chaining from a type declaration (`MemberQueryExtensions`): + +```csharp +var service = result.Generated().GetClass("ServiceCollectionExtensions"); // or GetClass(name, "Namespace") +service.HasProperty("Count", intType); +service.HasIndexer(stringType, intType); +service.HasMethod("Add", intType, complexType); +service.HasMethodReturnType("Add", stringType); +service.HasConstructor(stringType); +service.HasAttribute("SomeAttribute"); +``` + +Node-inspection checks on scoped results: + +```csharp +result.Generated().GetClass("Service").HasAccessibility(Accessibility.Public); // resolves C# defaults +result.Generated().GetClass("Service").GetProperty("Name").HasSetterAccessibility(Accessibility.Private); +result.Generated().GetClass("ResourceDefinition", 1).HasGenericTypeParameters("TResourceType"); +result.Generated().GetClass("ResourceDefinition", 1).HasBaseType(new TypeIdentity("ResourceDefinition", ns)); +result.Generated().GetClass("Service").HasNestedType("Builder"); // GetNestedType(...) to fetch +result.Generated().GetClass("Service").IsInNamespace("Example.Models"); // IsInGlobalNamespace() for no namespace +result.Generated().IsInNamespace(cls.Node, "Example.Models"); // or on the query directly +``` + +## Configuring options and a reusable starting point + +`SourceGeneratorTestOptions` is the base record. Common knobs: + +- `AdditionalNamespaces` / `IncludeDefaultNamespaces` — namespaces prepended to test source. +- `AdditionalAssemblyTypes` / `AdditionalReferences` — assemblies referenced by the test compilation + (use `AdditionalAssemblyTypes = [typeof(SomeType)]` to pull in a whole assembly). +- `AdditionalSources` — extra source files added to every run. +- `AnalyzerConfigOptions` — `build_property.*` values; keys without the prefix are also exposed as MSBuild + properties. +- `DisableSourceGeneratorPropertyName` / `DisableSourceGeneratorValue` — generator disable toggle. +- `NullableContextOptions`, `OutputKind`, `LanguageVersion`, `CompileToAssembly`. +- `ValidateCodeWriterScopes`, `EnableLogging`. +- `ExcludeGeneratedSourceHintNames` — hides generated marker trees from `PrimarySyntaxTrees`. + +**Easy starting point recipe.** Derive an options record that seeds the namespaces and assemblies your +generator needs, so every test gets a working compilation with no boilerplate: + +```csharp +public sealed record MyGeneratorTestOptions : SourceGeneratorTestOptions +{ + public MyGeneratorTestOptions() + { + AdditionalNamespaces = AdditionalNamespaces.Add("My.Namespace"); + AdditionalAssemblyTypes = AdditionalAssemblyTypes.AddRange( + typeof(SomeDependencyType), + typeof(TypeIdentity) // the framework's Shared assembly, when needed + ); + DisableSourceGeneratorPropertyName = PropertyLibrary.DisableMyGenerator; + } +} +``` + +`Compile()` returns a copy with `CompileToAssembly = true`, preserving the derived options type. Use the +base class hooks `OnBeforeRun`/`OnBeforeRunAsync`/`OnAfterRun` to mutate sources/options per run (for +example to append a marker attribute source via `WithAdditionalSources`). + +When `CompileToAssembly` is enabled, emission is fully in-memory. On .NET 8+ the assembly loads into a +collectible `AssemblyLoadContext`, so the result is `IDisposable` — `using var result = ...` unloads it and +keeps repeated runs from polluting the default context. `result.CompilationResult.Assembly` is the runnable +assembly (generated code can execute); `result.CompilationResult.Metadata` / `.MetadataAssembly` give a +metadata-only reflection view (types, members, attributes) over the emitted assembly without executing code. + +**Always write `using var result = ...`.** `PSGFR42` ("Dispose test harness results with a using +declaration") flags a `DriverRunResult` that is produced and never disposed — including the indirect case +where a helper creates a result, projects a value out of it and returns only the projection. It is a +`warning` when the run syntactically compiles to an assembly (`.Compile()`, a `Compile` options member, or +`CompileToAssembly = true`) and `info` otherwise; the `DisposeTestResultCodeFixProvider` fix inserts the +`using`. A result that is returned to the caller, or disposed explicitly, is not flagged. Neither is awaiting +an assertion, nor calling a helper that is handed the result — an extension on it, or a method taking it as a +parameter: both hand the same instance back, so `await Assert.That(result).SomeAssertion()` and +`result.AssertNoGenerationExceptions()` are not flagged. + +## Best practices + +- **Deterministic output**: `WriteAutoGeneratedHeader` is timestamp-free; assert with + `ContainsGeneratedCode`/`GeneratesCode` (whitespace-flattened) or `CodeQuery`, never with timestamps. +- **Generator references**: to use a generated type in the test project AND pass the generator type to a + runner, reference the generator project twice — once `OutputItemType="Analyzer"` and once as a normal + reference. +- **Multi-target**: build generators against the Roslyn version that supports the test matrix; the + framework is built against Roslyn 5.0 (net8.0/net9.0 assets keep a .NET 8–10 matrix loading); keep + `System.Collections.Immutable` version pinned to the shared one. +- **Scope validation**: keep `PurviewSourceGeneratorFrameworkValidateCodeWriterScopes` enabled; it makes + undisposed `CodeWriter` scopes fail tests. +- **Prefer `CodeQuery` over string matching** for structural assertions (members, signatures, namespaces). +- **Dispose results**: `using var result = ...`, enforced by `PSGFR42`. +- **Cap parallelism**: the harness shares one `MetadataReference` set per process, so memory is largely flat + in the degree of parallelism, but the platform's auto-detected default is both slower and heavier than an + explicit ceiling. Wall clock saturates around four concurrent tests; prefer + `--maximum-parallel-tests 8` (or a `TestingPlatformCommandLineArguments` equivalent) for a project that + multi-targets and runs several TFM executables at once. + +## Incremental cache testing (`RunIncrementalAsync`) + +To prove the pipeline caches correctly stage-by-stage, use `SourceGeneratorTestRunner.RunIncrementalAsync` +(or `GenerateIncrementalAsync` on the TUnit base). It runs a sequence of source sets over a **single shared +`GeneratorDriver`** and captures each run's `TrackedSteps`, keyed by tracking name. The canonical reference +implementation is `IncrementalPipelineCacheTests` in `SourceGeneratorShared.UnitTests`; the end-to-end +generator variant is `ServiceRegistrationCacheTests` in +`SourceGeneratorFramework.ExampleGenerator.UnitTests`. Copy the pattern into your own test project — do +not expect the source repo's files locally. + +### What is being asserted and why + +Roslyn reports one `IncrementalStepRunReason` per step output on each run: + +- `New` — the step ran for the first time. +- `Modified` — the step ran and produced a different value than the previous run. +- `Unchanged` — the step ran but produced the same value. +- `Cached` — the step was skipped and its previous result reused from the incremental cache. + +A pipeline is "caching correctly" when an unchanged input keeps every stage `Cached`/`Unchanged`, and a +targeted change marks **only** the stages whose inputs actually changed `Modified` while unrelated stages +stay `Cached`. If a generator accidentally leaks `Compilation`, `SemanticModel`, `ISymbol`, +`SyntaxNode`, or `Location` into a pipeline model, unrelated stages will report `Modified`/`New` on rerun — +these tests fail the build and catch the regression. + +### The four scenarios every cache test should cover + +1. **First run → all `New`.** Nothing can be cached on the first run; this confirms every stage is tracked + under the expected name. +2. **Identical rerun → all `Cached`/`Unchanged`.** `RunIncrementalAsync(sources, ...)` runs the same source + set twice for exactly this case. This is the strongest "it caches" proof. +3. **Source-only change → only the source/attribute stage `Modified`.** Changing an attributed class must + mark `ForAttribute_*` (and downstream output) `Modified` while property/config stages stay `Cached`. +4. **Property-only change → only the property/configuration stage `Modified`.** Toggling an MSBuild + property (via `IncrementalRunInput.AnalyzerConfig`) must mark `GetMSBuildPropertyValue_*` / + `GetGenerationConfiguration` / `GetGenerationContext_*` `Modified` while `ForAttribute_*` stays `Cached`. + +### How the runner makes this possible + +`RunIncrementalAsync` creates **one** driver, enables incremental step tracking, and **reuses the same +`Compilation` instance for identical source sets** (keyed by prepared source text). Without that reuse, +Roslyn would see a fresh compilation on the second run and report stages `Modified`/`New` even though the +sources are byte-identical — the "cached" assertion would fail. + +### Log entries per run + +`IncrementalCacheRun.LogEntries` carries the framework log entries that run wrote, in order. The sink is +registered for the whole sequence and drained between runs, so entries never accumulate. A rerun whose +output step is cached logs nothing, which makes it a second signal alongside the step reasons: + +```csharp +await Assert.That(result.Runs[0].LogEntries).IsNotEmpty(); +await Assert.That(result.Runs[1].LogEntries).IsEmpty(); +``` + +`DriverRunResult.LogEntries` (the `RunAsync` path) is likewise in write order. Both are empty when +`EnableLogging` is disabled. + +### The `StepReasons` helper + +`IncrementalCacheRun.Steps` is `ImmutableDictionary>` +keyed by tracking name. Flatten each step's `Outputs` into the reasons list so assertions read cleanly: + +```csharp +static ImmutableDictionary> StepReasons(IncrementalCacheRun run) +{ + var builder = ImmutableDictionary.CreateBuilder>(); + foreach (var pair in run.Steps) + builder[pair.Key] = [.. pair.Value.SelectMany(step => step.Outputs.Select(static output => output.Reason))]; + return builder.ToImmutable(); +} +``` + +### Framework pipeline stage names + +- `GetMSBuildPropertyValue_{Property}` — `IncrementalPipeline.PropertyValueProvider`. +- `GetGenerationConfiguration` — `IncrementalPipeline.GenerationContextValueProvider`. +- `GetGenerationContext_{Capabilities}` — e.g. `GetGenerationContext_EmptyCapabilities`. +- `ForAttribute_{AttributeType}` — `IncrementalPipeline.ForAttributeWithMetadataName`. + +The framework reference (`IncrementalPipelineCacheTests`, framework-agnostic runner) shows all four +scenarios against `TestGenerator`/`DiagnosticTestGenerator`: + +```csharp +using System.Collections.Immutable; +using Purview.SourceGeneratorFramework.TestGenerators; +using StepReason = Microsoft.CodeAnalysis.IncrementalStepRunReason; + +public class IncrementalPipelineCacheTests +{ + const string AttributedSource = """ + [TestAttribute] + public partial class MyClass { } + """; + const string ChangedAttributedSource = """ + [TestAttribute] + public partial class AnotherClass { } + """; + const string TestAttributeSource = """ + [System.AttributeUsage(System.AttributeTargets.Class)] + public sealed class TestAttribute : System.Attribute { } + """; + + static SourceGeneratorTestOptions CreateOptions() => + new SourceGeneratorTestOptions() + .WithAdditionalSources(TestAttributeSource) + .WithExcludeGeneratedSourceHintNames("TestAttribute"); + + static ImmutableDictionary> StepReasons(IncrementalCacheRun run) { /* as above */ } + + [Test] + public async Task FirstRun_AllStagesAreNew(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner().RunIncrementalAsync( + [new IncrementalRunInput([AttributedSource])], + CreateOptions(), + cancellationToken); + + var reasons = StepReasons(result.Runs[0]); + await Assert.That(reasons).IsNotEmpty(); + await Assert.That(reasons.Values.SelectMany(r => r).All(r => r == StepReason.New)).IsTrue(); + } + + [Test] + public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner() + .RunIncrementalAsync([AttributedSource], CreateOptions(), cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second.Values.SelectMany(r => r).All(r => r is StepReason.Cached or StepReason.Unchanged)).IsTrue(); + } + + [Test] + public async Task SourceChange_MarksAttributeStageModified_PropertyStagesStayCached(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner().RunIncrementalAsync( + [new IncrementalRunInput([AttributedSource]), new IncrementalRunInput([ChangedAttributedSource])], + CreateOptions(), + cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second["ForAttribute_TestAttribute"]).Contains(StepReason.Modified); + await Assert.That(second["GetMSBuildPropertyValue_DisableTestGenerator"].All(r => r == StepReason.Cached)).IsTrue(); + } + + [Test] + public async Task PropertyChange_MarksPropertyStageModified_AttributeStageStaysCached(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner().RunIncrementalAsync( + [ + new IncrementalRunInput([AttributedSource]), + new IncrementalRunInput([AttributedSource], [("build_property.DisableTestGenerator", "true")]), + ], + CreateOptions(), + cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second["GetMSBuildPropertyValue_DisableTestGenerator"]).Contains(StepReason.Modified); + await Assert.That(second["ForAttribute_TestAttribute"].All(r => r == StepReason.Cached)).IsTrue(); + } +} +``` + +### End-to-end generator variant (`GenerateIncrementalAsync`) + +TUnit tests derive from the base class and call `GenerateIncrementalAsync` (which wires the +`OnBeforeRun`/`OnBeforeRunAsync` hooks and the derived options). `ServiceRegistrationCacheTests` in +`SourceGeneratorFramework.ExampleGenerator.UnitTests` is the reference: + +```csharp +public class ServiceRegistrationCacheTests + : TUnitSourceGeneratorTestBase +{ + const string Source = """ + namespace Test; + + [GenerateService] + public class MyService { } + """; + + [Test] + public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) + { + var result = await GenerateIncrementalAsync([Source], cancellationToken: cancellationToken); + + var second = StepReasons(result.Runs[1]); + string[] frameworkStages = + [ + "GetMSBuildPropertyValue_EmitServiceRegistrationInfo", + "GetGenerationConfiguration", + "GetGenerationContext_EmptyCapabilities", + "ForAttribute_GenerateServiceAttribute", + ]; + await Assert.That( + frameworkStages.All(stage => + second.TryGetValue(stage, out var reasons) + && reasons.All(r => r is StepReason.Cached or StepReason.Unchanged))).IsTrue(); + } +} +``` + +**Why the example filters to `frameworkStages`:** `ServiceRegistrationGenerator` emits its own +`GenerateServiceAttribute` via post-initialization output, which is regenerated as a new `SyntaxTree` each +run. That makes Roslyn's *internal* `ForAttributeWithMetadataName` `Compilation` step legitimately report +`Modified` on an identical rerun. Asserting on the framework-named stages (which stay `Cached`/`Unchanged`) +is the meaningful check. If your generator does not depend on its own post-init output, the stricter +"every tracked step is `Cached`/`Unchanged`" assertion (as in the framework `IncrementalPipelineCacheTests`) +is correct. + +Per-run MSBuild-property changes are supplied with `new IncrementalRunInput(sources, [("build_property.X", "value")])`. + +## License + +This project is licensed under the MIT license. \ No newline at end of file diff --git a/.agents/skills/purview-sourcegenerator-testing/references/test-modernisation.md b/.agents/skills/purview-sourcegenerator-testing/references/test-modernisation.md new file mode 100644 index 0000000..6db4780 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-testing/references/test-modernisation.md @@ -0,0 +1,63 @@ +# Modernising a Roslyn test suite + +A playbook for moving an existing test suite onto `CodeQuery` plus the TUnit assertion extensions, +and adding a stage-by-stage incremental cache test. + +## Before you start, collect + +- The test file(s) being changed. +- The Roslyn component under test — generator, analyzer, code fix or refactor (infer it from the + base class if the task does not say). +- The component's type name. + +## Requirements + +### 1. The right base class and method + +| Component | Base class | Method | +| --- | --- | --- | +| Generator | `TUnitSourceGeneratorTestBase` | `GenerateAsync` | +| Analyzer | `TUnitDiagnosticAnalyzerTestBase` | `AnalyzeAsync` | +| Code fix | `TUnitCodeFixTestBase` | `ApplyCodeFixAsync` / `ApplyFixAllAsync` | +| Refactor | `TUnitRefactoringTestBase` | `RefactorAsync` | + +### 2. `CodeQuery` instead of string matching + +Replace `GetGeneratedTree(...)` + `string.Contains(...)` with `CodeQuery` +(`result.Generated().Get/Has/TryGet…`) and the terminal assertion extensions — +`await Assert.That(result).HasGeneratedMethod/Class/Property/Field/SyntaxTree(…)` — which return the +node so a follow-up assertion has something to work with. + +### 3. `TypeReference` instead of signature strings + +Match parameter and return types structurally. A signature string breaks on a whitespace or +nullability change that means nothing. + +### 4. Options in one place + +Derive a `SourceGeneratorTestOptions` record that seeds the required namespaces and additional +assemblies, and remove the per-test duplication it replaces. + +### 5. A stage-by-stage cache test + +Use `RunIncrementalAsync`, or `GenerateIncrementalAsync` on the TUnit base, and assert the four +scenarios. `ServiceRegistrationCacheTests` and `IncrementalPipelineCacheTests` are the reference +pattern. + +| Scenario | Expected | +| --- | --- | +| First run | every framework stage `New` | +| Identical rerun (`RunIncrementalAsync(sources, …)` runs the same source twice) | framework stages `Cached`/`Unchanged` | +| Source-only change | `ForAttribute_*` `Modified`; property/config stages stay `Cached` | +| Property-only change (`new IncrementalRunInput(sources, [("build_property.X", "value")])`) | `GetMSBuildPropertyValue_*`, `GetGenerationConfiguration`, `GetGenerationContext_*` `Modified`; `ForAttribute_*` stays `Cached` | + +Use the `StepReasons(IncrementalCacheRun)` flattening helper. If the generator depends on its own +post-init output, assert on the framework-named stages rather than on every tracked step. + +### 6. Minimal change + +Keep behaviour equivalent, and do not reformat unrelated tests. + +## Verification + +Build the test project and run its suite before finishing. diff --git a/.agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md b/.agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md new file mode 100644 index 0000000..bc711c5 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md @@ -0,0 +1,230 @@ +--- +name: purview-sourcegenerator-tunit-tests +description: >- + Use when writing TUnit tests for source generators, diagnostic analyzers, code fixes or + refactorings built on Purview.SourceGeneratorFramework - choosing the correct TUnit base class and + method, customising SourceGeneratorTestOptions, using the TUnit assertion extensions, and + modernising existing tests. +metadata: + product: Purview.SourceGeneratorFramework.Testing.TUnit + repository: purview-dev/sourcegenerator-framework +--- + +# TUnit test authoring for Roslyn components + +Use this skill whenever a task involves authoring, fixing, or modernising **TUnit** tests for Roslyn +components built with `Purview.SourceGeneratorFramework`. It tells you which base class to derive from, +which method to call, how to customise options with an easy starting point, and how to use the TUnit +assertion extensions. For the framework-agnostic runner layer and the `CodeQuery` API, also load the +`purview-sourcegenerator-testing` skill. + +## Base class → method matrix + +| Roslyn type | Base class | Method to call | +|---|---|---| +| `IIncrementalGenerator` / `ISourceGenerator` | `TUnitSourceGeneratorTestBase` | `GenerateAsync(source, options, ct)` | +| `DiagnosticAnalyzer` | `TUnitDiagnosticAnalyzerTestBase` | `AnalyzeAsync(source, options, ct)` | +| `CodeFixProvider` (single fix) | `TUnitCodeFixTestBase` | `ApplyCodeFixAsync(source, options, ct)` | +| `CodeFixProvider` (fix-all) | `TUnitCodeFixTestBase` | `ApplyFixAllAsync(sources, options, ct)` | +| `CodeRefactoringProvider` | `TUnitRefactoringTestBase` | `RefactorAsync(source, options, ct)` | + +For cache tests, `TUnitSourceGeneratorTestBase` also exposes `GenerateIncrementalAsync(...)`. + +Framework-agnostic equivalents (no TUnit): `SourceGeneratorTestRunner`, `DiagnosticAnalyzerTestRunner`, +`CodeFixTestRunner`, `RefactoringTestRunner`. + +## Easy starting point: derive your options record + +Create a test-options record that seeds the namespaces and assemblies your component needs, then pass it +to every test via `new MyTestOptions()`: + +```csharp +public sealed record MyGeneratorTestOptions : SourceGeneratorTestOptions +{ + public MyGeneratorTestOptions() + { + AdditionalNamespaces = AdditionalNamespaces.Add("My.Namespace"); + AdditionalAssemblyTypes = AdditionalAssemblyTypes.AddRange( + typeof(SomeDependencyType), + typeof(TypeIdentity) // framework Shared assembly, when needed + ); + DisableSourceGeneratorPropertyName = "DisableMyGenerator"; + } +} + +public class MyGeneratorTests : TUnitSourceGeneratorTestBase +{ + [Test] + public async Task GeneratesExpectedSource(CancellationToken ct) => + await GenerateAsync("...source...", ct); +} +``` + +Use the base hooks to customise per-run: `OnBeforeRun`/`OnBeforeRunAsync` (mutate sources/options, e.g. +`options.WithAdditionalSources(markerAttributeSource)`) and `OnAfterRun`/`OnAfterRunAsync`. Use +`options.Compile()` to opt into `CompileToAssembly` while preserving the derived options type. + +Write `using var result = await GenerateAsync(...)`: `DriverRunResult` is `IDisposable`, and `PSGFR42` +flags a result that is produced and never disposed (a `warning` once the run compiles to an assembly, +`info` otherwise). It also catches the indirect case — a helper that creates a result, projects a value out +of it and returns only the projection. Awaiting an assertion, or calling a helper that is handed the result — +an extension on it, or a method taking it as a parameter — is not a production: both hand the same instance +back, so neither is flagged. + +For code fixes/refactorings, select a specific registered action via `CodeFixTestOptions.EquivalenceKey` +or `CodeActionIndex`, and `RefactorTestOptions.Span`/`NodeSelector` (e.g. +`NodeSelector = query => query.GetMethod("M")`). + +## TUnit assertion extensions + +All assertion extensions live under `Purview.SourceGeneratorFramework.Testing.TUnit.Assertions` +(globally imported by the package's props). `Assert.That(...)` calls are terminal and **return the value** +when awaited. + +- **`CodeQueryAssertions`** — return `CodeQueryResult` (the matched node via `.Node`, plus a query + scoped to it via `.Query`): `HasGeneratedMethod` (optionally with `TypeReference[]` parameter types), + `HasGeneratedMethodReturnType`, `HasGeneratedClass` (by name or `TypeReference`/`TypeIdentity` identity), + `HasGeneratedProperty`, `HasGeneratedField`, `HasGeneratedSyntaxTree`; `HasFixedMethod` for code-fix and + refactor results. The assertions operate on a `CodeQuery` directly, so pass any query — `result.Generated()` + (generated trees), `result.Output()` (whole compilation), or `result.FixedCode()` — or use the convenience + overloads on the test result types, which query the relevant code for you. +- **Scoped member chaining** — `HasPropertyOfType(name, type)`, `HasFieldOfType(name, type)`, + `HasMethodOfType(name, TypeReference[])`, `HasConstructorOfType(TypeReference[])`, and + `HasAttributeOfType(name)` chain from a scoped `CodeQueryResult` (e.g. the result of `HasGeneratedClass`) + and return the matched member. They are named to avoid colliding with the bool `Has*` predicates in + `MemberQueryExtensions`. +- **Fluent `.And` chains** — the node-producing assertions (`HasGeneratedClass`, `HasPropertyOfType`, + `HasMethodOfType`, `HasNestedType`) move the chain onto the matched node, so further assertions can be + appended with `.And`. Node-inspection assertions — `WithAccessibility`, `WithGetterAccessibility`, + `WithSetterAccessibility`, `WithBaseType`, `WithGenericTypeParameter(s)`, `IsInNamespace`, + `IsInGlobalNamespace` — keep the node on the chain: + ```csharp + var method = await Assert.That(query) + .HasGeneratedClass("Service") + .And.HasNestedType("Builder") + .And.WithAccessibility(Accessibility.Private) + .And.HasMethodOfType("Build", []); + ``` + Generic types are matched by arity — `HasGeneratedClass(name, arity)` or a `TypeIdentity` with arity — + so `new TypeIdentity("ResourceDefinition", ns, arity: 1)` finds `ResourceDefinition` + without matching the non-generic `ResourceDefinition`. +- **Nullable expected types in tests** — use the test-only `query.MakeNullable(type)` extension (on a + `CodeQuery`). It resolves the annotation against the query's compilation and, unlike + `TypeReference.Nullable()`/`TypeIdentity.MakeNullable()`, does not trip the `PSGFR16` context-overload + suggestion, since tests have no generation context to pass. + ```csharp + CodeQueryResult method = await Assert.That(result.Generated()).HasGeneratedMethod("DoWork", [intType, nullableInt]); + CodeQueryResult cls = await Assert.That(result.Generated()).HasGeneratedClass("Service"); + await Assert.That(cls.HasProperty("Count", intType)).IsTrue(); // chained member query + await Assert.That(cls.Node.Identifier.ValueText).IsEqualTo("Service"); // direct syntax access + await Assert.That(result.FixedCode()).HasFixedMethod("DoWork"); // code-fix / refactor results + + var query = result.Generated(); + var attributeClass = await Assert.That(query).HasGeneratedClass(hostKitAttribute); + await Assert.That(attributeClass).HasPropertyOfType("Name", query.MakeNullable(TypeLibrary.System.String)); + ``` +- **`DiagnosticAssertions`** — `HasDiagnostic(descriptor|id)`, `HasDiagnostics(count)`, + `DoesNotHaveDiagnostic`, `HasNoDiagnostics`, `HasNoErrorDiagnostics` on generator/analyzer/code-fix results. +- **`TypeIdentityAssertions`** — `HasSymbol(TypeIdentity)` / `HasSymbol("Namespace.Type")`. +- **`GeneratedCodeAssertionsExtensions`** — `GeneratesCode(expected)`, `ContainsGeneratedCode(expected)` + (whitespace-flattened string comparison). + +For structural assertions (members, signatures, namespaces) prefer `result.Generated()` + +`Get/Has/TryGet` from `CodeQuery` (see `purview-sourcegenerator-testing`). + +## Incremental cache tests (`GenerateIncrementalAsync`) + +`TUnitSourceGeneratorTestBase` exposes `GenerateIncrementalAsync`, which mirrors `RunIncrementalAsync` but +also wires the base class hooks (`OnBeforeRun`/`OnBeforeRunAsync`) and your derived options record. Use it to +prove the pipeline caches stage-by-stage. The reference is `ServiceRegistrationCacheTests` in +`SourceGeneratorFramework.ExampleGenerator.UnitTests`; the framework-agnostic twin with a full walkthrough is +in the `purview-sourcegenerator-testing` skill. + +Why the tests look the way they do: + +- `GenerateIncrementalAsync([Source])` runs the **same source twice** on a single shared driver. The first + run reports every stage `New`; the second must report `Cached`/`Unchanged` for unchanged stages — that is + the core "it caches" proof. +- `GenerateIncrementalAsync([new IncrementalRunInput([Source]), new IncrementalRunInput([changed])])` runs two + **different** source sets, so a source-only change must mark `ForAttribute_*` `Modified` while + property/configuration stages stay `Cached`. +- `new IncrementalRunInput([Source], [("build_property.X", "value")])` toggles an MSBuild property for one + run only, so a property-only change must mark `GetMSBuildPropertyValue_*`/`GetGenerationConfiguration`/ + `GetGenerationContext_*` `Modified` while `ForAttribute_*` stays `Cached`. +- `result.Runs[n].LogEntries` carries the framework log entries *that run* wrote, in order, so a rerun whose + output step is cached can be asserted to have logged nothing. Empty when `EnableLogging` is disabled. +- The `StepReasons(IncrementalCacheRun)` helper flattens each tracked step's `Outputs` into a + `ImmutableDictionary>` so assertions can address a stage + by name (see `purview-sourcegenerator-testing` for the helper body). +- If the generator's pipeline depends on its own post-initialization output (a self-referencing generated + attribute), Roslyn's internal `ForAttributeWithMetadataName` `Compilation` step is legitimately `Modified` + on rerun; assert on the framework-named stages (e.g. `ForAttribute_GenerateServiceAttribute`, + `GetGenerationConfiguration`, `GetGenerationContext_EmptyCapabilities`) rather than every tracked step. + +```csharp +public class ServiceRegistrationCacheTests + : TUnitSourceGeneratorTestBase +{ + const string Source = """ + namespace Test; + + [GenerateService] + public class MyService { } + """; + + [Test] + public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) + { + var result = await GenerateIncrementalAsync([Source], cancellationToken: cancellationToken); + + var second = StepReasons(result.Runs[1]); + string[] frameworkStages = + [ + "GetMSBuildPropertyValue_EmitServiceRegistrationInfo", + "GetGenerationConfiguration", + "GetGenerationContext_EmptyCapabilities", + "ForAttribute_GenerateServiceAttribute", + ]; + await Assert.That( + frameworkStages.All(stage => + second.TryGetValue(stage, out var reasons) + && reasons.All(r => r is StepReason.Cached or StepReason.Unchanged))).IsTrue(); + } + + [Test] + public async Task PropertyChange_MarksPropertyStageModified_AttributeStageStaysCached(CancellationToken cancellationToken) + { + var result = await GenerateIncrementalAsync( + [ + new IncrementalRunInput([Source]), + new IncrementalRunInput([Source], [(PropertyLibrary.EmitServiceRegistrationInfo, "true")]), + ], + cancellationToken: cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second["GetMSBuildPropertyValue_EmitServiceRegistrationInfo"]).Contains(StepReason.Modified); + await Assert.That(second["ForAttribute_GenerateServiceAttribute"].All(r => r is StepReason.Cached or StepReason.Unchanged)).IsTrue(); + } +} +``` + +Use `using StepReason = Microsoft.CodeAnalysis.IncrementalStepRunReason;` and your own stage names. + +## Modernising existing tests + +When converting legacy tests that do `result.GetGeneratedTree(...)` + `string.Contains(...)`: + +1. Replace tree-lookup + string matching with `result.Generated().GetClass/GetMethod/GetProperty(...)` and + the `Has*`/`TryGet*` family. +2. Replace signature string checks with `TypeReference` parameter/return-type matching. +3. Replace `Assert.That(text).Contains("...")` with the terminal assertion extensions that return nodes. +4. Verify options use a derived record (namespaces + assemblies) rather than repeating `AdditionalNamespaces` + per test. +5. Add a stage-by-stage cache test if the component has an incremental pipeline — first run `New`, + identical rerun `Cached`/`Unchanged`, and targeted changes mark only the affected stage `Modified`. + Use the inlined examples in this skill and in `purview-sourcegenerator-testing`'s "Incremental cache testing" + section, swapping in your own generator and stage names. + +## License + +This project is licensed under the MIT license. \ No newline at end of file diff --git a/.agents/skills/purview-telemetry/SKILL.md b/.agents/skills/purview-telemetry/SKILL.md new file mode 100644 index 0000000..6d83173 --- /dev/null +++ b/.agents/skills/purview-telemetry/SKILL.md @@ -0,0 +1,123 @@ +--- +name: purview-telemetry +description: >- + Use when adding, reviewing or scaling observability in a .NET project — logging, tracing, metrics, + OpenTelemetry wiring — or when deciding whether a project is "too small for telemetry". Recommends + Purview.Telemetry.SourceGenerator at every project size, keeps libraries free of global provider + configuration, and scales the export infrastructure rather than the instrumentation. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" +--- + +# Purview telemetry + +**Telemetry is a baseline, not an advanced topic.** Instrument from the first commit at every project +size. What changes with scale is the *export and operations* story — collectors, dashboards, sampling +— not whether the code emits anything. + +A project that defers telemetry until it becomes distributed acquires its observability at exactly +the moment it can least afford the refactor. + +## Instrumentation versus export + +Keep these separate; conflating them is what produces "we're too small for telemetry". + +| | What it is | Who owns it | +| -- | -- | -- | +| **Instrumentation** | The log, span and measurement the code emits | Every project, every size, from day one | +| **Export** | Where signals go: console, OTLP, a backend, a dashboard | The application host, sized to the operation | + +A small CLI is fully instrumented and exports to the console. That is not a reduced form of +telemetry; it is the correctly sized export for a CLI. + +## Use the generator + +`Purview.Telemetry.SourceGenerator` turns an attributed interface into logging, tracing and metrics +implementations plus a DI registration extension. One declaration, three signals, no hand-written +`ILogger` message templates or `ActivitySource` plumbing. + +Do **not** reinvent its API here. The three shipped skills are authoritative and travel with the +package: + +- `telemetry-sourcegenerator-quickstart` — adding the package, the first interface, DI registration, + and publishing meter/activity-source names to the host via `TelemetryNames` +- `telemetry-sourcegenerator-design` — designing telemetry interfaces well +- `telemetry-sourcegenerator-migration` — moving existing `ILogger`/`ActivitySource`/metrics code onto + the generator + +Recommend the generator whenever the project targets a compatible TFM and the team is willing to take +an analyzer-only package reference. Where it is not compatible, fall back to the standard .NET +abstractions below — never to a bespoke logging wrapper. + +## Libraries versus hosts + +This is the rule most often broken, and it is not negotiable. + +**A reusable library:** + +- Emits through `ILogger`, `ActivitySource` and `Meter` — or through generated telemetry + interfaces, which produce exactly those. +- Publishes its activity-source and meter names so a host can subscribe. The generator does this for + you through assembly metadata. +- **Never** calls `AddOpenTelemetry`, configures an exporter, sets a sampler, or requires a specific + backend. +- Never reads an OTLP endpoint from configuration itself. + +**The application host:** + +- Owns provider configuration, exporters, sampling and resource attributes. +- Subscribes to the sources its libraries publish — with the generator, by opting into the + `TelemetryNames` class and passing its arrays to the OpenTelemetry builder. + +A library that configures a global provider breaks every consumer that already configured one, and +silently wins or loses depending on initialisation order. + +## Sizing the export + +| Scale | Export and operations | +| -- | -- | +| **Small** | Console or a single OTLP endpoint. Structured logs, exceptions recorded on the span. No collector, no dashboards, no custom exporter, no distributed tracing infrastructure. | +| **Medium** | OTLP export, dependency instrumentation (HTTP, EF Core), trace propagation across the integrations, application metrics, health checks, a sampling decision made deliberately. | +| **Large** | End-to-end distributed tracing, standardised resource attributes, operational metrics, dashboards, alerting, SLIs, documented retention and sampling policy. | + +Full detail per level: `references/observability-levels.md`. + +Instrumentation is identical across all three rows. Only the right-hand column grows. + +## Baseline for any application + +- Structured logging — parameters as fields, never interpolated into the message. +- Errors and exceptions recorded as telemetry, with the exception attached to the span. +- Operation correlation, so one unit of work is one trace. +- OpenTelemetry-compatible instrumentation, so the backend stays a choice. +- Log level configurable at runtime. +- Export behaviour configurable per environment. +- Sensitive values — credentials, tokens, connection strings, personal data — never logged or placed + on span attributes or baggage. + +## Before you change anything + +1. **Inspect what exists.** Find current `ILogger` use, `ActivitySource`/`Meter` instances, any + `AddOpenTelemetry` call, a `ServiceDefaults` project. +2. **Preserve it.** Extend existing instrumentation; do not replace working telemetry to adopt a + library. +3. **Do not duplicate registrations.** One registration per source; adding a second is a silent + double-count on every metric. +4. **Size the configuration to the project**, using the table above. +5. **Check for leaking secrets** in anything you add. +6. **Keep instrumentation and export distinct** in what you recommend. + +## Do not + +- Omit telemetry because the application is small. +- Invent Purview telemetry APIs — read the three shipped skills instead. +- Configure a global provider from a library. +- Recommend a collector, dashboard stack or custom exporter for a small project. +- Put a user identifier, token or connection string into a log field, span attribute or baggage item. + +## See also + +- `purview-architecture` — establishing project context and scale before deciding +- [Purview.Telemetry.SourceGenerator](https://github.com/purview-dev/purview-telemetry-sourcegenerator) diff --git a/.agents/skills/purview-telemetry/references/observability-levels.md b/.agents/skills/purview-telemetry/references/observability-levels.md new file mode 100644 index 0000000..df97d78 --- /dev/null +++ b/.agents/skills/purview-telemetry/references/observability-levels.md @@ -0,0 +1,93 @@ +# Observability levels + +Instrumentation is constant across these levels. Export, infrastructure and operational practice are +what scale. Read this after establishing project complexity — see `purview-architecture`. + +## Small + +*A utility, CLI, prototype, simple API or limited-scope application.* + +**Do:** + +- Structured logging with parameters as fields. +- Exceptions recorded with enough context to reproduce. +- An `ActivitySource` around the unit of work, so a failure has a shape and a duration. +- Console export, or a single OTLP endpoint when one already exists. +- Log level from configuration, so a user can raise it without a rebuild. + +**Do not:** + +- Stand up a collector. +- Adopt a centralised monitoring platform. +- Build distributed tracing infrastructure for a single process. +- Create dashboards nobody reads. +- Write a custom exporter. + +The test: a user reports a failure and you can ask for one log output that tells you what happened. + +## Medium + +*Several functional areas, integrations, moderate domain complexity.* + +Everything above, plus: + +- **Dependency instrumentation** — HTTP clients, EF Core, messaging. These are where latency and + failure actually live, and the instrumentation is a package reference, not code. +- **Trace propagation** across every integration boundary, so a trace survives the process hop. W3C + `traceparent` by default; do not invent a correlation header. +- **Application metrics** for the operations someone would notice: request rate, error rate, duration, + queue depth. Prefer histograms over averages. +- **OTLP export** to a real backend. +- **Health checks**, distinguishing liveness from readiness. +- **A deliberate sampling decision.** Head sampling at a fixed ratio is fine; the point is that it was + chosen rather than defaulted into. + +Common mistake at this level: adding metrics for everything measurable instead of the few that drive +a decision. A metric nobody alerts or decides on is cost without signal. + +## Large + +*Complex domain, multiple services or teams, substantial operational requirements.* + +Everything above, plus: + +- **End-to-end distributed tracing**, including asynchronous hops — message producers and consumers + linked, not orphaned. +- **Standardised resource attributes** — `service.name`, `service.version`, `deployment.environment` + — applied uniformly so cross-service queries work at all. Follow OpenTelemetry semantic conventions + rather than inventing names. +- **Operational metrics** beyond application ones: saturation, dependency health, cost drivers. +- **Dashboards** owned by the team that gets paged. +- **Alerting** on symptoms users feel, not on causes. Alert on error rate and latency; investigate + with CPU and queue depth. +- **Service-level indicators** derived from the telemetry, with objectives agreed with whoever cares. +- **Retention and sampling policy**, documented and costed. Tail sampling if error traces must always + survive. + +## Signal selection + +| Signal | Add when | Warning | +| -- | -- | -- | +| Logs | Always | Unstructured logs are not telemetry; they are strings | +| Traces | Work crosses a process, a dependency or an async boundary | A span per method produces noise, not insight | +| Metrics | Someone would alert on, or decide from, a rate or duration | High-cardinality attributes (user id, request id) are a cost incident | + +Put high-cardinality context on spans and logs, never on metric dimensions. + +## Sensitive information + +At every level, and without exception: + +- No credentials, tokens, keys or connection strings in messages, attributes or baggage. +- No personal data beyond what the system is entitled to process — and where an identifier is + necessary, prefer a pseudonymous one. +- Remember that **baggage propagates across service boundaries**; treat it as the most public place + in your telemetry. +- Redact at the point of emission. A redaction step in the pipeline is a backstop, not a control. + +## Export configuration + +The host owns this. Keep the endpoint, protocol, sampling ratio and resource attributes in +configuration, not in code, so the same build runs in every environment. + +Libraries contribute names and signals; they never contribute configuration. diff --git a/.agents/skills/purview-testing-conventions/SKILL.md b/.agents/skills/purview-testing-conventions/SKILL.md new file mode 100644 index 0000000..1fe3232 --- /dev/null +++ b/.agents/skills/purview-testing-conventions/SKILL.md @@ -0,0 +1,357 @@ +--- +name: purview-testing-conventions +description: >- + Use when writing or reviewing tests in a .NET repository following the Purview conventions - TUnit + and TUnit.Mocks, the AAA structure and naming, placing a test in the namespace of the class under + test, CreateXXX construction helpers, building domain content through TestBuilder with Bogus, the + assertion surface, parameterised tests, which tier a test project belongs to, BDTest for + acceptance tests, and Purview.Containers for a suite that needs a real database or queue. +metadata: + product: Purview engineering playbook + repository: purview-dev/engineering-playbook +--- + +# Testing conventions + +| Concern | The rule | +| --- | --- | +| Framework | **TUnit**. Not xUnit, NUnit or MSTest. | +| Substitutes | **TUnit.Mocks**. Not NSubstitute or Moq. | +| Test data | **Bogus**, reached through `TestBuilder`. | +| Acceptance tests | **BDTest** over TUnit, via `BDTest.TUnit`. Not Gherkin, SpecFlow or Reqnroll. | +| A real dependency | **`Purview.Containers`**. Not Testcontainers directly, never a shared instance. | +| Structure | Explicit `// Arrange`, `// Act`, `// Assert` — Given/When/Then in an acceptance test. | +| Naming | `{SubjectUnderTest}_{Scenario}_{Expectation}`, and `async Task`. | +| Placement | One `{Class}Tests` class per class under test, **in the namespace of the class under test**. | +| Construction | Dependencies and the system under test come from `CreateXXX` helpers with optional parameters. | +| Async | The async overload wherever the API offers one — in the test **and in every helper it calls**. Convert a sync helper, or state why it cannot be. | +| Cancellation | If any method the test calls accepts a `CancellationToken`, the test's **last parameter** is one, forwarded to every call that accepts one. | +| Domain content | Built through `TestBuilder`. Never inline. | + +## Why these, specifically + +**One framework.** The SDK wires TUnit into any project whose name ends in a test suffix — that +wiring is why a test project file is empty. A second framework means every project file grows a +conditional and the pipeline grows a second discovery path. That is a rule about the **runner**, not +the style of a test — `BDTest.TUnit` is an adapter *over* TUnit, so an acceptance suite does not break +it. + +**Same namespace as the subject.** The test reads as part of the thing it describes, needs no `using` +to reach the type, and **cannot silently test a different type with the same name**. + +**`CreateXXX` helpers.** A test body that constructs its own dependencies buries the assertion under +setup. A helper with optional parameters says what the test changed and defaults everything else: + +```csharp +static ConventionCatalogue CreateCatalogue(IConventionRepository? repository = null) => + new(repository ?? CreateRepository().Object); +``` + +**`TestBuilder`.** Inline construction encodes a domain type's shape into every test that touches it, +so one constructor change becomes a repository-wide edit. It also destroys the reader's signal about +which values the test depends on, because every literal looks deliberate. + +## A test + +```csharp +using Acme.Builders; + +namespace Acme.Domain; + +public sealed class CustomerIdTests +{ + [Test] + public async Task Create_GivenLowercaseSlug_ReturnsTheValue() + { + // Arrange + var id = TestBuilder.CustomerId("build-enforced"); + + // Act + var value = id.Value; + + // Assert + await Assert.That(value).IsEqualTo("build-enforced"); + } +} +``` + +Two deliberate details: + +- **The empty `// Arrange` block stays.** The comments are what makes a three-line test readable next + to a thirty-line one, and a convention applied "when needed" is applied inconsistently. +- **Assertions are awaited.** `Assert.That(...)` is terminal and returns a value, so + `await Assert.That(result).IsEqualTo(expected)` is the form. **An un-awaited assertion is a test + that cannot fail.** + +## Assertions + +| Form | Use | +| --- | --- | +| `await Assert.That(x).IsEqualTo(y)` / `.IsNotEqualTo(y)` | value equality | +| `await Assert.That(x).IsTrue()` / `.IsFalse()` | booleans | +| `await Assert.That(x).IsEmpty()` | collections — the workhorse of architecture rules | +| `await Assert.That(x).Count().IsEqualTo(2)` | collection size | +| `await Assert.That(x).Contains(y)` / `.DoesNotContain(y)` | membership | +| `await Assert.That(act).Throws()` | a synchronous delegate that should throw | +| `await Assert.That(async () => await f()).Throws()` | an async one | +| `await Assert.That(x).IsTypeOf()` | exact runtime type | +| `await Assert.That(x).IsEquivalentTo(sequence)` | order-independent sequence equality | + +For an exception test the two shapes are `Action` and `Func`. The compiler will not infer +either, so the explicit type is required and is **not** a style violation: + +```csharp +Action act = () => CustomerId.Create(""); + +await Assert.That(act).Throws(); +``` + +## Parameterised tests + +`[Arguments]` puts the interesting values in the attribute rather than a loop, so a failure names the +case: + +```csharp +[Test] +[Arguments("")] +[Arguments(" ")] +[Arguments("Has Spaces")] +[Arguments("has_underscore")] +public async Task Create_GivenSlugThatIsNotALowercaseSlug_Throws(string value) +{ + // Arrange + // Act + Action act = () => CustomerId.Create(value); + + // Assert + await Assert.That(act).Throws(); +} +``` + +## Async all the way down + +**Where an API offers an async overload, use it** — `File.ReadAllTextAsync`, not `File.ReadAllText`. +**And the rule reaches through test helpers:** a helper calling such an API is itself converted. Being +a helper is not an exemption. + +The build enforces the first half and cannot enforce the second, which is why the second needs saying: + +- `VSTHRD103` is an **`error`** and flags a sync call with an `Async`-suffixed equivalent **when the + call sits inside a `Task`-returning method**. In an `async Task` test, `File.ReadAllText` fails the + build. +- A **sync helper is not `Task`-returning**, so the identical call inside it is invisible to the + analyzer. Pushing the call one level down turns a build error into nothing. + +It also compounds with the cancellation rule, which keys off whether anything in the chain accepts a +`CancellationToken`: a sync helper accepts none, so it **silently removes the trigger** and the test is +uncancellable while looking compliant. (TUnit also runs tests in parallel, so a blocking read holds a +thread-pool thread an async read would release.) + +Converting — three things change together, and the first two are not optional: + +```csharp +// Before — invisible to VSTHRD103, and the chain accepts no token +static string Documentation => File.ReadAllText(DocumentationPath); + +// After +static async Task ReadDocumentationAsync(CancellationToken cancellationToken) => + await File.ReadAllTextAsync(DocumentationPath, cancellationToken); +``` + +- It returns a `Task`, bringing every call inside it back under `VSTHRD103`. +- It gains the **`Async` suffix** — `VSTHRD200` is an `error`, so the rename is enforced. +- It takes a trailing `CancellationToken`, because the chain now accepts one; its callers declare and + forward one. + +**A property cannot be `async`, so it becomes a method** — that is the conversion, not an exemption. +A read-only property hiding IO is the most common form of this violation. + +It genuinely cannot be converted when **no async overload exists** (`File.Exists`, `File.Delete`, +`Directory.EnumerateFiles`; likewise `ArchitecturePolicy.Load()` and `PolicySchemaValidator.Validate()`, +which `Purview.Architecture.Testing` ships synchronous-only) or when **a base type or interface fixes +the signature**. State the reason at the call. Never fake it: `Task.Run` around a blocking call, +`Task.FromResult` around a sync result, or `.Result` / `.GetAwaiter().GetResult()` to flatten a task — +the last two are `VSTHRD002`, an `error`. + +## Cancellation + +Three parts, triggered by the **call chain** rather than by the test: + +1. **If any method the test calls accepts a `CancellationToken`, the test declares one.** Not only the + method under test — anything in the chain, including a `File.ReadAllTextAsync` or the delegate handed + to `Assert.That`. +2. **It is the last parameter**, and TUnit supplies the value. With `[Arguments]`, the data binds to the + leading parameters and the token is supplied on top, so the attribute's positional order still matches + the signature: + + ```csharp + [Test] + [Arguments("build-enforced", true)] + public async Task ListEnforcedByBuildAsync_GivenAConvention_FiltersIt( + string id, + bool enforcedByBuild, + CancellationToken cancellationToken + ) + ``` + +3. **It is forwarded to every call that accepts one.** Never `CancellationToken.None`, never `default`, + never a token the test creates. TUnit's token is the *test's* token — cancelled on timeout or on an + aborted run — so a call given `None` cannot be stopped, and **one hop that drops it makes the whole + chain uncancellable**, which is why partial forwarding is a failure and not a lesser version of the + rule. It is also the only place that proves the production code threads the token through at all. + +A test whose chain accepts no token takes no parameter; an unused token is noise, not compliance. + +## Substitutes + +TUnit.Mocks is globally imported by the SDK, so no `using` is needed: + +```csharp +[Test] +public async Task RegisterAsync_GivenAnAlreadyRegisteredConvention_DoesNotStoreIt(CancellationToken cancellationToken) +{ + // Arrange + var repository = CreateRepository(); + repository.ExistsAsync(Any(), Any()).Returns(true); + var catalogue = CreateCatalogue(repository.Object); + + // Act + await Assert + .That(async () => await catalogue.RegisterAsync(TestBuilder.EnforcedConvention(), cancellationToken)) + .Throws(); + + // Assert + repository.AddAsync(Any(), Any()).WasNeverCalled(); +} +``` + +- `Mock.Of()` produces a `Mock`; `.Object` is the instance you pass in. +- `Any()` matches any argument, `Is(value)` a specific one, `AnyArgs()` the whole call. +- `.Returns(value)` sets the result. For a `Task`-returning method, return the `T` — the task is + unwrapped for you. +- `.WasCalled(Times.Once)` and `.WasNeverCalled()` are the assertions. + +The `// Assert` after a `Throws` is not a mistake: that test's point is that the exception was thrown +**and** that nothing was written, and both are assertions about the same act. + +## `TestBuilder` + +It lives in the shared testing project and is the single means by which a test creates domain +content: + +```csharp +public static class TestBuilder +{ + public const int DefaultCount = 3; + + internal static Faker Faker { get; } = new(); + + public static Domain.ConventionId ConventionId(string? value = null) => + Domain.ConventionId.Create(value ?? $"{Slug()}-{Faker.Random.String2(6, LowercaseAlphabet)}"); + + public static Domain.Convention Convention( + Domain.ConventionId? id = null, + string? title = null, + VerificationTier? tier = null, + bool? enforcedByBuild = null) => + Domain.Convention.Create( + id ?? ConventionId(), + title ?? Faker.Lorem.Sentence(4), + tier ?? Faker.Random.Enum(), + enforcedByBuild ?? Faker.Random.Bool()); +} +``` + +Four rules make it work: + +- **Omitted arguments are generated; supplied arguments are used exactly.** A test overrides only the + value it is proving, and everything else is realistic without the test saying so. +- **A collection factory comes with a companion.** `Conventions(int count = DefaultCount)` generates a + distinct set, so a test needing "some" gets "some" without its own loop. +- **Bogus is a global `using`.** `Faker` is available with no import — which is also why an explicit + `using Bogus;` in a shared testing project is reported as unnecessary. +- **A value-object test is the one exception.** A test proving what `Create` and `Hydrate` accept and + reject has to name the rejected input itself, so it constructs the invalid value directly. + Everything valid still goes through the builder. + +## Two constraints that shaped the builder + +Both are consequences of SDK rules, not preferences, and both were found by building it. + +**The factories are flat, not grouped.** The natural shape is a nested class per feature area +(`TestBuilder.Catalogue.Convention(...)`), and it cannot compile: the SDK ships +`dotnet_diagnostic.CA1034.severity = error`, so a publicly visible nested type fails the build, and +`PRSGD0008` refuses to let a repository add `CA1034` to `NoWarn`. Making the group `internal` does +not help either — the SDK's `InternalsVisibleTo` target runs on non-test projects and grants access +*to* test and shared-testing projects rather than *from* them, so a shared testing project's +internals are not visible to the tests beside it. An area that outgrows a flat builder gets its own +top-level builder class. + +**`var` is the house style, with one carve-out.** `csharp_style_var_elsewhere = true:warning` makes +an explicit type on a declaration initialised from a method call a warning (`IDE0007`). The carve-out +is object creation: the `PDS0003` analyzer prefers an explicit type with target-typed `new()`: + +```csharp +var id = TestBuilder.ConventionId("build-enforced"); // method call -> var +string title = new('a', Convention.MaxTitleLength); // new() -> explicit type +``` + +## What is enforced, and what is review + +| Rule | Enforced by | +| --- | --- | +| TUnit is the only framework | the build — the SDK adds it; a second has to be added by hand | +| Test projects are discovered and categorised | the build (name-suffix detection) | +| A shared testing project is referenced, not executed | the build (`IsSharedTestingProject`) | +| Style, including the `var` carve-out | the build (analyzers, `warning` or above) | +| An acceptance suite is categorised `Acceptance` and kept out of the fast tier | the build (name-suffix detection) and the pipeline's `TestFilter` | +| A container-backed suite fails rather than passes when its backend is missing in CI | the pipeline, when the backend is pinned with `PURVIEW_CONTAINERS_BACKEND` | +| AAA structure, naming, `CreateXXX` helpers, `TestBuilder` usage | **review** | +| Given/When/Then chains end in `.BDTestAsync()` | **review** | +| A real dependency comes from `Purview.Containers`, not a shared instance | **review** | +| An async overload is used inside an `async Task` test | the build — `VSTHRD103` is `error` | +| A converted helper carries the `Async` suffix | the build — `VSTHRD200` is `error` | +| A **sync helper** calling an API with an async overload is converted | **review** — a non-`Task`-returning method is outside `VSTHRD103`'s scope | +| A declared `CancellationToken` is the last parameter | the build — `CA1068` is `warning`, and a clean build has no warnings | +| A test whose chain accepts a token declares one and forwards it everywhere | **review** | + +## Acceptance tests and container-backed tests + +An acceptance test — a behaviour somebody outside the team asked for, in their words — is written with +**BDTest over TUnit** (`BDTest.TUnit`) in a `.AcceptanceTests` project, where Given/When/Then +replaces the AAA comments. A suite that needs a real database, queue or emulator gets a throwaway +container from **`Purview.Containers`** in its own `.IntegrationTests` project, skipping +locally when no backend is usable and failing in CI. + +Read `references/acceptance-and-container-tests.md` before writing either: it carries the scenario and +container shapes, and the two traps — a chain that never calls `.BDTestAsync()` runs no steps and still +passes, and a suite that skips in CI turns a missing runtime into a green job. + +## Tiers + +A test project's **name** carries its tier — the SDK categorises `*UnitTests` as `Unit`, and takes the +word before `Tests` as a pattern, so `*AcceptanceTests` gives `Acceptance` with nothing added to the +pipeline. The `TestFilter` then selects a tier without a hand-written attribute. Split a slow suite +into its own project rather than tagging individual tests. + +Filtering uses TUnit's `--treenode-filter`, not `--filter`: these repositories run on +Microsoft.Testing.Platform, where `dotnet test --filter` reports "Zero tests ran". + +## At your scale + +- **One test project.** TUnit, AAA, the naming convention, `TestBuilder`. That is the whole of it. +- **A shared fixture.** Move it into `SharedTestingInfra` and make the class public — a shared + testing project is a library, so its fixtures are legitimately public API and `CA1515` does not + apply. +- **A database or a container.** One `.IntegrationTests` project and one `Purview.Containers` + module. Do not build a fixture framework around it until a second suite needs the same container. +- **A behaviour somebody outside the team asked for.** That, and not before, is when BDTest earns its + place: one `.AcceptanceTests` project with one story. Reach for the HTML report only once + somebody will read it. +- **A package with a documented contract.** Add a consumer-verification script; see + `purview-engineering-principles`. + +## Related skills + +`purview-engineering-principles` for the rules this implements, and `purview-architecture-testing` +for turning an architectural boundary into a failing build. diff --git a/.agents/skills/purview-testing-conventions/references/acceptance-and-container-tests.md b/.agents/skills/purview-testing-conventions/references/acceptance-and-container-tests.md new file mode 100644 index 0000000..e8b9110 --- /dev/null +++ b/.agents/skills/purview-testing-conventions/references/acceptance-and-container-tests.md @@ -0,0 +1,117 @@ +# Acceptance tests and container-backed tests + +Two shapes that sit outside the unit tier. Both keep TUnit as the runner, and both live in their own +test project so the tier comes from the project name rather than an attribute. + +## Acceptance tests: BDTest + +An acceptance test states a behaviour somebody outside the team asked for, in their words. Use +**BDTest over TUnit through the `BDTest.TUnit` adapter**, in a `.AcceptanceTests` project +(the SDK emits `Category=Acceptance` for it). + +Not Gherkin, SpecFlow or Reqnroll, for two reasons: + +- **BDTest is code-only.** No `.feature` file and no generated test body, so there is no second + artefact that can drift from the step implementing it. That drift is the failure that makes a BDD + suite stop being trusted. +- **One runner stays one runner.** `BDTest.TUnit` is an adapter over TUnit, so the SDK's wiring, the + emitted category and `--treenode-filter` are all unchanged. A BDD framework with its own runner is + exactly what the one-framework rule refuses. + +```csharp +[Story(AsA = "account holder", IWant = "to withdraw money", SoThat = "I can use my savings")] +public class WithdrawalTests : TUnitBDTestBase +{ + AccountSteps Account => new(Context); + + [Test] + [ScenarioText("Withdrawing money reduces the balance")] + public async Task WithdrawMoney() => + await Given(() => Account.CreateAnAccount(100m)) + .When(() => Account.Withdraw(25m)) + .Then(() => Account.TheBalanceIs(75m)) + .BDTestAsync(); +} + +public class AccountSteps(AccountContext context) +{ + [StepText("I have an account with a balance of {0}")] + public void CreateAnAccount(decimal balance) => context.Balance = balance; + + [StepText("I withdraw {0}")] + public void Withdraw(decimal amount) => context.Balance -= amount; + + [StepText("the account balance is {0}")] + public async Task TheBalanceIs(decimal expected) => await Assert.That(context.Balance).IsEqualTo(expected); +} +``` + +Five rules, and the first is the one that bites: + +- **`.BDTestAsync()` runs the scenario.** A chain stopping at `.Then(...)` compiles, passes, and + executes none of its steps — the same failure mode as an un-awaited `Assert.That`. +- **Given/When/Then replaces Arrange/Act/Assert, and only here.** The chain already names the three + phases. Everywhere else the AAA comments stay. +- **`Context` is the only state.** Steps take it through the constructor and hold no fields of their + own; BDTest gives each test its own context, which is what keeps parallel and repeated runs honest. +- **`[StepText]` is written for the report's reader.** `{0}` is replaced with the argument's actual + value at run time, so the report states what the scenario did, not what it was called. +- **Domain content still comes from `TestBuilder`.** The vocabulary changes at the step; the test-data + rule does not. + +Assertions inside a step are ordinary awaited TUnit assertions. `BDTest.Reporting` produces the +single-file HTML report — publish it as a build artifact from the job that ran the suite. + +## A test that needs a real dependency: `Purview.Containers` + +A database, queue, blob store or emulator comes from `Purview.Containers`: a throwaway Linux container +behind one API over Microsoft WSL Containers or Docker, with typed modules for SQL Server, PostgreSQL, +Redis, MySQL, RabbitMQ, Azurite and NATS. + +The two refused alternatives: + +- **A shared instance** (a local SQL Server, a team database) makes tests order-dependent and failures + unreproducible — the state a test found was left by whatever ran before it, on whatever machine. +- **Testcontainers directly** ties the suite to a Docker installation, which on Windows is an install + and a licensing question per developer and per runner. The WSL backend removes it, and the same test + code runs on either. + +```csharp +await using var postgres = new PostgreSqlBuilder() + .WithDatabase("tests") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + +await postgres.StartAsync(); + +await using var connection = new NpgsqlConnection(postgres.GetConnectionString()); +await connection.OpenAsync(); +``` + +`Build()` validates the configuration, `StartAsync()` returns only once every wait strategy is +satisfied — there is no sleep to tune — and `await using` disposes the container at end of scope. + +Three rules: + +- **Its own `.IntegrationTests` project**, so the tier is in the name and the suite stays out + of the pull-request filter without anybody remembering an attribute. +- **Skip locally when no backend is usable; fail in CI.** Two different situations, so never one + outcome. Locally, probe and skip. In CI, pin the backend with `PURVIEW_CONTAINERS_BACKEND` — a named + backend never falls back, so a missing runtime fails loudly instead of letting a green job mean + nothing ran: + + ```csharp + [Before(Class)] + public static async Task EnsureABackendIsAvailable() + { + var backends = await ContainerBackends.ProbeAllAsync(); + + Skip.Unless(backends.Any(backend => backend.IsUsable), "No container backend is available."); + } + ``` + + Left unpinned, automatic selection probes in priority order and prefers WSL Containers over Docker. +- **Pin the version exactly.** `Purview.Containers` is **experimental and prerelease** — API, defaults + and packaging rules can change between releases. The packages are a family, so one property in + `Directory.Packages.props` governs all of them and a bump cannot be applied to half. diff --git a/.agents/skills/purview-value-objects-diagnostics/SKILL.md b/.agents/skills/purview-value-objects-diagnostics/SKILL.md new file mode 100644 index 0000000..7f6b150 --- /dev/null +++ b/.agents/skills/purview-value-objects-diagnostics/SKILL.md @@ -0,0 +1,89 @@ +--- +name: purview-value-objects-diagnostics +description: >- + Use when a Purview.ValueObjects build reports a VO1xxx diagnostic, when deciding whether to + suppress one, or when adding a new analyzer rule to the value-objects source generator and its + AnalyzerReleases tracking files. +metadata: + product: Purview.ValueObjects + repository: purview-dev/value-objects +--- + +# Purview.ValueObjects diagnostics + +`Purview.ValueObjects` ships a Roslyn analyzer and source generator that report the `VO1xxx` rules +below. Every rule is enabled by default. + +Suppress a warning the usual ways — `#pragma warning disable VO1006`, a `NoWarn` entry, or +`dotnet_diagnostic.VO1006.severity = none` in `.editorconfig`. **Errors cannot be downgraded**: they +mark declaration shapes the generator cannot emit code for at all, so silencing one only moves the +failure to the missing member. + +## Core shape rules + +| Rule | Severity | Reported when | Fix | +| --- | --- | --- | --- | +| `VO1001` | Error | A `[Scalar]` or `[ValueObject]` type is not declared `partial`. | Add `partial`. The code fix in `Purview.ValueObjects.SourceGenerator.Refactorings` does it for you. | +| `VO1002` | Error | The value object is nested inside another type. | Move it to its own top-level type; nesting is not supported. | +| `VO1003` | Error | The value object is generic. | Remove the type parameters, or declare one concrete value object per closed type. | +| `VO1004` | Error | A `[Scalar]` type does not declare the configured scalar property. | Declare it, or set `ScalarAttribute.PropertyName` to the one you have. | +| `VO1006` | Warning | A `[Scalar]` type is not a `readonly record struct`. | Prefer `readonly record struct`, so equality, immutability and allocation behaviour match the contract. | +| `VO1007` | Warning | `ValueObjectDeserializationMode.Strict` is set but no `Create` overload exists to re-validate through. | Add the `Create` overload, or use the default `Hydrate` mode. | +| `VO1008` | Error | `[Scalar]` and `[ValueObject]` are both applied to one type. | Pick one: a scalar wraps a single primitive, a value object composes members. | +| `VO1016` | Warning | A value object member is mutable. | Make it `readonly`/`init`-only — a mutable member breaks immutability and makes equality unstable. | +| `VO1022` | Error | A `[Scalar]` type declares a member with the name of the property the generator owns. | Remove the declaration, or use `[Scalar]` without a type argument and own the property yourself. | +| `VO1028` | Error | `[Scalar(Nullable = true)]` is combined with a declared `Value`, so nullability has two sources. | Remove the declared property; convert to the manual `[Scalar]` form; or remove `Nullable = true`. The code fix offers all three. | +| `VO1029` | Error | Explicit `Nullable = false` contradicts a declared nullable `Value`. | Remove the declared `Value`, or use the manual `[Scalar]` form. | +| `VO1030` | Error | The automatic form's declared `Value` type does not match the underlying type. | Remove the declaration, or use the manual `[Scalar]` form with a matching type. | + +## String normalization rules + +| Rule | Severity | Reported when | Fix | +| --- | --- | --- | --- | +| `VO1023` | Warning | Built-in string normalization is configured but the type implements `OnNormalize` or declares its own `Create`. | Remove the option, or move the normalization into the hook/`Create`. The hand-written path wins either way. | +| `VO1024` | Warning | Built-in string normalization is configured on a non-string scalar or member. | Remove the option; `Trim`/`Casing` apply only to `string`. | +| `VO1025` | Error | `[StringNormalize]` is on a member of a `[Scalar]`, on a type that is neither `[Scalar]` nor `[ValueObject]`, or on a property that is not a value member. | Move it to a string member of a complex `[ValueObject]`; on a scalar set `Trim`/`Casing` on `[Scalar]`. | +| `VO1026` | Error | An author-declared `[assembly: ValueObjectDefaults]` repeats a value already supplied by the packaged targets from `ValueObjectsDefault*` MSBuild properties. | Remove the argument, or set a different value. | +| `VO1027` | Error | A `[Scalar]`/`[ValueObject]` option repeats the effective assembly default. | Remove the option; it repeats the inherited value. | + +Restating an assembly or type default is reported as `VO1026`/`VO1027` so each option has a single source of +truth. Only `[StringNormalize]` remains a per-member override outside that check; a value that changes the +effective configuration still raises `VO1023`/`VO1024` where those rules apply. + +## ZodSharp validation rules + +| Rule | Severity | Reported when | Fix | +| --- | --- | --- | --- | +| `VO1013` | Warning | `OnValidate` is declared but never invoked, because `ZodSchemaMode.InsteadOfHooks` is set. | Remove `OnValidate`, or choose a mode that keeps the hooks (`InAdditionToHooks`). | +| `VO1015` | Error | The ZodSharp `SchemaName` is not a valid C# identifier. | Give it a valid identifier. | + +## Entity Framework rules + +| Rule | Severity | Reported when | Fix | +| --- | --- | --- | --- | +| `VO1009` | Warning | EF mapping is requested but `Microsoft.EntityFrameworkCore` is not referenced. | Add the package reference, or stop requesting EF mapping. | +| `VO1010` | Warning | EF auto-conversion was skipped: the underlying type is not mappable. | Map it by hand with a custom `ValueConverter`. | +| `VO1017` | Warning | EF JSON mapping is requested without the JSON converter. | Register the JSON converter for the value object. | +| `VO1018` | Warning | EF complex mapping cannot convert one of the members. | Simplify the member, or map it explicitly. | +| `VO1019` | Warning | EF complex-type mapping is requested but the project resolves to EF Core 7 or earlier. | Target EF Core 8+, or choose a different `EntityFrameworkMapping`. | +| `VO1021` | Warning | EF key value generation is unavailable for the value object. | Assign keys explicitly rather than relying on store generation. | +| `VO1031` | Warning | A nullable scalar is used as a non-nullable property on an entity or complex-type-mapped `[ValueObject]`. EF Core maps that to a required column and cannot persist the scalar's `Null()`. | Declare the property as `T?`, or use a non-nullable scalar behind a nullable property. The rule only runs where EF Core is referenced and only for entity/complex types the compilation can see; DTOs are not reported. | + +## Retired identifiers — never reused + +`VO1005`, `VO1011`, `VO1012`, `VO1014` and `VO1020` were used during development and withdrawn before +release. No version reports them, and the numbering is intentionally discontinuous. An occurrence of +one of these IDs means stale tooling or a stale suppression: remove it. + +`VO1005` is worth calling out. It was catalogued as an error ("scalar constructor is missing") and +documented as requiring you to declare a constructor. It never had a reporting site, and the +requirement was the **opposite** of how the generator works: when a `[Scalar]` type declares no +constructor taking the underlying type, the generator emits a private one. Nothing ever asked you to +write it. + +## Adding or changing a rule + +Rules are tracked in `src/src/SourceGenerator/AnalyzerReleases.Shipped.md` and +`AnalyzerReleases.Unshipped.md`, which Roslyn's `RS2008` catalogue rule enforces. A new or changed +diagnostic must be recorded in the same change; unshipped entries move into a new +`## Release ` block when that version ships. Never reuse a retired identifier. diff --git a/.agents/skills/purview-value-objects-efcore/SKILL.md b/.agents/skills/purview-value-objects-efcore/SKILL.md new file mode 100644 index 0000000..8800c71 --- /dev/null +++ b/.agents/skills/purview-value-objects-efcore/SKILL.md @@ -0,0 +1,330 @@ +--- +name: purview-value-objects-efcore +description: >- + Use when persisting Purview.ValueObjects with Entity Framework Core - ConfigureValueObjects, + UseValueObjects, scalar-to-column conversion, complex-type versus JSON mapping, value objects as + keys and foreign keys, query translation, generated key values, migrations, and the VO1009-VO1031 + Entity Framework diagnostics. +metadata: + product: Purview.ValueObjects + repository: purview-dev/value-objects +--- + +# Value objects and Entity Framework Core + +`Purview.ValueObjects` source-generates the EF Core mapping **into the consuming project**, and only +when that project references `Microsoft.EntityFrameworkCore`. The runtime package itself carries no +EF dependency, so a domain assembly stays free of Entity Framework while the persistence assembly +gets the converters, comparers and conventions. + +Everything is opt-in per feature with a three-level opt-out: the `DisableValueObjectsEFGeneration` +MSBuild property, then assembly defaults (the attribute, or the `ValueObjectsDefaultEFMapping`, +`ValueObjectsDefaultGenerateEFConverter`, `ValueObjectsDefaultGenerateEFComparer` and +`ValueObjectsDefaultGenerateEFValueGenerator` properties), then per-type options. + +## Wiring it up + +Two ways, pick one: + +```csharp +// 1. In the context. +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.ConfigureValueObjects(); +} +``` + +```csharp +// 2. At registration - no OnModelCreating override needed. +services.AddDbContext(options => + options.UseSqlServer(connectionString).UseValueObjects()); + +services.AddDbContextFactory(options => + options.UseSqlite("Data Source=shop.db").UseValueObjects()); +``` + +`ConfigureValueObjects` is emitted into the `Microsoft.EntityFrameworkCore` namespace — the same +namespace as `ModelBuilder` — so no extra `using` is needed. `UseValueObjects()` registers a +generated `ModelCustomizer` that runs it after `OnModelCreating`, and works for `AddDbContext`, +`AddDbContextFactory`, `AddDbContextPool` and manual construction. + +## What gets mapped + +| Shape | Maps to | Notes | +| --- | --- | --- | +| `[Scalar]` | one column of the underlying primitive | via `{Type}.EF.Converter` + `ValueComparer`; `EmailAddress` is a `TEXT` column | +| `[ValueObject]` (default) | an EF Core complex type — one column per member | EF Core 8+ only; nested scalars convert too | +| `[ValueObject(EFMapping = EntityFrameworkMapping.Json)]` | a single JSON column | a value converter, so it works on every EF Core version | + +The provider-to-model direction always goes through `Hydrate(...)`. **Materialization is a replay +path**: neither `OnValidate` nor a ZodSharp schema runs when a row is read. That matches +`ValueObjectDeserializationMode.Hydrate`, and `Strict` applies to the JSON wire format, not to the +database — validate before persisting, or enforce the invariant in the schema. + +An enum-backed scalar converts through the enum's **integral** type (`ValueConverter`). +Leaving the enum as the provider type makes EF compose its own enum-to-number converter with the +generated one, and the composite loses the provider tolerance that raw-primitive comparisons rely on. + +A plain `enum` property that is not a value object keeps EF Core's own enum mapping, untouched. + +## Nullable scalars + +A nullable scalar (`Nullable = true`, `[Scalar]`, or a manually declared `T?` value) is still a +struct. The registry unwraps `Nullable` before matching, so a `T?` property uses the generated `T` +converter and needs no hand-written converter. It marks the property optional where EF Core accepts it, +never overrides an explicit `IsRequired(...)`/`[Required]`, and never marks a key nullable. + +| Entity property | Column | Database `NULL` means | +| --- | --- | --- | +| `NullableString` (non-nullable struct) | required | impossible — `VO1031` warns | +| `NullableString?` | nullable | both CLR `null` and `NullableString.Null()` | +| `NonNullableString?` | nullable | absence only (two-scalar pattern) | + +`HasValue` is generated for a nullable scalar and for any reference-backed scalar, so +`dto.Email.HasValue` is the non-validating check. + +EF Core cannot mark a non-nullable struct property optional (`CannotBeNullable`), so a nullable scalar +on the non-nullable shape stays a required column. Declare the entity property as `T?`, or keep a +non-nullable scalar on the entity and map the nullable DTO scalar at the boundary: + +```csharp +[Scalar(Nullable = true)] public readonly partial record struct NullableString { } +[Scalar(Trim = true)] public readonly partial record struct NonNullableString { } + +public sealed class Customer { public NonNullableString? Email { get; set; } } // nullable column +public sealed record CustomerDto { public NullableString Email { get; init; } } // Null() = no value + +static CustomerDto ToDto(Customer entity) => new() +{ + Email = entity.Email is null + ? NullableString.Null() + : NullableString.Hydrate(entity.Email.Value.Value), +}; + +static Customer ToEntity(CustomerDto dto) => new() +{ + Email = dto.Email.HasValue + ? NonNullableString.Create(dto.Email.Value!) + : (NonNullableString?)null, // see the note below +}; +``` + +`Hydrate` is the replay path (no re-validation); `Create` applies the non-nullable scalar's +normalization and validation. **For a reference-backed scalar the null branch needs the explicit cast.** +A bare `null` is convertible to `NonNullableString` through the implicit creation operator, so the +conditional would produce a populated scalar whose `Value` is null instead of a null `Nullable`. A +value-type-backed scalar (`int`, `DateTimeOffset`) does not have that problem. + +`Null()` on a `T?` property is a null provider value: it is written as SQL `NULL` and reads back as a +CLR `null`. Consumers can also declare their own explicit conversion operators in one scalar's partial; +the generator does not invent cross-scalar conversions. + +## Querying — no `.Value` + +Because a scalar converts to its primitive column, a predicate compares the value object directly: + +```csharp +EmailAddress email = EmailAddress.Create("demo@example.com"); + +var customers = await db.Customers + .Where(c => c.Email == email) // translates to [email] = @p + .ToListAsync(); +``` + +Complex value objects map as complex types, so their members are queryable: + +```csharp +var orders = await db.Orders + .Where(o => o.Total.Amount > 20m) + .Where(o => o.Total.Currency == CurrencyCode.Create("USD")) + .ToListAsync(); +``` + +A property may also be compared to the **raw underlying value** — `c.Email == "demo@example.com"`, +`m.Id == guid` — because every generated converter accepts either shape. Without that, EF hands the +raw provider value to the converted property's built-in converter, which coerces with +`Convert.ChangeType` and throws for provider types that do not implement `IConvertible` (`Guid`, +`DateTimeOffset`, `TimeSpan`, `DateOnly`, `TimeOnly`) or cannot be converted at all (strings). See +[dotnet/efcore#32030](https://github.com/dotnet/efcore/issues/32030). + +Compiled models (`dotnet ef dbcontext optimize`) keep that tolerance because every generated +converter exposes a `JsonValueReaderWriter`-taking constructor and a `JsonReaderWriter` property, +which is what EF's design-time generator looks for. That detection is an undocumented EF +implementation detail: if it changes, compiled models fall back to the built-in converter and only +raw-primitive comparisons break. + +## Keys, foreign keys and indexes + +A value object key needs no special handling — the converter maps it to its primitive column, and +convention still makes `{Type}Id` the primary key: + +```csharp +sealed class Customer +{ + public CustomerId Id { get; set; } // primary key, stored as uniqueidentifier + public TenantId TenantId { get; set; } // foreign key to Tenant.Id + public Tenant Tenant { get; set; } = default!; +} +``` + +Column facets are configured on the value object property and apply to the converted column, exactly +as for a primitive: + +```csharp +public sealed class TenantConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.Property(entity => entity.Id).ValueGeneratedNever(); // when the domain owns the key + builder.Property(entity => entity.Key).HasMaxLength(100).IsRequired(); + builder.HasIndex(entity => new { entity.TenantId, entity.Key }).IsUnique(); + } +} +``` + +The converter is expression-based, so composite indexes, unique constraints and `HasQueryFilter` +behave like their primitive equivalents: + +```csharp +modelBuilder.Entity().HasQueryFilter(invoice => invoice.TenantId == currentTenant.TenantId); +``` + +**Complex types cannot be keys.** An identity spanning two or more members is awkward as a complex +type: either flatten the members onto the entity and put a unique index over them, or store the +value object as JSON and index the derived columns you actually query. + +## Generated key values + +A Guid-backed scalar can generate its own time-ordered keys without a database round trip. Opt in, +register one convention, and pick the ordering the store needs: + +```csharp +[Scalar(GenerateEFValueGenerator = true)] +public readonly partial record struct CustomerId { public Guid Value { get; } } + +protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) +{ + base.ConfigureConventions(configurationBuilder); + configurationBuilder.UseValueObjectKeyGenerators(); // version 7 + // configurationBuilder.UseValueObjectKeyGenerators(ValueObjectKeyOrdering.SqlServer); +} +``` + +Ordering matters: SQL Server's `uniqueidentifier` compares the trailing six bytes first, so plain +version 7 values are effectively random there and a clustered key fragments. Full detail — the +ordering table, the public `ValueObjectSequentialGuid` helper, and the rules about when EF invokes a +generator — is in [references/key-value-generation.md](references/key-value-generation.md). + +Value generation is Guid-backed scalars only, and requires the EF converter; anywhere else reports +`VO1021`. + +## Value objects in other assemblies + +| Declaring assembly | What it emits | How the consumer discovers it | +| --- | --- | --- | +| References EF Core | a nested `EF` class and an `IEFScalarValueObject`/`IEFComplexValueObject` marker per type | through the marker interfaces | +| Does **not** reference EF Core | nothing EF-related | through the `[Scalar]`/`[ValueObject]` attributes; converters are generated **inline** in the consumer's registry | + +Either way the consuming project's registry maps them, so a shared `EmailAddress` is converted on +your entities without being configured twice. + +A provider assembly that only *defines* value objects and owns no `DbContext` should opt out of its +own registry, so consumers do not get duplicate `ValueObjectEFExtensions`/`ValueObjectModelCustomizer` +types in the `Microsoft.EntityFrameworkCore` namespace: + +```xml + + true + +``` + +A complex value object is discovered across assemblies when it opted into a mapping or a comparer +(including the default `ComplexType` mapping with `GenerateEFComparer = false`). Only one that opted +out entirely — `EFMapping = None` **and** `GenerateEFComparer = false` — is not discovered; configure +that one on the entity by hand. + +## Configuring a property explicitly + +The nested `EF` class is there when you want the registry's work done by hand: + +```csharp +builder.Entity() + .Property(c => c.Email) + .HasConversion(EmailAddress.EF.Converter, EmailAddress.EF.Comparer); + +builder.Entity() + .ComplexProperty(o => o.Total, money => + { + money.Property(m => m.Amount); + money.Property(m => m.Currency).HasConversion(CurrencyCode.EF.Converter, CurrencyCode.EF.Comparer); + }); +``` + +## Opting out + +```csharp +// Per type. +[Scalar(GenerateEFConverter = false, GenerateEFComparer = false)] +public readonly partial record struct InternalCode { } + +[ValueObject(EFMapping = EntityFrameworkMapping.Json)] +public readonly partial record struct Audit { } + +[ValueObject(EFMapping = EntityFrameworkMapping.None, GenerateEFComparer = false)] +public readonly partial record struct Notes { } +``` + +```csharp +// Per assembly. +[assembly: ValueObjectDefaults(EFMapping = EntityFrameworkMapping.Json)] +[assembly: ValueObjectDefaults(GenerateEFConverter = false, GenerateEFComparer = false)] +``` + +```xml + + + true + +``` + +## Schema and migrations + +- A scalar is a **single column** of its primitive, so `dotnet ef migrations add` sees the primitive. + Renaming the value object's property changes no schema; changing the underlying type is a column + type change you review like any other. +- A complex-type-mapped value object is a **set of columns** named after its members; a JSON-mapped + one is a **single JSON column**. Moving between the two is a schema change that usually needs a + data migration to reshape existing values. +- Converters and the key generator convention are provider-independent, so one model serves SQL + Server, PostgreSQL and SQLite; only the primitive column types differ. Keep one migration set per + provider. +- `IDesignTimeDbContextFactory` and model-cache keys that depend on runtime state (a query + filter built from the current tenant, say) must be applied consistently — a cached model is reused + for every context created the same way. + +## Version support + +Compiled and tested against EF Core **7, 9 and 10** (EF Core 7 needs `net8.0`), in +`src/tests/ValueObjects.EFCompatibility.IntegrationTests`, so version-specific API shifts are caught +by this build rather than by a consumer. + +EF Core 8+ is required for **complex type mapping**, the complex-type block in the registry, and the +compiled-model `JsonReaderWriter` members. On EF Core 7, a complex value object reports `VO1019` and +is left unmapped — use `EFMapping = Json` or configure the property manually. Everything else +(converters, the key value generator, the convention, `ValueObjectSequentialGuid`) works on EF Core 7 +and later. + +## Diagnostics + +`VO1009` (EF option set without an EF reference), `VO1010` (underlying type not mappable), `VO1017` +(JSON mapping without the JSON converter), `VO1018` (a complex member cannot be converted), `VO1019` +(complex type on EF Core 7 or earlier), `VO1021` (value generation unavailable) and `VO1031` (a +nullable scalar on a non-nullable mapped property) are the EF rules. +The `purview-value-objects-diagnostics` skill carries the full `VO1xxx` catalogue. + +## Where to look in the repository + +- `src/src/Sample` — a runnable EF Core (SQLite) example. +- `src/tests/ValueObjects.IntegrationTests/Serialization/EntityFrameworkIntegrationTests.cs` — the + integration tests. +- `src/tests/ValueObjects.EFCompatibility.IntegrationTests` — the per-version compatibility suite. diff --git a/.agents/skills/purview-value-objects-efcore/references/key-value-generation.md b/.agents/skills/purview-value-objects-efcore/references/key-value-generation.md new file mode 100644 index 0000000..c12b84f --- /dev/null +++ b/.agents/skills/purview-value-objects-efcore/references/key-value-generation.md @@ -0,0 +1,101 @@ +# Generated key values + +Opt a Guid-backed scalar into key generation per type, or once for the assembly: + +```csharp +[Scalar(GenerateEFValueGenerator = true)] +public readonly partial record struct CustomerId +{ + public Guid Value { get; } +} + +// or: +[assembly: ValueObjectDefaults(GenerateEFValueGenerator = true)] +``` + +The generator emits an `EF.ValueGeneratorFactory` on the value object and registers every opted-in +type in the generated `ValueObjectKeyValueGeneratorConvention`. Register that convention once: + +```csharp +protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) +{ + base.ConfigureConventions(configurationBuilder); + configurationBuilder.UseValueObjectKeyGenerators(); +} + +protected override void OnModelCreating(ModelBuilder modelBuilder) +{ + modelBuilder.ConfigureValueObjects(); +} +``` + +## What the convention guarantees + +- An **unset** key (still `Guid.Empty`) is assigned a time-ordered identifier when the entity is + added, so keys are unique without a database round trip and sort by creation time in stores that + compare identifiers byte by byte. +- A key the **domain already set** is never touched. Entity Framework Core only invokes a generator + while the property holds its CLR default. +- The convention runs at the lowest configuration source, so an entity configuration that owns its + keys — `ValueGeneratedNever()`, an explicit generator, a store-generated default — always wins. + +The identifier is a time-ordered UUID created with base Entity Framework Core APIs only, so the +convention holds for every provider. + +## Key ordering + +Where a store compares the identifier's sixteen bytes in order — PostgreSQL, SQLite, MySQL, and +non-clustered SQL Server keys — version 7 ordering is what you want. SQL Server's `uniqueidentifier` +compares the **trailing six bytes first**, so plain version 7 values are effectively random there and +a clustered key fragments. + +```csharp +// Default: version 7, ascending in plain byte order. +configurationBuilder.UseValueObjectKeyGenerators(); + +// SQL Server: the timestamp moves into the trailing six bytes, which SQL Server compares first. +configurationBuilder.UseValueObjectKeyGenerators(ValueObjectKeyOrdering.SqlServer); +``` + +| Ordering | Timestamp location | Ascends by creation time in | Well-formed version 7 UUID | +| --- | --- | --- | --- | +| `ValueObjectKeyOrdering.UuidV7` (default) | bytes 0-5 | PostgreSQL, SQLite, MySQL, non-clustered SQL Server keys | Yes | +| `ValueObjectKeyOrdering.SqlServer` | bytes 10-15 | SQL Server's `uniqueidentifier` ordering | No — a version 4 UUID | + +Both orderings produce unique identifiers in process, keep the domain's own value when it set one, +and start from a random value so nothing leaks about the sequence. + +Which bytes carry the timestamp is a **store** concern, so the ordering is chosen where the +convention is registered — in the persistence project, never on the value object. + +## Minting a key in application code + +`ValueObjectSequentialGuid` is public, in the `Microsoft.EntityFrameworkCore` namespace, so +application code — including code in another assembly — can mint the identifier it wants an entity to +carry, before the round trip that would otherwise assign one: + +```csharp +// The same value the convention would have generated, created where the domain needs it. +var customerId = CustomerId.Create(ValueObjectSequentialGuid.NewGuid()); +var customer = Customer.Create(customerId, tenantId, email, "Contoso"); +``` + +Because Entity Framework Core only invokes a generator while the property still holds its CLR +default, the key the application set is persisted as-is. + +| Member | Purpose | +| --- | --- | +| `NewGuid()` / `NewGuid(DateTimeOffset)` | Creates a version 7 identifier, from now or a given creation time. | +| `NewSqlServerGuid()` / `NewSqlServerGuid(DateTimeOffset)` | Creates a SQL Server-ordered identifier. | +| `TryGetTimestamp(Guid, out DateTimeOffset)` | Reads the creation time out of a version 7 identifier; false for any other shape. | +| `TryGetSqlServerTimestamp(Guid, out DateTimeOffset)` | Reads the creation time out of a SQL Server-ordered identifier. | +| `MinSqlServerGuidFor(DateTimeOffset)` / `MaxSqlServerGuidFor(DateTimeOffset)` | Inclusive bounds for a creation time, so `id >= Min… && id <= Max…` is an index seek. | + +## Limits + +- **Guid-backed scalars only**, and the Entity Framework converter must be enabled. Requesting it + anywhere else reports `VO1021` and emits no generator. +- A value object declared in an assembly that does not reference Entity Framework Core still gets a + generator: the consuming project emits it alongside the inline converters. +- A generated key value generator is typed as the **value object**, not its provider value, because + Entity Framework Core assigns what a generator returns straight to the property. diff --git a/.agents/skills/purview-value-objects-zodsharp/SKILL.md b/.agents/skills/purview-value-objects-zodsharp/SKILL.md new file mode 100644 index 0000000..ad82684 --- /dev/null +++ b/.agents/skills/purview-value-objects-zodsharp/SKILL.md @@ -0,0 +1,325 @@ +--- +name: purview-value-objects-zodsharp +description: >- + Use when validating Purview.ValueObjects with Purview.ZodSharp - the [ZodSchema] integration that + routes Create through a generated schema, ZodSchemaMode, the OnZodValidate refinement hook, custom + rules and [ZodRule] attributes on scalars, schema-first Z.* validation, DTO validation, and mapping + ZodException to ASP.NET Core Problem Details. +metadata: + product: Purview.ValueObjects + repository: purview-dev/value-objects +--- + +# Validating value objects with ZodSharp + +[Purview.ZodSharp](https://www.nuget.org/packages/Purview.ZodSharp) is a C# port of +[Zod](https://github.com/colinhacks/zod). It complements `Purview.ValueObjects` by splitting the +responsibility: **the value object owns the invariant, ZodSharp owns the rule definition and the +result shape**. + +Reference the package explicitly when you use its types — do not rely on transitive flow. The +ZodSharp source generator is active in any project that references the package. + +```text +dotnet add package Purview.ZodSharp +``` + +Four ways to use it, in rough order of how often you want them: + +1. **Generator-integrated** — `[Scalar]` + `[ZodSchema]`, and `Create` validates through the schema. +2. **Generated validators** — `[ZodSchema]` + DataAnnotations emits a zero-allocation `{Type}Schema`. +3. **Schema-first** — hand-build `Z.String()` / `Z.Number()` / `Z.Enum()` and construct through `Create`. +4. **DTO-first** — validate the request shape, then map onto value objects. + +## 1. Generator-integrated validation + +Annotate a value object with **both** `[Scalar]`/`[ValueObject]` **and** `[ZodSchema]`, and the +value-object generator routes the generated `Create` through the ZodSharp schema — no wiring: + +```csharp +using System.ComponentModel.DataAnnotations; +using Purview.ValueObjects.Serialization; +using ZodSharp; + +[Scalar] +[ZodSchema] +public readonly partial record struct EmailAddress +{ + [EmailAddress] + [StringLength(254, MinimumLength = 3)] + public string Value { get; } + + static partial void OnNormalize(ref string value) => value = value?.Trim().ToLowerInvariant()!; +} + +EmailAddress.Create("not-an-email"); // throws ZodException via EmailAddressSchema.Validate +``` + +`Create` constructs the instance, calls `{Type}Schema.Validate(instance)` and throws `ZodException` +on failure. **`Hydrate` stays replay-safe** — no schema, no hooks. +`ValueObjectDeserializationMode.Strict` deserializes through `Create`, so it picks the schema up too. + +`[ZodSchema]` reads DataAnnotations: `[Required]`, `[StringLength]`, `[Range]`, +`[RegularExpression]`, `[EmailAddress]`, `[AllowedValues]`, `[DeniedValues]`. It supports classes, +structs and records. + +### ZodSchemaMode + +| Mode | Behaviour | +| --- | --- | +| `InAdditionToHooks` (default) | the schema runs **and** `OnValidate` runs | +| `InsteadOfHooks` | the schema runs **instead of** `OnValidate`; `OnNormalize` still runs first | + +Leaving an `OnValidate` in place under `InsteadOfHooks` makes it unreachable — that is `VO1013`. + +### The automatic scalar form + +`[Scalar]` / `[Scalar(typeof(T))]` have the value-object generator declare the underlying +property, so a property-attached DataAnnotation has no host. For those types use Zod-native +**type-level** rules (`[RequiredZod]`, `[ZodRule(typeof(...))]`), which ZodSharp reads from the type +and adapts to the scalar's underlying value — or keep the manual `[Scalar]` form when you need +property-level DataAnnotations. Purview.ZodSharp 2.0.2+ generates `{Type}Schema` from the attribute +itself, so the automatic form validates through `Create` exactly like the manual one. + +A nullable scalar (`Nullable = true`, or a `T?` value type) round-trips JSON `null`, so its schema +and any string rule must be null-tolerant: use the shipped `[NullOrNonWhiteSpace]`, which accepts +`null` but rejects whitespace, rather than `[RequiredZod]`, which always rejects `null`. + +### `[ZodSchema]` options + +- `SchemaName` — overrides the generated class name (default `{TypeName}Schema`); the DI adapter + becomes `{SchemaName}Validator`. ZodSharp applies any non-empty value **verbatim**, including + whitespace, so an unusable name is reported as `VO1015` rather than silently falling back. +- `CustomValidationMethodName` — a static async method the generated validator's `ValidateAsync` + awaits after the synchronous rules pass (default `CustomValidationAsync`). + +## The `OnZodValidate` refinement hook + +For rules DataAnnotations cannot express — cross-member invariants, allowed domains, state checks — +implement the partial hook the ZodSharp generator declares: + +```csharp +[Scalar] +[ZodSchema] +public readonly partial record struct CorporateEmail +{ + [EmailAddress] + public string Value { get; } + + static partial void OnNormalize(ref string value) => value = value?.Trim().ToLowerInvariant()!; + + partial void OnZodValidate(RefineCtx context) + { + if (!context.Value.Value.EndsWith("@contoso.com", StringComparison.Ordinal)) + context.AddIssue("invalid_domain", "Corporate emails must use the contoso.com domain.", [nameof(Value)]); + } +} +``` + +Facts worth knowing: + +- The hook is declared **and invoked by the ZodSharp generator**, inside `{Type}Schema.Validate`, so + it runs for every entry point: `Validate`, `Parse`, the DI adapter, `IValidateOptions`, the + generated `Create`, and `Strict` deserialization. `Hydrate` never validates. +- Because it belongs to the schema, `{Type}Schema.Validate(instance)` reports exactly what `Create` + reports. +- The type and every containing type must be `partial`, so the generator can declare the hook. + ZodSharp reports `ZODSGEN034` (not partial) and `ZODSGEN035` (malformed signature). +- It is independent of `ZodSchemaMode`: `InsteadOfHooks` skips `OnValidate`, never `OnZodValidate`. +- The old `IEnumerable Validate()` refinement contract is **retired**; ZodSharp + reports `ZODSGEN036` for a member still using it. + +## Custom rules on scalars + +A scalar is validated as a **unit** — its single `Value` *is* the value. ZodSharp ships a validation +attribute for every built-in rule in `ZodSharp.Rules`: + +| Attribute | Rule | Notes | +| --- | --- | --- | +| `[NonSentinel(Message = "…")]` | `NonSentinelRule` | Rejects `Guid.Empty`, the `DateTime`/`DateTimeOffset`/`DateOnly`/`TimeOnly` bounds, and null/empty/whitespace strings. | +| `[Email]` | `EmailRule` | | +| `[E164]` | `E164Rule` | | +| `[UUID(UuidVersion.V4)]` | `UUIDRule` | A rule value with no default is a required constructor argument. | +| `[MinLengthZod(3)]` | `MinLengthRule` | The `Zod` suffix avoids the DataAnnotations clash. Also `[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`. | + +Each attribute mirrors its rule's constructor; `Message` and `Code` stay named properties, so an +error code or message can be overridden per use. + +```csharp +using ZodSharp; +using ZodSharp.Rules; + +[Scalar] +[ZodSchema] +[NonSentinel(Message = "AssetId must not be empty.")] +public readonly partial record struct AssetId +{ + public Guid Value { get; init; } +} +``` + +`AssetId.Create(Guid.Empty)` throws a `ZodException` whose error carries the rule's own `Code`, no +structured `Origin`, and an **empty path** — the rule applies to the value object, not a member. +Because it runs inside the schema, `Hydrate` stays replay-safe. + +Writing your own rule attribute, the automatic adapter that lets a rule written against the +underlying value validate the value object, and the `Code`/`Origin` resolution order are in +[references/custom-rules.md](references/custom-rules.md). + +## Schema-first validation + +When you do not want the generator involved, build a schema for the **underlying** value and +construct through `Create`: + +```csharp +using ZodSharp; + +public static class ScalarSchemas +{ + public static readonly IZodSchema EmailSchema = Z.String().Email().Min(3).Max(254); + public static readonly IZodSchema CurrencySchema = Z.String().Regex("^[A-Z]{3}$"); + public static readonly IZodSchema OrderStatusSchema = Z.Enum(); + public static readonly IZodSchema MoneyAmountSchema = Z.Number().Positive(); +} +``` + +ZodSharp validates the raw value **exactly as supplied** — normalization is the value object's job in +`OnNormalize`. So: validate the raw input, then `Create`, which normalizes and wraps it. + +## Validating DTOs before mapping + +```csharp +[ZodSchema] +public sealed partial class RegistrationDto +{ + [Required, StringLength(100, MinimumLength = 2)] + public string Name { get; init; } = string.Empty; + + [Range(13, 120)] + public int Age { get; init; } + + [Required, EmailAddress] + public string Email { get; init; } = string.Empty; + + partial void OnZodValidate(RefineCtx context) + { + if (context.Value.Name.StartsWith("x", StringComparison.OrdinalIgnoreCase)) + context.AddIssue("name", "Name cannot start with 'x'.", [nameof(Name)]); + } +} + +var result = RegistrationDtoSchema.Validate(dto); + +if (result.IsSuccess) +{ + var email = EmailAddress.Create(result.Value.Email); + var money = Money.Create(19.99m, CurrencyCode.Create("USD")); +} +``` + +Async rules go through `CustomValidationMethodName`, naming a +`static ValueTask> Method(T value, CancellationToken)` that the generated +`{Type}SchemaValidator.ValidateAsync` awaits once the synchronous rules pass. + +## Dependency injection + +`ZodSchemaFactory` resolves validators by validated type: + +```csharp +using ZodSharp.Core; + +ZodSchemaFactory factory = new(); + +factory.Register(new EmailAddressSchemaValidator()); // generated adapter +factory.Register(new ZodSchemaValidator(ScalarSchemas.EmailSchema)); // hand-built + +var emailResult = factory.Validate(EmailAddress.Create("demo@example.com")); +``` + +## Error handling + +`ValidationResult` is a struct with `IsSuccess`, `Value` (only on success) and `Errors` +(`ImmutableArray`). Each `ValidationError` carries `Code`, `Message` and `Path`, +plus optional `Origin`, `Category` and size bounds: + +```csharp +foreach (var error in result.Errors) + Console.WriteLine($"{string.Join(".", error.Path)}: {error.Message} ({error.Code}, {error.Origin})"); +``` + +Use `Parse` / `GetValueOrThrow()` to throw instead of inspecting. The value-object layer never +reconstructs codes or messages — it forwards `result.Errors` into a `ZodException`, so a rule's +`Code`, `Message`, `Origin`, `Category` and path arrive intact. + +### ASP.NET Core Problem Details + +`Purview.ZodSharp.AspNetCore` maps a thrown `ZodException` to `HttpValidationProblemDetails`: + +```csharp +builder.Services.AddZodSharpProblemDetails(); +builder.Services.AddProblemDetails(); + +var app = builder.Build(); +app.UseExceptionHandler(); +``` + +Make the value object validate during model binding: + +```csharp +[Scalar( + ZodSchemaMode = ZodSchemaMode.InsteadOfHooks, + DeserializationMode = ValueObjectDeserializationMode.Strict)] +[ZodSchema] +public readonly partial record struct EmailAddress +{ + [Required, EmailAddress] + public string Value { get; } +} + +builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.Converters.Add(new ScalarJsonConverterFactory())); +``` + +An invalid body now returns `400 application/problem+json` with the structured issues in the `issues` +extension. + +Map error codes to HTTP statuses with `[ErrorType]` on a `static readonly ErrorType` field; the +bundled `ErrorTypeGenerator` emits `Create{Field}(...)` and a `[DoesNotReturn]` `Throw{Field}(...)`. +The `ZODSASP001` analyzer flags a `MessageFormat` placeholder missing from `Parameters` at compile +time. + +## JSON Schema export + +```csharp +var jsonSchema = Z.ToJsonSchema(ScalarSchemas.EmailSchema, new ToJsonSchemaOptions { Title = "Email" }); +``` + +Draft 2020-12, for sharing a contract with TypeScript Zod. + +## Diagnostics + +`VO1013` (unreachable `OnValidate` under `InsteadOfHooks`) and `VO1015` (invalid `SchemaName`) are the +value-object side. ZodSharp's own `ZODSGEN030`-`ZODSGEN043` cover the refinement hook and rule +mapping; `ZODSASP001` covers the ASP.NET Core error types. + +## Testing the dual-generator integration + +Two shapes, and the distinction is load-bearing: + +- **In-memory (unit)** — `src/tests/SourceGenerator.UnitTests` registers the packaged ZodSharp + generator through `ZodSchemaValidationGeneratorTestOptions`, resolving the component types out of + band via `Common/ZodSharpSourceGenerators.cs`. The project copies + `analyzers/dotnet/cs/Purview.ZodSharp.SourceGenerators.dll` beside the test binaries + (`GeneratePathProperty` + `None`/`CopyToOutputDirectory`) and loads it with `Assembly.LoadFrom`. + **Never turn that copy into a ``**: a merged analyzer component carried + `Purview.SourceGeneratorFramework.*` types that then collided (`CS0433`) with the framework + assembly the harness loads. `Common/ZodSharpSourceGeneratorsTests.cs` guards it. +- **Real compile (integration)** — `src/tests/ValueObjects.IntegrationTests` declares `[Scalar]` + + `[ZodSchema]` fixtures and asserts runtime behaviour; both generators run in the real compiler, so + nothing is reflected or registered. + +The loaded generator carries its own framework implementation, so it keeps its own log sink and +CodeWriter scope validation: do not assert on its log entries, and leave `ValidateCodeWriterScopes` +off for that run. + +`src/src/ZodSharpSample` is the runnable example. diff --git a/.agents/skills/purview-value-objects-zodsharp/references/custom-rules.md b/.agents/skills/purview-value-objects-zodsharp/references/custom-rules.md new file mode 100644 index 0000000..43c9fc2 --- /dev/null +++ b/.agents/skills/purview-value-objects-zodsharp/references/custom-rules.md @@ -0,0 +1,130 @@ +# Custom ZodSharp rules for value objects + +## Writing a rule attribute + +A custom rule reaches a value object as a `[ZodRule]`-mapped attribute, in one of two ways. + +### 1. Map a hand-authored attribute to the rule + +Declare a `ValidationAttribute` and point it at the rule. The open generic form serves both a +primitive member and a scalar: + +```csharp +using ZodSharp.Core; + +// Reads the value object through IScalarValueObject. Implementing IZodRule lets the +// rule own its error identity, so one attribute can report a different code per scalar. +public readonly record struct NoWhitespaceRule(string? Message = null) + : IValidationRule, IZodRule + where TSelf : IScalarValueObject +{ + public const string ErrorCode = "invalid_string"; + public const string MessageFormat = "Value must not contain whitespace."; + + public bool IsValid(in TSelf value) => !value.Value.Any(char.IsWhiteSpace); + + public string GetErrorMessage(in TSelf value) => Message ?? MessageFormat; + + string? IZodRule.Code => ErrorCode; + + string? IZodRule.Origin => "value_object"; +} + +[ZodRule(typeof(NoWhitespaceRule<>))] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] +public sealed class NoWhitespaceAttribute : ValidationAttribute +{ + public string? Message { get; set; } +} +``` + +### 2. Let the generator write the attribute + +Mark the rule with the parameterless `[ZodRule]` marker and ZodSharp emits a `{RuleName}Attribute` in +the rule's namespace. A trailing `Rule` becomes `Attribute`; a name clashing with a +`System.ComponentModel.DataAnnotations` attribute gets a `Zod` suffix: + +```csharp +[ZodRule] // the generator emits NoWhitespaceAttribute +public readonly record struct NoWhitespaceRule(string? Message = null) + : IValidationRule, IZodRule + where TSelf : IScalarValueObject +{ + // ... +} +``` + +The generator runs over the compilation that **declares** the rule, so the marker form suits a rule +in the same project, or in a rules library that references Purview.ZodSharp. + +Requirements the generator enforces: + +- A rule accepting a `code`/`origin` constructor parameter must implement `IZodRule`, or + `ZODSGEN039`. +- A rule should expose `public const string ErrorCode` and `public const string MessageFormat`, or + `ZODSGEN042`. +- `ZODSGEN043` reports a built-in rule that cannot produce a validation attribute. + +## How `Code` and `Origin` resolve + +First match wins: + +1. **Rule-owned** — the rule implements `IZodRule` (`IZodRule.Code` / `IZodRule.Origin`). +2. **Attribute-declared** — `Code`/`Origin` named arguments on the applied attribute. +3. **Mapping** — `[ZodRule(typeof(X), Code = "…", Origin = "…")]` on the attribute type. +4. **Default** — `validation_failed`, no origin. + +The value-object layer never reconstructs codes or messages. It calls +`{Type}Schema.Validate(instance)` and forwards `result.Errors` into a `ZodException`, so `Code`, +`Message`, `Origin`, `Category` and path arrive intact. + +## Reusing a rule written against the underlying value + +A rule written against the underlying value — `NonSentinelRule` — validates *that value*, not +the value object. Closing it with the scalar type (`NonSentinelRule`) compiles but **always +passes**, because the sentinel check only knows the primitive. + +The value-object generator emits `ScalarRuleAdapter` into every project that +references both `Purview.ValueObjects` and `Purview.ZodSharp`: + +```csharp +internal readonly record struct ScalarRuleAdapter(TRule Rule) : IValidationRule + where TSelf : IScalarValueObject + where TRule : IValidationRule +{ + public bool IsValid(in TSelf value) => Rule.IsValid(value.Value); + + public string GetErrorMessage(in TSelf value) => Rule.GetErrorMessage(value.Value); +} +``` + +**Do not declare it yourself.** A project that already declares its own copy — the guidance before +the generator emitted it — is detected and the generated copy is skipped, so both keep compiling. + +ZodSharp applies the adapter automatically when a rule is applied to a `[Scalar]` type, so the common +case needs no extra code. The built-in `[NonSentinel]` is exactly this shape: + +```csharp +using ZodSharp; +using ZodSharp.Rules; + +[Scalar] +[ZodSchema] +[NonSentinel(Message = "UserId must not be empty.")] +public readonly partial record struct UserId +{ + public Guid Value { get; init; } +} +``` + +The generator closes `NonSentinelRule<>` with the underlying `Guid` and wraps it in +`ScalarRuleAdapter>`, so the rule runs against the value object +with an empty path, and the **wrapped** rule owns the reported `Code` (the built-in rules report no +structured `Origin`). One attribute serves every scalar backed by the same primitive. + +The runnable version is `src/src/ZodSharpSample/NonSentinelModels.cs`, and the fixtures in +`src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaRuleModels.cs` cover rule +`Code`/`Origin` and the empty path end to end. + +See ZodSharp's [Custom Rules](https://purview.dev/docs/zodsharp/custom-rules/) for the rule contract +and the full list of shipped attributes. diff --git a/.agents/skills/purview-value-objects/SKILL.md b/.agents/skills/purview-value-objects/SKILL.md new file mode 100644 index 0000000..9636d32 --- /dev/null +++ b/.agents/skills/purview-value-objects/SKILL.md @@ -0,0 +1,275 @@ +--- +name: purview-value-objects +description: >- + Use when modelling domain values with Purview.ValueObjects - scalar, complex or contextual value + objects, typed identifiers, the Create/Hydrate split, OnNormalize/OnValidate hooks, built-in string + normalization, assembly-level ValueObjectDefaults, JSON deserialization modes, or deciding where an + invariant belongs. +metadata: + product: Purview.ValueObjects + repository: purview-dev/value-objects +--- + +# Purview value objects + +`Purview.ValueObjects` source-generates the plumbing for value objects: factories, equality, +comparison, formatting, parsing and JSON. You declare the shape and the invariants; the generator +emits the rest. + +Three shapes, and the choice is forced by what the value *is*: + +| Attribute | Use for | Declaration | +| --- | --- | --- | +| `[Scalar]` / `[Scalar]` | one primitive with a name and rules (ids, codes, emails) | `readonly partial record struct` | +| `[ValueObject]` | a small fixed group of members that travel together | `partial record` / `readonly partial record struct` | +| `IContextualValueObject` | a value whose validity depends on the owner's current state | a `[Scalar]` that also implements the interface | + +All three must be **top-level** and **partial**, and none may be generic. Nesting is `VO1002`, +generics are `VO1003`, a missing `partial` is `VO1001`. The `purview-value-objects-diagnostics` +skill carries the full rule list. + +## The `Create` / `Hydrate` split + +This is the single most important thing to get right. Every value object gets both: + +- **`Create(value)`** — the strict path. Runs `OnNormalize`, then `OnValidate`, then constructs. + Use it for command input, user input, parsing, and anything crossing a trust boundary. +- **`Hydrate(value)`** — the reconstruction path. Constructs without validating. Use it for + persisted state, event replay and deserialization, where validation already happened and + re-running it would reject historical data that was legal when it was written. +- **`TryCreate(value, out result)`** — `Create` without the throw; returns `false` on failure. + +| Path | Behaviour | +| --- | --- | +| `Create(...)` | Throws — the hook's exception, or `ZodException` when a ZodSharp schema is generated for the type. | +| `TryCreate(...)` | Returns `false` instead of throwing. | +| `Hydrate(...)` | Never validates, never normalizes. | + +Never make `Hydrate` validate "for safety". Replay paths exist precisely because yesterday's rules +are not today's. + +## Scalar value objects + +```csharp +[Scalar] +public readonly partial record struct EmailAddress +{ + public string Value { get; } + + static partial void OnNormalize(ref string value) => value = value?.Trim().ToLowerInvariant()!; + + static partial void OnValidate(string value) + { + if (string.IsNullOrWhiteSpace(value)) + throw new ArgumentException("Email is required.", nameof(value)); + + if (!value.Contains('@', StringComparison.Ordinal)) + throw new ArgumentException("Invalid email format.", nameof(value)); + } +} +``` + +- Declare the scalar property yourself (`[Scalar]`), or let the generator own it (`[Scalar]`). + With `[Scalar]` you must **not** declare a member of that name — that is `VO1022`. +- `Nullable = true` makes the automatic property nullable for both reference and non-nullable value + types (`[Scalar(Nullable = true)]` emits `DateTimeOffset? Value`). A nullable scalar + round-trips JSON `null` and exposes `Null()`, which is `Hydrate(null)` for the unknown case and never + validates, plus `HasValue` for a non-validating null check; use `Create(null)` when `null` is input + that must be checked. +- The automatic form cannot also declare `Value`: that is `VO1022`, or `VO1028` when + `Nullable = true`, `VO1029` when explicit `Nullable = false` contradicts a nullable `Value`, and + `VO1030` when the declared type does not match. `VO1028` has a code fix offering all three repairs. +- A nullable scalar compares with `null` and with the underlying value in either order + (`value == null`, `null == value`, `value == "x"`). `is null` is impossible on a struct (`CS0037`). + Extraction is explicit for a nullable scalar (`(string?)value` or `value.Value`) and implicit for a + non-nullable one; creation stays implicit and goes through `Create`. +- The default property name is `Value`; `ScalarAttribute.PropertyName` changes it. A `[Scalar]` type + with no matching property is `VO1004`. +- Prefer `readonly record struct`. Anything else is `VO1006`, because equality, immutability and + allocation behaviour stop matching the contract. +- You do not need to declare the private constructor. The generator emits one. + +### Built-in string normalization + +Trim and casing are common enough to configure rather than hand-write: + +```csharp +[Scalar(Trim = true, Casing = StringCasing.LowerInvariant)] +public readonly partial record struct EmailAddress { } + +[ValueObject] +public readonly partial record struct Contact +{ + [StringNormalize(Trim = true)] + public string Email { get; init; } + + public string Name { get; init; } +} +``` + +Trim runs first, then casing, then validation — and only inside the generated `Create`. Casing is +invariant-culture only, deliberately: a canonical form that depends on the ambient thread culture is +not canonical. A hand-written `OnNormalize` or `Create` wins and the option is ignored (`VO1023`); +the option on a non-string is ignored (`VO1024`); restating a value the assembly default already sets +is reported as redundant (`VO1026`/`VO1027`). `[StringNormalize]` itself only belongs on a complex value +object member — on a `[Scalar]` or any other type it is the error `VO1025`, and MSBuild can supply the +assembly defaults with `ValueObjectsDefaultTrim` / `ValueObjectsDefaultCasing`. + +## Complex value objects + +```csharp +[ValueObject] +public sealed partial record UserDetails(Guid Id, string? DisplayName, bool IsActive = true) +{ + static partial void OnNormalize(ref Guid id, ref string? displayName, ref bool isActive) + { + if (!isActive) + displayName = null; + } + + partial void OnValidate(Guid id, string? displayName, bool isActive) + { + if (id == Guid.Empty) + throw new ArgumentException("Id must be a valid GUID.", nameof(id)); + } +} +``` + +Normalize every component together, before validation, so cross-member canonicalization is possible. +Keep members `readonly`/`init`-only — a mutable member is `VO1016` and makes equality unstable. On a +`struct`, declare `OnValidate` as `readonly` when it does not mutate; the generator mirrors the +modifier. + +## Contextual value objects + +When validity depends on the owning instance's current state — a state machine, a cross-field rule +that needs the aggregate — implement `IContextualValueObject`: + +```csharp +[Scalar] +public readonly partial record struct OrderStatus + : IContextualValueObject +{ + public OrderStatusCode Value { get; } + + public static OrderStatus Create(OrderStatusCode value, in ValueObjectContext context) + { + var current = context.Owner.Status.Value; + + return IsValidTransition(current, value) + ? new(value) + : throw new InvalidOperationException($"Invalid transition {current} -> {value}."); + } +} +``` + +`ValueObjectContext` carries `Owner`, `MemberName` and an optional `Reason`. Keep the +contextual `Create` deterministic and side-effect free, and keep plain `Hydrate` available so replay +does not fail on historical transitions. + +## Where an invariant belongs + +| Concern | Lives in | +| --- | --- | +| Primitive format, canonicalization | `OnNormalize` / `OnValidate` on the value object | +| Cross-field invariants within the value | `partial void OnValidate(...)` on a `[ValueObject]` | +| Owner lifecycle / state-machine transitions | contextual `Create(TValue, in ValueObjectContext)` | +| Schema rules, DTO validation, API error shapes | ZodSharp — the `purview-value-objects-zodsharp` skill | +| Persistence and query shape | the entity mapping — the `purview-value-objects-efcore` skill | + +Validation must be pure: deterministic, no I/O, no clock, no ambient culture. + +## Standard interfaces + +A scalar mirrors the standard interfaces its underlying type actually implements, so it behaves like +that type in generic code: + +| Interface | Mirrored when | Forwarded member | +| --- | --- | --- | +| `IEquatable` | the underlying type implements it | `Equals(TValue)` | +| `IFormattable`, `ToString(string?)` | the underlying type implements/declares them | `ToString(format[, provider])` | +| `ISpanFormattable` / `IUtf8SpanFormattable` | the underlying type implements them | `TryFormat` | +| `IParsable` / `ISpanParsable` / `IUtf8SpanParsable` | the underlying type implements them | `Parse` / `TryParse` | + +Detection is per type **and** per target framework, so `string` gets parsing but not span formatting +and an enum gets the reverse. Declaring one of an interface's members yourself skips that interface. + +`Parse` goes through `Create` (so it normalizes and validates); `TryParse` goes through `TryCreate`. +`IComparable` and `IComparable` are always implemented, and a scalar always exposes +`CompareTo(TValue)`, because the contract requires it. Numeric/arithmetic interfaces, `IConvertible` +and collection interfaces are deliberately **not** mirrored. + +## JSON and deserialization modes + +Both attributes default to `ValueObjectDeserializationMode.Hydrate`, so a JSON read does not +re-validate. Choose `Strict` only when round-trip fidelity genuinely requires re-running validation +on read — and then make sure a `Create` overload exists to re-validate through, or you get `VO1007`. + +## Assembly-level defaults + +Repeat nothing per type that the assembly can say once: + +```csharp +[assembly: ValueObjectDefaults(Trim = true, Casing = StringCasing.LowerInvariant)] +``` + +Per-type and per-member settings override it. + +## Value objects in a domain model + +Value objects carry identity and invariants; entities and aggregates carry lifecycle. Keep the +dependency graph clean by splitting them: + +| Project | References | Contains | +| --- | --- | --- | +| Domain (`MyApp.Core`) | `Purview.ValueObjects`, optionally `Purview.ZodSharp` | Value objects, entities/aggregates, domain services. **No Entity Framework.** | +| Persistence (`MyApp.Persistence`) | EF Core, the domain project | `DbContext`, entity configuration, `ConfigureValueObjects()`, migrations | + +A domain project with no EF reference gets no EF members generated into it. That is the point: the +*consuming* project discovers the value objects from their attributes and generates the converters +into the persistence assembly, so no EF code leaks into the domain. + +**Typed identifiers.** Make every identity a scalar value object, so a `CustomerId` can never be +passed where an `OrderId` is expected: + +```csharp +// Domain project - no Entity Framework reference. +[Scalar] +public readonly partial record struct CustomerId +{ + public Guid Value { get; } +} + +public sealed class Customer +{ + Customer(CustomerId id, EmailAddress email) => (Id, Email) = (id, email); + + public CustomerId Id { get; } + + public EmailAddress Email { get; private set; } + + public static Customer Create(CustomerId id, EmailAddress email) => new(id, email); +} +``` + +Entities beside them are ordinary classes: private constructor, static `Create` factory that +validates, members typed as value objects rather than primitives. The factory is the command +boundary; mutation methods enforce invariants spanning members. + +## Queryability + +Primitive-backed scalars are the most query-friendly shape — a provider compares the value object +directly. A scalar wrapping a complex CLR type keeps its invariants and serialization, but a deep +predicate through `.Value` may not translate to SQL. Do not assume +`aggregate.ComplexScalar.Value.Child.Prop` is translatable; if you need to filter on it, expose a +separately mapped mirror property derived from canonical state, and test the exact predicate. + +## Checklist for a new value object + +1. Shape: scalar, complex, or contextual. +2. Normalization rules per member (hand-written hook, or configured trim/casing). +3. Validation rules and the exception each one throws. +4. Whether anything constructs it from storage — if so, it uses `Hydrate`. +5. Deserialization mode, if not the default. +6. Persistence shape, if it is stored. +7. Queryability, if it is filtered on. diff --git a/.agents/skills/purview-zodsharp-aspnetcore/SKILL.md b/.agents/skills/purview-zodsharp-aspnetcore/SKILL.md new file mode 100644 index 0000000..a11c258 --- /dev/null +++ b/.agents/skills/purview-zodsharp-aspnetcore/SKILL.md @@ -0,0 +1,242 @@ +--- +name: purview-zodsharp-aspnetcore +description: >- + Use when wiring Purview.ZodSharp into ASP.NET Core or any DI host - ProblemDetails from a + ValidationResult or a thrown ZodException, minimal-API endpoint validation, registering + IZodSchemaFactory with AddZodSharp or AddZodSharpFactory, validating options objects with + IValidateOptions, and mapping error codes to HTTP statuses with ErrorType. +license: MIT +metadata: + product: Purview.ZodSharp.AspNetCore + repository: purview-dev/zodsharp +--- + +# ASP.NET Core and dependency injection + +`Purview.ZodSharp.AspNetCore` turns failed validation into standard `ProblemDetails` / +`HttpValidationProblemDetails` payloads **without losing the structured issues**, and registers +schema resolution into DI. + +```bash +dotnet add package Purview.ZodSharp.AspNetCore +``` + +## ProblemDetails from a result + +```csharp +using ZodSharp.AspNetCore; + +var result = BasketSchema.Validate(basket); + +if (!result.IsSuccess) +{ + var problem = result.ToHttpValidationProblemDetails(); + + return Results.ValidationProblem( + problem.Errors, + extensions: new Dictionary { ["issues"] = problem.Extensions["issues"] }); +} +``` + +| Member | Produces | +| --- | --- | +| `ToHttpValidationProblemDetails(result, statusCode = 400)` | `HttpValidationProblemDetails`; paths flattened to dotted keys (`user.email`, array indexes as `[0]`) | +| `ToValidationProblemDetails(result, statusCode = 400)` | `ValidationProblemDetails` | + +Both **throw `InvalidOperationException` when the result is successful** — they are failure +converters, not general mappers. The structured metadata survives in the `issues` extension as a +`ValidationIssue[]`: + +```csharp +public sealed class ValidationIssue +{ + public required string Code { get; init; } + public string? Category { get; init; } + public string? Origin { get; init; } + public int? Minimum { get; init; } + public int? Maximum { get; init; } + public bool? Inclusive { get; init; } + public required string[] Path { get; init; } + public required string Message { get; init; } + public IReadOnlyDictionary? Parameters { get; init; } +} +``` + +## Minimal APIs + +```csharp +// Validate the bound DTO automatically - needs AddZodSharp to have registered the validator. +app.MapPost("/users", (UserDto dto) => dto) + .WithZodSharpValidation(); + +// Or validate in the handler and return the IResult directly. +app.MapPost("/users", (UserDto dto) => +{ + var result = UserDtoSchema.Validate(dto); + return result.IsSuccess ? TypedResults.Ok(result.Value) : result.ToValidationProblem(); +}); +``` + +`WithZodSharpValidation()` adds an endpoint filter that validates the first bound argument of type +`T` against the `IZodSchemaFactory` registered by `AddZodSharp`/`AddZodSharpFactory`. On failure it +returns the same payload as `ToHttpValidationProblemDetails()`; on success it passes through. + +## Thrown `ZodException` + +A `ZodException` from `Parse`, `GetValueOrThrow()`, or a value object's generated `Create` under +strict deserialization maps the same way: + +```csharp +catch (ZodException ex) +{ + return Results.ValidationProblem(ex.ToHttpValidationProblemDetails().Errors); +} +``` + +Or handle it globally: + +```csharp +builder.Services.AddZodSharpProblemDetails(); + +var app = builder.Build(); +app.UseExceptionHandler(); +``` + +`AddZodSharpProblemDetails(configure?, problemDetails?)` registers `ZodExceptionHandler`, calls +`services.AddProblemDetails(problemDetails)`, and configures `ZodProblemDetailsOptions`: + +- `ErrorTypeRegistry Registry` — resolves codes to `ErrorType`s; defaults to `ErrorTypeRegistry.Default`. +- `bool FormatMessages` — format messages from the matched `ErrorType.MessageFormat`. +- `Func, int>? StatusCodeSelector` — total control of the status code. + +> It does **not** register `IZodSchemaFactory`. Pair it with `AddZodSharp` (or the core +> `AddZodSharpFactory`) when you also want DI validator resolution. + +## Error codes to HTTP statuses + +`ErrorType`, the `[ErrorType]` attribute, the generator that emits `Create`/`Throw` helpers and the +`ZODSASP001`–`ZODSASP003` analyzers all ship in the **core** package and need no ASP.NET Core +dependency. The AspNetCore package adds `ErrorTypeRegistry` and the ProblemDetails mapping on top. + +```csharp +public static partial class ConcurrentErrorType +{ + [ErrorType] + public static readonly ErrorType SaveFailed = new( + Code: "aggregate_save_failed", + Category: "invalid_value", + Description: "The aggregate could not be saved.", + HttpStatus: 409, + MessageFormat: "Aggregate '{AggregateId}' (of type {AggregateType}) failed to save", + Parameters: + [ + new("AggregateId", typeof(string)), + ErrorType.Param("AggregateType"), + ]); +} + +ErrorTypeRegistry.Default.Register(ConcurrentErrorType.SaveFailed); +``` + +`HttpStatus` lives on `ErrorType` purely for convenience — the core library never reads it; only the +ASP.NET Core mapping does. `Category` is a broad grouping that can span many specific codes (code +`invalid_tenant_id`, category `invalid_value`); the generated helpers copy it onto each +`ValidationError` and it surfaces on the serialized `ValidationIssue`. + +Marking the field `[ErrorType]` and the containing class `partial` gets you strongly typed helpers, +one parameter per declared entry: + +```csharp +var error = ConcurrentErrorType.CreateSaveFailed("agg-123", "Invoice"); +ConcurrentErrorType.ThrowSaveFailed("agg-123", "Invoice"); + +var detailed = ConcurrentErrorType.CreateSaveFailed( + "agg-123", "Invoice", + path: ["order", "items", "[0]"], + origin: "collection", minimum: 1, maximum: 10, inclusive: true); +``` + +`Create` builds a typed `ErrorTypeParameters` (validated against the declared types, exposed through +`ValidationError.Parameters`) and sets the message from `ErrorType.FormatMessage`, so +`error.Message` already reads the formatted text. + +Mapping rules: + +- **Status** — the highest matched `ErrorType.HttpStatus` wins; unmapped codes fall back to `400`. + `StatusCodeSelector` overrides everything. +- **Title / Type / Detail** — from the highest-status matched `ErrorType`, otherwise defaulted. +- **Message** — named placeholders are substituted from `ValidationError.Parameters`. A placeholder + with no matching value is **left as-is**, so a templating gap stays visible rather than silently + disappearing. `ZODSASP001` warns at compile time when a placeholder is not declared in `Parameters`. +- **Parameters** — surfaced per-issue in `issues`, and merged (camel-cased) into the top-level + extensions for client correlation. + +`ToHttpValidationProblemDetails` / `ToValidationProblemDetails` also accept an `ErrorTypeRegistry` or +a `Func` for on-demand mapping. + +## Registering the factory + +`IZodSchemaFactory` (`ZodSharp.Core`) resolves validators by type: + +| Member | Behaviour | +| --- | --- | +| `Resolve()` | the validator, or `null` | +| `ResolveRequired()` | the validator, or `InvalidOperationException` | +| `Validate(value)` | validate through the registered validator | +| `Register(…)` / `Register(Type, …)` / `TryRegister(…)` / `IsRegistered()` | registration | + +```csharp +builder.Services.AddZodSharp(options => +{ + options.ScanAssemblies.Add(typeof(UserDto).Assembly); + options.ScanAssemblyGraphs.Add(typeof(Program).Assembly); + options.ScanLoadedAssemblies = true; +}); + +// Additive, for modular apps: +builder.Services.AddZodSharpAssembly(typeof(UserDto).Assembly); +builder.Services.AddZodSharpAssemblyGraph(typeof(Program).Assembly); +builder.Services.AddZodSharpLoadedAssemblies(); +``` + +Scanning finds the generator's `[assembly: ZodSchemaGenerated(typeof(…))]` markers and registers each +`{Type}SchemaValidator`. A hand-built schema registers by wrapping: +`factory.Register(new ZodSchemaValidator>(mySchema))`. + +> **`TryAdd` semantics.** The **first** `AddZodSharp`/`AddZodSharpFactory` call wins; later calls — +> including their options and `ConfigureFactory` callbacks — are ignored. State is never overwritten, +> so calling it more than once is safe, and the additive helpers still contribute. + +| Method | Package | Registers | Use when | +| --- | --- | --- | --- | +| `AddZodSharp(options)` | AspNetCore | factory + auto-registered generated validators | an ASP.NET Core app | +| `AddZodSharpAssembly/AssemblyGraph/LoadedAssemblies` | AspNetCore | additive assembly sources | modular apps where deeper layers contribute their own schemas | +| `AddZodSharpFactory(configure)` | core | the factory only | any host; you call `RegisterFromAssembly` yourself | +| `AddZodSharpProblemDetails(…)` | AspNetCore | exception handler + ProblemDetails, **not** the factory | mapping thrown `ZodException`s | +| `AddZodSchemaOptionsValidator(…)` | core | `IValidateOptions` | options validation; needs a factory first | + +## Options validation + +```csharp +builder.Services.AddZodSharpFactory(f => f.RegisterFromAssembly(typeof(UserOptions).Assembly)); +builder.Services.AddZodSchemaOptionsValidator(); + +// or chained: +builder.Services + .AddOptions() + .Bind(configuration.GetSection("User")) + .AddZodSchemaValidator() + .ValidateOnStart(); // fail at startup, not first access +``` + +`MissingValidatorBehavior` decides what happens with no validator registered for `T`: +`Throw` (default — `ResolveRequired()`, so a missing schema is loud rather than a silent skip) or +`Ignore` (passes through as success). + +The generator also auto-generates `IValidateOptions` for types whose names end in configurable +suffixes — see the `purview-zodsharp-source-generator` skill. + +## Related skills + +`purview-zodsharp-core` for the runtime API, `purview-zodsharp-source-generator` for the compile-time +validators this resolves, and `purview-zodsharp-diagnostics` for `ZODSASP*`. diff --git a/.agents/skills/purview-zodsharp-core/SKILL.md b/.agents/skills/purview-zodsharp-core/SKILL.md new file mode 100644 index 0000000..98280dd --- /dev/null +++ b/.agents/skills/purview-zodsharp-core/SKILL.md @@ -0,0 +1,176 @@ +--- +name: purview-zodsharp-core +description: >- + Use when validating data with Purview.ZodSharp at runtime - picking the right package, building + schemas with the Z factory, Validate/SafeParse/Parse, reading ValidationResult and ValidationError, + composing with Transform/Refine/Pipe/Catch/Default, and how null handling and type coercion work. +license: MIT +metadata: + product: Purview.ZodSharp + repository: purview-dev/zodsharp +--- + +# Purview.ZodSharp core + +A C# port of [Zod](https://github.com/colinhacks/zod): schemas you build fluently, validated without +exceptions, reporting machine-readable issues. + +## Packages + +```bash +dotnet add package Purview.ZodSharp # core, source generator, JSON Schema export +dotnet add package Purview.ZodSharp.SystemTextJson # STJ deserialize-and-validate + schema import +dotnet add package Purview.ZodSharp.NewtonsoftJson # Newtonsoft equivalent +dotnet add package Purview.ZodSharp.AspNetCore # ProblemDetails + DI +``` + +JSON Schema **export** (`Z.ToJsonSchema`) is in the core package; **import** (`Z.FromJsonSchema`) comes +from whichever JSON integration you reference, so import its namespace +(`ZodSharp.JsonSchema.SystemTextJson` or `ZodSharp.JsonSchema.NewtonsoftJson`). + +## First schema + +```csharp +using ZodSharp; + +var nameSchema = Z.String().Min(3).Max(50); +var result = nameSchema.Validate("John"); + +if (result.IsSuccess) + Console.WriteLine(result.Value); +``` + +## Validate, SafeParse, Parse, ValidateAsync + +| Member | Behaviour | +| --- | --- | +| `Validate(value)` | returns `ValidationResult`; **never throws** | +| `SafeParse(value)` | alias of `Validate` | +| `Parse(value)` | returns the validated value, or throws `ZodException` (via `GetValueOrThrow()`) | +| `ValidateAsync(value, ct)` | `ValueTask` wrapper. The pipeline is synchronous; the token is observed before validation and throws `OperationCanceledException` if already cancelled. Genuinely async work only happens in the source generator's custom async validation. | + +Reach for `Validate` by default and `Parse` only where a throw is the right control flow. Exceptions +for expected invalid input are expensive and lose the structured issue list unless you catch. + +## `ValidationResult` + +A `readonly record struct` in `ZodSharp.Core`: + +- `bool IsSuccess` — annotated `[MemberNotNullWhen(true, nameof(Value))]`, so the compiler knows + `Value` is non-null inside the success branch. +- `T? Value` — valid only when `IsSuccess`. +- `ImmutableArray Errors`. + +Factories: `Success(value)`, `Failure(ValidationError)`, `Failure(ImmutableArray)`, +`Failure(IEnumerable)`, and `Merge(lhs, rhs)` — succeeds only if both succeed, and +concatenates the errors otherwise. + +## `ValidationError` + +A `readonly record struct` carrying machine-readable metadata, not just a message: + +| Member | Meaning | +| --- | --- | +| `Code` | `invalid_type`, `too_small`, `too_big`, `invalid_union`, `missing_field`, `unrecognized_key`, `validation_failed`, `refinement_failed`, `transform_error`, … | +| `Message` | human-readable | +| `Path` | `ImmutableArray` — `["user", "email"]`; array indexes are string segments, `"0"` | +| `Origin` | category for size issues: `"string"`, `"array"`, `"collection"` | +| `Minimum` / `Maximum` / `Inclusive` | the bound, for size issues | +| `Parameters` | `IReadOnlyDictionary`; a union failure carries every option error under `"errors"` | + +Build one with +`ValidationError.Create(code, message, path, parameters, origin, minimum, maximum, inclusive)`. + +`ZodException` carries `ImmutableArray Errors`; its `ToString()` renders one line per +error as `"{joinedPath}: {Message} ({Code})"`. + +## The `Z` factory + +| Method | Returns | +| --- | --- | +| `Z.String()` / `Z.Number()` / `Z.Boolean()` / `Z.Null()` / `Z.Date()` / `Z.BigInt()` | the matching `Zod*` schema | +| `Z.Array(elementSchema)` | `ZodArray` | +| `Z.Optional(schema)` (`T : class`) / `Z.Nullable(schema)` (`T : struct`) | `ZodOptional` / `ZodNullable` | +| `Z.Object()` | `ZodObjectBuilder` | +| `Z.Union(params ...)` / `Z.Union(...)` | `ZodUnion` / `ZodTypedUnion` | +| `Z.DiscriminatedUnion(discriminator)` | `ZodDiscriminatedUnionBuilder` | +| `Z.Intersection(left, right)` | `ZodIntersection` | +| `Z.Literal(value)` (`T : IEquatable`) | `ZodLiteral` | +| `Z.Lazy(schemaGetter)` | `ZodLazy` — for recursive schemas | +| `Z.Enum(params string[])` / `Z.Enum()` | `ZodEnum` / `ZodNativeEnum` | +| `Z.Record(valueSchema)` | `ZodRecord` | +| `Z.Tuple(…)` / `Z.Tuple(…)` | `ZodTuple<…>` | +| `Z.ToJsonSchema(schema, options?)` | `JsonSchemaDefinition` | + +`Enum` and `Union` are overloaded — `Enum(params string[])` versus `Enum()`, and +`Union(params …)` versus `Union(…)`. + +```csharp +var userSchema = Z.Object() + .Field("name", Z.String().Min(1)) + .Field("age", Z.Number().Min(0).Max(120).Int()) + .Field("email", Z.String().Email()) + .Build(); +``` + +## The validation pipeline + +Every schema derives from `ZodType` (`ZodSharp.Core`). Two phases: + +1. **`ParseInternal`** — the schema's own type check and traversal: rejecting `null` where not + allowed, coercing, walking objects/arrays/tuples/unions, producing structured failures. +2. **Rules** — on success, the accumulated `IValidationRule` structs run. A failing rule emits + a `ValidationError` with its Zod-compatible code. + +Rules are `readonly record struct`s implementing `IValidationRule` (`bool IsValid(in T value)`, +`string GetErrorMessage(in T value)`) — zero allocation. Each exposes `public const string ErrorCode` +and `MessageFormat`, so a test asserts against the rule rather than a duplicated literal. (A rule +without them is `ZODSGEN042`.) + +`ZodString` also exposes `ValidateSpan(ReadOnlySpan)` and `IsValidSpan(value, out errors)` for +allocation-free string validation; string rules implementing `IStringValidationRule` participate. + +## Composition + +`ZodType` composes by **wrapping**, never mutating. Each of these returns a new schema: + +| Call | Produces | +| --- | --- | +| `.Transform(func)` | `ZodTransform` | +| `.Refine(predicate, message?)` | `ZodRefinement` | +| `.SuperRefine(Action>)` | `ZodSuperRefinement` | +| `.Pipe(schema)` | `ZodPipe` | +| `.Catch(value \| func)` | `ZodCatch` | +| `.Prefault(value)` / `.Default(value)` | `ZodPrefault` / `ZodDefault` | +| `.And(schema)` / `.Or(schema)` | `ZodIntersection` / `ZodTypedUnion` | +| `.Describe("…")` | sets `Description` | + +Because composition lives on the base type, every schema chains all of them. + +## Null and optionality + +Schemas report optionality through the internal `IOptionalSchema`: + +- `IsOptional` — true for `ZodOptional`, `ZodNullable`, `ZodDefault`, `ZodPrefault`. +- `ProvidesValueOnMissing` — true for `ZodDefault` and `ZodPrefault`. Object fields route a missing + value through `Validate(null!)` so the produced value is injected. +- `IAcceptsNull.ValidateNull()` is implemented by the nullable/optional/default/prefault schemas and + `ZodNull`; object-field and union wrappers route `null` to it rather than failing coercion. + +## Type coercion + +Object fields and union options wrap typed schemas, so boxed values out of a +`Dictionary` validate: + +- `null` routes to `IAcceptsNull.ValidateNull()` where supported. +- An exactly-typed value reuses the original box. +- Numeric coercion goes through `IConvertible` in the **invariant** culture, so a boxed `long` + satisfies a `Z.Number()` field. +- A non-nullable value type rejects `null`; anything else falls through to failure. + +## Related skills + +`purview-zodsharp-source-generator` for compile-time `[ZodSchema]` validators (which is what you want +for a DTO), `purview-zodsharp-custom-rules` for writing rules and rule attributes, +`purview-zodsharp-aspnetcore` for ProblemDetails and DI, and `purview-zodsharp-diagnostics` for the +`ZODSGEN`/`ZODSASP` catalogue. diff --git a/.agents/skills/purview-zodsharp-custom-rules/SKILL.md b/.agents/skills/purview-zodsharp-custom-rules/SKILL.md new file mode 100644 index 0000000..06215cc --- /dev/null +++ b/.agents/skills/purview-zodsharp-custom-rules/SKILL.md @@ -0,0 +1,241 @@ +--- +name: purview-zodsharp-custom-rules +description: >- + Use when writing a custom validation rule for Purview.ZodSharp - implementing IValidationRule and + IStringValidationRule, the ErrorCode/MessageFormat constants convention, IZodRule runtime identity, + attaching a rule with AddRule or a fluent extension, exposing it as a DataAnnotations-style + attribute with [ZodRule], generic rules, and type-level rules on scalar value objects. +license: MIT +metadata: + product: Purview.ZodSharp + repository: purview-dev/zodsharp +--- + +# Custom rules + +A **rule** is a `readonly record struct` (or class) implementing `ZodSharp.Core.IValidationRule`. +Rules run after a schema's type and structural check succeeds; each failing rule adds a +`ValidationError`. + +Rules are first class in two directions: + +- attach one to a schema with the public `AddRule`/`Rule` API, or +- surface it as a `System.ComponentModel.DataAnnotations`-style attribute that the `[ZodSchema]` + generator honours exactly like `[EmailAddress]` or `[Range]`. + +## The contract + +```csharp +namespace ZodSharp.Core; + +public interface IValidationRule +{ + bool IsValid(in T value); + string GetErrorMessage(in T value); +} +``` + +Implement it as a **struct** so validation does not allocate. `IsValid` is called only when the +surrounding schema succeeded and — for a nullable property — the value is not `null`. + +For a **string** rule, also implement `ZodSharp.Core.IStringValidationRule` +(`bool IsValid(ReadOnlySpan)` / `string GetErrorMessage(ReadOnlySpan)`) so the rule +participates in `ZodString.ValidateSpan`/`IsValidSpan` without materialising the input. A rule that +implements only `IValidationRule` still works; it falls back to the string pipeline. + +## `ErrorCode` and `MessageFormat` — not optional in practice + +Every rule **should** expose its error identity as public constants, so code and tests assert against +the rule rather than re-typing literals: + +```csharp +public readonly record struct EmailRule : IValidationRule +{ + public const string ErrorCode = "invalid_string"; + public const string MessageFormat = "Invalid email format: {0}"; + + public string Code => ErrorCode; + + public string GetErrorMessage(in string value) => + string.Format(CultureInfo.CurrentCulture, MessageFormat, value); +} +``` + +- **`ErrorCode`** is the canonical code. `IValidationRule.Code` defaults to `"validation_failed"`; + a rule accepting a per-usage `code` override returns it (falling back to `ErrorCode`) from both + `Code` and `IZodRule.Code`, so every route reports the same effective value. +- **`MessageFormat`** is a `string.Format` template. `{0}` is the offending value; `{1}`, … are + rule-specific arguments. Format with `CultureInfo.CurrentCulture`. +- The constants may be inherited from a base rule class, and **abstract bases are exempt**, so a + shared base can host them for its concrete derivations. + +A source-declared rule missing either constant is reported as **`ZODSGEN042`**. The payoff: + +```csharp +await Assert.That(error.Code).IsEqualTo(EmailRule.ErrorCode); +await Assert.That(error.Message).IsEqualTo( + string.Format(CultureInfo.CurrentCulture, EmailRule.MessageFormat, value)); +``` + +### Runtime identity: `IZodRule` + +The constants are the rule's *static* default. When one attribute must report a different code per +annotated member, implement `IZodRule` and supply code/origin at runtime: + +```csharp +public interface IZodRule +{ + string? Code { get; } // null falls back to the attribute-mapped code + string? Origin { get; } // null falls back to the attribute-mapped origin +} +``` + +The generated code casts to `IZodRule` when reading them, so identity can depend on constructor +arguments. A rule that **accepts** a `code`/`origin` constructor parameter without implementing +`IZodRule` never reaches the reported identity — that is `ZODSGEN039`. + +Code and origin resolve first-match-wins: + +1. the rule, when it implements `IZodRule`; +2. `Code`/`Origin` named arguments on the applied attribute; +3. the `[ZodRule(typeof(X), Code = "…", Origin = "…")]` mapping on the attribute type; +4. default — `validation_failed`, no origin. + +### Message precedence + +1. A `message` argument on the rule (typically a `Message` constructor parameter), when set. +2. The attribute's `ErrorMessage` / `ErrorMessageResourceName` / `ErrorMessageResourceType`, mapped + to a constructor parameter named `message`. +3. `GetErrorMessage(value)`, formatting `MessageFormat`. + +`MessageFormat` is the fallback, not the only message. + +## Writing one + +```csharp +using System.Globalization; +using ZodSharp.Core; + +namespace MyRules; + +/// Rejects strings that contain whitespace. +public readonly record struct NoWhitespaceRule(string? Message = null) : IValidationRule +{ + public const string ErrorCode = "invalid_string"; + public const string MessageFormat = "Whitespace is not allowed in '{0}'."; + + public bool IsValid(in string value) + { + if (value is null) + return false; + + foreach (var character in value) + { + if (char.IsWhiteSpace(character)) + return false; + } + + return true; + } + + public string Code => ErrorCode; + + public string GetErrorMessage(in string value) => + Message ?? string.Format(CultureInfo.CurrentCulture, MessageFormat, value); +} +``` + +It works standalone, and attaches to any schema: + +```csharp +var rule = new NoWhitespaceRule(); +if (!rule.IsValid("John Doe")) + Console.WriteLine(rule.GetErrorMessage("John Doe")); + +var schema = Z.String().Rule(new NoWhitespaceRule("No spaces allowed.")); +``` + +`ZodType.AddRule(IValidationRule)` and the generic `Rule(TRule)` +helper are both public. Give the rule a first-class fluent method with an ordinary extension method on +its schema type, the way the built-in rules are surfaced. + +## Exposing it as an attribute + +Two routes. + +**Map a hand-authored attribute to the rule.** The open generic form serves both a primitive member +and a scalar value object: + +```csharp +[ZodRule(typeof(NoWhitespaceRule<>))] +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct | AttributeTargets.Property)] +public sealed class NoWhitespaceAttribute : ValidationAttribute +{ + public string? Message { get; set; } +} +``` + +**Or let the generator write it.** Mark the rule with the parameterless `[ZodRule]` and a +`{RuleName}Attribute` is emitted in the rule's namespace — a trailing `Rule` becomes `Attribute`, and +a name colliding with a DataAnnotations attribute takes a `Zod` suffix: + +```csharp +[ZodRule] // emits NoWhitespaceAttribute +public readonly record struct NoWhitespaceRule(string? Message = null) : IValidationRule; +``` + +The generator runs over the compilation that **declares** the rule, so the marker form suits a rule +in the same project, or in a rules library referencing `Purview.ZodSharp`. + +## Generic rules and rule families + +A generic rule closed with the property type lets one attribute serve every member of that shape — +`[ZodRule(typeof(NonSentinelRule<>))]` on a `NonSentinelAttribute` covers `Guid`, `DateTime`, +`string` and the rest. + +`ZodSharp.Rules.NonSentinelRule` is the canonical example: it rejects the framework +default/boundary values an ORM stores to mean "no value" — `Guid.Empty`, +`DateTime.MinValue`/`MaxValue`, the `DateTimeOffset`/`DateOnly`/`TimeOnly` bounds, and +null/empty/whitespace strings. A type with no known sentinel always passes, so the rule never rejects +something it does not understand. Its JSON Schema mapping is exact for `DateOnly`, `TimeOnly`, deterministic +`DateTime` forms, and `DateTimeOffset`; the optional UTC opt-in only narrows the accepted offset (see the JSON +skill). + +```csharp +var schema = Z.Date().AddRule(new NonSentinelRule()); +var result = schema.Validate(DateTime.MinValue); +// result.Errors[0].Code == NonSentinelRule.ErrorCode ("invalid_value") +``` + +## Type-level rules and scalar value objects + +An attribute applied to a **type** validates the value as a unit, reporting an **empty path**. That is +what makes a rule usable on a `Purview.ValueObjects` `[Scalar]`: + +```csharp +[Scalar] +[ZodSchema] +[NonSentinel(Message = "UserId must not be empty.")] +public readonly partial record struct UserId +{ + public Guid Value { get; init; } +} +``` + +A rule written against the *underlying* value — `NonSentinelRule` — is adapted automatically: +the generator closes it with the underlying type and wraps it, so it runs against the value object +with an empty path, and the wrapped rule owns the reported `Code`. Closing such a rule with the +scalar type instead (`NonSentinelRule`) compiles but **always passes**, because the check only +knows the primitive. + +## Diagnostics you will meet + +`ZODSGEN030` (mapped rule does not implement `IValidationRule` for the property type), +`ZODSGEN031` (a constructor parameter could not be mapped from the attribute), `ZODSGEN032` (no +attribute could be generated), `ZODSGEN033` (a rule-mapped attribute on a type that gets no generated +schema, so the rule never runs), `ZODSGEN037`/`ZODSGEN038` (attribute-name collisions and incomplete +mappings), `ZODSGEN039` (`code`/`origin` without `IZodRule`), `ZODSGEN040` (an attribute argument with +no effect), `ZODSGEN042` (missing `ErrorCode`/`MessageFormat`) and `ZODSGEN043` (a built-in rule whose +constructor parameter cannot be an attribute property). + +The `purview-zodsharp-diagnostics` skill carries the full catalogue. diff --git a/.agents/skills/purview-zodsharp-diagnostics/SKILL.md b/.agents/skills/purview-zodsharp-diagnostics/SKILL.md new file mode 100644 index 0000000..101e872 --- /dev/null +++ b/.agents/skills/purview-zodsharp-diagnostics/SKILL.md @@ -0,0 +1,125 @@ +--- +name: purview-zodsharp-diagnostics +description: >- + Use when a Purview.ZodSharp build reports a ZODSGEN or ZODSASP diagnostic - the [ZodSchema] + generator's configuration and usage rules, the custom-rule mapping rules, and the ErrorType + analyzers - to find what triggered it and how to fix it. +license: MIT +metadata: + product: Purview.ZodSharp + repository: purview-dev/zodsharp +--- + +# Purview.ZodSharp diagnostics + +Two families, both enabled by default: + +- **`ZODSGEN*`** — the `[ZodSchema]` generator's analyzer (category `ZodSharp.SourceGenerator`). +- **`ZODSASP*`** — the `ErrorType` factory's analyzer and generator, which ship in the **core** + package and need no ASP.NET Core dependency. + +`ZODSGEN033` and `ZODSGEN037`–`ZODSGEN040` and `ZODSGEN042` are warnings; `ZODSGEN041` and +`ZODSGEN043` are informational; the rest are errors. + +IDs `ZODSGEN002` and `ZODSGEN022`–`ZODSGEN026` are intentionally unused. Rule identifiers are never +renumbered or reused, so a sighting of one means stale tooling or a stale suppression. + +## Generator failures and DataAnnotations usage + +| ID | Meaning | +| --- | --- | +| `ZODSGEN001` | Unhandled generator exception. | +| `ZODSGEN003` | `[Length]` with `min > max`. | +| `ZODSGEN004` | `[Length]` on an unsupported target. | +| `ZODSGEN005` | Error-message resource name without type, or type without name. | +| `ZODSGEN006` | Unsupported DataAnnotations usage: a string-only attribute on a non-string, `[AllowedValues]`/`[DeniedValues]` on an unsupported type, `[RegularExpression]` on a non-string, `[Range]` on an unsupported type. | +| `ZODSGEN020` | `[Compare]` references an unknown property. | +| `ZODSGEN021` | `System.ComponentModel.DataAnnotations` reference is missing. | + +## Custom async validation method + +All of these mean the method the generator was told to call cannot be called: + +| ID | Meaning | +| --- | --- | +| `ZODSGEN007` | Configured but not found. | +| `ZODSGEN008` | Return type is not `ValueTask>`. | +| `ZODSGEN009` | Parameter count is not 2. | +| `ZODSGEN010` | First parameter is not the model type. | +| `ZODSGEN011` | Second parameter is not `CancellationToken`. | +| `ZODSGEN012` | The method is generic. | +| `ZODSGEN013` | Declared on the model type but not `static`. | +| `ZODSGEN014` | Inaccessible from the generated validator (private/protected). | +| `ZODSGEN015` | Ambiguous overloads — only when at least two valid candidates exist. | +| `ZODSGEN016` | The configured name is not a valid C# identifier. | +| `ZODSGEN017` | The method is abstract. | +| `ZODSGEN018` | An unimplemented partial method. | +| `ZODSGEN019` | Uses `ref`/`in`/`out`/`params`/`scoped` parameters. | + +## `IValidateOptions` + +| ID | Meaning | +| --- | --- | +| `ZODSGEN027` | Requested, but the `Microsoft.Extensions.Options` reference is missing. | +| `ZODSGEN028` | Requested on a struct — it requires a class. | + +## The refinement hook + +| ID | Meaning | +| --- | --- | +| `ZODSGEN029` | The model declares **both** `OnZodValidate` and an async custom validation method. Exactly one is allowed. | +| `ZODSGEN034` | `OnZodValidate` is on a type that is not `partial`, or whose containing types are not all `partial`, so the generated declaration cannot be emitted. | +| `ZODSGEN035` | Not declared as `partial void OnZodValidate(RefineCtx context)` — wrong modifiers, return type or parameters. | +| `ZODSGEN036` | A member still uses the retired `IEnumerable Validate()` contract, which the generator no longer binds. Implement `OnZodValidate`. | + +## Custom rules and rule attributes + +| ID | Meaning | +| --- | --- | +| `ZODSGEN030` | A rule mapped through `[ZodRule(typeof(…))]` does not implement `IValidationRule` for the property type — including an unbound generic rule that cannot be closed with it. | +| `ZODSGEN031` | A rule constructor parameter could not be mapped from the attribute. | +| `ZODSGEN032` | No validation attribute could be generated for a rule marked `[ZodRule]`. | +| `ZODSGEN033` | *(warning)* A rule-mapped attribute is applied to a type that gets no generated schema — no `[ZodSchema]`, and not reachable as a complex property — so the rule never runs. | +| `ZODSGEN037` | *(warning)* A `[ZodRule]`-derived attribute name (`XRule` → `XAttribute`) is already declared by hand, so no attribute is generated and that declaration's own mapping governs every usage. | +| `ZODSGEN038` | *(warning)* A hand-authored attribute's `[ZodRule(typeof(…))]` mapping does not address every rule declared under the name it encodes (`XAttribute` → `XRule`), so some usages resolve to no rule. | +| `ZODSGEN039` | *(warning)* A rule accepts a `code`/`origin` constructor parameter but does not implement `IZodRule`, so the value never reaches the reported error identity. | +| `ZODSGEN040` | *(warning)* An attribute argument has no effect: the resolved rule has no matching constructor parameter, and the value is not part of the reported identity. | +| `ZODSGEN042` | *(warning)* A rule does not expose a public `const string ErrorCode` **and** a public `const string MessageFormat`, so its error identity cannot be asserted in tests without duplicating literals. | +| `ZODSGEN043` | *(info)* A built-in rule marked `[ZodRule]` generates no attribute, because a constructor parameter cannot be represented as an attribute property. The rule is still reachable through the runtime/fluent API. A name colliding with DataAnnotations is **not** skipped — it is emitted with a `Zod` suffix. | + +## Collections + +| ID | Meaning | +| --- | --- | +| `ZODSGEN046` | *(warning)* `[CollectionBehavior]` is applied to a property that is not a collection of nullable elements or values (a scalar, a collection whose element cannot be `null`, or a dictionary whose value cannot be `null`), so the setting can never take effect. | + +## Unions, value objects, JSON Schema + +| ID | Meaning | +| --- | --- | +| `ZODSGEN041` | *(info)* A typed `Z.Union` whose option types are all reference types can use the allocation-free native C# 15 union from `Z.NativeUnion` on .NET 11+. A code fix is offered. | +| `ZODSGEN044` | A `[ZodSchema]` type using an automatic `[Scalar]` form (`[Scalar]` / `[Scalar(typeof(TValue))]`) cannot produce a usable schema, so the value-object generator's `Create` would reference a `{Type}Schema` that does not exist or cannot validate. Reported when `GenerateValidateMethod = false` suppresses the `Validate` it calls, or the declared underlying type cannot be represented. | +| `ZODSGEN045` | A type referenced by an exported type has `GenerateJsonSchema = false`, so the cross-platform contract surface is incomplete. | + +## `ErrorType` — `ZODSASP*` + +These ship with the **core** package, alongside the `ErrorType` factory, the `[ErrorType]` attribute +and the generator that emits `Create`/`Throw` helpers: + +| ID | Severity | Meaning | +| --- | --- | --- | +| `ZODSASP001` | Warning | A `MessageFormat` placeholder is not declared in `ErrorType.Parameters`. | +| `ZODSASP002` | Warning | The containing type of an `[ErrorType]` field is not `partial`. | +| `ZODSASP003` | Warning | An `[ErrorType]` field is not `static readonly`. | +| `ZODSASP100` | Error | Unhandled exception in the `ErrorType` source generator. | +| `ZODSASP101` | Error | The `Parameters` of an `[ErrorType]` field could not be extracted. | + +`ZODSASP001`–`ZODSASP003` explain why `Create`/`Throw` helpers were **not** generated — a silent +absence otherwise. `ZODSASP100`/`ZODSASP101` are fatal generator failures and name the field. + +## Suppressing + +Standard mechanisms: `#pragma warning disable ZODSGEN006`, a `NoWarn` entry, or an `.editorconfig` +severity override. The canonical catalogue is the analyzer's `AnalyzerReleases.Shipped.md` / +`AnalyzerReleases.Unshipped.md` in the generator project — a new or changed rule must be recorded +there in the same change. diff --git a/.agents/skills/purview-zodsharp-json/SKILL.md b/.agents/skills/purview-zodsharp-json/SKILL.md new file mode 100644 index 0000000..adaedda --- /dev/null +++ b/.agents/skills/purview-zodsharp-json/SKILL.md @@ -0,0 +1,223 @@ +--- +name: purview-zodsharp-json +description: >- + Use when combining Purview.ZodSharp with JSON - deserialize-and-validate and validating converters + for System.Text.Json or Newtonsoft.Json, choosing between the two packages, exporting a schema to + JSON Schema with Z.ToJsonSchema, importing one with Z.FromJsonSchema, and sharing a contract with + TypeScript Zod. +license: MIT +metadata: + product: Purview.ZodSharp + repository: purview-dev/zodsharp +--- + +# JSON and JSON Schema + +Two integration packages, and they are **mutually exclusive in practice** — pick the one matching +your JSON library: + +```bash +dotnet add package Purview.ZodSharp.SystemTextJson +dotnet add package Purview.ZodSharp.NewtonsoftJson +``` + +Each adds deserialize-and-validate, a validating converter, and JSON Schema **import**. JSON Schema +**export** is in the core package. + +Both can be referenced from one project without `extern alias` — the JSON Schema types live in +`ZodSharp.JsonSchema.SystemTextJson` / `ZodSharp.JsonSchema.NewtonsoftJson` — but the +deserialize/serialize extension overloads share names, so **import exactly one package namespace per +file**. + +## Deserialize and validate + +```csharp +using ZodSharp; + +var userSchema = Z.Object() + .Field("name", Z.String().Min(3)) + .Field("age", Z.Number().Min(0).Int()) + .Build(); + +var result = userSchema.DeserializeAndValidate("""{ "name": "John", "age": 30 }"""); + +await using var stream = File.OpenRead("user.json"); +var streamed = await userSchema.DeserializeAndValidateAsync(stream); +``` + +Going the other way: + +```csharp +var json = userSchema.ValidateAndSerialize(user); // ValidationResult +await userSchema.ValidateAndSerializeAsync(user, outputStream); +``` + +Failures add `deserialization_failed` and `json_error` codes alongside the schema's own. + +## Validating converters + +```csharp +// System.Text.Json +var options = new JsonSerializerOptions { Converters = { userSchema.CreateValidatingConverter() } }; +var value = JsonSerializer.Deserialize(json, options); + +// Newtonsoft.Json +var value2 = JsonConvert.DeserializeObject(json, userSchema.CreateValidatingConverter()); +``` + +Invalid JSON **throws** — `JsonException` (STJ) or `JsonSerializationException` (Newtonsoft), with a +`"Validation failed: …"` message. Each converter removes itself from the options/serializer it uses +internally, to avoid recursing into itself. + +## Which package differs how + +| Aspect | SystemTextJson | NewtonsoftJson | +| --- | --- | --- | +| Async result | `ValueTask<…>` | `Task<…>` | +| Options parameter | `JsonSerializerOptions` | `JsonSerializerSettings` | +| Converter return | generic `JsonConverter` | non-generic `JsonConverter` | +| `JToken` overload | no | yes | +| Formatting | via options | explicit `Formatting` argument | +| Invalid-data exception | `JsonException` | `JsonSerializationException` | +| Plumbing type | `JsonElement` | `JToken`/`JObject`/`JArray` | + +## JSON Schema export + +`Z.ToJsonSchema` (core package) produces a Draft 2020-12 `JsonSchemaDefinition`: + +```csharp +var jsonSchema = Z.ToJsonSchema>(userSchema, new ToJsonSchemaOptions +{ + Title = "User", + Id = "https://example.com/schemas/user.json", +}); +``` + +`ToJsonSchemaOptions`: `IncludeSchema` (default `true`, emits `$schema`), `Id`, `Title`. + +`JsonSchemaDefinition` (`ZodSharp.JsonSchema`) is a mutable POCO mirroring the keywords — identity +(`$schema`/`$id`/`$ref`), type and title, string/number/array/object constraints, composition +(`anyOf`/`oneOf`/`allOf`), `enum`/`const`, `$defs` and draft-07 `definitions`, and the metadata +keywords including OpenAPI 3.0 `nullable`. + +Covered schema types: `ZodString`, `ZodNumber`, `ZodBoolean`, `ZodNull`, `ZodObject`, `ZodOptional`, +`ZodUnion`, `ZodArray`, `ZodLiteral`, `ZodNullable`, `ZodNativeEnum`/`ZodEnum`, `ZodLazy`. Notable +behaviours: + +- objects emit `additionalProperties: false`; +- literals emit `const` plus `type`; +- native enums emit `enum` in their System.Text.Json wire form — numbers by default, member names + when serialized as strings. A `[Flags]` enum emits `type: integer` instead, because a combination is + valid without being a defined member; +- lazy/recursive schemas emit `$ref` entries under `$defs` (`#/$defs/__lazyN`); +- rules that describe their own constraint implement `IJsonSchemaRule` and are applied during export; +- `[NonSentinel]` exports exact patterns for `DateOnly`, `TimeOnly`, `DateTime`, and `DateTimeOffset`. The + default `DateTimeOffset` pattern is offset-aware and needs no converter. An optional narrower UTC contract + uses `ZodSharpDateTimeOffsetSerialization=Utc` or + `[assembly: ZodSharpSchemaDefaults(DateTimeOffsetSerialization = DateTimeOffsetSerialization.Utc)]`, paired + with the integration package's `UtcDateTimeOffsetConverter` (which rejects non-UTC input on read). Override + per type/property with `[DateTimeOffsetBehavior(...)]`. + +### Exporting a generated `[ZodSchema]` + +No need to rebuild the rules by hand with `Z.Object()`: + +```csharp +var jsonSchema = RepositoryInventoryItemSchema.BuildJsonSchema(); +var exported = Z.ToJsonSchema(new RepositoryInventoryItemSchemaValidator()); +``` + +The definition comes from the same metadata the generated validator uses, so the two cannot drift. +Things worth knowing: + +- **Property names** use the System.Text.Json wire form: `[JsonPropertyName]` wins, otherwise the name + is camelCased to match `JsonSerializerDefaults.Web`. A non-camelCase global naming policy configured + at runtime is invisible to the generator, so adjust the exported names or declare + `[JsonPropertyName]`. +- **Required** follows the nullable reference-type annotation: a non-nullable reference type (or + `Nullable`) is required; a nullable one is wrapped in `anyOf: [ …, { "type": "null" } ]` and is + not required. `[Required]` also makes a field required, and a rule that rejects an absent value — + `[RequiredZod]` — makes the field non-null even with a nullable annotation. The generated validator + agrees with all of this. +- **Scalar value objects** (`[Scalar]`) export as the **underlying** value's schema, not as an + object wrapping a `Value` property. A `Guid` scalar exports as a canonical 8-4-4-4-12 hexadecimal + `pattern`, not Zod's RFC `uuid` format, because a C# `Guid` accepts any 128-bit value. +- **Collection items** follow the null-items setting: `T[]`/`List` maps to `z.array(T)` and + `T?[]`/`List` (or a property allowing nulls via `[CollectionBehavior(AllowNulls = true)]`) to + `z.array(T.nullable())`. A collection whose elements may be null exports + `items.anyOf: [ …, { "type": "null" } ]`; by default the generated validator rejects a null element with + `invalid_type` at the element's path, matching `z.array(T)`. +- **Dictionaries map to `z.record(z.string(), V)`.** A property implementing `IDictionary<,>` or + `IReadOnlyDictionary<,>` exports `{ "type": "object", "additionalProperties": }` (a nullable value + wraps that value schema in a `null` branch), and its size constraints use `minProperties`/`maxProperties`. + `Z.Record(schema)` exports the same shape, and `Z.FromJsonSchema` reads an object with + `additionalProperties` back as a record in both JSON integrations. + +## JSON Schema import + +```csharp +using ZodSharp; +using ZodSharp.JsonSchema.SystemTextJson; // or …NewtonsoftJson + +var userSchema = Z.FromJsonSchema(jsonSchemaString); +var result = userSchema.Validate(userData); +``` + +Overloads take a `string` or an already-deserialized `JsonSchemaDefinition`, and both return +`IZodSchema`. + +`Z.FromJsonSchema` is a **C# 14 extension member on `Z`**, so it only exists when a JSON integration +package is referenced. `Z.ToJsonSchema` is a real static member in the core package. + +Mapped keywords: + +- `type` — `string`, `number`, `integer`, `boolean`, `null`, `object`, `array`. +- `enum` → a union of literals (a single member becomes a literal); `const` → a literal. +- `anyOf`/`oneOf` → union; `allOf` → the intersection of every branch, plus the base schema when the + schema also declares a `type`. A type-less branch is `any`, matching Zod's `fromJSONSchema`. +- Strings — `minLength`, `maxLength`, `pattern`, and `format` (`email`, `uri`, `uuid`). The + `uuid`/`guid` format maps to the versionless `.UUID()`; a versioned `.UUID(UuidVersion.V7)` schema + exports `format: "uuid"` plus a version-pinning `pattern`, and the `allOf` composition preserves it + on import. Multiple `pattern` constraints compose under `allOf`, and every branch is applied. +- Numbers — `minimum`, `maximum`, `multipleOf`; `integer` additionally applies `.Int()`. +- Objects — `required` and optional fields. Arrays — `items`, `minItems`, `maxItems` (a nullable item + schema — `anyOf` with a `{"type":"null"}` branch — imports as a nullable element). + +### Limits + +`$ref` resolves **local** references only (`#/…`, for example `#/$defs/Name`). An external target +throws `NotSupportedException` naming it — inline the referenced schema, or move it under the root +`$defs`, before importing. Local references **may be cyclic**: one still being resolved becomes a lazy +schema. + +The reader binds `$schema`, `$id`, `$ref`, `$defs` and the draft-07 `definitions` through the +integration package's `JsonSchemaSerializerOptions`, and the same options write them back, so exported +definitions round-trip through either package. `JsonSchemaDefinition` itself carries no serializer +annotations — serialize it with `JsonSchemaSerializerOptions` to get keyword-compliant output. + +## Sharing a contract with TypeScript Zod + +```typescript +import { z } from "zod"; +const UserSchema = z.object({ username: z.string().min(3), email: z.string().email() }); +const jsonSchema = z.toJSONSchema(UserSchema); // Zod v4+ +``` + +```csharp +var userSchema = Z.FromJsonSchema(jsonSchemaString); +``` + +This repository also proves agreement by fixture rather than by assertion. `bun run generate-fixtures` +runs `src/ts/generate-fixtures.ts`, writing `src/ts/fixtures/*.json` plus a `manifest.json` in which +**Zod is the authority** on each fixture's validity. `just test` then reads both, asserts the C# +outcome matches, and writes validated C# output to +`src/tests/cross-platform/output/{systemtext,newtonsoft}-valid.json`. Finally `bun run test` (vitest) +re-checks the fixtures under Zod and parses the C# output — closing the loop in both directions. + +If the C# output directory is empty, the vitest suite tells you to run the C# cross-platform tests +first. + +## Related skills + +`purview-zodsharp-core` for the schema API, and `purview-zodsharp-source-generator` for the generated +validators that export through `BuildJsonSchema`. diff --git a/.agents/skills/purview-zodsharp-source-generator/SKILL.md b/.agents/skills/purview-zodsharp-source-generator/SKILL.md new file mode 100644 index 0000000..31d140e --- /dev/null +++ b/.agents/skills/purview-zodsharp-source-generator/SKILL.md @@ -0,0 +1,224 @@ +--- +name: purview-zodsharp-source-generator +description: >- + Use when generating a compile-time validator with Purview.ZodSharp's [ZodSchema] attribute - which + types it emits, the attribute options, DataAnnotations coverage, the OnZodValidate refinement hook, + async custom validation, automatic enum validation, IValidateOptions support, and the MSBuild + switches that control it. +license: MIT +metadata: + product: Purview.ZodSharp + repository: purview-dev/zodsharp +--- + +# The `[ZodSchema]` source generator + +Mark a class, struct or record with `[ZodSchema]` and a static, zero-allocation validator is emitted +at compile time — no reflection at runtime. The attribute itself is generated into the `ZodSharp` +namespace by `Purview.ZodSharp.SourceGenerators`, so nothing beyond `Purview.ZodSharp` is needed. + +```csharp +using System.ComponentModel.DataAnnotations; +using ZodSharp; + +[ZodSchema] +public class User +{ + [Required] + [StringLength(50, MinimumLength = 3)] + public string Name { get; set; } = string.Empty; + + [Range(0, 120)] + public int Age { get; set; } + + [EmailAddress] + public string? Email { get; set; } +} + +var result = UserSchema.Validate(user); +var validated = UserSchema.Parse(user); // throws ZodException on failure +``` + +## What gets emitted + +| Artifact | Shape | +| --- | --- | +| `{TypeName}Schema` | static partial class — the validator. Accessibility mirrors the target. Holds `Validate`, `Parse`, `BuildJsonSchema`, and (with composition on) `ApplyAnd`, `ApplyOr`, `ApplyRefine`. Renamed by `SchemaName`. | +| `{TypeName}SchemaValidator` | `partial class : IZodSchemaValidator<{TypeName}>, IJsonSchemaProvider` — the DI adapter, with `Validate`/`ValidateAsync`/`BuildJsonSchema`. Primary schema only; `{SchemaName}Validator` when renamed. | +| `{TypeName}Validator` | `sealed partial class : IValidateOptions<{TypeName}>` — only when `IValidateOptions` support is on, and the target is a class. | +| `[assembly: ZodSchemaGenerated(typeof({TypeName}))]` | the marker `IZodSchemaFactory` assembly scanning consumes. Primary, non-nested schemas only. | + +`BuildJsonSchema` is built from the same metadata as `Validate`, so a generated schema exports through +`Z.ToJsonSchema` without rebuilding its rules by hand. + +```csharp +var adult = UserSchema.ApplyRefine(user, u => u.Age >= 18, "Must be adult"); +var both = UserSchema.ApplyAnd(user, u => u.Name.Length > 5, "Name too short"); +var either = UserSchema.ApplyOr(user, u => u.Age < 18, "Must be an adult, or a minor with consent"); +``` + +## Attribute options + +| Property | Default | Purpose | +| --- | --- | --- | +| `SchemaName` | `null` | Renames `{TypeName}Schema`; the DI adapter becomes `{SchemaName}Validator`. | +| `GenerateValidateMethod` | `true` | `false` omits `Validate` **and everything that depends on it**. | +| `GenerateParseMethod` | `true` | `false` omits `Parse`. | +| `EnableComposition` | `true` | Emits `ApplyAnd`/`ApplyOr`/`ApplyRefine`. | +| `CustomValidationMethodName` | `null` | Names the async custom validation method (default lookup `CustomValidationAsync`). Mutually exclusive with `OnZodValidate`. | +| `GenerateIValidateOptions` | `false` | Forces `IValidateOptions` generation. | +| `SuppressIValidateOptions` | `false` | Opts out even when auto-detection would enable it. | +| `ValidateEnumValues` | `true` | `false` skips automatic enum validation. | +| `GenerateJsonSchema` | `true` | `false` omits `BuildJsonSchema` and `IJsonSchemaProvider`. A type referenced by an exported type is `ZODSGEN045`. | +| `AllowNulls` | unset | Whether this type's collection properties may contain `null` elements. Unset defers to the assembly default. | + +`Parse`, the composition methods, the `IZodSchemaValidator` adapter and the `IValidateOptions` +validator all depend on `Validate` — `GenerateValidateMethod = false` removes them together. + +## What is validated + +- Properties must be public, non-static, non-indexer. +- A property gets validation when it carries **any** DataAnnotations attribute, when its type is a + source-defined complex type with a nested schema, or when its type is an enum. +- Nested complex types are discovered recursively and get their own `{TypeName}Schema`, **even + without** their own `[ZodSchema]`. +- Nullable properties are null-guarded before value-set/type validation; a nullable target rejects + `null` with `invalid_type`. +- Collection elements reject `null` by default: the error is `invalid_type` at the element's own path + (`["Items", 0]`). Control it with `[CollectionBehavior(AllowNulls = ...)]` on the property, a `T?` + element annotation (implies allow), `[ZodSchema(AllowNulls = ...)]` on the containing type, + `[assembly: ZodSharpSchemaDefaults(AllowNulls = ...)]`, then the `ZodSharpAllowNulls` MSBuild + property. The most specific statement wins; an unset level defers to the next. An explicit + `AllowNulls = true` exports a `null` branch on the collection's `items` schema. +- **Dictionaries are maps, not arrays.** A property whose type implements `IDictionary` + or `IReadOnlyDictionary` (for example `Dictionary`, `SortedDictionary`, + `ConcurrentDictionary`, `ImmutableDictionary`) validates every **value** against the value type's + nested schema and reports a failure at `["Items", "key"]`. Keys are used only in error paths, and + `AllowNulls` governs a null value (`Dictionary` rejects it by default; `Child?` or + `[CollectionBehavior(AllowNulls = true)]` accepts it). A dictionary exports as + `{ "type": "object", "additionalProperties": … }` and `[Length]`/`[MinLength]`/`[MaxLength]` map to + `minProperties`/`maxProperties` — never `z.array`/`items`. +- Classes, structs and records are supported. Structs get no `IValidateOptions` (`ZODSGEN028` if + requested). + +DataAnnotations coverage, positional-record attribute targets, structured size issues and the +`[Range]` shapes are in [references/dataannotations.md](references/dataannotations.md). + +## The `OnZodValidate` refinement hook + +Rules DataAnnotations cannot express are written as a **generator-declared partial method**. The +generator emits the declaration, so the IDE offers the implementation with the right signature and +nothing is resolved by naming convention: + +```csharp +[ZodSchema] +public partial class Order +{ + public decimal Total { get; set; } + + partial void OnZodValidate(RefineCtx context) + { + if (context.Value.Total < 0) + context.AddIssue("invalid_range", "Total cannot be negative", [nameof(Total)]); + } +} +``` + +- The type **and every containing type** must be `partial` (`ZODSGEN034`). That is the only shape + requirement the hook adds. +- The signature must be `partial void OnZodValidate(RefineCtx context)`, `T` being the model + (`ZODSGEN035`). The parameter is a plain by-value `RefineCtx`. +- The hook runs for **every** entry point — `Validate`, `Parse`, the `IZodSchemaValidator` adapter, + `IValidateOptions`, any factory that validates through the schema — so a rule written here behaves + exactly like an attribute rule. +- A type that does not implement it allocates nothing: the generated `Validate` only builds a + `RefineCtx` and calls the hook when a body exists. +- The retired `IEnumerable Validate()` contract is no longer bound, and is reported + as `ZODSGEN036`. + +Alongside the hook the generator emits one `internal static` +`InvokeZodRefinementHook(T value, RefineCtx context)` bridge on the target type. A classic partial +method is private and `{Type}Schema` is a different type, so the bridge is what lets `Validate` reach +the hook while keeping the hook optional. **It is not part of your API; never implement it by hand.** + +## Async custom validation + +Mutually exclusive with `OnZodValidate` — declaring both is `ZODSGEN029`. + +```csharp +public partial class UserSchemaValidator +{ + public async ValueTask> CustomValidationAsync(User value, CancellationToken ct) + { + await Task.Delay(1, ct); + return ValidationResult.Success(value); + } +} +``` + +Signature `ValueTask> Name(T value, CancellationToken ct)`. The default lookup +name is `CustomValidationAsync` unless `CustomValidationMethodName` overrides it. A method on the +**model type** must be `static`; one on the generated `{TypeName}SchemaValidator` partial may be an +instance method. The generated `ValidateAsync` runs the synchronous `Validate`, awaits the custom +method, and merges the error sets. + +## Automatic enum validation + +Every non-`[Flags]` enum property is validated automatically: a value that is not a defined member is +rejected with `invalid_enum_value`, emitted as an `EnumRule`. + +```csharp +public enum ExampleEnum +{ + [ZodIgnore] // never a valid value, for every property of this type + Unspecified, + AValidValue, + AnotherValidValue, +} + +[ZodSchema] +public class Model +{ + public ExampleEnum Status { get; set; } + + [DeniedValues(ExampleEnum.AnotherValidValue)] // excluded for this property only + public ExampleEnum SecondaryStatus { get; set; } +} +``` + +It is skipped when the enum is `[Flags]` (a combination is valid without being a defined member), +when the property declares an explicit `[AllowedValues]` allow-list (which governs instead), or with +`[ZodSchema(ValidateEnumValues = false)]`. A nullable enum is validated only when not `null`. + +## `IValidateOptions` support + +Enabled by `GenerateIValidateOptions = true`, by auto-detection (unset, target is not a value type, +and the type name ends with a configured suffix — `Options` or `Settings` by default), or by MSBuild. + +| MSBuild property | Default | Behaviour | +| --- | --- | --- | +| `DisableZodSharpSourceGenerator` | unset | truthy disables the generator entirely | +| `ZodSharpAutoGenerateOptionsValidators` | `true` | only an explicit `false` disables auto-detection | +| `ZodSharpAutoGenerateOptionsValidatorSuffixes` | `Options;Settings` | semicolon/comma-separated | +| `ZodSharpGenerateJsonSchemas` | `true` | explicit `false` omits `BuildJsonSchema` and `IJsonSchemaProvider` assembly-wide | +| `ZodSharpEnumSerialization` | `Number` | `String` exports enums as member names, matching a global `JsonStringEnumConverter` | +| `ZodSharpEnumNamingPolicy` | `Unchanged` | `CamelCase` applies `JsonNamingPolicy.CamelCase` to string-serialized enum names | +| `ZodSharpAllowNulls` | `false` | assembly-wide default for whether collections may contain `null` elements; explicit `true` accepts them | +| `ZodSharpDateTimeOffsetSerialization` | `PreserveOffset` | `Utc` narrows `[NonSentinel] DateTimeOffset` to a zero-offset wire contract; pair with the integration package's `UtcDateTimeOffsetConverter`. Both modes export an exact sentinel pattern | + +`DateTimeOffset` precedence: `[DateTimeOffsetBehavior]` on the property/parameter, then on the type, then +`[assembly: ZodSharpSchemaDefaults(DateTimeOffsetSerialization = ...)]`, then the MSBuild property, then +`PreserveOffset`. The attribute shapes the schema only; `Utc` requires a converter that also rejects non-UTC +input on read. + +## Value objects + +A `Purview.ValueObjects` scalar marked `[Scalar]` can carry `[ZodSchema]` on the same type. The +generated validator validates the scalar **as a unit** and reports an empty path, and a rule written +against the underlying value is adapted automatically. + +## Related skills + +`purview-zodsharp-custom-rules` for `[ZodRule]` and the built-in rule attributes, +`purview-zodsharp-diagnostics` for the `ZODSGEN` catalogue, and `purview-zodsharp-core` for the +runtime API the generated code reports through. diff --git a/.agents/skills/purview-zodsharp-source-generator/references/dataannotations.md b/.agents/skills/purview-zodsharp-source-generator/references/dataannotations.md new file mode 100644 index 0000000..30e92ec --- /dev/null +++ b/.agents/skills/purview-zodsharp-source-generator/references/dataannotations.md @@ -0,0 +1,137 @@ +# DataAnnotations coverage + +The `[ZodSchema]` generator reads `System.ComponentModel.DataAnnotations` attributes and emits +direct, typed codegen. Nothing is reflected at runtime. + +## Supported attributes + +| Attribute | Generated behaviour | Failure code | +| --- | --- | --- | +| `[Required]` | nullable property must not be null; a string with `AllowEmptyStrings=false` must be non-empty | `missing_field` | +| `[Length(min, max)]` | min/max size; strings, arrays (including jagged and rectangular), countable collections | `too_small` / `too_big` | +| `[StringLength(max)]`, `[StringLength(max, MinimumLength=min)]` | string size via direct `.Length` | `too_small` / `too_big` | +| `[MinLength(n)]` | checked only when `n > 0` | `too_small` | +| `[MaxLength(n)]` | checked only when `n >= 0` | `too_big` | +| `[Range(…)]` | inclusive (or exclusive) numeric/parsed bounds | `invalid_range` | +| `[RegularExpression(pattern)]` | compiled `Regex` field, checked on non-empty strings | `invalid_string` | +| `[AllowedValues(…)]` | typed equality against the allowed set | `invalid_value` | +| `[DeniedValues(…)]` | typed equality against the denied set; on an enum property the values are absorbed into the automatic enum rule instead | `invalid_value` | +| `[EmailAddress]` | reuses `ZodSharp.Rules.EmailRule` on non-empty strings | `invalid_string` | +| `[Url]`, `[Phone]`, `[CreditCard]`, `[Base64String]` | reuse `UrlRule`, `PhoneRule`, `CreditCardRule`, `Base64StringRule` | `invalid_string` | +| `[Compare(otherProperty)]` | typed equality between two properties | `mismatch` | +| `[Display(Name=…)]` | not validated; `Name` is the display name in messages and `{0}` placeholders | — | + +`[Length]` follows DataAnnotations null semantics: `null` is valid unless `[Required]` is also +present. + +`[RequiredZod]` — the generated `RequiredRule` attribute — mirrors `[Required]`: it is emitted +*outside* the generator's non-null guard, so an absent value is reported as `missing_field` even +though every other rule bound to the member is skipped for `null`. Its `AllowEmptyStrings` has the +same meaning as `RequiredAttribute.AllowEmptyStrings`, and it adds `TrimWhitespace`, so it is a +drop-in replacement. + +## Positional records + +A positional record declares its properties from the primary-constructor parameter list, and the C# +compiler applies an attribute valid on both a parameter and a property **to the parameter only**. The +generator therefore reads attributes on the synthesized property *and* on the primary-constructor +parameter: + +```csharp +[ZodSchema] +public sealed record RepositoryInventorySummary( + [NonSentinel] string Name, + [NonSentinel] string CanonicalReference, + [NullOrNonWhiteSpace] string? DefaultBranch, + [NonSentinel] DateTimeOffset LastObservedAt +); +``` + +`[property: NonSentinel]` is equally honoured. When both targets carry the same attribute type the +property target wins and the rule runs **once**. Declared properties, classes and record structs are +unaffected. + +## Size validators and structured issues + +Size attributes generate direct `Length`/`Count` access where possible: + +- `string` → `.Length` +- arrays, including rectangular → `.Length` +- jagged arrays → outer-array `.Length` +- countable collections → `.Count` +- `IEnumerable` / `IEnumerable` → one counted pass via `CollectionCountHelper.GetCount`, with fast + paths for `ICollection`, `IReadOnlyCollection` and non-generic `ICollection` + +A structured size failure carries the same metadata as the runtime API: `Code` (`too_small` / +`too_big`), `Origin` (`string` for strings, `array` for arrays, `collection` for countable and +`IEnumerable` collections), `Minimum`/`Maximum` (the inclusive bound), `Inclusive` (`true`) and +`Path`. + +```csharp +[ZodSchema] +public sealed class Basket +{ + [Required] + [Length(2, 5)] + public List? Items { get; set; } +} + +var result = BasketSchema.Validate(new Basket { Items = ["apple"] }); +// Code == "too_small", Minimum == 2, Origin == "array", Inclusive == true +``` + +## `[Range]` + +Three constructor shapes, plus `MinimumIsExclusive`, `MaximumIsExclusive`, +`ConvertValueInInvariantCulture` and `ParseLimitsInInvariantCulture`: + +- `[Range(int, int)]` and `[Range(double, double)]` — literal numeric bounds. +- `[Range(typeof(T), "min", "max")]` — parsed bounds for numeric and comparable types. + +Comparable targets include `TimeSpan`, `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly` and +`Version` (which uses `CompareTo`), plus any `IComparable` with user-defined comparison operators. +Bounds are emitted as static typed fields and compared without running the attribute at runtime. + +## Error messages + +`ErrorMessage`, or `ErrorMessageResourceName` + `ErrorMessageResourceType`, are honoured, with `{0}` +(display name), `{1}` and `{2}` (bound) formatted through +`string.Format(CultureInfo.CurrentCulture, …)`. Supplying only one half of the resource name/type +pair is `ZODSGEN005`. + +## Applicability is a compile error, not a silent skip + +- `[Length]` with `min > max` → `ZODSGEN003`. +- `[Length]` on an unsupported target, such as `decimal` → `ZODSGEN004`. +- String-only attributes (`[RegularExpression]`, `[EmailAddress]`, `[Url]`, `[Phone]`, + `[CreditCard]`, `[Base64String]`) on a non-string, `[AllowedValues]`/`[DeniedValues]` on an + unsupported type, or `[Range]` on an unsupported type → `ZODSGEN006`. +- `[Compare]` naming an unknown property → `ZODSGEN020`. + +## Built-in rule attributes + +Every built-in rule ships a generated attribute in the `ZodSharp.Rules` namespace, usable alongside +the DataAnnotations above: + +`[Email]`, `[E164]`, `[Ulid]`, `[Uuid]`, `[Jwt]`, `[IpAddress]`, `[Hex]`, `[Regex]`, `[StartsWith]`, +`[EndsWith]`, `[Includes]`, `[MultipleOf]`, `[Finite]`, `[SafeInteger]`, `[Int]`, `[Uri]`, +`[Base64Url]`, `[Nanoid]`, `[Cuid2]`, `[DateString]`, `[DatetimeString]`, `[TimeString]`, `[Emoji]`, +`[Xid]`, `[Ksuid]`, `[Duration]`, `[Guid]`, `[Cidr]`, `[NonSentinel]`, `[NullOrNonWhiteSpace]`, +`[MinValue]`, `[MaxValue]`, `[GreaterThan]`, `[LessThan]`, `[GreaterThanOrEqual]`, +`[LessThanOrEqual]`, `[Even]`, `[Odd]`, `[Enum]`, `[RequiredZod]`, `[RangeZod]`, `[LengthZod]`, +`[StringLengthZod]`, `[CompareZod]`, `[AllowedValuesZod]`, `[DeniedValuesZod]`. + +A value the rule declares **without a default** becomes a required constructor argument — +`[Regex("…")]`, `[UUID(UuidVersion.V4)]`, `[Uri(UriKind.Absolute)]`, `[MinValue(3)]` — while +defaulted values and `Message`/`Code` stay named properties. A rule with overloaded constructors +mirrors each overload, so `[Uuid]` is the versionless rule and `[UUID(UuidVersion.V4)]` the versioned +one. `[UUID]` also applies to a `Guid` member or a `[Scalar]` type, because a `Guid` *is* a +UUID — which is how a time-ordered identifier is constrained to an RFC version. + +Names colliding with a DataAnnotations attribute take a `Zod` suffix: `[MinLengthZod]`, +`[MaxLengthZod]`, `[UrlZod]`, `[PhoneZod]`, `[CreditCardZod]`, `[Base64StringZod]`, `[RequiredZod]`, +`[RangeZod]`, `[LengthZod]`, `[StringLengthZod]`, `[CompareZod]`, `[AllowedValuesZod]`, +`[DeniedValuesZod]`. + +A custom rule reaches the same pipeline through `[ZodRule(typeof(MyRule))]` on an attribute, or +`[ZodRule]` on the rule to have the attribute generated. diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index f8a4785..3fc1c2c 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -17,11 +17,11 @@ "rollForward": false }, "purview.build": { - "version": "0.7.1", + "version": "0.8.0", "commands": [ "purview-build" ], "rollForward": false } } -} \ No newline at end of file +} diff --git a/.config/purview-build.json b/.config/purview-build.json index 558bc2c..b6fc291 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -49,11 +49,46 @@ "purview-logo-light.png" ] }, + // Every package ("*"). RequireExplicitContent already rejects anything RequiredContent does not declare; these + // rules hold even if a RequiredContent glob is ever widened. Mirrors purview-dev/zodsharp's rules. Globs are + // Microsoft.Extensions.FileSystemGlobbing patterns: "*" stays within one path segment, "**" spans any number, + // including none. scripts/test-pack-validation-rules.ps1 (`just verify-pack-rules`) proves every rule fails a + // package. "ForbiddenContent": { - // The generator ships a merged, self-contained analyzer, so the framework assembly must never - // appear loose beside it. "*": [ - "analyzers/**/Purview.SourceGeneratorFramework.dll" + // The generator ships a merged, self-contained analyzer, so the framework assembly must never + // appear loose beside it. + "analyzers/**/Purview.SourceGeneratorFramework*", + // The compiler supplies Roslyn to analyzers; a packed copy would shadow or conflict with it. + "analyzers/**/Microsoft.CodeAnalysis*", + // Compiler-host assemblies never appear outside analyzers/: anywhere else NuGet hands them to the + // application, which is how Purview.SourceGeneratorFramework.dll reached app and Blazor WebAssembly + // output and broke WebAssembly AOT compilation. + "Purview.SourceGeneratorFramework*", + "Microsoft.CodeAnalysis*", + "lib/**/Purview.SourceGeneratorFramework*", + "lib/**/Microsoft.CodeAnalysis*", + "ref/**/Purview.SourceGeneratorFramework*", + "ref/**/Microsoft.CodeAnalysis*", + "runtimes/**/Purview.SourceGeneratorFramework*", + "runtimes/**/Microsoft.CodeAnalysis*", + "build/**/Purview.SourceGeneratorFramework*", + "build/**/Microsoft.CodeAnalysis*", + "buildTransitive/**/Purview.SourceGeneratorFramework*", + "buildTransitive/**/Microsoft.CodeAnalysis*", + "buildMultiTargeting/**/Purview.SourceGeneratorFramework*", + "buildMultiTargeting/**/Microsoft.CodeAnalysis*", + "tools/**/Purview.SourceGeneratorFramework*", + "tools/**/Microsoft.CodeAnalysis*", + "content/**/Purview.SourceGeneratorFramework*", + "content/**/Microsoft.CodeAnalysis*", + "contentFiles/**/Purview.SourceGeneratorFramework*", + "contentFiles/**/Microsoft.CodeAnalysis*", + // The generator and its code fix run only inside the compiler, so they ship under analyzers/ alone: under + // lib/, ref/ or runtimes/ every application would load them. + "lib/**/Purview.Results.SourceGenerator*", + "ref/**/Purview.Results.SourceGenerator*", + "runtimes/**/Purview.Results.SourceGenerator*" ] } }, diff --git a/.csharpierignore b/.csharpierignore index a816241..7c12f5d 100644 --- a/.csharpierignore +++ b/.csharpierignore @@ -15,3 +15,5 @@ src/src/Examples.ValueObjects.Zod/TenantError.cs src/src/Examples.AspNetCore/TenantError.cs src/src/Examples.AspNetCore/BillingError.cs src/src/Examples.AspNetCore.Zod/TenantError.cs +scripts/packed-consumers/Shared/TenantError.cs +scripts/packed-consumers/AotConsole/Model.cs diff --git a/.editorconfig b/.editorconfig index b511789..ea03058 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,4 @@ -# Purview.BuildSdk 1.1.0 - managed file, do not edit +# Purview.BuildSdk 1.2.0 - managed file, do not edit # # This file is written by Purview.BuildSdk and is replaced whenever the SDK version changes. The version # on the first line is how the SDK recognises a file it owns: a .editorconfig without that marker is diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 237f8ba..fb0682d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -15,5 +15,5 @@ Keep product, architecture, and general engineering standards centralized in `AG - Apply the `AGENTS.md` testing bar strictly (TUnit with the source-generator framework's `CodeQuery` assertions, `{SubjectUnderTest}_{Scenario}_{Expectation}` naming, cancellation token rule). - Treat work as incomplete until relevant tests pass. -- Consult the repository `.agents/` folder for additional skills/workflows that may improve execution quality. Skill content is delivered by NuGet packages and is read-only here. +- Consult the skills installed in `.agents/skills/` (recorded in `skills-lock.json`) for upstream guidance that may improve execution quality. They are installed by the skills CLI and read-only here; see `AGENTS.md`, "Installed skills". - Keep edits minimal, focused, and aligned with existing SDK and repository conventions. diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index d48cd09..97b1c9f 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -20,7 +20,7 @@ jobs: # the .NET 11 SDK in global.json. Without the matching SDK the runner cannot resolve global.json # ("A compatible .NET SDK was not found") and every step fails. # Keep in sync with `sdk.version` in global.json. - dotnet-version: "11.0.100-rc.2.26504.105" + dotnet-version: "11.0.100-rc.3.26522.148" # Mirrors Build:TestFilter in purview-build.json: every test project is a *.UnitTests project # whose tests the SDK categorises as Unit. test-filter: "/*/*/*/*[Category=Unit]" @@ -28,3 +28,55 @@ jobs: run-pack: true validate-pack: true secrets: inherit + + # Every other job builds against ProjectReferences; this one builds what ships. It packs every Purview.Results + # package to an isolated feed and package cache, then publishes and runs consumers that reference the packages only - + # a Native AOT console, a Blazor WebAssembly app (trimmed and RunAOTCompilation, run in headless Chrome), a Blazor + # Server app (minimal API, static form, interactive page) and a net10.0 console - and leak-scans every bin and + # publish folder for compiler-host assemblies. It then proves every PackValidation ForbiddenContent rule rejects a + # package. `just smoke-packed-consumers` and `just verify-pack-rules` run the same locally. + packed-consumers: + name: Packed consumers (Native AOT, Blazor WebAssembly, Blazor Server) + runs-on: ubuntu-latest + env: + # An extra feed for Purview.SourceGeneratorFramework*, Purview.ZodSharp* and Purview.ValueObjects only, for + # versions that are not on nuget.org yet. Unset, the gate restores them from nuget.org alone. + RESULTS_DEPENDENCY_FEED: ${{ vars.RESULTS_DEPENDENCY_FEED }} + steps: + - uses: actions/checkout@v7 + + # The SDK from global.json, plus the .NET 10 runtime the net10.0 consumer runs on. + - uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + dotnet-version: 10.0.x + + # First, before any toolchain is installed: the generator is built against the pinned + # Purview.SourceGeneratorFramework, and the packages and consumers use the pinned Purview.ZodSharp and + # Purview.ValueObjects. Until all three are on nuget.org (or the RESULTS_DEPENDENCY_FEED repository variable names + # a feed that has them) this fails here, with what to do, rather than skipping. + - name: Check the build-time and consumer dependencies restore + shell: pwsh + run: ./scripts/test-packed-consumers.ps1 -PreflightOnly + + # Native AOT links with clang and needs the zlib headers; the hosted image may not carry both. + - name: Install the Native AOT prerequisites + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends clang zlib1g-dev + + # RunAOTCompilation needs the Emscripten toolchain and the Mono AOT cross compiler. -WasmAot Require below makes + # a missing workload an error instead of a skipped step. + - name: Install the wasm-tools workload + run: dotnet workload install wasm-tools + + # The hosted image ships Google Chrome; -Browser Require fails if it ever stops doing so. + - name: Pack, then publish, run and leak-scan consumers of the packages + shell: pwsh + run: >- + ./scripts/test-packed-consumers.ps1 -RuntimeIdentifier linux-x64 -WasmAot Require -Browser Require + -PackOutput artifacts/packed + + - name: Prove every pack-validation forbidden-content rule rejects a package + shell: pwsh + run: | + ./scripts/restore-dotnet-tools.ps1 + ./scripts/test-pack-validation-rules.ps1 -PackagesFolder artifacts/packed diff --git a/.gitignore b/.gitignore index 1b88e9b..728e6f5 100644 --- a/.gitignore +++ b/.gitignore @@ -661,3 +661,7 @@ sketch # Test run reports produced by the Microsoft.Testing.Platform runner TestResults/ + +# Agent skills are installed into the universal .agents/skills/ folder only, and that folder and +# skills-lock.json are committed. Per-agent folders (and Claude Code's local settings) stay out. +/.claude/ diff --git a/AGENTS.md b/AGENTS.md index 2faf879..e276e95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,7 @@ generator that makes C# 15 union error cases ergonomic), and the ZodSharp and AS | `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 | | `skills` | Authored agent skills, installable with the skills CLI (see [Agent skills](#agent-skills)) | +| `.agents/skills`, `skills-lock.json` | Skills installed from other repositories, committed (see [Installed skills](#installed-skills-agentsskills)) | | `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 | @@ -34,7 +35,7 @@ generator that makes C# 15 union error cases ergonomic), and the ZodSharp and AS | `src/Directory.Build.props` / `src/Directory.Build.targets` | Solution-wide SDK, package and build behaviour | | `global.json` | Required .NET SDK, `Purview.BuildSdk` and Microsoft.Testing.Platform selection | | `package.json` | Authoritative repository and package version | -| `purview-build.json` | Shared `Purview.Build` pipeline configuration (solution, test discovery, test filter) | +| `.config/purview-build.json` | Shared `Purview.Build` pipeline configuration (solution, test discovery, test filter, pack validation) | | `Justfile` | Supported local workflow commands | ## Standard workflow @@ -80,6 +81,9 @@ generator that makes C# 15 union error cases ergonomic), and the ZodSharp and AS must never coerce `default`. - `Purview.Results` stays dependency-free. The ZodSharp and ASP.NET Core packages depend on it, never the reverse, and `Purview.Results.AspNetCore` deliberately knows nothing about ZodSharp. +- `Purview.Results.ZodSharp`'s `ToResultAsync`/`ToUnitResultAsync` accept both `ValueTask>` (`ValidateAsync`) + and `Task>` (Purview.ZodSharp.Http's request and response validation). Keep the two overload sets + behaviourally identical, and keep Purview.ZodSharp.Http out of the package's dependencies: it integrates by shape. - Keep runtime packages free of reflection, `dynamic` and runtime type discovery. Union structure is inspected only by the source generator, through Roslyn symbols. - `src/src/Results/Extensions/Purview/Results/ResultExtensions.cs` is the home for value-style extension @@ -92,8 +96,8 @@ including the diagnostics table, build properties and activation rules. - **One analyzer, one generator, one diagnostics library.** `Diagnostics/DiagnosticLibrary.cs`, `Diagnostics/ResultDiagnostic.cs` and `Diagnostics/ResultUnionDiagnostics.cs` hold the single implementation - of the union rules. The analyzer reports the per-target rules (`RSG1000`–`RSG1004`, `RSG1007`, `RSG1008`); the - generator reports the compilation-wide rules (`RSG1005`, `RSG1006`). Never report the same rule from both + of the union rules. The analyzer reports the per-target rules (`RSG1000`–`RSG1004`, `RSG1007`–`RSG1009`); + the generator reports the compilation-wide rules (`RSG1005`, `RSG1006`) and its own catch-all (`RSG9000`). Never report the same rule from both hosts, and keep `DiagnosticLibrary.IsBlocking` as the single blocking policy. - New or changed rules require an `AnalyzerReleases.Unshipped.md` entry (the compiler's RS2008 rule catalogue). - **One suppressor.** `Suppressors/UnionEqualityDiagnosticSuppressor.cs` is the only programmatic suppression in @@ -152,8 +156,8 @@ including the diagnostics table, build properties and activation rules. `ResultUnionDiagnostics.Analyze`, so it needs no opted-in included union and no symbol in cached state. - Adding a code fix means adding it to `src/src/SourceGenerator.CodeFixes`, not to the generator project: the code fix needs `Microsoft.CodeAnalysis.*.Workspaces`, which must not enter the generator's analyzer closure. - The two projects stay independent (no project reference, no `InternalsVisibleTo`) so no reference cycle can - form, and the shared well-known names are pinned by the code-fix tests. + The code fix never references the generator and there is no `InternalsVisibleTo`; the generator references the + code fix analyzer-style only (`ReferenceOutputAssembly="false"`), so no reference cycle can form, and the shared well-known names are pinned by the code-fix tests. - Union-declaring test files are excluded from CSharpier through `.csharpierignore`, because the preview syntax is not parseable. Add any new file that declares a union to that list. @@ -177,14 +181,23 @@ including the diagnostics table, build properties and activation rules. the request's services on first use, so an unregistered mapper must fail with an `InvalidOperationException` naming the registration that is missing. - An unmapped failure is a host mapping gap, not a domain outcome: respond with `UnmappedStatusCode` (`500`) - and a `ProblemDetails` carrying the `errorType` extension, and log an error. `ThrowOnUnmappedFailure` exists - for development and must keep throwing `InvalidOperationException`. + and a `ProblemDetails` carrying `UnmappedTitle`, and log an error naming the case type. The type name goes into the + response's `errorType` extension only when `IncludeErrorTypeInProblemDetails` is set (off by default: it discloses + internal namespaces). `ThrowOnUnmappedFailure` exists for development, applies to unmapped failures only, and must + keep throwing `InvalidOperationException`. - An uninitialized result (`default`) takes the same path and is logged, because an endpoint returning `default` is a bug. - `AddResultsHttp` registers problem-details services and uses `TryAddSingleton` for `IResultsHttpMapper` and `ResultsEndpointFilter`, so a host can register its own mapper first and replace the defaults. - `WithResultsHttp()` exists on both `RouteHandlerBuilder` and `RouteGroupBuilder`; keep the endpoint filter non-invasive, so a handler that returns something other than an `IResultValue` is left untouched. +- `ToHttpResult()` (both result shapes) returns `ResultHttpResult` / `ResultHttpResult`, an + `IResult` that resolves `IResultsHttpMapper` from the request services when it executes, so its response must stay + identical to `WithResultsHttp()`'s. It is the supported Native AOT form: under `PublishAot` the request delegate + generator resolves JSON metadata for a handler's declared return type at endpoint build time, and a result (with a union + error, `SYSLIB1227`) has none, so a handler returning the result itself fails at startup. Keep it out of + `IResultValue` (the filter must pass it through), keep its OpenAPI metadata to the success response only, and keep the + packed-consumer `AotWebApi` step proving it. - `Purview.Results.ZodSharp.AspNetCore` registers its mapping as a `ResultsHttpOptions` failure mapper (`ZodResultsFailureMapper`), so any case- or error-type mapping the host registered for a specific error always wins, and so does a host failure mapper registered before `AddResultsZodSharpHttp`. It must reuse the ZodSharp @@ -212,8 +225,8 @@ including the diagnostics table, build properties and activation rules. repository-root `README.md`. Update it for any user-visible change, and keep the repository-root `README.md` consistent with it. - The packed shapes that must not regress: `Purview.Results` ships the merged Roslyn component and its code fix - under `analyzers/dotnet/cs/` with no `lib/` folder and **no PDB**, and every library package ships - `lib//.dll` plus the XML documentation file and a symbol package. + under `analyzers/dotnet/cs/` with **no analyzer PDB**, and every library package — `Purview.Results` included — + ships `lib//.dll` plus the XML documentation file and a symbol package. - The generator ships **no analyzer PDB** because the packaged analyzer is the framework's merged (ILRepack) assembly: the merge tool's rewritten PDB carries no Roslyn compiler-flags record, and the pipeline's pack validation asserts `optimization=release` for every assembly that ships a PDB, with no way to scope that check @@ -221,7 +234,7 @@ including the diagnostics table, build properties and activation rules. `GetPurviewMergedAnalyzerFile` target) and the code fix, so no analyzer PDB is contributed. Telemetry and the other component packages opt out the same way. Revisit if the framework's merge tool starts preserving the compiler-flags record. -- `purview-build.json`'s `PackValidation.RequiredContent` is the exhaustive declaration of what each package +- `.config/purview-build.json`'s `PackValidation.RequiredContent` is the exhaustive declaration of what each package ships (`RequireExplicitContent` defaults to `true`, so an undeclared entry fails too). Update it whenever package content changes — a new asset, a removed PDB, a renamed analyzer — and verify with `just pipeline-pack-validate`, which runs restore, build, lint, tests, pack and the validation. @@ -274,7 +287,8 @@ including the diagnostics table, build properties and activation rules. document: `Examples.Basic` (the result type, the value-less `Result`, the generated `AsFailure()` and `AsFailure()` helpers, and the union-receiver `Union.Failure(...)`/`Union.Success(...)` factories), `Examples.Zod` (`Purview.Results.ZodSharp`, including `ToUnitResult`), -`Examples.AspNetCore` (`Purview.Results.AspNetCore`, including a unit result answering `204`), +`Examples.AspNetCore` (`Purview.Results.AspNetCore`, including a unit result answering `204` and the filter-free +`ToHttpResult()` form), `Examples.AspNetCore.Zod` (`Purview.Results.ZodSharp.AspNetCore`) and `Examples.ValueObjects.Zod` (`Purview.Results.ZodSharp` + `Purview.ValueObjects`, a type-level `[ZodRule]` whose code and origin flow into the result and the HTTP mapping). @@ -307,6 +321,59 @@ result and the HTTP mapping). - Examples must compile conceptually against the current public API. Use placeholders for credentials and environment-specific values. +## Installed skills (`.agents/skills/`) + +Specialist guidance this repository depends on - the Purview engineering playbook, `Purview.BuildSdk` +configuration, the `Purview.SourceGeneratorFramework` generator and test APIs, ZodSharp, and Purview.ValueObjects +composition - is published by the repositories that own it, and installed here by the skills CLI. The installed skills are +**committed**: `.agents/skills/**` and the CLI's `skills-lock.json` (source, skill path and content hash of every +entry) are both in source, so a clone has them without running anything. + +| Skill | Source | Load before | +| --- | --- | --- | +| `purview-engineering-principles` | `purview-dev/engineering-playbook` | setting up, reviewing or rationalising the repository against the Purview conventions | +| `purview-architecture` | `purview-dev/engineering-playbook` | deciding project shape or generating substantial implementation code | +| `purview-architecture-testing` | `purview-dev/engineering-playbook` | adding or changing architecture tests | +| `purview-testing-conventions` | `purview-dev/engineering-playbook` | writing or reviewing tests (TUnit, AAA, naming, builders) | +| `purview-persistence` | `purview-dev/engineering-playbook` | choosing a storage approach, abstracting EF Core, or a GUID primary-key strategy | +| `purview-telemetry` | `purview-dev/engineering-playbook` | adding or reviewing observability, including the results HTTP telemetry | +| `purview-buildsdk-*` (8 skills) | `purview-dev/build-sdk` | build/SDK configuration, project layout and detection, testing, skill installation and skill tests | +| `purview-sourcegenerator-codewriter` | `purview-dev/sourcegenerator-framework` | writing or reviewing emitter code (`CodeWriter`, scopes) | +| `purview-sourcegenerator-pipeline` | `purview-dev/sourcegenerator-framework` | changing the incremental pipeline or its value-equatable models | +| `purview-sourcegenerator-diagnostics` | `purview-dev/sourcegenerator-framework` | adding or changing an `RSG` diagnostic or the analyzer | +| `purview-sourcegenerator-packaging` | `purview-dev/sourcegenerator-framework` | changing how the generator and code fix are packed or merged | +| `purview-sourcegenerator-testing` | `purview-dev/sourcegenerator-framework` | writing generator, analyzer or code-fix tests (`CodeQuery`) | +| `purview-sourcegenerator-tunit-tests` | `purview-dev/sourcegenerator-framework` | the TUnit base classes and assertion extensions | +| `purview-zodsharp-*` (6 skills) | `purview-dev/zodsharp` | the ZodSharp bridge and ProblemDetails integration: `[ZodSchema]`, rules, JSON, ASP.NET Core, `ZODSGEN` diagnostics | +| `purview-value-objects*` (4 skills) | `purview-dev/value-objects` | the value-objects composition example, tests and packed consumer | + +Skills are installed into the universal `.agents/skills/` location **only**. Never install copies into `.claude/` +or any other per-agent folder, and never add a `CLAUDE.md`: `AGENTS.md` is the one instruction file, and +`/.claude/` is gitignored. Pass `-a universal`, which writes `.agents/skills/` and nothing else: + +```bash +# add or update every skill from a source +npx -y skills@1 add purview-dev/engineering-playbook -s '*' -a universal -y +npx -y skills@1 add purview-dev/build-sdk -s '*' -a universal -y +npx -y skills@1 add purview-dev/sourcegenerator-framework -s '*' -a universal -y +npx -y skills@1 add purview-dev/zodsharp -s '*' -a universal -y +npx -y skills@1 add purview-dev/value-objects -s '*' -a universal -y + +# restore the committed set from skills-lock.json +npx -y skills@1 experimental_install +``` + +Commit the changed `.agents/skills/**` and `skills-lock.json` together, in their own `chore` commit. Do not edit +anything under `.agents/skills/` by hand - the CLI is its only writer, and a hand edit no longer matches the +lockfile's hash. `Purview.BuildSdk`'s `InstallPurviewSkills` target delegates to the same CLI; +`src/Directory.Build.props` sets `PurviewSkillsAgents=universal` so it is restricted the same way rather than +defaulting to every agent (`*`). + +`src/tests/Repository.UnitTests/InstalledSkillTests.cs` keeps this honest: every `skills-lock.json` entry has its +`.agents/skills//SKILL.md`, every installed folder is in the lockfile, no skill authored in `skills/` is +copied into `.agents/skills/`, no `.claude/skills/` copy exists, and there is no `CLAUDE.md` (at the root or in +`.github/`). + ## Agent skills Authored skills live in the repository-root `skills/` folder. **Nothing packs them into a NuGet package.** @@ -315,14 +382,14 @@ discovers a root `skills/` folder, so that is the one delivery route. `PurviewSk `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`. +`.agents/skills/` holds the skills installed from **other** repositories (above). 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/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-union-errors/` | modelling failures as a `[GenerateResult]` union, the generated helpers, the `RSG1000`–`RSG1009` and `RSG9000` 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 | @@ -350,15 +417,25 @@ rather than about the runtime's reach. Prefer the `Justfile` recipes or their equivalent commands: ```text -dotnet tool restore +./scripts/restore-dotnet-tools.ps1 just restore just build just test-unit just lint-check just pack just pipeline-pr +just smoke-packed-consumers +just verify-pack-rules ``` +`just smoke-packed-consumers` (`scripts/test-packed-consumers.ps1`) is the packed-consumer gate: it packs every +package to an isolated feed and cache and publishes and runs, from the packages alone, a Native AOT console, a Blazor +WebAssembly app (trimmed and `RunAOTCompilation`, in a headless browser), a Blazor Server app, a `net10.0` console +and a Native AOT minimal API (handlers returning `ToHttpResult()`, plus a Purview.ZodSharp.Http client self-test), then leak-scans every output for compiler-host assemblies. It needs the Native AOT toolchain, the +`wasm-tools` workload and Chrome or Edge; dependency versions not yet on nuget.org restore from +`LOCAL_NUGET_FEED_PATH`. Run it when the generator, packaging or a dependency version changes. The consumers' +union-declaring files are listed in `.csharpierignore` like every other union file. + Local CI-equivalent validation: ```text @@ -415,7 +492,7 @@ all). Prefer a new version, as the local release flow does. - Never create release tags or publish packages manually unless the user explicitly requests a documented recovery procedure. - `.github/workflows/*` and `global.json` must stay in sync: the workflow's `dotnet-version` input has to match - `sdk.version`, and `purview-build.json` has to keep pointing at `src/Results.slnx`. + `sdk.version`, and `.config/purview-build.json` has to keep pointing at `src/Results.slnx`. ## Completion checklist diff --git a/CHANGELOG.md b/CHANGELOG.md index 68971eb..21b5cc1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,133 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and Released versions correspond to `v` GitHub releases. +## 1.1.0-prerelease.2 + +A minor prerelease built against Purview.ZodSharp 3.0.0-prerelease.2 and Purview.ValueObjects 1.3.0-prerelease.2. It +makes the ASP.NET Core packages usable from a Native AOT host and folds Purview.ZodSharp.Http's client-side +validation into results. Every change is additive. + +### Upgrade notes + +- **A Native AOT minimal API must return `result.ToHttpResult()`, not the result.** + - *Previously:* the packages were marked `IsAotCompatible`, but a `PublishAot` host whose handlers returned + `Result` or `Result` failed every request at startup with a `NotSupportedException`. + ASP.NET Core's request delegate generator resolves JSON metadata for a handler's declared return type when it + builds the endpoints; a result has none, and System.Text.Json's source generator cannot describe a union error + (`SYSLIB1227`), so `WithResultsHttp()` never ran. Adding the result type to a `JsonSerializerContext` does not help. + - *Now:* `ToHttpResult()` returns a `ResultHttpResult`, an `IResult` that the registered mapper maps when it + executes. + - *To upgrade:* in a Native AOT host, end each handler with `.ToHttpResult()` and list the successful value types in + your `JsonSerializerContext`. JIT hosts (including Blazor Server) need no change; `WithResultsHttp()` keeps + working there. + +### Added + +- **`ToHttpResult()` and `ResultHttpResult` / `ResultHttpResult`** in + `Purview.Results.AspNetCore`. The response is exactly the one `WithResultsHttp()` produces (same success handling, + failure resolution order and unmapped-failure response), no endpoint filter is needed, and the type describes the + success response to OpenAPI (`TValue` with `SuccessStatusCode`, or `204` for a unit result). Executing one without + `AddResultsHttp()` throws an `InvalidOperationException` naming the missing registration. +- **`ToHttpResult(HttpContext)` and `ToHttpResult(IResultsHttpMapper, HttpContext)` for the unit `Result`**, + which previously existed for `Result` only. +- **`Task>` overloads of `ToResultAsync` and `ToUnitResultAsync`** in `Purview.Results.ZodSharp`. + Purview.ZodSharp.Http's `PostAsJsonValidatedAsync`/`PutAsJsonValidatedAsync`/`PatchAsJsonValidatedAsync` and + `HttpContent.ReadFromJsonAndValidateAsync` return a `Task`, so a client's request and response validation now folds + into a result the same way a local `ValidateAsync` does. A server's validation problem reads back with + `TryReadZodValidationProblemAsync()` and `ToValidationErrors()` into the same union case. No package dependency is + added. +- **A Native AOT web API in the packed-consumer gate** (`scripts/packed-consumers/AotWebApi`, step (e)). It is published + with `PublishAot` and warnings as errors, then started with `SuccessStatusCode` 200 and 201 and called (validation + problem, code rule, case mapping, success, unit `204`, uninitialized `500`). A client self-test then goes through + Purview.ZodSharp.Http. This is the check that found the startup failure above. + +### Documentation + +- Corrected the claim that the ASP.NET Core packages are not AOT-compatible (`docs/wiki/Blazor-and-Native-AOT.md`, the + project comments). Documented the Native AOT host shape, telemetry and log event ids in the ASP.NET Core README. +- The unmapped-failure response names the error type only when `IncludeErrorTypeInProblemDetails` is set; several + pages said it always did. `ThrowOnUnmappedFailure` does not apply to an uninitialized result. +- `RSG1009` and `RSG9000` were missing from the diagnostics tables, and all rules are tracked in + `AnalyzerReleases.Shipped.md`. `Purview.Results` ships `lib/` (only the generator is analyzer-only), and the + pipeline configuration lives in `.config/purview-build.json`. +- Snippets that did not compile were fixed in the READMEs and in Combinators and Value Objects Composition. The + skills gained the Native AOT, `ToHttpResult()` and Purview.ZodSharp.Http guidance. + +### Changed + +- Built and tested against Purview.ZodSharp 3.0.0-prerelease.2 and Purview.ValueObjects 1.3.0-prerelease.2. + +## 1.1.0-prerelease.1 + +A minor prerelease built against Purview.ZodSharp 3.0.0-prerelease.1. No package loses a target framework (every +package still ships `net10.0` and `net11.0`), and no public member of a Purview.Results package changed; the +integration packages now require Purview.ZodSharp 3, whose own upgrade notes apply to your code. + +> **Upgrading?** Start with [Upgrade notes](#upgrade-notes-1). + +### Upgrade notes + +- **`Purview.Results.ZodSharp` and `Purview.Results.ZodSharp.AspNetCore` depend on Purview.ZodSharp 3.** + - *Previously:* Purview.ZodSharp 2.4.x. + - *Now:* Purview.ZodSharp 3.0.0-prerelease.1, which ships `net10.0` and `net11.0` (the same set as these packages) + and is binary compatible with 2.4 for them. + - *To upgrade:* reference Purview.ZodSharp 3 and read its upgrade notes. The changes a result user meets first: + built-in rules report a structured `Origin` and `Category`; generated paths render as `Items[0].Name` + (`FullPath.ToDottedString()`); default messages changed (`"{displayName} is required"`); ProblemDetails write + `issues[].path` as a dotted key with `segments` beside it and no longer copy parameters to the root unless + `MergeParametersIntoExtensions` is set; generated schemas gain `ValidateAsync`, so CA1849 flags a synchronous + `Validate` in async code - `ToResultAsync` folds it. +- **Result-carried validation problems honour every `ZodProblemDetailsOptions` setting.** + - *Previously:* `ZodValidationProblems.ToProblem` (and so `AddResultsZodSharpHttp`, `ToValidationProblem` and every + rule) wrote the problem from the options' `Registry` alone: no `ErrorType` message formatting, and with + Purview.ZodSharp 3 none of its new options (path naming, `IncludeIssues`, `MergeParametersIntoExtensions`, the + error type resolver, `StatusCodeOverride`). `StatusCodeSelector` ran first and an `ErrorType` status overrode it. + - *Now:* the problem is written by `ZodProblemDetailsWriter` with the host's options, as a thrown `ZodException`'s + is, and the status follows Purview.ZodSharp 3's precedence: the default (or a rule's status), then the highest + `ErrorType.HttpStatus`, then `StatusCodeSelector`, then `StatusCodeOverride`. + - *To upgrade:* nothing, unless a test asserted the old message text for an error code that has a registered + `ErrorType` with a `MessageFormat` (the message is now formatted), or relied on an `ErrorType` status overriding + `StatusCodeSelector` (the selector now wins, as documented). +- **The hidden `[Embedded]` attribute copies are gone from `Purview.Results.ZodSharp.dll` and + `Purview.Results.ZodSharp.AspNetCore.dll`.** Purview.ZodSharp 2's generator emitted `ZodSharp.ZodSchemaAttribute` + and `ZodSharp.Core.ErrorTypeAttribute` into every assembly it ran on; Purview.ZodSharp 3 defines them once, in + `Purview.ZodSharp`. Package validation reports their removal (CP0001), which `CompatibilitySuppressions.xml` in each + project records: `[Embedded]` types are invisible to the compiler, so no compiled consumer could bind to them. + - *To upgrade:* nothing. + +### Fixed + +- **The `net11.0` packages work in Blazor WebAssembly.** They were compiled with the runtime-async experiment + (`Features=runtime-async=on`), so every async member - `ToResultAsync`, `ToUnitResultAsync` and the async + combinators - threw `PlatformNotSupportedException` ("Runtime Async is not supported on this platform") on the Mono + runtime behind Blazor WebAssembly. Shipped assemblies now use the stable async lowering on every target framework; + tests, examples and other non-packable projects keep the experiment. Found by the new packed-consumer gate. + +### Added + +- **A packed-consumer gate** (`just smoke-packed-consumers`, `scripts/test-packed-consumers.ps1`, PR job + `packed-consumers`). It packs every package to an isolated feed and cache, then publishes and runs, from the + packages alone: a Native AOT console over a `[GenerateResult]` union, the generated helpers, the ZodSharp bridge and + a value object's type-level rule; a Blazor WebAssembly app with a `` form whose input then flows + through the result pipeline, published trimmed and with `RunAOTCompilation=true` and run in a headless browser; a + Blazor Server app whose minimal API maps the result onto HTTP, whose static form is posted and whose interactive + page runs over a live circuit; and a `net10.0` console. Every publish uses `-warnaserror`, and every output is + scanned for compiler-host assemblies (`scripts/assert-no-compiler-host-assemblies.ps1`). +- **Pack-validation `ForbiddenContent` rules** keep Purview.SourceGeneratorFramework, Roslyn and the generator out of + every folder an application loads from; `just verify-pack-rules` proves each rule rejects a package. + +### Changed + +- Built and tested against Purview.ZodSharp 3.0.0-prerelease.1, Purview.ValueObjects 1.3.0-prerelease.1, + Purview.SourceGeneratorFramework 1.2.2, Purview.BuildSdk 1.2.0 and Purview.Skills.Testing 1.2.0, on the .NET 11 rc.3 + SDK. + ## 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; +> **Upgrading?** Start with [Upgrade notes](#upgrade-notes-2). 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 @@ -91,7 +212,7 @@ prerelease goes straight to `1.0.2`, and the notes below cover that whole arc. Both paths are verified against the packed package, the first with `TreatWarningsAsErrors=true`. - 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). + [Upgrade notes](#upgrade-notes-2). - **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. @@ -161,9 +282,9 @@ prerelease goes straight to `1.0.2`, and the notes below cover that whole arc. shape for inspection; use `AcceptSuccess` whenever the type matters, such as serialization. - 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). + [Upgrade notes](#upgrade-notes-2). - Async combinators now await with `ConfigureAwait(false)`, so they no longer resume on a captured - synchronization context. Listed under [Upgrade notes](#upgrade-notes). + synchronization context. Listed under [Upgrade notes](#upgrade-notes-2). ### Fixed diff --git a/Directory.Packages.props b/Directory.Packages.props index a670d95..0664bb7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -7,20 +7,20 @@ default on the newest line so a new target framework fails loudly rather than silently resolving something older. --> - 11.0.0-rc.1.26425.128 - 10.0.13 + 11.0.100-rc.3.26522.148 + 10.0.14 - 2.4.2 - 1.2.3 + 3.0.0-prerelease.2 + 1.3.0-prerelease.2 - 1.2.1 + 1.2.2 5.9.0 1.76.0 - 1.1.0 + 1.2.0 @@ -42,10 +42,10 @@ /> - + - + diff --git a/Justfile b/Justfile index f1084b5..602c7f5 100644 --- a/Justfile +++ b/Justfile @@ -22,7 +22,7 @@ default: # Restore the repository's .NET local tools (.config/dotnet-tools.json) [private] ensure-pipeline-tool: - dotnet tool restore + pwsh -NoProfile -File scripts/restore-dotnet-tools.ps1 # Run the PR pipeline (restore, build, lint, tests) [group('Pipeline')] @@ -105,6 +105,26 @@ pack publish_folder=artifacts_folder *args: echo " Current version is {{ BLUE }}{{ current_version }}{{ NORMAL }}" dotnet pack {{ solution }} -c {{ build_configuration }} -o {{ publish_folder }} {{ args }} +# Prove the packed packages work for real consumers: packs every Purview.Results package to an isolated feed, then +# publishes and runs a Native AOT console, a Blazor WebAssembly app (trimmed and RunAOTCompilation, run in a headless +# browser), a Blazor Server app (minimal API, static form and interactive page) and a net10.0 console against them, +# and leak-scans every output for compiler-host assemblies. Needs the Native AOT toolchain, the wasm-tools workload for +# the WebAssembly AOT step, and Chrome or Edge for the browser steps. Dependency versions not yet on nuget.org +# (Purview.ZodSharp, Purview.ValueObjects, Purview.SourceGeneratorFramework) restore from LOCAL_NUGET_FEED_PATH (or +# RESULTS_DEPENDENCY_FEED). See scripts/test-packed-consumers.ps1 for the options. +[group('Build and Test')] +smoke-packed-consumers *args: + echo "Running the packed consumer gate..." + pwsh -NoProfile -File scripts/test-packed-consumers.ps1 {{ args }} + +# Prove every PackValidation ForbiddenContent rule in .config/purview-build.json rejects a package. Reads artifacts/, so +# pack first (`just pipeline-pack-validate`); `just pipeline-local-release` moves its packages to the local feed, so pass +# -PackagesFolder after it. +[group('Build and Test')] +verify-pack-rules *args: + just ensure-pipeline-tool + pwsh -NoProfile -File scripts/test-pack-validation-rules.ps1 {{ args }} + # Run the Basic example (result states, combinators and the generated AsFailure helpers) [group('Examples')] example-basic *args: diff --git a/README.md b/README.md index 3ae63cd..55adb0f 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,16 @@ Exceptional circumstances still throw; expected outcomes are values. | Package | Purpose | Targets | | --- | --- | --- | -| [`Purview.Results`](src/src/Results/Sdk/README.md) | `Result` and its value-less counterpart `Result`, the `Result` factories, and the bundled source generator that emits per-case `AsFailure()`/`AsFailure()` helpers and union-receiver `Union.Failure(...)`/`Union.Success(...)` factories for `[GenerateResult]` unions. No runtime dependencies. | `net11.0` | -| [`Purview.Results.ZodSharp`](src/src/ZodSharp/Sdk/README.md) | Bridges ZodSharp `ValidationResult` values into results. | `net11.0` | -| [`Purview.Results.AspNetCore`](src/src/AspNetCore/Sdk/README.md) | Maps results onto ASP.NET Core responses (`IResult`, `ProblemDetails`). | `net11.0` | -| [`Purview.Results.ZodSharp.AspNetCore`](src/src/ZodSharp.AspNetCore/Sdk/README.md) | Maps result failures that carry validation errors onto `HttpValidationProblemDetails`. | `net11.0` | +| [`Purview.Results`](src/src/Results/Sdk/README.md) | `Result` and its value-less counterpart `Result`, the `Result` factories, and the bundled source generator that emits per-case `AsFailure()`/`AsFailure()` helpers and union-receiver `Union.Failure(...)`/`Union.Success(...)` factories for `[GenerateResult]` unions. No runtime dependencies. | `net10.0`, `net11.0` | +| [`Purview.Results.ZodSharp`](src/src/ZodSharp/Sdk/README.md) | Bridges ZodSharp `ValidationResult` values into results. | `net10.0`, `net11.0` | +| [`Purview.Results.AspNetCore`](src/src/AspNetCore/Sdk/README.md) | Maps results onto ASP.NET Core responses (`IResult`, `ProblemDetails`). | `net10.0`, `net11.0` | +| [`Purview.Results.ZodSharp.AspNetCore`](src/src/ZodSharp.AspNetCore/Sdk/README.md) | Maps result failures that carry validation errors onto `HttpValidationProblemDetails`. | `net10.0`, `net11.0` | Each package README is the same file that ships inside the `.nupkg` (project `Sdk/README.md`), so this table links straight to the package documentation. The source generator is not a separate package: it ships inside -`Purview.Results` under `analyzers/dotnet/cs`, together with its IDE code fix. Every package also ships agent -skills under `.agents/` (union modelling and the result core, HTTP mapping, and the two ZodSharp bridges), which -`Purview.BuildSdk` mirrors into a consuming repository's own `.agents/` folder on the next restore or build. +`Purview.Results` under `analyzers/dotnet/cs`, together with its IDE code fix. Agent skills for every package (union +modelling and the result core, HTTP mapping, and the two ZodSharp bridges) are published from this repository rather +than any package: `npx skills add purview-dev/results`. See [Agent Skills](docs/wiki/Agent-Skills.md). ## Quick start @@ -70,9 +70,12 @@ builder.Services.AddResultsHttp(options => options .Map(error => TypedResults.Problem(statusCode: StatusCodes.Status409Conflict)) ); -app.MapGet("/tenants/{id:int}", (int id) => GetTenant(id)).WithResultsHttp(); +app.MapGet("/tenants/{id}", (string id) => GetTenant(new TenantId(id))).WithResultsHttp(); ``` +In a Native AOT host, return `GetTenant(id).ToHttpResult()` from the handler instead: the same mapping, as an +`IResult`, which the request delegate generator needs (see [Blazor and Native AOT](docs/wiki/Blazor-and-Native-AOT.md)). + ## Examples Every example is a runnable, non-packable project under `src/src/Examples.*`, built on the same @@ -82,7 +85,7 @@ Tenancy domain the Quick start uses, so one error-union vocabulary drives every | --- | --- | --- | | [`Examples.Basic`](src/src/Examples.Basic) | `Purview.Results` | States, `Match`/`Map`/`Bind`/`MapError`/`Ensure`, probing, the throw-on-misuse contract, the generated `AsFailure()` and `AsFailure()` helpers and the union-receiver `Union.Failure(...)`/`Union.Success(...)` factories, cross-service error-union widening, value-less `Result` commands, and `Throw()` | | [`Examples.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`](src/src/Examples.AspNetCore) | + `Purview.Results.AspNetCore` | `AddResultsHttp`/`Map`/`WithResultsHttp`, the filter-free `ToHttpResult()` form, nested error-union leaf mapping, a successful unit result answering `204`, the mapping-gap and uninitialized-result paths | | [`Examples.AspNetCore.Zod`](src/src/Examples.AspNetCore.Zod) | + `Purview.Results.ZodSharp.AspNetCore` | A validation-carrying failure rendered as `HttpValidationProblemDetails`, with a case mapping winning over the fallback | | [`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 | @@ -184,6 +187,10 @@ if (!validation.IsSuccess) return RegisterValidated(validation.Value); ``` +On a client, Purview.ZodSharp.Http's request and response validation returns `Task>`, which +`ToResultAsync`/`ToUnitResultAsync` fold the same way, and a server's validation problem reads back into the same +case (see [ZodSharp integration](docs/wiki/ZodSharp-Integration.md#validation-over-http)). + ### ASP.NET Core The host decides what each error case looks like on the wire. A mapping registered for the most specific **case** @@ -213,7 +220,10 @@ Running `Examples.AspNetCore` answers as follows: | `GET /tenants/acme/billing` | `503 Service Unavailable` — the leaf of the nested `TenantOperationError` union | | `GET /tenants/hooli/billing` | `402 Payment Required` — another leaf of the nested union | | `GET /tenants/initech/billing` | `404 Not Found` — a nested tenant failure resolves to its leaf | -| `GET /tenants/broken` | `500` with an `errorType` extension, because an endpoint returning `default` is a host bug | +| `GET /tenants/globex/billing` | `403 Forbidden` — a nested tenant failure resolves to the `TenantDisabled` leaf | +| `POST /tenants/newco` | `200 OK` with the created tenant | +| `GET /tenants/broken` | `500` with `errorType = "results_uninitialized"`, because an endpoint returning `default` is a host bug | +| `GET /explicit/tenants/initech` | `404 Not Found` — the same mapping through `ToHttpResult()`, the form a Native AOT host uses | **Case** and **error** mappings are keyed by type. When the answer depends on the *value* a failure carries — a validation code, a category, a field — add an `IResultsFailureMapper` instead, in the same ordered list: @@ -238,7 +248,6 @@ builder.Services.AddResultsHttp(options => options ); builder.Services.AddResultsZodSharpHttp(options => options .MapCode("tenant_id_matches_name", StatusCodes.Status422UnprocessableEntity) - .MapOrigin("value_object", StatusCodes.Status422UnprocessableEntity) ); ``` @@ -249,24 +258,38 @@ the host mapping it — unless a rule answers one of its codes, categories or or { "title": "One or more validation errors occurred.", "status": 400, - "errors": { "TenantId": ["Required field 'TenantId' is null"] }, - "issues": [{ "code": "missing_field", "path": ["TenantId"], "message": "Required field 'TenantId' is null" }], + "errors": { "TenantId": ["TenantId is required"] }, + "issues": [ + { + "path": "TenantId", + "segments": ["TenantId"], + "code": "missing_field", + "category": "invalid_type", + "message": "TenantId is required", + "parameters": { "displayName": "TenantId", "declaringType": "TenantInput", "member": "TenantId" } + } + ], "traceId": "0HNOUQVQNF7CV:00000001" } ``` +(Purview.ZodSharp 3 writes `issues[].path` as the dotted key and the segments separately, and keeps parameters per +issue; `declaringType` is the full CLR name of the validated type, shortened here.) + ## Documentation The full documentation suite lives in [`docs/wiki`](docs/wiki/Getting-Started.md): -- [Getting started](docs/wiki/Getting-Started.md) +- [Home](docs/wiki/Home.md) and [getting started](docs/wiki/Getting-Started.md) - [Core concepts](docs/wiki/Core-Concepts.md) and [combinators](docs/wiki/Combinators.md) - [Union errors](docs/wiki/Union-Errors.md) - [Source generator](docs/wiki/Source-Generator.md) and [diagnostics](docs/wiki/Diagnostics.md) - [ASP.NET Core integration](docs/wiki/AspNetCore-Integration.md) - [ZodSharp integration](docs/wiki/ZodSharp-Integration.md) and [problem details](docs/wiki/ZodSharp-ProblemDetails.md) - [Value objects composition](docs/wiki/Value-Objects-Composition.md) +- [Blazor and Native AOT](docs/wiki/Blazor-and-Native-AOT.md) - [Guarantees and limitations](docs/wiki/Guarantees-and-Limitations.md) +- [Testing](docs/wiki/Testing.md), [agent skills](docs/wiki/Agent-Skills.md), [release flow](docs/wiki/Release-Flow.md) and [contributing](docs/wiki/Contributing.md) ## Requirements @@ -278,6 +301,8 @@ The full documentation suite lives in [`docs/wiki`](docs/wiki/Getting-Started.md `net10.0`. Everything else works on both: the result types, the combinators, the ASP.NET Core mapping and the ZodSharp bridge. On `net10.0` an error type is simply a plain type rather than a union, which the HTTP mapper already handles — register a mapping for the error type itself. +- **Purview.ZodSharp 3** for the ZodSharp integrations, on the same `net10.0`/`net11.0` set. Trimmed, Native AOT and + Blazor applications: see [Blazor and Native AOT](docs/wiki/Blazor-and-Native-AOT.md). - **C# 15 preview — only to *declare* a union.** The requirement is per-project and scoped: - Using `Result`, `Result` and the combinators needs **nothing special**. No `LangVersion`, no `EnablePreviewFeatures`. @@ -319,7 +344,9 @@ just build # build the solution (Debug) just test-unit # run the unit tests just lint-check # CSharpier + .slnx GUID validation just pack # pack all four packages to ./artifacts -just pipeline-pr # the full PR pipeline (restore, build, lint, test, pack) +just pipeline-pr # the PR pipeline (restore, build, lint, tests) +just pipeline-pack-validate # the same, plus pack and pack-content validation +just smoke-packed-consumers # the packed-consumer gate (Native AOT, Blazor WebAssembly/Server, net10.0) ``` `just pipeline-pr` restores the repository's .NET local tools, which include the pinned `Purview.Build`. diff --git a/docs/wiki/Agent-Skills.md b/docs/wiki/Agent-Skills.md index a0be5be..e051fc0 100644 --- a/docs/wiki/Agent-Skills.md +++ b/docs/wiki/Agent-Skills.md @@ -28,11 +28,13 @@ Task-shaped detail lives in a skill's own `references/` folder — migrating exi ## Upstream skills -`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. +The Purview engineering playbook, `Purview.BuildSdk`, the source-generator framework, ZodSharp and +Purview.ValueObjects publish their own skills (engineering conventions, SDK configuration and project layout, +generator authoring and testing, ZodSharp integration, value-object composition) through the same CLI. Those are owned by the repository that publishes them — changes belong there, +not here. This repository installs them into the universal `.agents/skills/` folder only (`-a universal`) and +commits that folder together with the CLI's `skills-lock.json`, so a clone has them without running anything; +nothing is installed into `.claude/` or any other per-agent folder. This repository's own skills are never +installed into `.agents/skills/`, because two copies would drift apart. ## Related diff --git a/docs/wiki/AspNetCore-Integration.md b/docs/wiki/AspNetCore-Integration.md index cbc661c..5a3c296 100644 --- a/docs/wiki/AspNetCore-Integration.md +++ b/docs/wiki/AspNetCore-Integration.md @@ -26,7 +26,7 @@ builder.Services.AddResultsHttp(options => options var app = builder.Build(); -app.MapGet("/tenants/{id:int}", (int id) => GetTenant(id)).WithResultsHttp(); +app.MapGet("/tenants/{id}", (string id) => GetTenant(new TenantId(id))).WithResultsHttp(); ``` `AddResultsHttp` also registers problem-details services (`AddProblemDetails()`), so the unmapped-failure and @@ -53,11 +53,14 @@ union resolves to its leaf; or the error itself for a non-union error), then map 4. the **fallback stage**, in registration order: `AddFallback(...)` delegates and `AddFailureMapper()` mappers share one ordered list; a fallback or mapper receives the leaf case and returns `null` to defer to the next entry -5. a `ProblemDetails` response using `UnmappedStatusCode` (`500`), `UnmappedTitle`, and an `errorType` extension - naming the unmapped case — or an `InvalidOperationException` when `ThrowOnUnmappedFailure` is set +5. a `ProblemDetails` response using `UnmappedStatusCode` (`500`) and `UnmappedTitle` — or an + `InvalidOperationException` when `ThrowOnUnmappedFailure` is set. The case's type name is always logged, but it + is written into the response's `errorType` extension only when `IncludeErrorTypeInProblemDetails` is set, + because it discloses internal namespaces to the caller -An **uninitialized** result (`default`) takes the same path and is logged, because an endpoint returning `default` -is a host bug rather than a domain outcome. +An **uninitialized** result (`default`) is logged and answered with the same `500`, always with +`errorType = "results_uninitialized"` (a fixed discriminator, not a type name), because an endpoint returning +`default` is a host bug rather than a domain outcome. `ThrowOnUnmappedFailure` does not apply to it. ## Options @@ -67,14 +70,17 @@ is a host bug rather than a domain outcome. | `SuccessMapper` | `null` | Replaces the default success handling entirely | | `UnmappedStatusCode` | `500` | Status code for a failure with no mapping | | `UnmappedTitle` | *"The operation failed with an error that is not mapped to an HTTP response."* | Title of the unmapped `ProblemDetails` | -| `ThrowOnUnmappedFailure` | `false` | Throw instead of producing a problem response; useful during development | +| `ThrowOnUnmappedFailure` | `false` | Throw on an unmapped failure instead of producing a problem response; useful during development | +| `IncludeErrorTypeInProblemDetails` | `false` | Write the unmapped error's CLR type name into the `errorType` extension; enable per environment | | `IncludeTraceId` | `true` | Whether problem responses this package writes itself carry the request trace identifier | | `Map(Func)` | — | Maps a case (or the error itself) to a response | | `Map(Func)` | — | Same, with access to the request | | `AddFallback(Func)` | — | Consulted in order for unmapped failures, with the case value | | `AddFailureMapper()` | — | Same stage, for a mapper class resolved from dependency injection | -Registering the same type twice replaces the earlier mapping. +Registering the same type twice replaces the earlier mapping. A mapping matches the exact runtime type only, so +`Map` never matches; answer an interface or base-type shape with a failure mapper. The options are +startup configuration: the mappings and fallbacks are frozen the first time a request reads them. ## Choosing an extension point @@ -114,13 +120,40 @@ shapes you can name and return `null` for the rest. ## Converting a result by hand +`ToHttpResult()` wraps a result in a `ResultHttpResult`, an `IResult` that the registered `IResultsHttpMapper` maps +when it executes, so the response is exactly the one `WithResultsHttp()` produces and no filter is needed: + ```csharp -app.MapGet("/tenants/{id:int}", (int id, HttpContext context) => - GetTenant(id).ToHttpResult(context)); +app.MapGet("/tenants/{id}", (string id) => GetTenant(new TenantId(id)).ToHttpResult()); +app.MapDelete("/tenants/{id}", (string id) => DeleteTenant(new TenantId(id)).ToHttpResult()); // 204 on success ``` -`ToHttpResult(HttpContext)` resolves `IResultsHttpMapper` from the request services; the -`ToHttpResult(IResultsHttpMapper, HttpContext)` overload takes one directly. +It exists for both `Result` and the unit `Result`, and describes the success response to +OpenAPI: `TValue` as JSON with `SuccessStatusCode`, or `204` for a unit result (nothing when `SuccessMapper` is set). +`ToHttpResult(HttpContext)` maps immediately with the mapper resolved from the request services, and +`ToHttpResult(IResultsHttpMapper, HttpContext)` takes one directly. + +## Native AOT + +The package is `IsAotCompatible` and hands a successful value to `TypedResults` with its static type, so it binds +`Ok`, never `Ok`. A Native AOT host needs two things: + +- **Return `result.ToHttpResult()`, not the result.** Under `PublishAot` the request delegate generator resolves + JSON metadata for every handler's declared return type when it builds the endpoints. A `Result<...>` has none - it + is never serialized, and System.Text.Json's source generator cannot describe a union error (`SYSLIB1227`) - so a + handler that returns the result itself fails every request at startup, before `WithResultsHttp()` can run. +- **Put the value types in your `JsonSerializerContext`**, with the request body types. The problem responses resolve + from ASP.NET Core's own context. + +See [Blazor and Native AOT](Blazor-and-Native-AOT.md#a-native-aot-minimal-api) for a complete host. + +## Observability + +The mapper counts under the meter `ResultsHttpTelemetry.MeterName` (`Purview.Results.AspNetCore`): +`purview.results.failures.mapped`, `purview.results.failures.unmapped` and `purview.results.uninitialized`, each +tagged `purview.results.error_type` (the case type name, or `unknown` for an uninitialized result) and added to +`Activity.Current`. Register it with `metrics.AddMeter(ResultsHttpTelemetry.MeterName)`. Log statements carry +stable event ids: `1000` for an uninitialized result and `1001` for an unmapped failure. ## Extensibility @@ -145,7 +178,8 @@ Running `Examples.AspNetCore`: | `GET /tenants/hooli/billing` | `402 Payment Required` — the leaf `BillingAccountMissing` case | | `GET /tenants/initech/billing` | `404 Not Found` — a nested tenant failure resolves to the `TenantNotFound` leaf | | `GET /tenants/globex/billing` | `403 Forbidden` — a nested tenant failure resolves to the `TenantDisabled` leaf | -| `GET /tenants/broken` | `500` with an `errorType` extension, because an endpoint returning `default` is a host bug | +| `GET /tenants/broken` | `500` with `errorType = "results_uninitialized"`, because an endpoint returning `default` is a host bug | +| `GET /explicit/tenants/initech` | `404 Not Found` — the same mapping, through `ToHttpResult()` instead of the filter | ```bash dotnet run --project src/src/Examples.AspNetCore --urls http://localhost:5215 diff --git a/docs/wiki/Blazor-and-Native-AOT.md b/docs/wiki/Blazor-and-Native-AOT.md new file mode 100644 index 0000000..7254483 --- /dev/null +++ b/docs/wiki/Blazor-and-Native-AOT.md @@ -0,0 +1,162 @@ +# Blazor and Native AOT + +All four packages are marked `IsAotCompatible` and build clean under the trim and AOT analyzers. + +- `Purview.Results` and `Purview.Results.ZodSharp` contain no reflection, no dynamic code and no runtime type + discovery, and the generator's `AsFailure` helpers and union-receiver factories are plain constructor calls. A + trimmed application, a Native AOT application and a Blazor WebAssembly client (trimmed, or with + `RunAOTCompilation=true`) use them with no IL2xxx/IL3xxx warning. +- `Purview.Results.AspNetCore` and `Purview.Results.ZodSharp.AspNetCore` are server packages, and they work in a + Native AOT ASP.NET Core host as well as in Blazor Server and any JIT host. The mapper hands a successful value to + `TypedResults` through `IResultValue.AcceptSuccess`, so it binds `Ok` (or `Json` for another + `SuccessStatusCode`) with the endpoint's real value type, never `Ok`. A Native AOT host returns + `result.ToHttpResult()` from its handlers and lists the value types in its `JsonSerializerContext` - see + [A Native AOT minimal API](#a-native-aot-minimal-api). + +`just smoke-packed-consumers` proves all of this against the packed packages rather than the repository's projects: +it publishes a Native AOT console, a Native AOT minimal API (with a `Purview.ZodSharp.Http` client), a Blazor WebAssembly app (trimmed and AOT-compiled, then +loaded in a headless browser), a Blazor Server app and a `net10.0` console from the `.nupkg` files alone, with +warnings as errors, runs each one, and scans every output for compiler-host assemblies. + +## Target frameworks + +| Package | Target frameworks | +| --- | --- | +| `Purview.Results`, `.ZodSharp`, `.AspNetCore`, `.ZodSharp.AspNetCore` | `net10.0`, `net11.0` | +| `Purview.ZodSharp` 3.x | `net10.0`, `net11.0` | +| `Purview.ZodSharp.Blazor` 3.x (optional) | `net11.0` | +| `Purview.ZodSharp.Http` 3.x (optional) | `net10.0`, `net11.0` | + +A `[GenerateResult]` union needs `net11.0` and `LangVersion=preview` in the project that declares it, in a Blazor +client as anywhere else. + +## A Blazor form whose input flows through a result + +Validate the form with `Purview.ZodSharp.Blazor`'s ``, and send the same input through the result +pipeline when it is submitted - a rejected input and a domain failure are both values the page can render: + +```csharp +// Program.cs (WebAssembly client or server) +builder.Services.AddZodSharpMyAppSchemas(); // generated: AddZodSharp{Assembly}Schemas() +builder.Services.AddZodSharpBlazor(); +``` + +```razor + + + + + + + +@code { + readonly TenantRegistration registration = new(); + string? outcome; + + void Submit() => + outcome = service + .Register(registration) // Validate(...).ToResult<..., TenantError>(...).Bind(...) + .Match( + tenant => $"Registered {tenant.TenantId}.", + error => error switch + { + TenantAlreadyExists exists => $"Tenant {exists.TenantId} already exists.", + TenantInputInvalid invalid => $"Rejected with {invalid.Errors.Length} error(s).", + _ => "Failed.", + }); +} +``` + +On a Blazor Server or Web App host, the same service behind a minimal API maps onto HTTP with `WithResultsHttp()`: a +`TenantInputInvalid` (an `IValidationErrorCarrier`) becomes the ZodSharp validation problem, a code rule from +`AddResultsZodSharpHttp` answers its code, and a case mapping from `AddResultsHttp` answers its case. +`AddZodSharpBlazor()` validates form models only - it does not turn on .NET 11 minimal-API parameter validation - so +the handler runs and the result rules answer. See +[ZodSharp Problem Details](ZodSharp-ProblemDetails.md#net-11-request-validation). + +## Calling the API from a client + +`Purview.ZodSharp.Http` is the client half of the same contract: it validates a request body before it is sent, and +reads the validation problem `Purview.ZodSharp.AspNetCore` writes back into ZodSharp errors. Both fold into a result +the way a local `Validate` does, so a Blazor WebAssembly page renders a rejection it found itself and one the server +found with the same union case: + +```csharp +// Validated locally first: an invalid body is never sent, and its errors become the case. +var sent = await http + .PostAsJsonValidatedAsync("/api/tenants", registration, validator, AppJsonContext.Default.TenantRegistration, services) + .ToResultAsync(errors => new TenantInputInvalid(errors)); + +// Rejected by the server anyway (a rule only the server can check): the problem reads back into the same case. +if (sent.TryGetValue(out var response) && await response.TryReadZodValidationProblemAsync() is { } problem) + return new TenantInputInvalid(problem.ToValidationErrors()).AsFailure(); +``` + +`ToResultAsync` and `ToUnitResultAsync` accept the `Task>` that `PostAsJsonValidatedAsync`, +`PutAsJsonValidatedAsync`, `PatchAsJsonValidatedAsync` and `HttpContent.ReadFromJsonAndValidateAsync` return, as +well as the `ValueTask` that `ValidateAsync` returns. `validator` is the generated `{TypeName}SchemaValidator` (an +`IZodSchemaValidator` with a parameterless constructor; `AddZodSharp{Assembly}Schemas()` registers it in the +`IZodSchemaFactory`, not as a service). The package is trim-, Native +AOT- and Blazor WebAssembly-safe; pass the `JsonTypeInfo` from your own `JsonSerializerContext`. + +## A Native AOT minimal API + +Two rules make a minimal API Native AOT-clean: + +- **Return `result.ToHttpResult()` from each handler, not the result.** Under `PublishAot`, ASP.NET Core's request + delegate generator resolves JSON metadata for every handler's declared return type when it builds the endpoints. A + `Result<...>` has none - it is never serialized, and System.Text.Json's source generator cannot describe a union + error (`SYSLIB1227`) - so a handler declared to return the result fails every request at startup, before + `WithResultsHttp()` can run. `ToHttpResult()` returns a `ResultHttpResult`, an `IResult` that the registered mapper + maps when it executes, with exactly the responses `WithResultsHttp()` produces. It also describes the success + response to OpenAPI. +- **List the request bodies and the successful values in a `JsonSerializerContext`** that the HTTP JSON options + resolve from - the **value** type (`Tenant`), not `Result`. The problem responses - the + unmapped-failure and uninitialized `500`s, your own `TypedResults.Problem(...)` mappings, and the ZodSharp + validation problem - resolve from ASP.NET Core's and Purview.ZodSharp.AspNetCore's own contexts. + +```csharp +var builder = WebApplication.CreateSlimBuilder(args); + +builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default)); + +builder.Services.AddZodSharpMyAppSchemas(); +builder.Services.AddZodSharpProblemDetails(); +builder.Services.AddResultsHttp(options => + options.Map(error => + TypedResults.Problem(statusCode: StatusCodes.Status409Conflict, title: $"Tenant '{error.TenantId}' already exists."))); +builder.Services.AddResultsZodSharpHttp(); + +var app = builder.Build(); + +var tenants = app.MapGroup("/api/tenants"); +tenants.MapPost("/", (TenantRegistration registration, TenantRegistrationService service) => + service.Register(registration).ToHttpResult()); +tenants.MapDelete("/{tenantId}", (string tenantId, TenantRegistrationService service) => + service.Remove(tenantId).ToHttpResult()); // Result: 204 + +app.Run(); + +[JsonSerializable(typeof(TenantRegistration))] +[JsonSerializable(typeof(Tenant))] +internal sealed partial class AppJsonContext : JsonSerializerContext; +``` + +Two more things keep the host itself AOT-clean: + +- Set `ResultsHttpOptions` in code. Binding them from configuration with `Configure(section)` + uses the reflection-based configuration binder unless you enable its source generator + (`EnableConfigurationBindingGenerator`). +- A `SuccessMapper` receives the value as `object?`. Serializing that with `TypedResults.Json(value)` binds + `Json`, which has no metadata under Native AOT; cast to the value type first, or leave `SuccessMapper` + unset and let the mapper keep the static type. + +The packed-consumer gate's `scripts/packed-consumers/AotWebApi` is this host, published with `PublishAot` and +warnings as errors, then called - including a client self-test that goes through `Purview.ZodSharp.Http`. +## Value objects in a Blazor client + +A `[Scalar]` from Purview.ValueObjects with a type-level `[ZodRule]` such as `[NonSentinel]` composes with results +in a Native AOT application and in a Blazor WebAssembly client, trimmed or published with `RunAOTCompilation=true` +(see [Value Objects Composition](Value-Objects-Composition.md)). Purview.ValueObjects' own guide covers binding a +value object in a form and where its validation messages are placed. diff --git a/docs/wiki/Combinators.md b/docs/wiki/Combinators.md index fd39a0e..f85918e 100644 --- a/docs/wiki/Combinators.md +++ b/docs/wiki/Combinators.md @@ -1,8 +1,10 @@ # Combinators The combinators fold a result into a value or chain another operation, without ever inspecting a state that is -not there. All of them throw `InvalidOperationException` (`"The result is uninitialized."`) for `default`; the -throwing input check for a `null` delegate is `ArgumentNullException`. +not there. Every operation that depends on the state — the combinators, fallbacks, side effects, `MapAsync`/ +`BindAsync` and `Throw()` — throws `InvalidOperationException` (`"The result is uninitialized."`) for `default`; +only the `TryGetValue`/`TryGetError` probes never throw. The input check for a `null` delegate is +`ArgumentNullException`. ## Transforming @@ -63,7 +65,7 @@ failure and its error pass through unchanged. `TryGetValue` and `TryGetError` never throw — not even for `default`: ```csharp -if (result.TryGetValue(out Tenant? tenant)) +if (result.TryGetValue(out Tenant tenant)) Console.WriteLine(tenant.Name); if (result.TryGetError(out TenantError error)) @@ -96,16 +98,17 @@ failure. Use them after a state has been established (or after probing). ## Asynchronous composition ```csharp -Task> dto = GetTenantAsync(tenantId) - .MapAsync(tenant => _mapper.MapAsync(tenant)); +Result tenant = await GetTenantAsync(tenantId); -Task> saved = GetTenantAsync(tenantId) - .BindAsync(tenant => _store.SaveAsync(tenant)); +Result dto = await tenant.MapAsync(value => _mapper.MapAsync(value)); + +Result saved = await tenant.BindAsync(value => _store.SaveAsync(value)); ``` `MapAsync`, `BindAsync` and the widening `BindAsync(bind, mapError)` behave exactly like their synchronous counterparts, and all throw for `default`. They are extension methods in `ResultExtensions`, not members on the -result type. +result type, and their receiver is a result value rather than a `Task>`, so await the producing call +first. ## Unwrapping with `Throw` @@ -131,7 +134,7 @@ value to pass. `Match`, `Switch`, `Tap` and `Ensure` therefore use a no-argument Result deleted = DeleteTenant(tenantId); deleted.Match(() => "deleted", error => $"could not delete: {error}"); -deleted.Ensure(() => _tenants.Count > 0, () => new TenantError()); +deleted.Ensure(() => _tenants.Count > 0, () => new TenantNotFound(tenantId)); ``` `Map(() => value)` attaches a value to a success, producing a `Result`, and `Bind` continues with diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md index cc59fe1..ba1d0a6 100644 --- a/docs/wiki/Contributing.md +++ b/docs/wiki/Contributing.md @@ -19,11 +19,15 @@ src/ Examples.Zod/ Examples.AspNetCore/ Examples.AspNetCore.Zod/ - /Sdk/ Package-only assets: README.md and .agents/** skills - tests/ TUnit unit tests (one *.UnitTests project per package) + Examples.ValueObjects.Zod/ + /Sdk/ Package-only assets: the package README.md + tests/ TUnit unit tests (one *.UnitTests project per package, plus + Repository.UnitTests for the skills and installed-skill checks) docs/wiki/ This documentation suite +skills/ Authored agent skills, installed by the skills CLI +scripts/ Packed-consumer gate and pack-validation rule checks Directory.Packages.props Centrally managed NuGet versions -purview-build.json Shared pipeline configuration +.config/purview-build.json Shared pipeline configuration package.json Authoritative repository and package version ``` @@ -74,7 +78,7 @@ Commits follow [Conventional Commits](https://www.conventionalcommits.org/), enf - `just build` succeeds with no new warnings or errors. - The relevant tests pass (`just test-unit`). - `just lint-check` reports no formatting or `.slnx` changes. -- Packed packages match `purview-build.json` `PackValidation` (`just pipeline-pack-validate`). +- Packed packages match `.config/purview-build.json` `PackValidation` (`just pipeline-pack-validate`). - Generated code stays deterministic and reviewable. ## Documentation @@ -107,4 +111,5 @@ Conventions: - [Testing](Testing.md) — the TUnit conventions and source-generator testing approach. - [Release Flow](Release-Flow.md) — versioning, workflows and pack validation. -- [Agent Skills](Agent-Skills.md) — the guidance the packages ship to consumers. +- [Agent Skills](Agent-Skills.md) — the agent guidance the skills CLI installs from the repository's `skills/` + folder (no package ships skills). diff --git a/docs/wiki/Core-Concepts.md b/docs/wiki/Core-Concepts.md index ce8b77b..6b09b13 100644 --- a/docs/wiki/Core-Concepts.md +++ b/docs/wiki/Core-Concepts.md @@ -77,9 +77,14 @@ error types — the ASP.NET Core endpoint filter, for example. | `IsSuccess` | Whether the result represents success | | `SuccessValue` | The successful value, or `null` when the result is not a success | | `ErrorValue` | The error, or `null` when the result is not a failure | +| `AcceptSuccess(IResultValueVisitor, TState)` | Hands the successful value to the visitor's `VisitSuccess` with its static type intact; a failed or uninitialized value result visits `default`, and a unit result visits the `Success` marker | Its accessors **never throw**: the accessor that does not describe the current state returns `null`. That is what -makes it safe for a framework component to inspect a result without knowing the two type arguments. +makes it safe for a framework component to inspect a result without knowing the two type arguments. Use +`AcceptSuccess` rather than `SuccessValue` whenever the value's type matters — serialization is the usual case: +`SuccessValue` erases it to `object`, which under trimming or Native AOT leaves a serializer with nothing to +resolve. Like the accessors it never throws for a state (only `ArgumentNullException` for a `null` visitor), so +check `IsSuccess` first when the distinction matters. ## Value-less results diff --git a/docs/wiki/Diagnostics.md b/docs/wiki/Diagnostics.md index 490bff3..76b6c68 100644 --- a/docs/wiki/Diagnostics.md +++ b/docs/wiki/Diagnostics.md @@ -3,10 +3,10 @@ The union rules live in one shared library (`Diagnostics/DiagnosticLibrary.cs`, `Diagnostics/ResultUnionDiagnostics.cs`, `Diagnostics/ResultDiagnostic.cs`) that both hosts consume: -- **`ResultsDiagnosticAnalyzer` reports the per-target rules** (`RSG1000`–`RSG1004`, `RSG1007`, `RSG1008`) in the +- **`ResultsDiagnosticAnalyzer` reports the per-target rules** (`RSG1000`–`RSG1004`, `RSG1007`–`RSG1009`) in the IDE and in build output. - **The generator reports the compilation-wide rules** (`RSG1005`, `RSG1006`) that need every opted-in union in - the compilation. + the compilation, and `RSG9000` when the emitter fails unexpectedly. - The generator never reports a rule the analyzer reports. It still runs the same shared analysis, so `DiagnosticLibrary.IsBlocking` is the single blocking policy, but a finding is never surfaced twice. @@ -23,13 +23,16 @@ The union rules live in one shared library (`Diagnostics/DiagnosticLibrary.cs`, | `RSG1006` | Warning | Generator | No (only the shared leaf case's `AsFailure` helper is skipped) | A **leaf** case type is shared with another union, so its per-case helper is generated once; the union-receiver `Failure(...)` factory is generated for every union and is the shared-case safe form. A union-typed case shared across unions is composition, not a shared leaf, and is not reported. | | `RSG1007` | Error | Analyzer | Yes | The union uses an `IUnionMembers` member provider, which the first implementation does not support. | | `RSG1008` | Warning | Analyzer | No (only the ambiguous case's inclusion factory is skipped) | A case is reachable through two different included unions, so no inclusion factory can name its construction path. List the case through the union that directly declares it instead. | +| `RSG1009` | Warning | Analyzer | No (only the unsupported case is skipped) | A case type is not a named type (for example an array), so no helper or factory can be declared against it. Wrap it in a record or struct case instead. | +| `RSG9000` | Error | Generator | — | The generator failed unexpectedly. The failure is reported instead of escaping, because an escaping exception surfaces as `CS8785` and discards *all* generated output for the compilation. Please report it. | Case-level findings never block the union: the remaining cases are still generated and the skipped case is reported. Blocking is decided per rule in `DiagnosticLibrary.IsBlocking`, not derived from severity, because a finding may be an error the consumer must fix while usable output can still be produced. -Every rule is categorized as `Purview.Results.Usage` and tracked in -`src/src/SourceGenerator/AnalyzerReleases.Unshipped.md`, which the compiler's RS2008 rule catalogue validates. +Every rule is categorized as `Purview.Results.Usage` and release-tracked, which the compiler's RS2008 rule +catalogue validates: the shipped rules are recorded in `src/src/SourceGenerator/AnalyzerReleases.Shipped.md` under +Release 1.0.0, and a new or changed rule goes into `AnalyzerReleases.Unshipped.md` until the next release. ## Diagnostic suppressions @@ -48,7 +51,8 @@ The suppression is narrow on purpose: - The union's **case types** keep the warning, as does every other value type. A case declared as a plain `struct` still gets `CA1815`; a `record struct` case never gets it, because records synthesise equality. - Nothing is hidden: a suppressor can only suppress non-error, configurable diagnostics, and `RSG2000` is a - suppression id rather than a rule, so `RSG1000`–`RSG1008` remain the only diagnostics the package reports. + suppression id rather than a rule, so `RSG1000`–`RSG1009` and `RSG9000` remain the only diagnostics the + package reports. Every suppression is logged as an `Info` diagnostic against `RSG2000`, in the verbose build log and in an MSBuild binlog (and as a suppressed diagnostic in an `/errorlog` SARIF file), so a build can always be audited for what it diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index a98232d..b7c8e1d 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -17,6 +17,9 @@ This guide installs the packages, models an error union, returns a result, and m These packages do not set `EnablePreviewFeatures`, so they do not force preview features on your solution. See [Guarantees and Limitations](Guarantees-and-Limitations.md). +- **Purview.ZodSharp 3** for the ZodSharp integrations (`Purview.Results.ZodSharp`, + `Purview.Results.ZodSharp.AspNetCore`), which target the same `net10.0`/`net11.0` set. Trimmed, Native AOT and + Blazor applications are covered in [Blazor and Native AOT](Blazor-and-Native-AOT.md). ## 1. Reference the packages @@ -130,7 +133,8 @@ app.MapGet("/tenants/{id}", (string id) => GetTenant(new TenantId(id))).WithResu ``` A mapping for the **case** type wins, the mapping for the **error** type covers the remaining cases, and an -unmapped failure is a `500` that names the unmapped case, so a mapping gap is never silent. See +unmapped failure is a logged `500` (and is counted in telemetry with its case type), so a mapping gap is never +silent. See [ASP.NET Core Integration](AspNetCore-Integration.md). ## 6. Validate without exceptions diff --git a/docs/wiki/Guarantees-and-Limitations.md b/docs/wiki/Guarantees-and-Limitations.md index 9af6656..9e3bc58 100644 --- a/docs/wiki/Guarantees-and-Limitations.md +++ b/docs/wiki/Guarantees-and-Limitations.md @@ -36,7 +36,8 @@ change must not break. | `purview.results.failures.unmapped` | failures with no mapping, answered with the unmapped-failure response | | `purview.results.uninitialized` | endpoints that returned a `default` result, which is always a bug | -Each measurement carries a `purview.results.error_type` tag with the error's type name, and the failure +Each measurement carries a `purview.results.error_type` tag with the error's type name (`unknown` for an +uninitialized result, which has no error), and the failure paths add the same tag to `Activity.Current` — enriching the request span ASP.NET Core already created rather than starting one. The type name is always present in telemetry, because metrics and traces stay inside your own infrastructure; disclosing it in the HTTP *response* is separately opt-in through @@ -109,7 +110,10 @@ reference, so no telemetry package dependency is added. ## HTTP mapping philosophy - An unmapped failure is a host mapping gap, not a domain outcome. It is answered with `UnmappedStatusCode` - (`500`) and a `ProblemDetails` carrying the `errorType` extension, and it is logged. + (`500`) and a `ProblemDetails` carrying `UnmappedTitle`, and it is logged with its case type. The type name is + written into the response's `errorType` extension only when `IncludeErrorTypeInProblemDetails` is set (it is off by + default, because it discloses internal namespaces); an uninitialized result always carries + `errorType = "results_uninitialized"`. - `ThrowOnUnmappedFailure` exists for development and keeps throwing `InvalidOperationException`. - An uninitialized result (`default`) takes the unmapped path and is logged, because an endpoint returning `default` is a bug. @@ -140,6 +144,11 @@ reference, so no telemetry package dependency is added. outcome you want instead: unwrap with `Match`, `TryGetValue`/`TryGetError`, or — over HTTP — let [`Purview.Results.AspNetCore`](AspNetCore-Integration.md) map the result to a response. `IResultValue` is the non-throwing view available to infrastructure that has to inspect a result generically. +- **A Native AOT minimal API returns `result.ToHttpResult()`, not the result.** Under `PublishAot`, ASP.NET Core's + request delegate generator resolves JSON metadata for a handler's declared return type when it builds the + endpoints, and a result has none (a union error cannot be described by System.Text.Json's source generator + either), so a handler declared to return a result fails at startup there. `ToHttpResult()` returns an `IResult`; + see [ASP.NET Core Integration](AspNetCore-Integration.md#native-aot). ## Related diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index 6ec37bc..47681fa 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -52,10 +52,12 @@ This wiki is the project documentation hub. Packages are published under the `Pu - **Union error types** — a C# 15 union keeps every error case strongly typed while the result stays a single value, and the generator supplies the per-case `AsFailure()` and `AsFailure()` helpers plus the union-receiver `Union.Failure(...)`/`Union.Success(...)` factories the language cannot express itself. -- **Compile-time diagnostics** — `RSG1000`–`RSG1008` report unsupported union shapes at build time, and the one - suppression (`RSG2000`) answers `CA1815` only for opted-in unions. -- **Host-controlled HTTP** — the ASP.NET Core package resolves an error **case**, then the **error type**, then - ordered fallbacks, and answers an unmapped failure with a logged `500` so mapping gaps stay visible. +- **Compile-time diagnostics** — `RSG1000`–`RSG1009` report unsupported union shapes at build time, `RSG9000` + reports an unexpected generator failure instead of discarding all generated output, and the one suppression + (`RSG2000`) answers `CA1815` only for opted-in unions. +- **Host-controlled HTTP** — the ASP.NET Core package resolves an error **case**, then each enclosing **union** + from the inside out, then the **error type**, then ordered fallbacks, and answers an unmapped failure with a + logged `500` so mapping gaps stay visible. - **Validation without exceptions** — ZodSharp `ValidationResult` values become ordinary results, and a failure that carries validation errors becomes a standard ProblemDetails payload. - **No reflection at runtime** — dependency-free runtime packages; union structure is inspected only by the diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index be7e7fd..b936d27 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -2,7 +2,7 @@ Releases are driven by the shared [purview-dev/build](https://github.com/purview-dev/build) pipeline through the GitHub Actions workflows in `.github/workflows/`. Consuming repositories own configuration through -`purview-build.json`, not pipeline source code. +`.config/purview-build.json`, not pipeline source code. ## Versioning @@ -27,13 +27,15 @@ A push to `main` publishes, so everything below has to be true *before* the merg 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. +4. **Run `just pipeline-pack-validate`**, which covers restore, build, lint, tests, pack and pack validation in + one pass (`just pipeline-pr` stops after the tests), and `just smoke-packed-consumers` when the generator, + packaging or a dependency version changed. ## Workflows | Workflow | Trigger | Pipeline | | --- | --- | --- | -| `.github/workflows/pr.yml` | pull requests against `main` (opened, synchronize, reopened, ready_for_review) | shared `purview-build.yml`, with `run-pack: true` and `validate-pack: true` | +| `.github/workflows/pr.yml` | pull requests against `main` (opened, synchronize, reopened, ready_for_review) | `build`: shared `purview-build.yml`, with the `[Category=Unit]` test filter, `run-pack: true` and `validate-pack: true`; `packed-consumers`: packs to an isolated feed, publishes and runs the Native AOT, Blazor WebAssembly and Blazor Server consumers, leak-scans their output and proves every `ForbiddenContent` rule rejects a package | | `.github/workflows/release.yml` | push to `main` | shared `purview-release.yml` with `release-mode: NuGet` | Both workflows pin `dotnet-version` to the SDK in `global.json` — the shared workflow defaults to the 10.0.x SDK, @@ -50,10 +52,12 @@ not already exist. Do not create release tags or publish packages manually. | `Build:Solution` | `src/Results.slnx` | | `Build:TestRoot` | `src/tests` | | `Build:TestPatterns` | `*Tests.csproj` | -| `Build:TestProjects` | `*UnitTests*` | -| `Build:TestFilter` | `/*/*/*/*[Category=Unit]` | +| `Build:TestProjects` | `*Tests*` | +| `Build:TestFilter` | `/*/*/*/*` (the PR workflow narrows it to `[Category=Unit]` through its `test-filter` input) | +| `PackValidation:RequiredCompilerFlags` | `optimization=release`, asserted for every assembly that ships a PDB | | `PackValidation:RequireSymbolPackage` | `false` | | `PackValidation:RequiredContent` | The exhaustive per-package content declaration | +| `PackValidation:ForbiddenContent` | Paths no package may ship (`*`): compiler-host assemblies anywhere, and the generator outside `analyzers/` | | `Release:Mode` | `None` (publishing is enabled only by the release workflow) | `RequireExplicitContent` defaults to `true`, so `RequiredContent` is exhaustive: a package with no rule, or an @@ -62,8 +66,9 @@ removed PDB, a renamed analyzer. ## Packed shapes that must not regress -- `Purview.Results` ships the merged source generator under `analyzers/dotnet/cs/` with **no `lib/` folder** - and **no PDB**, because the packaged analyzer is the framework's ILRepack-merged assembly whose rewritten PDB +- `Purview.Results` ships the runtime library as `lib//Purview.Results.dll` plus its XML documentation, and + the merged source generator under `analyzers/dotnet/cs/` only — never under `lib/` — with **no analyzer PDB**, + because the packaged analyzer is the framework's ILRepack-merged assembly whose rewritten PDB carries no Roslyn compiler-flags record. The `CS0029` code fix ships beside it as `Purview.Results.SourceGenerator.CodeFixes.dll` and is never IL-merged into the generator. The generator is not published as a separate package. @@ -86,7 +91,7 @@ the build, and move into a `## Release ` block in `AnalyzerReleases.Shi ## Commands ```bash -just pipeline-pr # restore, build, lint, tests, pack, validate pack +just pipeline-pr # restore, build, lint, tests just pipeline-build # restore, build, lint (no tests, no release) just pipeline-pack-validate # restore, build, lint, tests, pack, validate contents just pipeline-release # pack, publish, GitHub release (NuGet mode) diff --git a/docs/wiki/Source-Generator.md b/docs/wiki/Source-Generator.md index e789792..f10e2ed 100644 --- a/docs/wiki/Source-Generator.md +++ b/docs/wiki/Source-Generator.md @@ -114,13 +114,15 @@ change, wrap or replace `Result` or `Result`. The switch reaches `PackageReference` consumers through `buildTransitive/Purview.Results.props`, so `-p:DisableResultsSourceGenerator=true` (or a `Directory.Build.props` setting) disables the helpers there too. -Release tracking for the rules lives in `src/src/SourceGenerator/AnalyzerReleases.Unshipped.md`. +Release tracking for the rules lives in `src/src/SourceGenerator/AnalyzerReleases.Shipped.md` (every rule, under +Release 1.0.0); `AnalyzerReleases.Unshipped.md` holds new or changed rules until the next release. ## Packaging -The generator is never packed loose: `Purview.Results` ships its merged analyzer under `analyzers/dotnet/cs/` -with no `lib/` folder and **no PDB**, because the packaged analyzer is the framework's ILRepack-merged assembly -whose rewritten PDB carries no Roslyn compiler-flags record. The `CS0029` code fix ships beside it as a second +The generator is never packed loose: `Purview.Results` ships the runtime library as +`lib//Purview.Results.dll` with its XML documentation, and the generator's merged analyzer under +`analyzers/dotnet/cs/` only — never under `lib/` — with **no analyzer PDB**, because the packaged analyzer is the +framework's ILRepack-merged assembly whose rewritten PDB carries no Roslyn compiler-flags record. The `CS0029` code fix ships beside it as a second analyzer assembly (`Purview.Results.SourceGenerator.CodeFixes.dll`) and is never IL-merged into the generator. The generator is not a separate NuGet package. diff --git a/docs/wiki/Testing.md b/docs/wiki/Testing.md index b41e408..ad479f8 100644 --- a/docs/wiki/Testing.md +++ b/docs/wiki/Testing.md @@ -28,6 +28,7 @@ category, which is what the filter matches. Use `--treenode-filter` for filterin | `src/tests/AspNetCore.UnitTests` | `DefaultResultsHttpMapper`, the endpoint filter, failure mappers and unmapped-failure behaviour | | `src/tests/ZodSharp.UnitTests` | `ToResult` and the validation-result integration | | `src/tests/ZodSharp.AspNetCore.UnitTests` | `ZodResultsFailureMapper`, registration order and the validation problem mapper | +| `src/tests/Repository.UnitTests` | The repository itself: `SkillTests` validates every authored skill in `skills/` against the Agent Skills rules (`Purview.Skills.Testing`), and `InstalledSkillTests` keeps `.agents/skills/` and `skills-lock.json` in step. Pinned to `net10.0` | ## Conventions diff --git a/docs/wiki/Union-Errors.md b/docs/wiki/Union-Errors.md index 6e8e806..2619069 100644 --- a/docs/wiki/Union-Errors.md +++ b/docs/wiki/Union-Errors.md @@ -178,6 +178,7 @@ mapping for the leaf (`Map`) wins, and a mapping for | A generic union | No — `RSG1002` | | A case type that itself contains type parameters | No — `RSG1002` (the remaining cases still generate) | | `IUnionMembers` member providers | No — `RSG1007` | +| A case type that is not a named type (for example an array) | No — `RSG1009` warning (the remaining cases still generate; wrap it in a record or struct case) | See [Diagnostics](Diagnostics.md) for the full rules table and the blocking policy. diff --git a/docs/wiki/Value-Objects-Composition.md b/docs/wiki/Value-Objects-Composition.md index f46ddff..57131c6 100644 --- a/docs/wiki/Value-Objects-Composition.md +++ b/docs/wiki/Value-Objects-Composition.md @@ -3,7 +3,9 @@ `Purview.Results` composes with [Purview.ValueObjects](https://www.nuget.org/packages/Purview.ValueObjects) and [Purview.ZodSharp](https://www.nuget.org/packages/Purview.ZodSharp) without depending on either. This page shows the end-to-end shape: a value object's invariants are validated by ZodSharp, the outcome becomes an ordinary -result failure, and the failure is rendered by the ZodSharp.AspNetCore mapping. +result failure, and an ASP.NET Core host can render that failure with the ZodSharp.AspNetCore mapping. The +runnable example stops at the result — it is a console app with no HTTP layer — so the rendering section shows the +host registration on its own. ## Why the composition works @@ -72,7 +74,7 @@ public readonly partial record struct AssetId `[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: +Purview.ZodSharp (the 3.x line this repository builds against) generates the schema from that attribute even though the member is not visible to it: ```csharp [Scalar] @@ -104,23 +106,42 @@ knowing the error type: [GenerateResult] readonly union TenantError(TenantInputInvalid, TenantAlreadyExists); -readonly record struct TenantInputInvalid(TenantInput Input, ImmutableArray Errors) +readonly record struct TenantInputInvalid(Guid TenantId, ImmutableArray Errors) : IValidationErrorCarrier { ImmutableArray IValidationErrorCarrier.ValidationErrors => Errors; } + +readonly record struct TenantAlreadyExists(TenantId TenantId); ``` -`ToResult` maps the validation outcome into that union, naming the error type explicitly because a union case does -not carry the union that contains it: +The service hydrates the value object without running its rule, validates it through the generated schema, and +turns a rejection into the carrier case with the generated `AsFailure()` helper: ```csharp -return TenantInputSchema - .Validate(input) - .ToResult(errors => new TenantInputInvalid(input, errors)) - .Bind(RegisterValidated); +public Result Register(Guid tenantId, string name) +{ + var candidate = TenantId.Hydrate(tenantId); + var validation = TenantIdSchema.Validate(candidate); + + if (!validation.IsSuccess) + return new TenantInputInvalid(tenantId, validation.Errors).AsFailure(); + + var id = validation.Value; + + if (_tenants.ContainsKey(id)) + return new TenantAlreadyExists(id).AsFailure(); + + Tenant tenant = new(id, name, Enabled: true); + _tenants[id] = tenant; + + return Result.Success(tenant); +} ``` +`ToResult(errors => ...)` from `Purview.Results.ZodSharp` is the one-call alternative when the +validated value flows straight on; see [ZodSharp Integration](ZodSharp-Integration.md). + ## Rendering it, keyed by the rule's identity `Purview.Results.ZodSharp.AspNetCore` renders the carrier as a validation problem, and the rules registered on @@ -147,7 +168,8 @@ registration-ordered; see [ZodSharp Problem Details](ZodSharp-ProblemDetails.md) [`Examples.ValueObjects.Zod`](https://github.com/purview-dev/results/tree/main/src/src/Examples.ValueObjects.Zod) 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", …)`. +failure. It prints each error's code, origin and (empty) path — the identity a host's `MapCode`/`MapOrigin` rules +would key on. ```bash dotnet run --project src/src/Examples.ValueObjects.Zod diff --git a/docs/wiki/ZodSharp-Integration.md b/docs/wiki/ZodSharp-Integration.md index 036fd1b..bc178c3 100644 --- a/docs/wiki/ZodSharp-Integration.md +++ b/docs/wiki/ZodSharp-Integration.md @@ -79,6 +79,26 @@ async Task> Validate(TenantInput input, CancellationToken ca The error type is named explicitly for the same reason as the synchronous forms, and the failure factory runs only when validation failed. +## Validation over HTTP + +`ToResultAsync` and `ToUnitResultAsync` also accept a `Task>`, the shape +[Purview.ZodSharp.Http](https://www.nuget.org/packages/Purview.ZodSharp.Http) returns. A client's request and response +validation therefore folds into a result like a local `Validate`, and a server's validation problem reads back into +the same case: + +```csharp +// Validated before it is sent: an invalid body is never sent, and its errors become the case. +var sent = await http + .PostAsJsonValidatedAsync("/api/tenants", input, validator, AppJsonContext.Default.TenantInput, services) + .ToResultAsync(errors => new TenantInputInvalid(input, errors)); + +// Rejected by the server: the problem Purview.ZodSharp.AspNetCore wrote reads back into ZodSharp errors. +if (sent.TryGetValue(out var response) && await response.TryReadZodValidationProblemAsync() is { } problem) + return new TenantInputInvalid(input, problem.ToValidationErrors()).AsFailure(); +``` + +`validator` is the generated `{Type}SchemaValidator`. This package takes no dependency on Purview.ZodSharp.Http; +reference it in the client. Both are trim-, Native AOT- and Blazor WebAssembly-safe. ## Carrying validation errors in the error value `IValidationErrorCarrier` is implemented by an error value that carries ZodSharp validation errors, so an HTTP @@ -108,6 +128,7 @@ map every validation-carrying error individually. | `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 | +| `Task>.ToResultAsync(...)` / `.ToUnitResultAsync(...)` | The same, for APIs that return a `Task` - Purview.ZodSharp.Http's request and response validation | | `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/docs/wiki/ZodSharp-ProblemDetails.md b/docs/wiki/ZodSharp-ProblemDetails.md index 7804525..99706df 100644 --- a/docs/wiki/ZodSharp-ProblemDetails.md +++ b/docs/wiki/ZodSharp-ProblemDetails.md @@ -84,8 +84,22 @@ category or origin registered twice with different behaviour is rejected at conf A failure that does **not** carry validation errors is declined (`null`), so other mappers and fallbacks still apply — this mapper never answers a failure it does not understand. -## API +## .NET 11 request validation + +On .NET 11, Purview.ZodSharp 3's `AddZodSharpValidation()` plugs ZodSharp into the platform's minimal-API validation. +A `[ZodSchema]` endpoint parameter is then validated **before the handler runs**, and an invalid request is answered +by ZodSharp's own validation problem: the handler never returns its result, so the code, category and origin rules +above never see the failure. + +`AddZodSharpBlazor()` does not turn that on - it validates form models only - so a Blazor host's result-returning +endpoints keep their result rules. A host whose handlers validate and return results either does not call +`AddZodSharpValidation()`, or registers it for type validation only (`validateParameters: false`, or +`ZodValidationOptions.ValidateParameters = false`). +Both paths write through ZodSharp's `ZodProblemDetailsWriter`, so the body has the same shape either way; only the +result rules' statuses are lost when the pipeline answers first. + +## API | Member | Purpose | | --- | --- | | `AddResultsZodSharpHttp(Action? configure = null)` | Registers the ZodSharp options and adds the validation failure mapper to `ResultsHttpOptions` | @@ -95,9 +109,13 @@ apply — this mapper never answers a failure it does not understand. | `ImmutableArray.ToValidationProblem(HttpContext, int statusCode = 400)` | Creates the validation problem for a set of errors | | `ZodValidationProblems.ToProblem(errors, options, defaultStatusCode = 400, traceId = null)` | The underlying mapper, for hosts that resolve the options themselves | -The status code resolved by `ZodProblemDetailsOptions.StatusCodeSelector` wins; `statusCode` (or -`defaultStatusCode`, or a rule's `statusCode`) is only used when the resolved error type does not define one. The -trace identifier is included when `ResultsHttpOptions.IncludeTraceId` is `true` (the default). +The response is written by Purview.ZodSharp's `ZodProblemDetailsWriter` with the host's `ZodProblemDetailsOptions`, +so every option applies exactly as it does to a thrown `ZodException` - path naming, `ErrorType` message formatting, +the `issues` extension, `MergeParametersIntoExtensions` and the error type resolver. The status code follows +Purview.ZodSharp 3's precedence: `statusCode` (or `defaultStatusCode`, or a rule's `statusCode`) is the default, the +highest `ErrorType.HttpStatus` replaces it, `ZodProblemDetailsOptions.StatusCodeSelector` wins over both, and +`StatusCodeOverride` has the last word. The trace identifier is included when `ResultsHttpOptions.IncludeTraceId` is +`true` (the default). The mapper reuses `ZodValidationProblems.ToProblem` rather than reimplementing error-to-problem mapping, which is what keeps a result-carried validation failure and a thrown `ZodException` in agreement on status code, title, diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 1885bb2..0ad970e 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -9,6 +9,7 @@ - [ZodSharp Integration](ZodSharp-Integration.md) - [ZodSharp Problem Details](ZodSharp-ProblemDetails.md) - [Value Objects Composition](Value-Objects-Composition.md) +- [Blazor and Native AOT](Blazor-and-Native-AOT.md) - [Guarantees and Limitations](Guarantees-and-Limitations.md) - [Testing](Testing.md) - [Agent Skills](Agent-Skills.md) diff --git a/docs/wiki/index.md b/docs/wiki/index.md deleted file mode 100644 index 7fa753b..0000000 --- a/docs/wiki/index.md +++ /dev/null @@ -1,19 +0,0 @@ -# Purview Results - -Result types for .NET — a dependency-light `Result` and its value-less `Result`, C# 15 -union ergonomics for their error cases, and integrations that let expected failures flow through a value instead -of an exception. - -[Get started](Getting-Started.md){ .md-button .md-button--primary } -[Documentation overview](Home.md){ .md-button } - -## Guides - -- [Core concepts](Core-Concepts.md) -- [Combinators and probing](Combinators.md) -- [Union error types](Union-Errors.md) -- [Source generator](Source-Generator.md) -- [Diagnostics](Diagnostics.md) -- [ASP.NET Core integration](AspNetCore-Integration.md) -- [ZodSharp integration](ZodSharp-Integration.md) -- [ZodSharp problem details](ZodSharp-ProblemDetails.md) diff --git a/global.json b/global.json index 235b914..fb52053 100644 --- a/global.json +++ b/global.json @@ -1,11 +1,11 @@ { "sdk": { - "version": "11.0.100-rc.2.26504.105", + "version": "11.0.100-rc.3.26522.148", "rollForward": "latestMajor", "allowPrerelease": true }, "msbuild-sdks": { - "Purview.BuildSdk": "1.1.0" + "Purview.BuildSdk": "1.2.0" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/package.json b/package.json index c46deaa..f31a8e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "results", - "version": "1.0.4", + "version": "1.1.0-prerelease.2", "license": "MIT", "private": true, "keywords": [], diff --git a/scripts/assert-no-compiler-host-assemblies.ps1 b/scripts/assert-no-compiler-host-assemblies.ps1 new file mode 100644 index 0000000..77076c0 --- /dev/null +++ b/scripts/assert-no-compiler-host-assemblies.ps1 @@ -0,0 +1,161 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Fails when a build or publish output carries a compiler-host-only assembly. + +.DESCRIPTION + Adapted from purview-dev/zodsharp's scripts/assert-no-compiler-host-assemblies.ps1. + + The Purview.Results source generator and its code fix, the framework they are built on + (Purview.SourceGeneratorFramework), Roslyn, and the Purview.ZodSharp and Purview.ValueObjects generators a consumer + pulls in alongside them all run inside the compiler. None of them may reach an application: Purview.SourceGeneratorFramework.dll + once leaked into app build and publish output through analyzer-only references, and Blazor WebAssembly AOT + compilation failed on it ("Could not load Microsoft.CodeAnalysis"). + + Every file under each -Path is checked by name, and every dependency manifest (*.deps.json, *.runtimeconfig.json, + blazor.boot.json, dotnet.boot.js, and dotnet.js, which embeds the boot manifest since .NET 10 - including fingerprinted + and Brotli/gzip compressed copies) is checked by content. A file, or a manifest entry, naming any of these fails: + + Purview.SourceGeneratorFramework* + Microsoft.CodeAnalysis* + Purview.Results.SourceGenerator* (the generator and Purview.Results.SourceGenerator.CodeFixes) + Purview.ValueObjects.SourceGenerator* (the generator and Purview.ValueObjects.SourceGenerator.Refactorings) + Purview.ZodSharp.SourceGenerators* + Purview.ZodSharp.CodeFixes* + + A path that does not exist, or that holds no files, fails too: a scan of nothing proves nothing. + + Used by scripts/test-packed-consumers.ps1 on every consumer bin and publish folder. + +.PARAMETER Path + One or more build or publish output directories to scan, recursively. Space-separated when run with -File. + +.PARAMETER Allow + Wildcard patterns (PowerShell -like, matched against each file's path relative to the scanned directory, with + forward slashes) for third-party tools that legitimately carry Roslyn and are never loaded by the application. + Allowed files are counted and reported, never silently ignored. Keep each pattern as narrow as the tool's folder. + +.EXAMPLE + ./scripts/assert-no-compiler-host-assemblies.ps1 -Path artifacts/wasm-trimmed + +.EXAMPLE + pwsh -NoProfile -File scripts/assert-no-compiler-host-assemblies.ps1 -Path artifacts/aot-console bin/Release +#> +[CmdletBinding()] +param ( + [Parameter(Mandatory, Position = 0)] + [string[]] $Path, + + [string[]] $Allow = @(), + + # Collects the rest of `pwsh -File ... -Path a b` (space-separated): -File passes arguments as strings and does + # not parse PowerShell's comma array syntax. + [Parameter(ValueFromRemainingArguments, DontShow)] + [string[]] $AdditionalPath = @() +) + +$ErrorActionPreference = 'Stop' +$Path = @($Path) + @($AdditionalPath) + +# File-name prefixes. Published WebAssembly files carry a fingerprint (Name.abc123.wasm), which a prefix still matches. +$forbiddenNames = @( + 'Purview.SourceGeneratorFramework' + 'Microsoft.CodeAnalysis' + 'Purview.Results.SourceGenerator' + 'Purview.ValueObjects.SourceGenerator' + 'Purview.ZodSharp.SourceGenerators' + 'Purview.ZodSharp.CodeFixes' +) + +$forbiddenContent = [regex]::new( + (($forbiddenNames | ForEach-Object { [regex]::Escape($_) }) -join '|'), + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase +) + +# Dependency manifests, optionally fingerprinted (dotnet.boot.abc123.js) and optionally compressed (.br, .gz). Since .NET 10 +# the WebAssembly boot manifest is embedded in _framework/dotnet.js, so that file is a manifest too; dotnet.native.*.js and +# dotnet.runtime.*.js are the runtime itself and never list assemblies. +$manifestPattern = [regex]::new( + '(\.deps\.json|\.runtimeconfig\.json|^blazor\.boot(\.[a-z0-9]+)?\.json|^dotnet(\.boot)?(\.[a-z0-9]+)?\.js)(\.br|\.gz)?$', + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase +) + +function Read-ManifestText([System.IO.FileInfo] $file) { + $stream = [System.IO.File]::OpenRead($file.FullName) + try { + $source = switch -Regex ($file.Name) { + '\.br$' { [System.IO.Compression.BrotliStream]::new($stream, [System.IO.Compression.CompressionMode]::Decompress) } + '\.gz$' { [System.IO.Compression.GZipStream]::new($stream, [System.IO.Compression.CompressionMode]::Decompress) } + default { $stream } + } + + $reader = [System.IO.StreamReader]::new($source) + try { return $reader.ReadToEnd() } finally { $reader.Dispose() } + } + finally { + $stream.Dispose() + } +} + +function Write-Failure([string] $message) { + if ($env:GITHUB_ACTIONS) { Write-Host "::error::$message" } else { Write-Host "ERROR: $message" -ForegroundColor Red } +} + +$violations = [System.Collections.Generic.List[string]]::new() + +foreach ($directory in $Path) { + if (-not (Test-Path -LiteralPath $directory -PathType Container)) { + $violations.Add("'$directory' does not exist or is not a directory, so it cannot be scanned.") + continue + } + + $root = (Resolve-Path -LiteralPath $directory).Path + $files = @(Get-ChildItem -LiteralPath $root -Recurse -File -Force) + if ($files.Count -eq 0) { + $violations.Add("'$root' holds no files, so the scan would prove nothing.") + continue + } + + $manifests = 0 + $allowed = 0 + foreach ($file in $files) { + $relative = [System.IO.Path]::GetRelativePath($root, $file.FullName) + $forwardSlashed = $relative.Replace('\', '/') + if ($Allow | Where-Object { $forwardSlashed -like $_ }) { + $allowed++ + continue + } + + foreach ($name in $forbiddenNames) { + if ($file.Name.StartsWith($name, [System.StringComparison]::OrdinalIgnoreCase)) { + $violations.Add("compiler-host file '$relative' in '$root'.") + break + } + } + + if ($manifestPattern.IsMatch($file.Name)) { + $manifests++ + $matched = $forbiddenContent.Matches((Read-ManifestText $file)) | + ForEach-Object Value | + Sort-Object -Unique + if ($matched) { + $violations.Add("manifest '$relative' in '$root' names $($matched -join ', ').") + } + } + } + + $allowedNote = $allowed -gt 0 ? (", {0} allowed by -Allow {1}" -f $allowed, ($Allow -join ', ')) : '' + Write-Host ("Scanned {0}: {1} file(s), {2} manifest(s){3}." -f $root, $files.Count, $manifests, $allowedNote) +} + +if ($violations.Count -gt 0) { + foreach ($violation in $violations) { Write-Failure $violation } + Write-Failure ( + "$($violations.Count) compiler-host leak(s) found. Source generators, analyzers and their dependencies run " + + 'inside the compiler and must never reach build or publish output; check analyzer-only references ' + + '(OutputItemType="Analyzer", ReferenceOutputAssembly="false", PrivateAssets="all") and the package layout.' + ) + exit 1 +} + +Write-Host 'No compiler-host assemblies or manifest references found.' -ForegroundColor Green diff --git a/scripts/packed-consumers/AotConsole/AotConsole.csproj b/scripts/packed-consumers/AotConsole/AotConsole.csproj new file mode 100644 index 0000000..913afdc --- /dev/null +++ b/scripts/packed-consumers/AotConsole/AotConsole.csproj @@ -0,0 +1,39 @@ + + + + Exe + net11.0 + preview + PackedResultsAot + PackedResultsAot + enable + enable + true + true + false + true + true + true + + + + + + + + + + + + + diff --git a/scripts/packed-consumers/AotConsole/Model.cs b/scripts/packed-consumers/AotConsole/Model.cs new file mode 100644 index 0000000..6d0ad71 --- /dev/null +++ b/scripts/packed-consumers/AotConsole/Model.cs @@ -0,0 +1,43 @@ +// Declares a C# 15 union, which CSharpier cannot parse yet, so this file is listed in .csharpierignore. +using System.Collections.Immutable; +using System.ComponentModel.DataAnnotations; +using Purview.Results; +using Purview.Results.ZodSharp; +using Purview.ValueObjects.Serialization; +using ZodSharp; +using ZodSharp.Core; +using ZodSharp.Rules; + +namespace PackedResultsAot; + +/// A tenant identifier whose type-level rule validates the value object as a unit. +[Scalar] +[ZodSchema] +[NonSentinel(Code = TenantId.InvalidCode, Message = "A tenant id is required.")] +public readonly partial record struct TenantId +{ + public const string InvalidCode = "invalid_tenant_id"; + + public string Value { get; } +} + +[ZodSchema] +public sealed class RenameTenant +{ + [Required] + [StringLength(40, MinimumLength = 2)] + public string? Name { get; set; } + + [MinCount(1)] + public List Owners { get; set; } = []; +} + +[GenerateResult] +public readonly union TenantError(TenantInputInvalid, TenantNotFound); + +public readonly record struct TenantInputInvalid(ImmutableArray Errors) : IValidationErrorCarrier +{ + ImmutableArray IValidationErrorCarrier.ValidationErrors => Errors; +} + +public readonly record struct TenantNotFound(TenantId TenantId); diff --git a/scripts/packed-consumers/AotConsole/Program.cs b/scripts/packed-consumers/AotConsole/Program.cs new file mode 100644 index 0000000..2deef8f --- /dev/null +++ b/scripts/packed-consumers/AotConsole/Program.cs @@ -0,0 +1,88 @@ +// Packed-consumer gate, consumer (a): a Native AOT console built from the packages alone. Every check records a +// failure rather than throwing, so one run reports every mismatch; any failure makes the process exit non-zero. +using PackedResultsAot; +using Purview.Results; +using Purview.Results.ZodSharp; + +var failures = new List(); +var passed = 0; + +void Check(bool condition, string description) +{ + if (condition) + { + passed++; + Console.WriteLine($" pass {description}"); + } + else + { + failures.Add(description); + Console.WriteLine($" FAIL {description}"); + } +} + +static Result Find(TenantId id) => + id.Value == "acme" ? Result.Success("Acme") : new TenantNotFound(id).AsFailure(); + +Console.WriteLine("native image"); +Check(System.Runtime.JitInfo.GetCompiledMethodCount() == 0, "the process is a Native AOT image (no JIT)"); + +Console.WriteLine("result states and combinators"); +Result uninitialized = default; +Check(!uninitialized.IsInitialized && uninitialized.ToString() == "Uninitialized", "default is Uninitialized"); +var found = Find(TenantId.Create("acme")).Map(static name => name.Length); +Check(found.IsSuccess && found.Value == 4, "Map runs on success"); +var missing = Find(TenantId.Create("nope")).Map(static name => name.Length); +Check( + missing.TryGetError(out var error) && error is TenantNotFound { TenantId.Value: "nope" }, + "the generated AsFailure() builds a failure carrying the union case" +); +Check( + TenantError.Failure(new TenantNotFound(TenantId.Create("x"))).IsFailure, + "the generated union-receiver factory builds a failure" +); +Check(new TenantNotFound(TenantId.Create("x")).AsFailure().IsFailure, "the unit-result AsFailure() helper"); + +Console.WriteLine("ZodSharp validation folded into results"); +var invalid = RenameTenantSchema + .Validate(new RenameTenant { Name = "x" }) + .ToResult(static errors => new TenantInputInvalid(errors)); +Check( + invalid.TryGetError(out var invalidError) + && invalidError is TenantInputInvalid rejected + && rejected.Errors.Select(static e => e.FullPath.ToDottedString()).Order().SequenceEqual(["Name", "Owners"]), + "ToResult maps a rejected input onto the union case with every error" +); + +var valid = await RenameTenantSchema + .ValidateAsync(new RenameTenant { Name = "Acme", Owners = ["ada"] }) + .ToResultAsync(static errors => new TenantInputInvalid(errors)); +Check(valid.IsSuccess && valid.Value.Name == "Acme", "ValidateAsync folds through ToResultAsync"); + +var unit = RenameTenantSchema + .Validate(new RenameTenant { Name = "Acme", Owners = ["ada"] }) + .ToUnitResult(static errors => new TenantInputInvalid(errors)); +Check(unit.IsSuccess && unit.ToString() == "Success", "ToUnitResult discards the value"); + +Console.WriteLine("a value object's type-level rule through a result"); +var tenantId = TenantIdSchema + .Validate(TenantId.Hydrate(" ")) + .ToResult(static errors => new TenantInputInvalid(errors)); +Check( + tenantId.TryGetError(out var tenantIdError) + && tenantIdError is TenantInputInvalid { Errors: [{ Code: TenantId.InvalidCode, Origin: "string" }] }, + "the value object's own code and Purview.ZodSharp 3's structured origin reach the result" +); + +Console.WriteLine(); +if (failures.Count == 0) +{ + Console.WriteLine($"Packed Native AOT consumer passed: {passed} checks."); + return 0; +} + +Console.WriteLine($"Packed Native AOT consumer FAILED: {failures.Count} failure(s), {passed} passed."); +foreach (var failure in failures) + Console.WriteLine($" {failure}"); + +return 1; diff --git a/scripts/packed-consumers/AotWebApi/AotWebApi.csproj b/scripts/packed-consumers/AotWebApi/AotWebApi.csproj new file mode 100644 index 0000000..36faaee --- /dev/null +++ b/scripts/packed-consumers/AotWebApi/AotWebApi.csproj @@ -0,0 +1,48 @@ + + + + net11.0 + preview + PackedResultsAotWeb + PackedResultsAotWeb + enable + enable + true + true + false + true + true + true + + + + + + + + + + + + + + + + diff --git a/scripts/packed-consumers/AotWebApi/Program.cs b/scripts/packed-consumers/AotWebApi/Program.cs new file mode 100644 index 0000000..4957cb0 --- /dev/null +++ b/scripts/packed-consumers/AotWebApi/Program.cs @@ -0,0 +1,143 @@ +// Packed-consumer gate, consumer (e): a Native AOT minimal API built from the packages alone. The gate publishes it, +// starts it, and calls each endpoint; a wrong status, body or content type fails the gate. +using System.Net.Http.Json; +using System.Text; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Mvc; +using PackedResultsAotWeb; +using PackedResultsBlazor; +using Purview.Results; +using Purview.Results.ZodSharp; + +var builder = WebApplication.CreateSlimBuilder(args); + +// Native AOT has no reflection-based JSON metadata, so the request and response types come from the app's own context. +// The mapper writes a success as Ok (or Json for another status) with the value's static type, which +// is what lets this context resolve it; the problem responses resolve from ASP.NET Core's and ZodSharp's own contexts. +builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.TypeInfoResolverChain.Insert(0, PackedResultsAotWebJsonContext.Default) +); + +builder.Services.AddZodSharpPackedResultsAotWebSchemas(); +builder.Services.AddZodSharpProblemDetails(); +builder.Services.AddResultsHttp(options => +{ + // Started twice by the gate: once with the default 200 (TypedResults.Ok) and once with --SuccessStatusCode=201, + // which takes the TypedResults.Json path. Parsed by hand: the configuration binder needs reflection. + if (int.TryParse(builder.Configuration["SuccessStatusCode"], out var successStatusCode)) + options.SuccessStatusCode = successStatusCode; + + options.Map(error => + TypedResults.Problem( + statusCode: StatusCodes.Status409Conflict, + title: $"Tenant '{error.TenantId}' already exists." + ) + ); +}); +builder.Services.AddResultsZodSharpHttp(options => + options.MapCode(TenantRegistration.TenantIdMatchesNameCode, StatusCodes.Status422UnprocessableEntity) +); +builder.Services.AddSingleton(); + +var app = builder.Build(); + +// Every handler returns ToHttpResult(), never the result itself: under Native AOT the request delegate generator +// resolves JSON metadata for a handler's declared return type when the endpoint is built, and a Result<...> whose error +// is a union has none. ResultHttpResult is an IResult, so only the value type (Tenant) needs metadata. +var tenants = app.MapGroup("/api/tenants"); + +// A value result: the validation problem, the code rule's 422, the case mapping's 409, or the tenant. +tenants.MapPost( + "/", + (TenantRegistration registration, TenantRegistrationService service) => + service.Register(registration).ToHttpResult() +); + +// A unit result: a success carries no payload, so it answers 204 No Content. +tenants.MapDelete("/{tenantId}", (string tenantId) => Result.Success().ToHttpResult()); + +// An uninitialized result is a bug in the endpoint: 500 with the results_uninitialized discriminator. +tenants.MapGet("/uninitialized", () => default(Result).ToHttpResult()); + +// The client side, also under Native AOT: Purview.ZodSharp.Http validates a request before sending it and reads the +// server's validation problem back into ZodSharp errors, and both fold into a result through Purview.Results.ZodSharp. +app.MapGet( + "/client-self-test", + // [FromServices]: the request delegate generator otherwise treats an interface parameter as a possible JSON body + // and resolves metadata for it, which Native AOT does not have. + async (HttpContext context, [FromServices] IServiceProvider services) => + { + // The generated validator adapter (an IZodSchemaValidator); AddZodSharp{Assembly}Schemas() registers it in the + // IZodSchemaFactory rather than as a service, so it is created directly. + TenantRegistrationSchemaValidator validator = new(); + using HttpClient client = new() { BaseAddress = new Uri($"{context.Request.Scheme}://{context.Request.Host}") }; + var jsonTypeInfo = PackedResultsAotWebJsonContext.Default.TenantRegistration; + StringBuilder report = new(); + var failed = false; + + void Check(bool condition, string description) + { + failed |= !condition; + report.AppendLine($"{(condition ? "PASS" : "FAIL")} {description}"); + } + + // Rejected locally: nothing is sent, and the errors become the union case. + TenantRegistration invalid = new() + { + TenantId = "Not Valid!", + Name = "x", + OwnerEmail = "nope", + }; + var notSent = await client + .PostAsJsonValidatedAsync("/api/tenants", invalid, validator, jsonTypeInfo, services) + .ToResultAsync(errors => new TenantInputInvalid(errors)); + Check( + notSent.TryGetError(out var localError) && localError is TenantInputInvalid { Errors.Length: 3 }, + "PostAsJsonValidatedAsync folds a locally rejected request into the union case through ToResultAsync" + ); + + // Rejected by the server: the validation problem it writes reads back into the same case. + using var rejected = await client.PostAsJsonAsync("/api/tenants", invalid, jsonTypeInfo); + var problem = await rejected.TryReadZodValidationProblemAsync(); + Result serverVerdict = problem is null + ? Result.Success(new Tenant("", "", "")) + : new TenantInputInvalid(problem.ToValidationErrors()).AsFailure(); + Check( + serverVerdict.TryGetError(out var serverError) + && serverError is TenantInputInvalid { Errors: var serverErrors } + && serverErrors + .Select(static error => error.FullPath.ToDottedString()) + .Order() + .SequenceEqual(["Name", "OwnerEmail", "TenantId"]), + "TryReadZodValidationProblemAsync reads the server's validation problem back into the union case" + ); + + // Accepted: validated locally, sent, and answered with the tenant. + TenantRegistration valid = new() + { + TenantId = "clientco", + Name = "Clientco", + OwnerEmail = "owner@example.com", + }; + var sent = await client + .PostAsJsonValidatedAsync("/api/tenants", valid, validator, jsonTypeInfo, services) + .ToResultAsync(errors => new TenantInputInvalid(errors)); + Check(sent.IsSuccess && sent.Value.IsSuccessStatusCode, "a valid request is sent and accepted"); + if (sent.IsSuccess) + sent.Value.Dispose(); + + return failed + ? Results.Text(report.ToString(), statusCode: StatusCodes.Status500InternalServerError) + : Results.Text(report.ToString()); + } +); + +await app.RunAsync(); + +namespace PackedResultsAotWeb +{ + /// The app's JSON metadata: the request body and the successful value. + [JsonSerializable(typeof(TenantRegistration))] + [JsonSerializable(typeof(Tenant))] + internal sealed partial class PackedResultsAotWebJsonContext : JsonSerializerContext; +} diff --git a/scripts/packed-consumers/BlazorServer/BlazorServer.csproj b/scripts/packed-consumers/BlazorServer/BlazorServer.csproj new file mode 100644 index 0000000..e4f8e37 --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/BlazorServer.csproj @@ -0,0 +1,40 @@ + + + + net11.0 + preview + PackedResultsServer + PackedResultsServer + enable + enable + true + true + + + + + + + + + + + + + + + + diff --git a/scripts/packed-consumers/BlazorServer/Components/App.razor b/scripts/packed-consumers/BlazorServer/Components/App.razor new file mode 100644 index 0000000..a196174 --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/Components/App.razor @@ -0,0 +1,13 @@ + + + + + + Packed Purview.Results Blazor Server consumer + + + + + + + diff --git a/scripts/packed-consumers/BlazorServer/Components/Pages/Interactive.razor b/scripts/packed-consumers/BlazorServer/Components/Pages/Interactive.razor new file mode 100644 index 0000000..2098823 --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/Components/Pages/Interactive.razor @@ -0,0 +1,6 @@ +@page "/interactive" +@rendermode InteractiveServer + +

Register a tenant (interactive server)

+ + diff --git a/scripts/packed-consumers/BlazorServer/Components/Pages/Register.razor b/scripts/packed-consumers/BlazorServer/Components/Pages/Register.razor new file mode 100644 index 0000000..cae9900 --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/Components/Pages/Register.razor @@ -0,0 +1,50 @@ +@page "/register" +@inject TenantRegistrationService Service + +@* Statically rendered: the gate posts this form and reads the response. rejects an invalid form; a + valid one is registered through the result pipeline, whose outcome is rendered. *@ + +

Register a tenant (static server rendering)

+ + + + + + + + + + + + + + + + +@if (_outcome is not null) +{ +

@_outcome

+} + +@code { + [SupplyParameterFromForm] + TenantRegistration? Model { get; set; } + + string? _outcome; + + protected override void OnInitialized() => Model ??= new(); + + void Submit() => + _outcome = Service + .Register(Model!) + .Match( + tenant => $"Registered {tenant.TenantId}.", + error => + error switch + { + TenantAlreadyExists exists => $"Tenant {exists.TenantId} already exists.", + TenantInputInvalid invalid => $"Rejected with {invalid.Errors.Length} error(s).", + _ => "Failed.", + } + ); +} diff --git a/scripts/packed-consumers/BlazorServer/Components/Routes.razor b/scripts/packed-consumers/BlazorServer/Components/Routes.razor new file mode 100644 index 0000000..e5dec77 --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/Components/Routes.razor @@ -0,0 +1,8 @@ + + + + + +

Not found.

+
+
diff --git a/scripts/packed-consumers/BlazorServer/Components/_Imports.razor b/scripts/packed-consumers/BlazorServer/Components/_Imports.razor new file mode 100644 index 0000000..e9c261c --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/Components/_Imports.razor @@ -0,0 +1,8 @@ +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Purview.Results +@using ZodSharp.Blazor +@using PackedResultsBlazor +@using PackedResultsServer.Components diff --git a/scripts/packed-consumers/BlazorServer/Program.cs b/scripts/packed-consumers/BlazorServer/Program.cs new file mode 100644 index 0000000..d757692 --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/Program.cs @@ -0,0 +1,41 @@ +using PackedResultsBlazor; +using PackedResultsServer.Components; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddRazorComponents().AddInteractiveServerComponents(); + +// ZodSharp for Blazor forms and ProblemDetails, then the result-to-HTTP mapping: a case mapping for an existing +// tenant, and a code rule for the refinement. Every other validation failure becomes the standard validation problem. +builder.Services.AddZodSharpPackedResultsServerSchemas(); +builder.Services.AddZodSharpBlazor(); +builder.Services.AddZodSharpProblemDetails(); +builder.Services.AddResultsHttp(options => + options.Map(error => + TypedResults.Problem( + statusCode: StatusCodes.Status409Conflict, + title: $"Tenant '{error.TenantId}' already exists." + ) + ) +); +builder.Services.AddResultsZodSharpHttp(options => + options.MapCode(TenantRegistration.TenantIdMatchesNameCode, StatusCodes.Status422UnprocessableEntity) +); +builder.Services.AddSingleton(); + +var app = builder.Build(); +app.UseAntiforgery(); +app.MapStaticAssets(); + +// The service validates and returns a result, which WithResultsHttp maps. AddZodSharpBlazor validates form models only +// and does not turn on minimal-API parameter validation, so the handler always runs and the result rules above answer: +// the refinement's 422, the existing tenant's 409, and the standard validation problem for everything else. +app.MapPost( + "/api/tenants", + (TenantRegistration registration, TenantRegistrationService service) => service.Register(registration) + ) + .WithResultsHttp() + .DisableAntiforgery(); + +app.MapRazorComponents().AddInteractiveServerRenderMode(); + +await app.RunAsync(); diff --git a/scripts/packed-consumers/BlazorWasm/App.razor b/scripts/packed-consumers/BlazorWasm/App.razor new file mode 100644 index 0000000..abdf5c3 --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/App.razor @@ -0,0 +1,8 @@ + + + + + +

Not found.

+
+
diff --git a/scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj b/scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj new file mode 100644 index 0000000..9fb7fa6 --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj @@ -0,0 +1,41 @@ + + + + net11.0 + preview + PackedResultsWasm + PackedResultsWasm + enable + enable + true + false + true + true + true + + + + + + + + + + + + + + diff --git a/scripts/packed-consumers/BlazorWasm/Pages/Index.razor b/scripts/packed-consumers/BlazorWasm/Pages/Index.razor new file mode 100644 index 0000000..000642c --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/Pages/Index.razor @@ -0,0 +1,5 @@ +@page "/" + +

Register a tenant

+ + diff --git a/scripts/packed-consumers/BlazorWasm/Program.cs b/scripts/packed-consumers/BlazorWasm/Program.cs new file mode 100644 index 0000000..ff5cfe1 --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/Program.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using PackedResultsWasm; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +// The generated, trim-safe registration of this assembly's [ZodSchema] types, then ZodSharp for Blazor forms. +builder.Services.AddZodSharpPackedResultsWasmSchemas(); +builder.Services.AddZodSharpBlazor(); + +await builder.Build().RunAsync(); diff --git a/scripts/packed-consumers/BlazorWasm/_Imports.razor b/scripts/packed-consumers/BlazorWasm/_Imports.razor new file mode 100644 index 0000000..0d598c7 --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/_Imports.razor @@ -0,0 +1,6 @@ +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using ZodSharp.Blazor +@using PackedResultsBlazor +@using PackedResultsWasm diff --git a/scripts/packed-consumers/BlazorWasm/wwwroot/index.html b/scripts/packed-consumers/BlazorWasm/wwwroot/index.html new file mode 100644 index 0000000..3e23599 --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/wwwroot/index.html @@ -0,0 +1,13 @@ + + + + + + Packed Purview.Results Blazor consumer + + + +
Loading...
+ + + diff --git a/scripts/packed-consumers/Net10Console/Net10Console.csproj b/scripts/packed-consumers/Net10Console/Net10Console.csproj new file mode 100644 index 0000000..9a48f06 --- /dev/null +++ b/scripts/packed-consumers/Net10Console/Net10Console.csproj @@ -0,0 +1,30 @@ + + + + Exe + net10.0 + PackedResultsNet10 + PackedResultsNet10 + enable + enable + true + true + + + + + + + + + + + + diff --git a/scripts/packed-consumers/Net10Console/Program.cs b/scripts/packed-consumers/Net10Console/Program.cs new file mode 100644 index 0000000..525b5f5 --- /dev/null +++ b/scripts/packed-consumers/Net10Console/Program.cs @@ -0,0 +1,44 @@ +// Packed-consumer gate, consumer (d): the net10.0 target of the packages, without unions. +using System.Collections.Immutable; +using System.ComponentModel.DataAnnotations; +using Purview.Results; +using Purview.Results.ZodSharp; +using ZodSharp; +using ZodSharp.Core; + +var failures = 0; +void Check(bool condition, string description) +{ + Console.WriteLine($" {(condition ? "pass" : "FAIL")} {description}"); + if (!condition) + failures++; +} + +Console.WriteLine($"net10.0 consumer on {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}"); +Check(Environment.Version.Major == 10, "running on the .NET 10 runtime"); + +var rejected = InviteSchema + .Validate(new Invite { Email = "nope" }) + .ToResult(static errors => new InviteRejected(errors)); +Check( + rejected.TryGetError(out var error) && error.Errors.Single().FullPath.ToDottedString() == "Email", + "ToResult folds a rejected input into the error type" +); + +var accepted = InviteSchema + .Validate(new Invite { Email = "ada@example.com" }) + .ToResult(static errors => new InviteRejected(errors)) + .Map(static invite => invite.Email!.Length); +Check(accepted.IsSuccess && accepted.Value == 15, "and a valid one flows through Map"); + +return failures == 0 ? 0 : 1; + +[ZodSchema] +sealed class Invite +{ + [Required] + [EmailAddress] + public string? Email { get; set; } +} + +readonly record struct InviteRejected(ImmutableArray Errors); diff --git a/scripts/packed-consumers/Shared/RegistrationSelfTest.razor b/scripts/packed-consumers/Shared/RegistrationSelfTest.razor new file mode 100644 index 0000000..3100b06 --- /dev/null +++ b/scripts/packed-consumers/Shared/RegistrationSelfTest.razor @@ -0,0 +1,119 @@ +@* Shared by the Blazor consumers (copied in by scripts/test-packed-consumers.ps1). A form over the + registration model, and a self-test that runs once the component is interactive: it validates the form, then sends + the same input through the result pipeline - ToResult, Bind, the generated AsFailure helpers and the union-receiver + factory - and reports in #self-test. The gate loads the page in a headless browser and reads that element. *@ +@namespace PackedResultsBlazor +@using System.Text +@using Microsoft.AspNetCore.Components.Forms +@using Purview.Results +@using ZodSharp.Blazor + + + + + + + + + + + + + + + + + + + + +
@_report
+ +@code { + readonly TenantRegistration _model = new(); + EditContext _context = default!; + string _result = "running"; + string _report = string.Empty; + + protected override void OnInitialized() => _context = new(_model); + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + return; + + StringBuilder report = new(); + var failures = 0; + void Check(bool condition, string description) + { + report.AppendLine($"{(condition ? "pass" : "FAIL")} {description}"); + if (!condition) + failures++; + } + + string[] Messages(string field) => [.. _context.GetValidationMessages(new FieldIdentifier(_model, field))]; + + try + { + // The form: every rule placed on its field. + _model.TenantId = "Not Valid!"; + _model.Name = "x"; + _model.OwnerEmail = "not-an-email"; + var invalid = await _context.ValidateAsync(); + foreach (var message in _context.GetValidationMessages()) + report.AppendLine($" * {message}"); + Check(!invalid, " rejects an invalid registration"); + Check(Messages("TenantId").Length == 1, "the tenant id pattern is placed on TenantId"); + Check(Messages("Name").Length == 1, "the name length is placed on Name"); + Check(Messages("OwnerEmail").Length == 1, "the email rule is placed on OwnerEmail"); + + _model.TenantId = "same-name"; + _model.Name = "same-name"; + _model.OwnerEmail = "owner@example.com"; + var refined = await _context.ValidateAsync(); + Check(!refined && Messages("TenantId").Length == 1, "the OnZodValidate refinement is placed on TenantId"); + + // The result pipeline over the same input. + TenantRegistrationService service = new(); + var rejected = service.Register(_model); + Check( + rejected.TryGetError(out var error) + && error is TenantInputInvalid { Errors: [{ Code: TenantRegistration.TenantIdMatchesNameCode }] }, + "a rejected input is a TenantInputInvalid failure carrying the refinement's code" + ); + + _model.TenantId = TenantRegistrationService.ExistingTenantId; + _model.Name = "Acme Again"; + var duplicate = await service.RegisterAsync(_model); + Check( + duplicate.TryGetError(out var duplicateError) && duplicateError is TenantAlreadyExists, + "an existing tenant is a TenantAlreadyExists failure, through ValidateAsync and ToResultAsync" + ); + + _model.TenantId = "newco"; + _model.Name = "Newco"; + var valid = await _context.ValidateAsync(); + var registered = service.Register(_model); + Check(valid && _context.GetValidationMessages().Count() == 0, "a valid registration passes the form"); + Check( + registered.Match(tenant => tenant.TenantId == "newco", _ => false), + "and registers through the result pipeline" + ); + + var viaFactory = TenantError.Failure(new TenantAlreadyExists("x")); + Check( + viaFactory.IsFailure && viaFactory.ToString().StartsWith("Failure(", StringComparison.Ordinal), + "the generated union-receiver factory builds a failure" + ); + } + catch (Exception ex) + { + failures++; + report.AppendLine($"FAIL the self-test threw {ex.GetType().Name}: {ex.Message}"); + } + + _result = failures == 0 ? "passed" : "failed"; + _report = report.ToString(); + StateHasChanged(); + } +} diff --git a/scripts/packed-consumers/Shared/TenantError.cs b/scripts/packed-consumers/Shared/TenantError.cs new file mode 100644 index 0000000..1d426be --- /dev/null +++ b/scripts/packed-consumers/Shared/TenantError.cs @@ -0,0 +1,21 @@ +// Shared by the Blazor consumers (copied in by scripts/test-packed-consumers.ps1). Declares a C# 15 union, which +// CSharpier cannot parse yet, so this file is listed in .csharpierignore. +using System.Collections.Immutable; +using Purview.Results; +using Purview.Results.ZodSharp; +using ZodSharp.Core; + +namespace PackedResultsBlazor; + +/// The expected failures of a registration; the packed generator emits the result helpers for it. +[GenerateResult] +public readonly union TenantError(TenantInputInvalid, TenantAlreadyExists); + +/// The input did not satisfy its schema. +public readonly record struct TenantInputInvalid(ImmutableArray Errors) : IValidationErrorCarrier +{ + ImmutableArray IValidationErrorCarrier.ValidationErrors => Errors; +} + +/// A tenant with the requested id already exists. +public readonly record struct TenantAlreadyExists(string TenantId); diff --git a/scripts/packed-consumers/Shared/TenantRegistration.cs b/scripts/packed-consumers/Shared/TenantRegistration.cs new file mode 100644 index 0000000..6022f97 --- /dev/null +++ b/scripts/packed-consumers/Shared/TenantRegistration.cs @@ -0,0 +1,41 @@ +// Shared by the Blazor consumers: scripts/test-packed-consumers.ps1 copies this folder into BlazorWasm and +// BlazorServer before building them. +using System.ComponentModel.DataAnnotations; +using ZodSharp; +using ZodSharp.Schemas; + +namespace PackedResultsBlazor; + +/// The form model: ZodSharp validates it, and a rejection becomes a result failure. +[ZodSchema] +public sealed partial class TenantRegistration +{ + public const string TenantIdMatchesNameCode = "tenant_id_matches_name"; + + [Required] + [RegularExpression("^[a-z0-9-]{3,20}$")] + public string? TenantId { get; set; } + + [Required] + [StringLength(40, MinimumLength = 2)] + public string? Name { get; set; } + + [Required] + [EmailAddress] + public string? OwnerEmail { get; set; } + + partial void OnZodValidate(RefineCtx context) + { + if (context.Value.TenantId is { } tenantId && tenantId == context.Value.Name) + { + context.AddIssue( + TenantIdMatchesNameCode, + "The tenant id must differ from the display name.", + [nameof(TenantId)] + ); + } + } +} + +/// A registered tenant. +public sealed record Tenant(string TenantId, string Name, string OwnerEmail); diff --git a/scripts/packed-consumers/Shared/TenantRegistrationService.cs b/scripts/packed-consumers/Shared/TenantRegistrationService.cs new file mode 100644 index 0000000..ceed81f --- /dev/null +++ b/scripts/packed-consumers/Shared/TenantRegistrationService.cs @@ -0,0 +1,44 @@ +// Shared by the Blazor consumers (copied in by scripts/test-packed-consumers.ps1). +using Purview.Results; +using Purview.Results.ZodSharp; + +namespace PackedResultsBlazor; + +/// Registers tenants: a rejected input and an existing tenant are values, not exceptions. +public sealed class TenantRegistrationService +{ + public const string ExistingTenantId = "acme"; + + readonly HashSet _tenants = new(StringComparer.Ordinal) { ExistingTenantId }; + + /// Validates with the generated schema, then registers - through ToResult and Bind. + public Result Register(TenantRegistration registration) => + TenantRegistrationSchema + .Validate(registration) + .ToResult(errors => new TenantInputInvalid(errors)) + .Bind(Store); + + /// The asynchronous form: ValidateAsync folded through ToResultAsync. + public async ValueTask> RegisterAsync( + TenantRegistration registration, + CancellationToken cancellationToken = default + ) + { + var validated = await TenantRegistrationSchema + .ValidateAsync(registration, services: null, cancellationToken) + .ToResultAsync(errors => new TenantInputInvalid(errors)); + + return validated.Bind(Store); + } + + Result Store(TenantRegistration registration) + { + // The generated per-case helper: the case does not name the union, so AsFailure() builds the result. + if (!_tenants.Add(registration.TenantId!)) + return new TenantAlreadyExists(registration.TenantId!).AsFailure(); + + return Result.Success( + new Tenant(registration.TenantId!, registration.Name!, registration.OwnerEmail!) + ); + } +} diff --git a/scripts/packed-consumers/WasmHost/Program.cs b/scripts/packed-consumers/WasmHost/Program.cs new file mode 100644 index 0000000..fbf5fd5 --- /dev/null +++ b/scripts/packed-consumers/WasmHost/Program.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.FileProviders; + +// Serves a published Blazor WebAssembly wwwroot folder: the files as they are, index.html for any other path. +if (args.Length != 1 || !Directory.Exists(args[0])) +{ + Console.Error.WriteLine("Usage: PackedWasmHost "); + return 2; +} + +var root = Path.GetFullPath(args[0]); +var builder = WebApplication.CreateSlimBuilder(); +var app = builder.Build(); + +PhysicalFileProvider files = new(root); +FileExtensionContentTypeProvider contentTypes = new(); +contentTypes.Mappings[".dat"] = "application/octet-stream"; +contentTypes.Mappings[".wasm"] = "application/wasm"; +contentTypes.Mappings[".blat"] = "application/octet-stream"; + +app.UseDefaultFiles(new DefaultFilesOptions { FileProvider = files }); +app.UseStaticFiles( + new StaticFileOptions + { + FileProvider = files, + ContentTypeProvider = contentTypes, + ServeUnknownFileTypes = true, + } +); +app.MapFallback(async context => +{ + context.Response.ContentType = "text/html"; + await context.Response.SendFileAsync(Path.Combine(root, "index.html")); +}); + +await app.RunAsync(); +return 0; diff --git a/scripts/packed-consumers/WasmHost/WasmHost.csproj b/scripts/packed-consumers/WasmHost/WasmHost.csproj new file mode 100644 index 0000000..09c72a7 --- /dev/null +++ b/scripts/packed-consumers/WasmHost/WasmHost.csproj @@ -0,0 +1,14 @@ + + + + net11.0 + PackedWasmHost + enable + enable + true + + diff --git a/scripts/restore-dotnet-tools.ps1 b/scripts/restore-dotnet-tools.ps1 new file mode 100644 index 0000000..5bbc78e --- /dev/null +++ b/scripts/restore-dotnet-tools.ps1 @@ -0,0 +1,79 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Restores the repository's .NET local tools (.config/dotnet-tools.json), retrying a failed attempt. + +.DESCRIPTION + `dotnet tool restore` can fail on a cold package cache, exit 1, and still leave every tool installed. The + .NET 11 SDK (rc.3) mis-attributes the command of a RID-specific tool to the entry beside it in the manifest: + dotnet-inspect 0.26.0 declares DotNetCliTool version 2 with RuntimeIdentifierPackages, and the purview.build + entry after it is then rejected with + + The command "purview-build" specified in the tool manifest file is not contained in the package with + Package Id purview.build. The commands contained in the package are dotnet-inspect. + + although Purview.Build 0.8.1 declares and restores correctly on its own. The + failed attempt has downloaded and extracted the packages by then, so the next attempt succeeds - the bug is + an ordering/state artefact of a cold cache, not a wrong manifest. + + Retrying is bounded and never hides a genuine failure: every attempt must fail before this script fails, and a + failed attempt's output is printed when none succeeds. It is quiet on success, because + scripts/test-pack-validation-rules.ps1 restores once per validation run. This is the only place the repository + restores its local tools, so `just` and CI behave the same way. + +.PARAMETER Attempts + Total attempts, including the first. Defaults to 3. + +.PARAMETER DelaySeconds + Seconds to wait between attempts. Defaults to 2. + +.PARAMETER ToolManifest + Manifest to restore. Defaults to the repository's .config/dotnet-tools.json. A caller that made its own copy - + scripts/test-pack-validation-rules.ps1 builds a scratch repository - passes that copy instead. + +.EXAMPLE + ./scripts/restore-dotnet-tools.ps1 + +.EXAMPLE + ./scripts/restore-dotnet-tools.ps1 -Attempts 5 -DelaySeconds 10 +#> +[CmdletBinding()] +param ( + [ValidateRange(1, 10)] + [int] $Attempts = 3, + [ValidateRange(0, 60)] + [int] $DelaySeconds = 2, + [string] $ToolManifest +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$manifest = $ToolManifest ? $ToolManifest : (Join-Path $repositoryRoot '.config/dotnet-tools.json') +if (-not (Test-Path $manifest)) { throw "No local tool manifest at '$manifest'." } + +# Each failed attempt's output is buffered, not streamed: the retry is expected once on a cold cache, and a caller +# such as scripts/test-pack-validation-rules.ps1 restores 27 times. A genuine failure still prints every attempt. +$failures = [System.Collections.Generic.List[string]]::new() +for ($attempt = 1; $attempt -le $Attempts; $attempt++) { + $output = & dotnet tool restore --tool-manifest $manifest 2>&1 | ForEach-Object { "$_" } + if ($LASTEXITCODE -eq 0) { + # A retried attempt leaves the failed attempt's 1 in $LASTEXITCODE; a caller run as a CI step exits with it. + $global:LASTEXITCODE = 0 + Write-Host 'Local tools restored.' -ForegroundColor Green + return + } + + $failures.Add(($output -join "`n")) + if ($attempt -lt $Attempts) { + Write-Host ( + "dotnet tool restore failed (attempt $attempt of $Attempts); retrying in $DelaySeconds s. " + + "The attempt still populates the package cache, which is what the next one needs - see this script's help." + ) -ForegroundColor Yellow + Start-Sleep -Seconds $DelaySeconds + } +} + +$failures | ForEach-Object { Write-Host $_ } +throw "dotnet tool restore failed after $Attempts attempt(s)." diff --git a/scripts/test-pack-validation-rules.ps1 b/scripts/test-pack-validation-rules.ps1 new file mode 100644 index 0000000..ec0a8de --- /dev/null +++ b/scripts/test-pack-validation-rules.ps1 @@ -0,0 +1,186 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Proves every PackValidation ForbiddenContent rule in .config/purview-build.json actually fails a package. + (Copied from purview-dev/zodsharp; only the core package id differs.) + +.DESCRIPTION + A forbidden-content glob that matches nothing - a typo, a wrong folder, a "*" where "**" was meant - passes + silently forever. This script proves each rule bites, with the real tool (purview-build, the version + .config/dotnet-tools.json pins) and the real rules. Each run validates copies of the packed packages in a scratch + repository that holds the repository's purview-build.json verbatim and an empty solution, so the pipeline's build is + a no-op and only pack validation does any work. RequireExplicitContent is turned off in every run, so the + explicit-content rule cannot be what fails one: only ForbiddenContent can. + + 1. Control: every package, untouched. Must pass, or the harness itself is broken. + 2. Per package: each package on its own, with one file per rule injected - a concrete path the rule's glob should + match (lib/**/Microsoft.CodeAnalysis* becomes lib/probe/Microsoft.CodeAnalysis.dll). Must fail, reporting every + rule. This proves the rules apply to every package id. + 3. Per rule: Purview.Results with only that rule's file injected. Must fail, reporting that rule. This proves each + rule is triggered by its own file rather than by another rule's. + + The packages in -PackagesFolder are never modified. + +.PARAMETER PackagesFolder + Folder holding the packed .nupkg and .snupkg files of the current version. Defaults to artifacts/, where + `just pipeline-pack-validate` and `just pack` write them. `just pipeline-local-release` moves them to the local feed, + so pass that folder after it. + +.PARAMETER KeepArtifacts + Leave the scratch repositories in place for inspection. +#> +[CmdletBinding()] +param ( + [string] $PackagesFolder = (Join-Path (Split-Path -Parent $PSScriptRoot) 'artifacts'), + [switch] $KeepArtifacts +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +Add-Type -AssemblyName System.IO.Compression.FileSystem + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$configPath = Join-Path $repositoryRoot '.config/purview-build.json' +$version = (Get-Content (Join-Path $repositoryRoot 'package.json') -Raw | ConvertFrom-Json).version +$config = Get-Content $configPath -Raw | ConvertFrom-Json + +$rules = @($config.PackValidation.ForbiddenContent.PSObject.Properties['*'].Value) +if ($rules.Count -eq 0) { throw "No ForbiddenContent rules for '*' in $configPath." } + +$packages = @(Get-ChildItem $PackagesFolder -Filter "*.$version.nupkg" -File | Sort-Object Name) +if ($packages.Count -eq 0) { + throw ( + "No $version packages in '$PackagesFolder'. Pack first (just pipeline-pack-validate), or pass -PackagesFolder: " + + 'just pipeline-local-release moves its packages to the local feed.' + ) +} + +# A concrete entry each glob must match: "**" spans one made-up folder, "*" becomes an assembly extension. +$cases = foreach ($rule in $rules) { + [pscustomobject]@{ Rule = $rule; Entry = ($rule -replace '\*\*', 'probe' -replace '\*', '.dll') } +} + +$scratch = Join-Path ([System.IO.Path]::GetTempPath()) "rs-pack-rules-$([guid]::NewGuid().ToString('N').Substring(0, 8))" +$runs = 0 + +# Validates $nupkgs (and their .snupkg) with $entries injected into each, and returns the rules the tool reported. +function Invoke-Validation([string] $name, [System.IO.FileInfo[]] $nupkgs, [string[]] $entries) { + $root = Join-Path $scratch $name + $artifacts = Join-Path $root 'artifacts' + New-Item -ItemType Directory -Path (Join-Path $root '.config'), $artifacts -Force | Out-Null + Copy-Item $configPath (Join-Path $root '.config/purview-build.json') + Copy-Item (Join-Path $repositoryRoot '.config/dotnet-tools.json') (Join-Path $root '.config/dotnet-tools.json') + Copy-Item (Join-Path $repositoryRoot 'global.json'), (Join-Path $repositoryRoot 'package.json') $root + '' | Set-Content (Join-Path $root 'empty.slnx') -Encoding utf8 + & git -C $root init --quiet + + foreach ($nupkg in $nupkgs) { + $copy = Join-Path $artifacts $nupkg.Name + Copy-Item $nupkg.FullName $copy + $symbols = [System.IO.Path]::ChangeExtension($nupkg.FullName, '.snupkg') + if (Test-Path $symbols) { Copy-Item $symbols $artifacts } + + if ($entries) { + $archive = [System.IO.Compression.ZipFile]::Open($copy, [System.IO.Compression.ZipArchiveMode]::Update) + try { + foreach ($entry in $entries) { + $writer = [System.IO.StreamWriter]::new($archive.CreateEntry($entry).Open()) + try { $writer.Write('injected by scripts/test-pack-validation-rules.ps1') } finally { $writer.Dispose() } + } + } + finally { $archive.Dispose() } + } + } + + Push-Location $root + try { + # The scratch repository holds a copy of the manifest; the retry is scripts/restore-dotnet-tools.ps1's, so a + # cold package cache behaves the same here as in `just` and CI. + & (Join-Path $PSScriptRoot 'restore-dotnet-tools.ps1') -ToolManifest (Join-Path $root '.config/dotnet-tools.json') + $output = & dotnet purview-build ` + --Build:Solution=empty.slnx ` + --Build:ArtifactsFolder=artifacts ` + --Build:CleanArtifacts=false ` + --Build:RunPack=false ` + --Build:ValidatePack=true ` + --Build:RunTests=false ` + --Build:RunLint=false ` + --PackValidation:RequireExplicitContent=false ` + --Release:Mode=None 2>&1 | ForEach-Object { "$_" } + $exitCode = $LASTEXITCODE + } + finally { + Pop-Location + } + + $script:runs++ + # The console wraps long lines, so rejoin them before reading "Forbidden content '' must not be in ...". + $text = ($output -join "`n") -replace '\r?\n\s*', ' ' + $reported = @([regex]::Matches($text, "Forbidden content '(?[^']+)'") | ForEach-Object { $_.Groups['rule'].Value } | Sort-Object -Unique) + return [pscustomobject]@{ ExitCode = $exitCode; Reported = $reported; Output = ($output -join "`n") } +} + +$failures = [System.Collections.Generic.List[string]]::new() +$stopwatch = [System.Diagnostics.Stopwatch]::StartNew() +try { + Write-Host "Control: $($packages.Count) untouched package(s)..." -ForegroundColor Cyan + $control = Invoke-Validation 'control' $packages @() + if ($control.ExitCode -ne 0) { + Write-Host $control.Output + throw 'The control run failed on untouched packages, so the harness cannot prove anything. Fix that first.' + } + Write-Host ' passed' + + Write-Host "Per package: each with all $($cases.Count) rule file(s) injected..." -ForegroundColor Cyan + foreach ($package in $packages) { + $result = Invoke-Validation "package-$($package.BaseName)" @($package) @($cases.Entry) + $missed = @($cases.Rule | Where-Object { $result.Reported -notcontains $_ }) + if ($result.ExitCode -eq 0 -or $missed.Count -gt 0) { + $failures.Add("$($package.Name): exit $($result.ExitCode); rules not reported: $($missed -join ', ')") + Write-Host " NOT REJECTED $($package.Name) ($($missed.Count) rule(s) missed)" -ForegroundColor Red + } + else { + Write-Host " rejected $($package.Name) ($($result.Reported.Count) of $($cases.Count) rules)" + } + } + + $core = $packages | Where-Object { $_.Name -ieq "Purview.Results.$version.nupkg" } + if (-not $core) { throw "Purview.Results.$version.nupkg is not in '$PackagesFolder'." } + + Write-Host "Per rule: $($core.Name) with one rule's file injected..." -ForegroundColor Cyan + $index = 0 + foreach ($case in $cases) { + $index++ + $result = Invoke-Validation "rule-$index" @($core) @($case.Entry) + $others = @($result.Reported | Where-Object { $_ -ne $case.Rule }) + if ($result.ExitCode -eq 0 -or $result.Reported -notcontains $case.Rule) { + $failures.Add("$($case.Rule): injecting '$($case.Entry)' was not rejected by it (exit $($result.ExitCode)).") + Write-Host (" NOT REJECTED {0,-55} {1}" -f $case.Entry, $case.Rule) -ForegroundColor Red + } + else { + $also = $others.Count -gt 0 ? " (also matched: $($others -join ', '))" : '' + Write-Host (" rejected {0,-55} {1}{2}" -f $case.Entry, $case.Rule, $also) + } + } +} +catch { + $failures.Add($_.Exception.Message) +} +finally { + if ($KeepArtifacts) { Write-Host "Scratch repositories kept at $scratch" -ForegroundColor Yellow } + else { Remove-Item $scratch -Recurse -Force -ErrorAction SilentlyContinue } +} + +Write-Host ("{0} validation run(s) in {1:N1}s." -f $runs, $stopwatch.Elapsed.TotalSeconds) +if ($failures.Count -gt 0) { + foreach ($failure in $failures) { + if ($env:GITHUB_ACTIONS) { Write-Host "::error::$failure" } else { Write-Host "ERROR: $failure" -ForegroundColor Red } + } + exit 1 +} + +Write-Host "Every ForbiddenContent rule ($($cases.Count)) rejected its file, in every package ($($packages.Count))." -ForegroundColor Green + +# The runs validate by expecting purview-build to fail, so $LASTEXITCODE is 1 here; GitHub's pwsh step appends +# `exit $LASTEXITCODE`, which would fail the step despite every rule passing. +exit 0 diff --git a/scripts/test-packed-consumers.ps1 b/scripts/test-packed-consumers.ps1 new file mode 100644 index 0000000..74ed487 --- /dev/null +++ b/scripts/test-packed-consumers.ps1 @@ -0,0 +1,879 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Proves the Purview.Results packages work for real consumers - a Native AOT console and web API, Blazor WebAssembly + (trimmed and AOT) and Blazor Server - built from the .nupkg files alone, alongside the Purview.ZodSharp and + Purview.ValueObjects packages they integrate with. + +.DESCRIPTION + Modelled on purview-dev/zodsharp's scripts/test-packed-consumers.ps1. Every in-repository build references the + generator and the libraries as ProjectReferences, which exercises none of what ships: the packed libraries, the + merged generator and code fix under analyzers/, the buildTransitive imports, and the generated union helpers + compiled into somebody else's trimmed or AOT application. That gap is how Purview.SourceGeneratorFramework.dll + leaked into application output (and broke Blazor WebAssembly AOT) while every gate stayed green. + + This gate consumes the packages exactly as a user does: + + 1. Preflight: checks the build-time Purview.SourceGeneratorFramework and the Purview.ZodSharp and + Purview.ValueObjects packages the packages and consumers use can be restored, and fails early, with what to do, + if they cannot. + 2. Packs every shipping package (the ids in .config/purview-build.json PackValidation.RequiredContent) to an + isolated feed, with an isolated NUGET_PACKAGES, so no previously extracted package of the same version can be + reused. Package validation against the API baseline runs as part of the pack. The feed must hold exactly the + expected packages, and no package may carry a compiler-host assembly outside analyzers/. + 3. Copies the consumers in scripts/packed-consumers/ outside the repository (the Blazor ones with the shared + files in scripts/packed-consumers/Shared), beside guard Directory.Build.props/.targets and + Directory.Packages.props files that stop any inheritance, with a nuget.config that resolves Purview.Results* + from the isolated feed only: + (a) AotConsole - a Native AOT console over a [GenerateResult] union, the generated helpers, the ZodSharp bridge + and a value object's type-level rule. Published with -warnaserror, then run; it exits non-zero on any + mismatch. + (b) BlazorWasm - a Blazor WebAssembly app with a form whose input then flows through the result + pipeline. Published trimmed with -warnaserror, then with RunAOTCompilation=true. Each publish is served and + loaded in a headless Chromium-family browser, where the page runs its checks and reports them in the DOM. + (c) BlazorServer - a Blazor Web App over every package. Published and started: a minimal API maps the result + onto HTTP (validation problem, code rule, case mapping, success), a statically rendered form is posted, and + an interactive-server page is prerendered and then run over a live circuit in the browser. + (d) Net10Console - a net10.0 console over the result type and the ZodSharp bridge, built and run: the packages + still serve both target frameworks they ship. + (e) AotWebApi - a Native AOT minimal API over every package (CreateSlimBuilder, the app's own + JsonSerializerContext, handlers returning result.ToHttpResult()). Published with -warnaserror, then started + twice (SuccessStatusCode 200 and 201) and called: validation problem, code rule, case mapping, success, unit + 204 and the uninitialized 500; then a client self-test calls the API through Purview.ZodSharp.Http and + folds the local rejection, the server's validation problem and the success into results. + 4. Scans every consumer bin and publish folder with scripts/assert-no-compiler-host-assemblies.ps1. + + Every check is a hard failure, including the ones that exercise Purview.ZodSharp behaviour the consumers rely on. + + The temporary workspace is deleted afterwards unless -KeepArtifacts is passed. Packing restores the shipping + projects against the isolated cache, so they are restored again against the normal cache at the end. + +.PARAMETER Configuration + Configuration the packages are packed in. Defaults to Release: this gate checks what ships. + +.PARAMETER RuntimeIdentifier + RID for the Native AOT console. Native AOT cannot cross-compile between operating systems, so it defaults to this + machine's RID (win-x64, linux-x64, osx-arm64, ...). + +.PARAMETER DependencyFeed + A NuGet feed (folder or URL) to restore Purview.SourceGeneratorFramework*, Purview.ZodSharp* and + Purview.ValueObjects from, in addition to nuget.org - for versions that are not on nuget.org yet. Used for those + packages only. Defaults to $env:RESULTS_DEPENDENCY_FEED, then $env:LOCAL_NUGET_FEED_PATH. + +.PARAMETER WasmAot + Auto (default) runs the WebAssembly AOT publish when the wasm-tools workload is installed and says so when it is + skipped. Require fails when the workload is missing (CI installs it). Skip never runs it. + +.PARAMETER Browser + Auto (default) loads the published Blazor apps in a headless Chromium-family browser (Edge, Chrome or Chromium; + PACKED_CONSUMER_BROWSER names one explicitly) when one is found, and says so when it is not. Require fails when + none is found. Skip never runs a browser. + +.PARAMETER PackOutput + Also copy the packed .nupkg/.snupkg files here, so a later step can inspect exactly what this gate consumed. + +.PARAMETER PreflightOnly + Run only the dependency restore check, so CI can fail on it before installing toolchains. + +.PARAMETER KeepArtifacts + Leave the temporary workspace in place for inspection. + +.EXAMPLE + just smoke-packed-consumers + +.EXAMPLE + ./scripts/test-packed-consumers.ps1 -RuntimeIdentifier linux-x64 -WasmAot Require -Browser Require -KeepArtifacts +#> +[CmdletBinding()] +param ( + [string] $Configuration = 'Release', + [string] $RuntimeIdentifier = [System.Runtime.InteropServices.RuntimeInformation]::RuntimeIdentifier, + [string] $DependencyFeed = ($env:RESULTS_DEPENDENCY_FEED ? $env:RESULTS_DEPENDENCY_FEED : $env:LOCAL_NUGET_FEED_PATH), + [ValidateSet('Auto', 'Require', 'Skip')] + [string] $WasmAot = 'Auto', + [ValidateSet('Auto', 'Require', 'Skip')] + [string] $Browser = 'Auto', + [string] $PackOutput, + [switch] $PreflightOnly, + [switch] $KeepArtifacts +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$leakScan = Join-Path $PSScriptRoot 'assert-no-compiler-host-assemblies.ps1' +$consumerTemplates = Join-Path $PSScriptRoot 'packed-consumers' + +$version = (Get-Content (Join-Path $repositoryRoot 'package.json') -Raw | ConvertFrom-Json).version +$packageVersions = [xml] (Get-Content (Join-Path $repositoryRoot 'Directory.Packages.props') -Raw) +$sgfVersion = $packageVersions.SelectSingleNode('//PurviewSourceGeneratorFrameworkVersion')?.InnerText +$zodSharpVersion = $packageVersions.SelectSingleNode('//PurviewZodSharpVersion')?.InnerText +$sdk = (Get-Content (Join-Path $repositoryRoot 'global.json') -Raw | ConvertFrom-Json).sdk + +if (-not $sgfVersion) { throw 'Could not read PurviewSourceGeneratorFrameworkVersion from Directory.Packages.props.' } +$valueObjectsVersion = $packageVersions.SelectSingleNode('//PurviewValueObjectsVersion')?.InnerText +if (-not $valueObjectsVersion) { throw 'Could not read PurviewValueObjectsVersion from Directory.Packages.props.' } +if (-not $zodSharpVersion) { throw 'Could not read PurviewZodSharpVersion from Directory.Packages.props.' } + +# The shipping packages are exactly the ids pack validation knows about, so a new package is covered without editing +# this script. Each id maps to src/src/ (Purview.Results itself to src/src/Results). +$packValidation = (Get-Content (Join-Path $repositoryRoot '.config/purview-build.json') -Raw | ConvertFrom-Json).PackValidation +$sourceProjects = Get-ChildItem (Join-Path $repositoryRoot 'src/src') -Directory +$shipping = foreach ($id in $packValidation.RequiredContent.PSObject.Properties.Name) { + $folder = $id -ieq 'purview.results' ? 'Results' : ($id -ireplace '^purview\.results\.', '') + $directory = $sourceProjects | Where-Object { $_.Name -ieq $folder } + $project = $directory ? (Join-Path $directory.FullName "$($directory.Name).csproj") : $null + if (-not $project -or -not (Test-Path $project)) { + throw "No project for the shipping package '$id' (expected src/src/$folder/$folder.csproj)." + } + + [pscustomobject]@{ Id = $id; Project = $project } +} + +# Purview.Results packs the merged generator and the code fix through MSBuild tasks rather than ProjectReferences, +# so restoring Results.csproj does not restore them (NETSDK1004 otherwise). +$generatorProjects = @( + (Join-Path $repositoryRoot 'src/src/SourceGenerator/SourceGenerator.csproj') + (Join-Path $repositoryRoot 'src/src/SourceGenerator.CodeFixes/SourceGenerator.CodeFixes.csproj') +) + +$workspace = Join-Path ([System.IO.Path]::GetTempPath()) "rs-packed-consumers-$([guid]::NewGuid().ToString('N').Substring(0, 8))" +$feed = Join-Path $workspace 'feed' +$packages = Join-Path $workspace 'packages' +$logs = Join-Path $workspace 'logs' +$consumers = Join-Path $workspace 'consumers' +$output = Join-Path $workspace 'out' +$exe = $IsWindows ? '.exe' : '' + +$timings = [System.Collections.Generic.List[object]]::new() +$scanned = [System.Collections.Generic.List[string]]::new() +$previousNuGetPackages = $env:NUGET_PACKAGES +$repositoryRestoreTouched = $false + +function Write-Failure([string] $message) { + if ($env:GITHUB_ACTIONS) { Write-Host "::error::$message" } else { Write-Host "ERROR: $message" -ForegroundColor Red } +} + +function Invoke-Step([string] $name, [scriptblock] $body) { + if ($env:GITHUB_ACTIONS) { Write-Host "::group::$name" } else { Write-Host "`n=== $name" -ForegroundColor Cyan } + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + $status = 'passed' + try { + & $body + } + catch { + $status = 'FAILED' + throw + } + finally { + $stopwatch.Stop() + $timings.Add([pscustomobject]@{ Step = $name; Result = $status; Seconds = [math]::Round($stopwatch.Elapsed.TotalSeconds, 1) }) + if ($env:GITHUB_ACTIONS) { Write-Host '::endgroup::' } + } +} + +function Invoke-Skipped([string] $name, [string] $reason) { + Write-Host "`n=== $name - SKIPPED: $reason" -ForegroundColor Yellow + if ($env:GITHUB_ACTIONS) { Write-Host "::warning::$name skipped: $reason" } + $timings.Add([pscustomobject]@{ Step = $name; Result = 'skipped'; Seconds = 0 }) +} + +# Runs dotnet, tees its output to a log, and fails on a non-zero exit. -warnaserror already fails the publish on any +# trim or AOT warning; the log check is a second gate in case one is ever logged without failing the command. +function Invoke-Dotnet([string] $logName, [string[]] $arguments) { + $log = Join-Path $logs "$logName.log" + Write-Host "dotnet $($arguments -join ' ')" -ForegroundColor DarkGray + & dotnet @arguments 2>&1 | Tee-Object -FilePath $log | Out-Host + if ($LASTEXITCODE -ne 0) { throw "dotnet $($arguments[0]) failed with exit code $LASTEXITCODE (log: $log)." } + + $warnings = Select-String -Path $log -Pattern 'warning IL[23][0-9]{3}' | Select-Object -First 5 + if ($warnings) { + throw "dotnet $($arguments[0]) reported trim or AOT warnings:`n$($warnings.Line -join "`n")" + } +} + +function Assert-NoLeaks([string[]] $directories) { + $global:LASTEXITCODE = 0 + & $leakScan -Path $directories + if ($LASTEXITCODE -ne 0) { throw "The compiler-host leak scan failed for: $($directories -join ', ')." } + $scanned.AddRange($directories) +} + +function Assert-Contains([string] $directory, [string] $pattern, [string] $what) { + $found = @(Get-ChildItem $directory -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -match $pattern }) + if ($found.Count -eq 0) { throw "$what is missing from '$directory' (no file matches '$pattern')." } + Write-Host " found $what ($($found[0].Name))" +} + +function New-NuGetConfig([string] $path, [hashtable[]] $sources) { + $sourceXml = ($sources | ForEach-Object { "`t`t" }) -join "`n" + $mappingXml = ($sources | ForEach-Object { + $patterns = ($_.Patterns | ForEach-Object { "`t`t`t" }) -join "`n" + "`t`t`n$patterns`n`t`t" + }) -join "`n" + + @" + + + + + +$sourceXml + + + + + +$mappingXml + + +"@ | Set-Content $path -Encoding utf8 +} + +function Start-App([string] $executable, [string[]] $arguments, [string] $directory, [int] $port, [string] $logName) { + $env:ASPNETCORE_URLS = "http://127.0.0.1:$port" + try { + $parameters = @{ + FilePath = $executable + WorkingDirectory = $directory + PassThru = $true + RedirectStandardOutput = (Join-Path $logs "$logName.stdout.log") + RedirectStandardError = (Join-Path $logs "$logName.stderr.log") + } + if ($arguments) { $parameters.ArgumentList = $arguments } + return Start-Process @parameters + } + finally { + $env:ASPNETCORE_URLS = $null + } +} + +function Stop-App($process) { + if ($process -and -not $process.HasExited) { $process.Kill($true) } + if ($process) { $process.WaitForExit() } +} + +function Wait-Http($process, [string] $url) { + $deadline = [DateTime]::UtcNow.AddSeconds(30) + while ([DateTime]::UtcNow -lt $deadline) { + if ($process.HasExited) { throw "The app exited with $($process.ExitCode) before $url answered." } + try { return Invoke-WebRequest $url -TimeoutSec 5 -SkipHttpErrorCheck } + catch { Start-Sleep -Milliseconds 500 } + } + throw "$url did not answer within 30 seconds." +} + +function Get-FreePort { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + $listener.Start() + try { return ([System.Net.IPEndPoint] $listener.LocalEndpoint).Port } finally { $listener.Stop() } +} + +function Find-Browser { + if ($env:PACKED_CONSUMER_BROWSER) { + if (Test-Path $env:PACKED_CONSUMER_BROWSER) { return $env:PACKED_CONSUMER_BROWSER } + throw "PACKED_CONSUMER_BROWSER is set to '$($env:PACKED_CONSUMER_BROWSER)', which does not exist." + } + + $candidates = @( + "${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe" + "$env:ProgramFiles\Microsoft\Edge\Application\msedge.exe" + "$env:ProgramFiles\Google\Chrome\Application\chrome.exe" + "${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe" + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge' + ) + foreach ($candidate in $candidates) { + if ($candidate -and (Test-Path $candidate)) { return $candidate } + } + + foreach ($command in 'google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'microsoft-edge') { + $found = Get-Command $command -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($found) { return $found.Source } + } + + return $null +} + +# Loads $url in a headless browser and polls the page's #self-test element over the Chrome DevTools Protocol until it +# reports passed or failed. The page runs its own checks once it is interactive (WebAssembly started, or the server +# circuit connected), so this proves the app actually runs - not only that it published. +function Invoke-BrowserSelfTest([string] $url, [string] $name) { + $profile = Join-Path $workspace "browser-$([guid]::NewGuid().ToString('N').Substring(0, 8))" + New-Item -ItemType Directory -Path $profile -Force | Out-Null + $arguments = @( + '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', '--disable-extensions', + '--remote-debugging-port=0', "--user-data-dir=$profile", 'about:blank' + ) + if ($IsLinux) { $arguments = @('--no-sandbox') + $arguments } + + $process = Start-Process -FilePath $script:browserPath -ArgumentList $arguments -PassThru ` + -RedirectStandardOutput (Join-Path $logs "$name.browser.stdout.log") ` + -RedirectStandardError (Join-Path $logs "$name.browser.stderr.log") + $socket = $null + try { + $portFile = Join-Path $profile 'DevToolsActivePort' + $deadline = [DateTime]::UtcNow.AddSeconds(30) + while (-not (Test-Path $portFile) -and [DateTime]::UtcNow -lt $deadline) { Start-Sleep -Milliseconds 200 } + if (-not (Test-Path $portFile)) { throw "The browser did not open a DevTools port within 30 seconds." } + $port = (Get-Content $portFile -TotalCount 1).Trim() + + $target = Invoke-RestMethod -Method Put "http://127.0.0.1:$port/json/new?$([uri]::EscapeDataString($url))" + $socket = [System.Net.WebSockets.ClientWebSocket]::new() + [void] $socket.ConnectAsync([uri] $target.webSocketDebuggerUrl, [Threading.CancellationToken]::None).GetAwaiter().GetResult() + + $requestId = 0 + $expression = "(() => { const e = document.getElementById('self-test'); " + + "return e ? JSON.stringify({ result: e.dataset.result, report: e.textContent }) : null; })()" + $state = $null + $deadline = [DateTime]::UtcNow.AddSeconds(90) + while ([DateTime]::UtcNow -lt $deadline) { + $requestId++ + $payload = @{ id = $requestId; method = 'Runtime.evaluate'; params = @{ expression = $expression; returnByValue = $true } } | + ConvertTo-Json -Depth 5 -Compress + $bytes = [Text.Encoding]::UTF8.GetBytes($payload) + [void] $socket.SendAsync([ArraySegment[byte]]::new($bytes), 'Text', $true, [Threading.CancellationToken]::None).GetAwaiter().GetResult() + + $response = $null + while (-not $response) { + $buffer = [byte[]]::new(1MB) + $builder = [Text.StringBuilder]::new() + do { + $received = $socket.ReceiveAsync([ArraySegment[byte]]::new($buffer), [Threading.CancellationToken]::None).GetAwaiter().GetResult() + [void] $builder.Append([Text.Encoding]::UTF8.GetString($buffer, 0, $received.Count)) + } while (-not $received.EndOfMessage) + $message = $builder.ToString() | ConvertFrom-Json + if ($message.PSObject.Properties['id'] -and $message.id -eq $requestId) { $response = $message } + } + + $value = $response.PSObject.Properties['result'] ? $response.result.result.PSObject.Properties['value']?.Value : $null + if ($value) { + $state = $value | ConvertFrom-Json + if ($state.result -ne 'running') { break } + } + Start-Sleep -Milliseconds 500 + } + + if (-not $state) { throw "$url never rendered #self-test within 90 seconds: the app did not start in the browser." } + $state.report | Set-Content (Join-Path $logs "$name.self-test.log") + $state.report -split "\r?\n" | Where-Object { $_.Trim() } | ForEach-Object { Write-Host " $_" } + if ($state.result -eq 'running') { throw "$url did not finish its self-test within 90 seconds." } + + if ($state.result -ne 'passed') { + $failed = ($state.report -split "\r?\n" | Where-Object { $_ -match '^FAIL ' }) -join "`n" + throw "The self-test at $url failed:`n$failed" + } + } + finally { + if ($socket) { $socket.Dispose() } + if (-not $process.HasExited) { $process.Kill($true) } + $process.WaitForExit() + } +} + +$exitCode = 0 +$totalStopwatch = [System.Diagnostics.Stopwatch]::StartNew() + +try { + New-Item -ItemType Directory -Path $feed, $packages, $logs, $consumers, $output -Force | Out-Null + + Write-Host "Packed-consumer gate: Purview.Results $version ($Configuration), RID $RuntimeIdentifier" -ForegroundColor Cyan + Write-Host "Purview.ZodSharp $zodSharpVersion, Purview.ValueObjects $valueObjectsVersion, Purview.SourceGeneratorFramework $sgfVersion" + Write-Host "Workspace: $workspace" + + # Guards at the workspace root: MSBuild stops at the first Directory.Build.props/.targets and + # Directory.Packages.props it finds walking up, so nothing above the workspace - a stray file in the temp folder, + # a user profile - can change how the consumers build. The consumers must see the packages and nothing else. + foreach ($guard in 'Directory.Build.props', 'Directory.Build.targets') { + @" + + + +"@ | Set-Content (Join-Path $workspace $guard) -Encoding utf8 + } + + @' + + + + false + + +'@ | Set-Content (Join-Path $workspace 'Directory.Packages.props') -Encoding utf8 + + # The repository's SDK, without its MSBuild SDKs (Purview.BuildSdk): the consumers are plain projects. + [ordered]@{ sdk = [ordered]@{ version = $sdk.version; rollForward = $sdk.rollForward; allowPrerelease = $true } } | + ConvertTo-Json | Set-Content (Join-Path $workspace 'global.json') -Encoding utf8 + + # The dependency feed serves the build-time framework, ZodSharp and ValueObjects only; everything else comes from nuget.org. + $sgfPatterns = @('Purview.SourceGeneratorFramework', 'Purview.SourceGeneratorFramework.*') + $zodSharpPatterns = @('Purview.ZodSharp', 'Purview.ZodSharp.*', 'Purview.ValueObjects') + $dependencySources = $DependencyFeed ? @(@{ Key = 'dependencies'; Value = $DependencyFeed; Patterns = $sgfPatterns + $zodSharpPatterns }) : @() + $nugetOrg = @{ Key = 'nuget.org'; Value = 'https://api.nuget.org/v3/index.json'; Patterns = @('*') + $sgfPatterns + $zodSharpPatterns } + + $packConfig = Join-Path $workspace 'pack.nuget.config' + New-NuGetConfig $packConfig (@($nugetOrg) + $dependencySources) + + # Consumer sources: Purview.Results* from the packages just packed and nowhere else - not nuget.org, not a local + # feed holding an older build of the same version. + $consumerConfig = Join-Path $consumers 'nuget.config' + New-NuGetConfig $consumerConfig (@( + @{ Key = 'packed'; Value = $feed; Patterns = @('Purview.Results', 'Purview.Results.*') } + $nugetOrg + ) + $dependencySources) + + # An isolated extraction directory: NuGet reuses an already-extracted package of the same id and version, so a + # stale copy from an earlier run would be used instead. + $env:NUGET_PACKAGES = $packages + + Invoke-Step "Preflight: restore Purview.SourceGeneratorFramework $sgfVersion, Purview.ZodSharp $zodSharpVersion and Purview.ValueObjects $valueObjectsVersion" { + $probe = Join-Path $workspace 'dependency-probe' + New-Item -ItemType Directory -Path $probe -Force | Out-Null + @" + + + net10.0 + + + + + + + + + + +"@ | Set-Content (Join-Path $probe 'probe.csproj') -Encoding utf8 + + & dotnet restore (Join-Path $probe 'probe.csproj') "-p:RestoreConfigFile=$packConfig" 2>&1 | + Tee-Object -FilePath (Join-Path $logs 'dependency-probe.log') | Out-Host + if ($LASTEXITCODE -ne 0) { + $from = ((@($nugetOrg) + $dependencySources) | ForEach-Object Value) -join ', ' + throw ( + "Purview.SourceGeneratorFramework $sgfVersion, Purview.ZodSharp $zodSharpVersion or Purview.ValueObjects $valueObjectsVersion (Directory.Packages.props) " + + "could not be restored from $from. The generator is built against the first and the packages and consumers use the " + + 'others, so nothing can be checked. If a version is not on nuget.org yet, publish it there, or point this ' + + 'gate at a feed that has it: -DependencyFeed , or set RESULTS_DEPENDENCY_FEED ' + + '(LOCAL_NUGET_FEED_PATH is used when set).' + ) + } + + # The Blazor consumers use the ASP.NET Core version Purview.ZodSharp.Blazor was built against. + $blazorNuspec = Join-Path $packages "purview.zodsharp.blazor/$($zodSharpVersion.ToLowerInvariant())/purview.zodsharp.blazor.nuspec" + $nuspec = [xml] (Get-Content $blazorNuspec -Raw) + $namespace = [System.Xml.XmlNamespaceManager]::new($nuspec.NameTable) + $namespace.AddNamespace('n', $nuspec.DocumentElement.NamespaceURI) + $script:aspNetCoreVersion = $nuspec.SelectSingleNode( + "//n:dependency[@id='Microsoft.AspNetCore.Components.Web']", $namespace + )?.GetAttribute('version') -replace '^\[|[,\]].*$', '' + if (-not $script:aspNetCoreVersion) { throw "Could not read the ASP.NET Core version from $blazorNuspec." } + Write-Host " ASP.NET Core $($script:aspNetCoreVersion) (from Purview.ZodSharp.Blazor)" + } + + if ($PreflightOnly) { + Write-Host 'The dependencies restore; stopping after the preflight (-PreflightOnly).' -ForegroundColor Green + return + } + + if ($IsLinux -and -not (Get-Command clang -ErrorAction SilentlyContinue)) { + throw 'Native AOT on Linux needs clang and the zlib headers: sudo apt-get install -y clang zlib1g-dev.' + } + + Invoke-Step 'Pack every shipping package to the isolated feed' { + $script:repositoryRestoreTouched = $true + foreach ($component in $generatorProjects) { + Invoke-Dotnet "restore-$([System.IO.Path]::GetFileNameWithoutExtension($component))" @( + 'restore', $component, "-p:RestoreConfigFile=$packConfig", '-nr:false' + ) + } + foreach ($package in $shipping) { + Invoke-Dotnet "restore-$($package.Id)" @('restore', $package.Project, "-p:RestoreConfigFile=$packConfig", '-nr:false') + Invoke-Dotnet "pack-$($package.Id)" @( + 'pack', $package.Project, '--configuration', $Configuration, '--output', $feed, '--no-restore', '-m:4', '-nr:false' + ) + } + + $expected = @($shipping.Id | ForEach-Object { "$_.$version.nupkg".ToLowerInvariant() } | Sort-Object) + $actual = @(Get-ChildItem $feed -Filter '*.nupkg' | ForEach-Object { $_.Name.ToLowerInvariant() } | Sort-Object) + $difference = Compare-Object $expected $actual + if ($difference) { throw "The feed does not hold exactly the shipping packages:`n$($difference | Out-String)" } + + # The bytes the consumers restore: a compiler-host assembly anywhere but analyzers/, or a generator or code-fix + # assembly under lib/, would reach every application that references the package. + Add-Type -AssemblyName System.IO.Compression.FileSystem + $hostOnly = '(^|/)(Purview\.SourceGeneratorFramework|Microsoft\.CodeAnalysis)[^/]*$' + $generatorOnly = '^(lib|ref|runtimes)/.*Purview\.Results\.SourceGenerator[^/]*$' + foreach ($nupkg in Get-ChildItem $feed -Filter '*.nupkg') { + $archive = [System.IO.Compression.ZipFile]::OpenRead($nupkg.FullName) + try { + $bad = @($archive.Entries.FullName | Where-Object { + ($_ -match $hostOnly -and $_ -notmatch '^analyzers/') -or $_ -match $generatorOnly + }) + } + finally { $archive.Dispose() } + + if ($bad.Count -gt 0) { throw "$($nupkg.Name) ships compiler-host content outside analyzers/: $($bad -join ', ')." } + Write-Host " $($nupkg.Name): no compiler-host content outside analyzers/" + } + + if ($PackOutput) { + New-Item -ItemType Directory -Path $PackOutput -Force | Out-Null + Copy-Item (Join-Path $feed '*') $PackOutput -Force + Write-Host " copied the packages to $PackOutput" + } + } + + Copy-Item (Join-Path $consumerTemplates '*') $consumers -Recurse -Force -Exclude 'Shared' + foreach ($blazor in 'BlazorWasm', 'BlazorServer') { + Copy-Item (Join-Path $consumerTemplates 'Shared/*') (Join-Path $consumers $blazor) -Force + } + # The Native AOT web API shares the model, the union and the service, but not the Razor self-test. + Copy-Item (Join-Path $consumerTemplates 'Shared/*.cs') (Join-Path $consumers 'AotWebApi') -Force + + $consumerProperties = @( + "-p:ResultsPackageVersion=$version" + "-p:ValueObjectsPackageVersion=$valueObjectsVersion" + "-p:ZodSharpPackageVersion=$zodSharpVersion" + "-p:AspNetCorePackageVersion=$($script:aspNetCoreVersion)" + "-p:RestoreConfigFile=$consumerConfig" + '-nr:false' + ) + + Invoke-Step '(a) Native AOT console: publish and run' { + $project = Join-Path $consumers 'AotConsole/AotConsole.csproj' + $publish = Join-Path $output 'aot-console' + + # A framework-dependent build first: its bin holds the deps.json and runtimeconfig.json that list every + # runtime dependency, which is where an analyzer-only assembly that leaks shows up first. + Invoke-Dotnet 'aot-console-build' (@('build', $project, '--configuration', 'Release', '-warnaserror') + $consumerProperties) + Assert-Contains (Join-Path $consumers 'AotConsole/bin/Release/net11.0') '^PackedResultsAot\.deps\.json$' 'the build deps.json' + + Invoke-Dotnet 'aot-console-publish' (@( + 'publish', $project, '--configuration', 'Release', '--runtime', $RuntimeIdentifier, '--output', $publish, + '-warnaserror' + ) + $consumerProperties) + + & (Join-Path $publish "PackedResultsAot$exe") | Out-Host + if ($LASTEXITCODE -ne 0) { throw "The Native AOT console exited with ${LASTEXITCODE}: a packed behaviour is wrong." } + + Assert-NoLeaks @((Join-Path $consumers 'AotConsole/bin'), $publish) + } + + $script:browserPath = $null + if ($Browser -ne 'Skip') { + $script:browserPath = Find-Browser + if (-not $script:browserPath -and $Browser -eq 'Require') { + throw 'No Chromium-family browser was found (-Browser Require): install Chrome or Edge, or set PACKED_CONSUMER_BROWSER.' + } + } + $browserSkipReason = 'disabled with -Browser Skip.' + if ($Browser -ne 'Skip') { + $browserSkipReason = 'no Chromium-family browser was found, so the published app was NOT run. Install Chrome or ' + + 'Edge, set PACKED_CONSUMER_BROWSER, or pass -Browser Require to make this an error.' + } + if ($script:browserPath) { Write-Host "`nBrowser: $($script:browserPath)" } + + # A static file host for the published WebAssembly apps; it references no package. + $wasmHost = Join-Path $output 'wasm-host' + if ($script:browserPath) { + Invoke-Step 'WebAssembly static file host: publish' { + Invoke-Dotnet 'wasm-host-publish' @( + 'publish', (Join-Path $consumers 'WasmHost/WasmHost.csproj'), '--configuration', 'Release', '--output', $wasmHost, + "-p:RestoreConfigFile=$consumerConfig", '-nr:false' + ) + } + } + + function Assert-WasmOutput([string] $publish) { + $framework = Join-Path $publish 'wwwroot/_framework' + Assert-Contains $framework '^Purview\.Results\.[a-z0-9]+\.wasm$' 'Purview.Results' + Assert-Contains $framework '^Purview\.Results\.ZodSharp\.[a-z0-9]+\.wasm$' 'Purview.Results.ZodSharp' + Assert-Contains $framework '^Purview\.ZodSharp\.[a-z0-9]+\.wasm$' 'Purview.ZodSharp' + Assert-Contains $framework '^Purview\.ZodSharp\.Blazor\.[a-z0-9]+\.wasm$' 'Purview.ZodSharp.Blazor' + Assert-Contains $framework '^PackedResultsWasm\.[a-z0-9]+\.wasm$' 'the app assembly' + } + + function Test-WasmInBrowser([string] $publish, [string] $name) { + if (-not $script:browserPath) { + Write-Host " browser self-test SKIPPED: $browserSkipReason" -ForegroundColor Yellow + return + } + + $port = Get-FreePort + $process = Start-App (Join-Path $wasmHost "PackedWasmHost$exe") @("`"$(Join-Path $publish 'wwwroot')`"") $wasmHost $port $name + try { + $index = Wait-Http $process "http://127.0.0.1:$port/" + if ($index.StatusCode -ne 200) { throw "The WebAssembly host answered $($index.StatusCode) for /." } + Invoke-BrowserSelfTest "http://127.0.0.1:$port/" $name + } + finally { Stop-App $process } + } + + $wasmProject = Join-Path $consumers 'BlazorWasm/BlazorWasm.csproj' + Invoke-Step '(b) Blazor WebAssembly: trimmed publish, run in a browser' { + $publish = Join-Path $output 'wasm-trimmed' + Invoke-Dotnet 'wasm-trimmed-publish' (@( + 'publish', $wasmProject, '--configuration', 'Release', '--output', $publish, '-warnaserror' + ) + $consumerProperties) + + Assert-WasmOutput $publish + Assert-NoLeaks @((Join-Path $consumers 'BlazorWasm/bin'), $publish) + Test-WasmInBrowser $publish 'wasm-trimmed' + } + + $wasmTools = $false + if ($WasmAot -ne 'Skip') { + Push-Location $workspace + try { $wasmTools = [bool] (& dotnet workload list 2>$null | Select-String -Pattern '^\s*wasm-tools\s') } + finally { Pop-Location } + } + + if ($WasmAot -eq 'Skip') { + Invoke-Skipped '(b) Blazor WebAssembly: RunAOTCompilation publish' 'disabled with -WasmAot Skip.' + } + elseif (-not $wasmTools -and $WasmAot -eq 'Auto') { + Invoke-Skipped '(b) Blazor WebAssembly: RunAOTCompilation publish' ( + 'the wasm-tools workload is not installed, so the WebAssembly AOT compile was NOT checked. ' + + 'Install it with `dotnet workload install wasm-tools` and rerun, or pass -WasmAot Require to make this an error.' + ) + } + else { + Invoke-Step '(b) Blazor WebAssembly: RunAOTCompilation publish, run in a browser' { + if (-not $wasmTools) { + throw 'The wasm-tools workload is not installed (-WasmAot Require): run `dotnet workload install wasm-tools`.' + } + + # A copy of the project, so the AOT build starts from clean obj/bin rather than the trimmed build's. + $directory = Join-Path $consumers 'BlazorWasmAot' + Copy-Item (Join-Path $consumers 'BlazorWasm') $directory -Recurse -Force -Exclude 'bin', 'obj' + $publish = Join-Path $output 'wasm-aot' + Invoke-Dotnet 'wasm-aot-publish' (@( + 'publish', (Join-Path $directory 'BlazorWasm.csproj'), '--configuration', 'Release', + '--output', $publish, '-p:RunAOTCompilation=true', '-warnaserror' + ) + $consumerProperties) + + Assert-WasmOutput $publish + Assert-Contains (Join-Path $publish 'wwwroot/_framework') '^dotnet\.native\.[a-z0-9]+\.wasm$' 'the AOT-compiled runtime' + Assert-NoLeaks @((Join-Path $directory 'bin'), $publish) + Test-WasmInBrowser $publish 'wasm-aot' + } + } + + Invoke-Step '(c) Blazor Server: publish, start, call the API, post the static form, run the interactive page' { + $project = Join-Path $consumers 'BlazorServer/BlazorServer.csproj' + $publish = Join-Path $output 'blazor-server' + Invoke-Dotnet 'blazor-server-publish' (@( + 'publish', $project, '--configuration', 'Release', '--output', $publish, '-warnaserror' + ) + $consumerProperties) + + $port = Get-FreePort + $base = "http://127.0.0.1:$port" + $process = Start-App (Join-Path $publish "PackedResultsServer$exe") @() $publish $port 'blazor-server' + try { + $page = Wait-Http $process "$base/register" + if ($page.StatusCode -ne 200) { throw "/register answered $($page.StatusCode)." } + + # The minimal API: the result of TenantRegistrationService.Register mapped onto HTTP by WithResultsHttp(). + function Send-Api([string] $json) { + $response = Invoke-WebRequest "$base/api/tenants" -Method Post -ContentType 'application/json' -Body $json -SkipHttpErrorCheck + $body = $response.Content -is [byte[]] ? [System.Text.Encoding]::UTF8.GetString($response.Content) : [string] $response.Content + $contentType = [string] ($response.Headers['Content-Type'] -join ',') + Write-Host " POST /api/tenants $json -> $($response.StatusCode) $contentType" + return [pscustomobject]@{ Status = [int] $response.StatusCode; Body = $body; ContentType = $contentType } + } + + $invalid = Send-Api '{"tenantId":"Not Valid!","name":"x","ownerEmail":"nope"}' + Write-Host " $($invalid.Body)" + if ($invalid.Status -ne 400 -or -not $invalid.ContentType.StartsWith('application/problem+json')) { + throw "An invalid registration answered $($invalid.Status) $($invalid.ContentType), not 400 application/problem+json." + } + $paths = @(($invalid.Body | ConvertFrom-Json).issues | ForEach-Object { $_.path }) + foreach ($expectedPath in 'TenantId', 'Name', 'OwnerEmail') { + if ($paths -notcontains $expectedPath) { + throw "The validation problem's issues do not include '$expectedPath' (got: $($paths -join ', '))." + } + } + + # The endpoint does not opt out of anything: AddZodSharpBlazor() validates form models only, so the handler + # runs and the result's code rule answers. Minimal-API parameter validation answering first would be a 400. + $refined = Send-Api '{"tenantId":"same-name","name":"same-name","ownerEmail":"owner@example.com"}' + if ($refined.Status -ne 422 -or -not $refined.Body.Contains('tenant_id_matches_name')) { + throw "The refinement's code rule answered $($refined.Status), not 422 with tenant_id_matches_name." + } + + $duplicate = Send-Api '{"tenantId":"acme","name":"Acme","ownerEmail":"owner@example.com"}' + if ($duplicate.Status -ne 409) { throw "An existing tenant answered $($duplicate.Status), not the case mapping's 409." } + + $created = Send-Api '{"tenantId":"newco","name":"Newco","ownerEmail":"owner@example.com"}' + if ($created.Status -ne 200 -or ($created.Body | ConvertFrom-Json).tenantId -ne 'newco') { + throw "A valid registration answered $($created.Status) $($created.Body), not 200 with the tenant." + } + + # The statically rendered form. + $session = [Microsoft.PowerShell.Commands.WebRequestSession]::new() + $page = Invoke-WebRequest "$base/register" -WebSession $session + $token = [regex]::Match($page.Content, 'name="__RequestVerificationToken" value="([^"]+)"').Groups[1].Value + if (-not $token) { throw 'The statically rendered form carries no antiforgery token.' } + + function Send-Form([hashtable] $fields) { + $body = @{ '__RequestVerificationToken' = $token; '_handler' = 'register' } + foreach ($key in $fields.Keys) { $body["Model.$key"] = $fields[$key] } + $response = Invoke-WebRequest "$base/register" -Method Post -Body $body -WebSession $session -SkipHttpErrorCheck + if ($response.StatusCode -ne 200) { throw "Posting the form answered $($response.StatusCode)." } + return [System.Net.WebUtility]::HtmlDecode($response.Content) + } + + $invalidForm = Send-Form @{ TenantId = 'Not Valid!'; Name = 'x'; OwnerEmail = 'nope' } + $messages = @([regex]::Matches($invalidForm, '
]*>(.*?)
') | ForEach-Object { $_.Groups[1].Value -replace '<[^>]+>', ' ' }) + $messages | ForEach-Object { Write-Host " invalid form: $_" } + if ($messages.Count -lt 3) { throw "The invalid form shows $($messages.Count) message(s), not one per field." } + if ($invalidForm.Contains('id="outcome"')) { throw 'The invalid form was submitted.' } + + $existingForm = Send-Form @{ TenantId = 'acme'; Name = 'Acme'; OwnerEmail = 'owner@example.com' } + $outcome = [regex]::Match($existingForm, '

]*>(.*?)

').Groups[1].Value + Write-Host " existing tenant form: $outcome" + if ($outcome -ne 'Tenant acme already exists.') { throw "The form did not render the TenantAlreadyExists failure (got '$outcome')." } + + $validForm = Send-Form @{ TenantId = 'formco'; Name = 'Formco'; OwnerEmail = 'owner@example.com' } + $outcome = [regex]::Match($validForm, '

]*>(.*?)

').Groups[1].Value + Write-Host " valid form: $outcome" + if ($outcome -ne 'Registered formco.') { throw "The valid form did not register the tenant (got '$outcome')." } + + $interactive = Invoke-WebRequest "$base/interactive" + if ($interactive.StatusCode -ne 200 -or -not $interactive.Content.Contains('id="owner-email"')) { + throw 'The interactive page did not prerender its form.' + } + Write-Host ' /interactive prerendered its form' + + if ($script:browserPath) { Invoke-BrowserSelfTest "$base/interactive" 'blazor-server' } + else { Write-Host " browser self-test SKIPPED: $browserSkipReason" -ForegroundColor Yellow } + } + finally { Stop-App $process } + + Assert-NoLeaks @((Join-Path $consumers 'BlazorServer/bin'), $publish) + } + + Invoke-Step '(d) net10.0 console: build and run' { + $project = Join-Path $consumers 'Net10Console/Net10Console.csproj' + Invoke-Dotnet 'net10-console-build' (@('build', $project, '--configuration', 'Release', '-warnaserror') + $consumerProperties) + $bin = Join-Path $consumers 'Net10Console/bin/Release/net10.0' + + $net10 = [bool] (& dotnet --list-runtimes 2>$null | Select-String -Pattern '^Microsoft\.NETCore\.App 10\.') + if ($net10) { + & dotnet (Join-Path $bin 'PackedResultsNet10.dll') | Out-Host + if ($LASTEXITCODE -ne 0) { throw "The net10.0 console exited with ${LASTEXITCODE}." } + } + else { + Write-Host ' the .NET 10 runtime is not installed: built for net10.0 but NOT run.' -ForegroundColor Yellow + if ($env:GITHUB_ACTIONS) { Write-Host '::warning::The net10.0 consumer was built but not run: no .NET 10 runtime.' } + } + + Assert-NoLeaks @((Join-Path $consumers 'Net10Console/bin')) + } + + Invoke-Step '(e) Native AOT web API: publish, start, call every endpoint' { + $project = Join-Path $consumers 'AotWebApi/AotWebApi.csproj' + $publish = Join-Path $output 'aot-web-api' + Invoke-Dotnet 'aot-web-api-publish' (@( + 'publish', $project, '--configuration', 'Release', '--runtime', $RuntimeIdentifier, '--output', $publish, + '-warnaserror' + ) + $consumerProperties) + + function Send-Request([string] $base, [string] $method, [string] $path, [string] $json) { + $parameters = @{ Uri = "$base$path"; Method = $method; SkipHttpErrorCheck = $true } + if ($json) { $parameters.ContentType = 'application/json'; $parameters.Body = $json } + $response = Invoke-WebRequest @parameters + $body = $response.Content -is [byte[]] ? [System.Text.Encoding]::UTF8.GetString($response.Content) : [string] $response.Content + $contentType = [string] ($response.Headers['Content-Type'] -join ',') + Write-Host " $method $path $json -> $($response.StatusCode) $contentType" + return [pscustomobject]@{ Status = [int] $response.StatusCode; Body = $body; ContentType = $contentType } + } + + # Started twice: the default SuccessStatusCode writes Ok, and 201 takes the Json path. + foreach ($successStatus in 200, 201) { + $port = Get-FreePort + $base = "http://127.0.0.1:$port" + $process = Start-App (Join-Path $publish "PackedResultsAotWeb$exe") @("--SuccessStatusCode=$successStatus") $publish $port "aot-web-api-$successStatus" + try { + [void] (Wait-Http $process "$base/api/tenants/uninitialized") + + $invalid = Send-Request $base 'POST' '/api/tenants' '{"tenantId":"Not Valid!","name":"x","ownerEmail":"nope"}' + if ($invalid.Status -ne 400 -or -not $invalid.ContentType.StartsWith('application/problem+json')) { + throw "An invalid registration answered $($invalid.Status) $($invalid.ContentType), not 400 application/problem+json." + } + $paths = @(($invalid.Body | ConvertFrom-Json).issues | ForEach-Object { $_.path }) + foreach ($expectedPath in 'TenantId', 'Name', 'OwnerEmail') { + if ($paths -notcontains $expectedPath) { + throw "The validation problem's issues do not include '$expectedPath' (got: $($paths -join ', '))." + } + } + + $refined = Send-Request $base 'POST' '/api/tenants' '{"tenantId":"same-name","name":"same-name","ownerEmail":"owner@example.com"}' + if ($refined.Status -ne 422 -or -not $refined.Body.Contains('tenant_id_matches_name')) { + throw "The refinement's code rule answered $($refined.Status), not 422 with tenant_id_matches_name." + } + + $duplicate = Send-Request $base 'POST' '/api/tenants' '{"tenantId":"acme","name":"Acme","ownerEmail":"owner@example.com"}' + if ($duplicate.Status -ne 409) { throw "An existing tenant answered $($duplicate.Status), not the case mapping's 409." } + + $created = Send-Request $base 'POST' '/api/tenants' '{"tenantId":"newco","name":"Newco","ownerEmail":"owner@example.com"}' + if ($created.Status -ne $successStatus -or ($created.Body | ConvertFrom-Json).tenantId -ne 'newco') { + throw "A valid registration answered $($created.Status) $($created.Body), not $successStatus with the tenant." + } + + $deleted = Send-Request $base 'DELETE' '/api/tenants/newco' + if ($deleted.Status -ne 204 -or $deleted.Body) { throw "A unit success answered $($deleted.Status), not 204 with no body." } + + $uninitialized = Send-Request $base 'GET' '/api/tenants/uninitialized' + if ($uninitialized.Status -ne 500 -or ($uninitialized.Body | ConvertFrom-Json).errorType -ne 'results_uninitialized') { + throw "An uninitialized result answered $($uninitialized.Status) $($uninitialized.Body), not 500 results_uninitialized." + } + + # The client side: Purview.ZodSharp.Http calls the API and each outcome folds into a result. + $client = Send-Request $base 'GET' '/client-self-test' + $client.Body -split "\r?\n" | Where-Object { $_.Trim() } | ForEach-Object { Write-Host " $_" } + if ($client.Status -ne 200) { throw "The Purview.ZodSharp.Http client self-test failed:`n$($client.Body)" } + } + finally { Stop-App $process } + } + + Assert-NoLeaks @((Join-Path $consumers 'AotWebApi/bin'), $publish) + } +} +catch { + $exitCode = 1 + Write-Failure "Packed-consumer gate failed: $($_.Exception.Message)" + if (Test-Path $logs) { Write-Host "Logs: $logs" } +} +finally { + if ($null -eq $previousNuGetPackages) { $env:NUGET_PACKAGES = $null } + else { $env:NUGET_PACKAGES = $previousNuGetPackages } + + # Packing restored the shipping projects against the isolated cache that is about to be deleted; restore them + # against the normal cache, so a later --no-restore build does not point at a missing folder. + if ($repositoryRestoreTouched) { + Write-Host "`nRestoring the shipping projects against the normal package cache..." -ForegroundColor Cyan + foreach ($project in @($generatorProjects) + @($shipping.Project)) { + & dotnet restore $project -nr:false | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Failure "Restoring $project against the normal cache failed; run dotnet restore." } + } + } + + $totalStopwatch.Stop() + Write-Host "`nPacked-consumer gate timings:" -ForegroundColor Cyan + $timings | Format-Table -AutoSize | Out-Host + Write-Host ("Total: {0:N1}s" -f $totalStopwatch.Elapsed.TotalSeconds) + if ($scanned.Count -gt 0) { Write-Host "Leak-scanned $($scanned.Count) folder(s)." } + + if ($KeepArtifacts) { + Write-Host "Artifacts kept at $workspace" -ForegroundColor Yellow + } + else { + Remove-Item $workspace -Recurse -Force -ErrorAction SilentlyContinue + } +} + +if ($exitCode -ne 0) { exit $exitCode } + +Write-Host 'Packed-consumer gate passed.' -ForegroundColor Green diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..806c0b7 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,185 @@ +{ + "version": 1, + "skills": { + "purview-architecture": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-architecture/SKILL.md", + "computedHash": "d194bd0f30091ffab6ef35c29582e56511d2daca32fdbb1942c50134198f9270" + }, + "purview-architecture-testing": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-architecture-testing/SKILL.md", + "computedHash": "5907c6bf28788b755b752041e2f0cd88222604a949988d487c25940b6284fce4" + }, + "purview-buildsdk-adoption": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-adoption/SKILL.md", + "computedHash": "ca2e13fc8074281f8ca2b510b742ae99db28f97d78f5fb36a6f27936a91941fc" + }, + "purview-buildsdk-configuration": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-configuration/SKILL.md", + "computedHash": "88b4a655efdc058b9a10adb1b5cbcd44e89bff6ed8c858e066092e5be0ca3e0b" + }, + "purview-buildsdk-project-detection": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-project-detection/SKILL.md", + "computedHash": "7c30062fe592105b7a2539384843759521c5a3f90ace8f7c60f0e6ae2405b0b1" + }, + "purview-buildsdk-project-layout": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-project-layout/SKILL.md", + "computedHash": "101f09c92cf97e93387b926c48adab9598a0cf0a64a4932d41085ab0a0078cfa" + }, + "purview-buildsdk-repository-review": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-repository-review/SKILL.md", + "computedHash": "d366821995ff410caa436ccb996eaa6429f1ba51ba985b210d74ec3963f6c06f" + }, + "purview-buildsdk-skill-installation": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-skill-installation/SKILL.md", + "computedHash": "b61cce9f0f213469c3acab4dd5ebaec450ee2be730680b166c9718c901aedffe" + }, + "purview-buildsdk-skill-tests": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-skill-tests/SKILL.md", + "computedHash": "807b64466de0a871e5f8e678e7686c48a06d8bb9d42a03448f604a35d40d3935" + }, + "purview-buildsdk-testing": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-testing/SKILL.md", + "computedHash": "d6a2ac1167faf68e8c27493de81c2e5b0d8183e523de4411aca5f5b9249c8a9c" + }, + "purview-engineering-principles": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-engineering-principles/SKILL.md", + "computedHash": "fd38d374b816ecf77964c41486e7c734ea34998c3f2a5538c90a2ba793905994" + }, + "purview-persistence": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-persistence/SKILL.md", + "computedHash": "0523ecc210bf3571e2fccb859128a499fd80694e9d4089b377a8d39d73c028f4" + }, + "purview-sourcegenerator-codewriter": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-codewriter/SKILL.md", + "computedHash": "f24ae680340784e62236d044cbc3658d5ca7bdc3cea3fc44f2bb5cf75a84f5e4" + }, + "purview-sourcegenerator-diagnostics": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-diagnostics/SKILL.md", + "computedHash": "c3c7920701e0e688f34ddece9e448472e010e69767e8c08914638128d34a8f17" + }, + "purview-sourcegenerator-packaging": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-packaging/SKILL.md", + "computedHash": "e0e6372e92aefc8df026a73010d9e7436b0a8c2378a96fdebd8b4de9e025d4a0" + }, + "purview-sourcegenerator-pipeline": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-pipeline/SKILL.md", + "computedHash": "7d48394d7e3890f288949c79f040646207f34af9ae85827a63d056bae1b1b985" + }, + "purview-sourcegenerator-testing": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-testing/SKILL.md", + "computedHash": "d88bf2353073013ff9ddf9d3fe1a2e9415eb60d319bbd053baa105f50fa3f889" + }, + "purview-sourcegenerator-tunit-tests": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-tunit-tests/SKILL.md", + "computedHash": "14809d3727643e9a779d590b6627b3283af7b36c835e902cb80162552cb45b4f" + }, + "purview-telemetry": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-telemetry/SKILL.md", + "computedHash": "08ba26e1379dfa2bb826ddfd011e95ac84cd583b865f0b89673d7302b2c2c27d" + }, + "purview-testing-conventions": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-testing-conventions/SKILL.md", + "computedHash": "388b0a79b565ed45a8513d77e8ed6e3d4491f96b43328cb09933cec7811c10f6" + }, + "purview-value-objects": { + "source": "purview-dev/value-objects", + "sourceType": "github", + "skillPath": "skills/purview-value-objects/SKILL.md", + "computedHash": "8d7c2474bfa729a19c8b03537550ca369d5b727915bba278912666030d774a7d" + }, + "purview-value-objects-diagnostics": { + "source": "purview-dev/value-objects", + "sourceType": "github", + "skillPath": "skills/purview-value-objects-diagnostics/SKILL.md", + "computedHash": "148557f3811631efb3e57bc64e4a6f60cebc3d32676d7c489874e79de2d090ca" + }, + "purview-value-objects-efcore": { + "source": "purview-dev/value-objects", + "sourceType": "github", + "skillPath": "skills/purview-value-objects-efcore/SKILL.md", + "computedHash": "9c7a8ab0f8299c71691f6810222df584b43a9424dcafa193c2eef8755c4cd13e" + }, + "purview-value-objects-zodsharp": { + "source": "purview-dev/value-objects", + "sourceType": "github", + "skillPath": "skills/purview-value-objects-zodsharp/SKILL.md", + "computedHash": "e42f9ee98ce66b59ae4ee0d7037d683de232955b35c627cb6bab13ac08204134" + }, + "purview-zodsharp-aspnetcore": { + "source": "purview-dev/zodsharp", + "sourceType": "github", + "skillPath": "skills/purview-zodsharp-aspnetcore/SKILL.md", + "computedHash": "a1c1f9fc008be609cb12622ca08022965497fe306cc1b4203333440a0edb4206" + }, + "purview-zodsharp-core": { + "source": "purview-dev/zodsharp", + "sourceType": "github", + "skillPath": "skills/purview-zodsharp-core/SKILL.md", + "computedHash": "e89c1000bb305420b85c1b93f3f77d9b500fc6b7d52b7016f670430fab1659b0" + }, + "purview-zodsharp-custom-rules": { + "source": "purview-dev/zodsharp", + "sourceType": "github", + "skillPath": "skills/purview-zodsharp-custom-rules/SKILL.md", + "computedHash": "3726a476b75b842daf67898e19b197331a1195061053183d66e9e05a66294266" + }, + "purview-zodsharp-diagnostics": { + "source": "purview-dev/zodsharp", + "sourceType": "github", + "skillPath": "skills/purview-zodsharp-diagnostics/SKILL.md", + "computedHash": "19e480b1eca26e417642b8c55f689580eb7b741f02e78387041ffebba1216ddb" + }, + "purview-zodsharp-json": { + "source": "purview-dev/zodsharp", + "sourceType": "github", + "skillPath": "skills/purview-zodsharp-json/SKILL.md", + "computedHash": "82da551adff5506232d5419d925ed198d71844f7901ad13ef7a3a0bead4a826f" + }, + "purview-zodsharp-source-generator": { + "source": "purview-dev/zodsharp", + "sourceType": "github", + "skillPath": "skills/purview-zodsharp-source-generator/SKILL.md", + "computedHash": "7172450983d6130c3335276fdcec80ca87b473ab7fbb43dfd08304b55c1632b0" + } + } +} diff --git a/skills/purview-results-core/SKILL.md b/skills/purview-results-core/SKILL.md index 93719fd..b8ba54a 100644 --- a/skills/purview-results-core/SKILL.md +++ b/skills/purview-results-core/SKILL.md @@ -71,6 +71,10 @@ convert to the result in one hop, which is why the source generator exists (see | `BindAsync(bind, mapError)` | Asynchronous widening `Bind` | | `Throw()` | Return the value, or throw `ResultException` carrying the error (escape hatch) | +`ResultException` exposes the error with its type through `Error`; its non-generic `ResultException` base +carries it as `object?` for a catch-all. `Throw()` on `default` throws `InvalidOperationException`, never +`ResultException`. + Prefer `Match`/`Map`/`Bind` over `if (result.IsSuccess)` when a value has to come out of the result: they keep the failure path explicit and cannot silently leak a `default`. @@ -92,7 +96,7 @@ Result DeleteTenant(TenantId tenantId) => deleted.Match(() => "deleted", error => $"could not delete: {error}"); deleted.Map(() => 1); // attach a value: Result deleted.Bind(() => GetTenant(tenantId)); // continue with another operation -deleted.Ensure(() => _tenants.Count > 0, () => new TenantError()); +deleted.Ensure(() => _tenants.Count > 0, () => new TenantNotFound(tenantId)); ``` - `Result.Success()` / `.Failure(error)`, `Result.Success()` / `Result.Failure(error)`, @@ -115,6 +119,18 @@ example. Its accessors never throw: the one that does not describe the current s successful unit `Result` reports the `Success` marker as its `SuccessValue`. Use it in infrastructure, and the generic type in domain code. +When the value's type matters — serialization, above all — use +`AcceptSuccess(IResultValueVisitor, state)` instead of `SuccessValue`: it calls the visitor's +`VisitSuccess` with the static type intact, so a serializer resolves `TValue` rather than `object` under +trimming and Native AOT. It never throws for a state — a failed or uninitialized value result visits `default`, a +unit result visits the `Success` marker — so check `IsSuccess` first. + +## Trimming, Native AOT and Blazor + +The package is `IsAotCompatible`: no reflection, no `dynamic` and no runtime code generation, so it is safe in +trimmed and Native AOT applications. The `net11.0` async combinators are compiled without runtime async, so they +also run on Blazor WebAssembly. + ## When to use a result **Use it** for expected, domain-level outcomes the caller must handle: not found, invalid, conflict, disabled, diff --git a/skills/purview-results-http-mapping/SKILL.md b/skills/purview-results-http-mapping/SKILL.md index ac4629e..c2a9d3c 100644 --- a/skills/purview-results-http-mapping/SKILL.md +++ b/skills/purview-results-http-mapping/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-results-http-mapping -description: "Use when an ASP.NET Core endpoint returns Purview.Results values — registering AddResultsHttp, mapping error cases to status codes with Map, answering a failure by its shape with an IResultsFailureMapper, using fallbacks, correcting unmapped-failure 500s, or converting a result with ToHttpResult." +description: "Use when an ASP.NET Core endpoint returns Purview.Results values — registering AddResultsHttp, mapping error cases to status codes with Map, answering a failure by its shape with an IResultsFailureMapper, using fallbacks, correcting unmapped-failure 500s, converting a result with ToHttpResult, or serving results from a Native AOT host." --- # Mapping results onto ASP.NET Core responses @@ -29,8 +29,12 @@ app.MapGet("/tenants/{id:int}", (int id) => GetTenant(id)).WithResultsHttp(); - `WithResultsHttp()` exists on both `RouteHandlerBuilder` and `RouteGroupBuilder` and adds the endpoint filter. A handler that returns something other than an `IResultValue` is left untouched, so the filter is safe to apply to a whole group. -- Mapping by hand is also supported: `result.ToHttpResult(context)` resolves the mapper from the request - services, and `result.ToHttpResult(mapper, context)` takes one directly. +- Mapping by hand: `result.ToHttpResult()` returns a `ResultHttpResult`, an `IResult` the registered mapper maps + when it executes (identical responses to `WithResultsHttp()`, no filter, OpenAPI success metadata included). + `result.ToHttpResult(context)` maps immediately, and `result.ToHttpResult(mapper, context)` takes a mapper. All + three exist for both `Result` and the unit `Result`. +- Options are startup configuration. The mappings and fallbacks are frozen the first time a request reads them, + so a `Map` or `AddFallback` call after that is ignored. ## Response resolution order @@ -55,8 +59,10 @@ resolves to its leaf; or the error itself when it is not a union), then: first; returning `null` defers to the next entry; 5. the **unmapped-failure** response. -Registering the same type twice replaces the earlier mapping. Fallbacks receive the *leaf* case value, so a -fallback never has to unwrap the union itself. +Registering the same type twice replaces the earlier mapping. A mapping matches the **exact runtime type** only: +`Map` or `Map` never matches a case that implements or derives from it. Answer an +interface or base-type shape with a failure mapper (below). Fallbacks receive the *leaf* case value, so a fallback +never has to unwrap the union itself. ## Shape-based rules @@ -91,23 +97,51 @@ builder.Services.AddResultsHttp(options => options.AddFailureMapper` — or `Json` for another `SuccessStatusCode` — +never `Ok`. Under Native AOT: + +- **return `result.ToHttpResult()` from every handler, never the result.** The request delegate generator resolves + JSON metadata for a handler's declared return type when it builds the endpoints; a `Result<...>` has none (and a + union error cannot get any: `SYSLIB1227`), so every request fails at startup with a `NotSupportedException`. + `WithResultsHttp()` runs too late to help, and adding the result type to a `JsonSerializerContext` does not work; +- register each request body and successful **value** type (not the `Result<...>` type) in the app's + `JsonSerializerContext`: + `builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default));` +- the problem responses need nothing extra; they resolve from ASP.NET Core's own context; +- a `SuccessMapper` receives `object?`, so cast to the value type before serializing — `TypedResults.Json(value)` + on an `object` has no metadata under AOT. ## Choosing status codes diff --git a/skills/purview-results-union-errors/SKILL.md b/skills/purview-results-union-errors/SKILL.md index ce6ea9a..4dff228 100644 --- a/skills/purview-results-union-errors/SKILL.md +++ b/skills/purview-results-union-errors/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-results-union-errors -description: "Use when modelling expected failures as a C# 15 union and converting its cases into Purview.Results values — declaring [GenerateResult] unions, using the generated per-case AsFailure()/AsFailure() helpers and the union-receiver Union.Failure(...)/Union.Success(...) factories, reacting to the RSG1000-RSG1008 diagnostics, or explaining why a union case cannot convert implicitly." +description: "Use when modelling expected failures as a C# 15 union and converting its cases into Purview.Results values — declaring [GenerateResult] unions, using the generated per-case AsFailure()/AsFailure() helpers and the union-receiver Union.Failure(...)/Union.Success(...) factories, reacting to the RSG1000-RSG1009 and RSG9000 diagnostics, or explaining why a union case cannot convert implicitly." --- # Modelling error unions for Purview.Results @@ -33,7 +33,9 @@ public static Result Get(TenantId id) => ``` `AsFailure()` and the non-generic `AsFailure()` are generated **once per case type**, into -`public static class {Union}ResultExtensions` in the union's own namespace. The generic helper calls +`static class {Union}ResultExtensions` in the union's own namespace (`public` only when the union and every case it +references are visible, otherwise `internal`; a nested union's class is prefixed with its containing types, as +`Outer_TenantErrorResultExtensions`). The generic helper calls `Result.Failure(case)`; the non-generic one calls `Result.Failure(case)` and produces a value-less unit result: @@ -83,8 +85,8 @@ Result Get(TenantId id) => (TenantError)new TenantNotFound( `return new TenantNotFound(id);` in a `Result`- or `Result`-returning member, the lightbulb offers the `Union.Failure(case)` or `Union.Failure(case)` rewrite. The fix names the union, so it is correct even when the case type is shared with another union. It is only offered when the rewrite will bind (the -converted type is a result, the error type is a `[GenerateResult]` union, the value is one of its cases, and the -union is in scope by simple name). +converted type is a result, the error type is a `[GenerateResult]` union, the value is one of its cases or a case +reachable through an included union, and the union is in scope by simple name). ## Chaining across services @@ -138,12 +140,21 @@ The package ships an analyzer and a generator that share one diagnostics library | `RSG1006` | Warning | A **leaf** case type is shared with another union, so its per-case `AsFailure` helper is generated once; the union-receiver `Failure(...)` factory is generated for every union. A union-typed case shared across unions is composition and is not reported | | `RSG1007` | Error | The union uses an `IUnionMembers` member provider, which is not supported | | `RSG1008` | Warning | A case is reachable through two different included unions, so no inclusion factory can name its construction path; that case's inclusion factory is skipped | +| `RSG1009` | Warning | A case type is not a named type (an array, for example), so no helper or factory can be declared against it; wrap it in a record or struct case | +| `RSG9000` | Error | The generator failed unexpectedly; reported instead of letting `CS8785` discard every union's output. Please report it | -`RSG1004`, `RSG1006` and `RSG1008` are non-blocking: the union (or the shared/ambiguous case) is skipped and -reported. +Not every finding stops generation. `RSG1004` is generated once; `RSG1006`, `RSG1008` and `RSG1009` skip only the +affected case's helper or factory; `RSG1002` and `RSG1003` skip only the offending case when it is a case type +rather than the union itself. The other rules skip the union. Unsupported by design: `IUnionMembers` member providers (`RSG1007`) and generic unions (`RSG1002`). +## Trimming, Native AOT and Blazor + +The generated helpers and factories are plain static calls to `Result<...>.Failure`/`.Success` and union +constructors: no reflection, no dynamic code. A `[GenerateResult]` union is safe in a trimmed or Native AOT +application and in a Blazor WebAssembly client (trimmed or `RunAOTCompilation=true`). + ## CA1815 needs no pragma A union declaration gets no value equality from the compiler, so it raises `CA1815` unless it is silenced by hand. @@ -160,7 +171,9 @@ compared by value. ## Requirements and switches -- **.NET 11 SDK or later** with `LangVersion=preview` — union declarations are a preview language feature. +- **.NET 11 SDK or later**, a `net11.0` target and `LangVersion=preview` in the project that declares the union — + union declarations are a preview language feature, and `IUnion`/`UnionAttribute` do not exist before .NET 11. + Projects that only *use* the result types can target `net10.0`. - A reference to `Purview.Results`, which provides both result shapes (`Result` and `Result`) and this generator. - `-p:DisableResultsSourceGenerator=true` (or a `Directory.Build.props` setting) disables the helpers while @@ -175,5 +188,5 @@ compared by value. success carries no value. 4. Failures are produced with `new Case(...).AsFailure()` (or `AsFailure()` for a unit result, the union-receiver `Union.Failure(case)` / `Union.Failure(case)` factory, or the cast form). -5. The build is clean of `RSG1000`–`RSG1008`. +5. The build is clean of `RSG1000`–`RSG1009` and `RSG9000`. 6. The union is mapped to responses once, in the host (see `purview-results-http-mapping`). diff --git a/skills/purview-results-union-errors/references/migrating-error-returns.md b/skills/purview-results-union-errors/references/migrating-error-returns.md index b0405f4..d502ec1 100644 --- a/skills/purview-results-union-errors/references/migrating-error-returns.md +++ b/skills/purview-results-union-errors/references/migrating-error-returns.md @@ -11,6 +11,9 @@ error-code enums with a C# 15 `union` of case types returned as `Result with ToResult or the value-discarding ToUnitResult, carrying ValidationError values on a union case, implementing IValidationErrorCarrier, or deciding what a rejected input becomes in the result pipeline." +description: "Use when ZodSharp validation has to feed Purview.Results — converting ValidationResult with ToResult or the value-discarding ToUnitResult (and their async forms, including over Purview.ZodSharp.Http), carrying ValidationError values on a union case, implementing IValidationErrorCarrier, or deciding what a rejected input becomes in the result pipeline." --- # Folding ZodSharp validation into results @@ -82,6 +82,32 @@ async Task> Validate(TenantInput input, CancellationToken ca - 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. +- With Purview.ZodSharp 3 every generated `{Type}Schema` also has a static `ValidateAsync(value, services, ct)`, + and CA1849 flags the synchronous `Validate` inside an async method: in async code, fold `ValidateAsync` with + `ToResultAsync`; keep `Validate` + `ToResult` for synchronous code. + +## Validation over HTTP (Purview.ZodSharp.Http) + +`ToResultAsync` and `ToUnitResultAsync` also accept a `Task>`, which is what +Purview.ZodSharp.Http returns, so a client folds its request and response validation the same way: + +```csharp +// An invalid body is never sent: its errors become the case. +var sent = await http + .PostAsJsonValidatedAsync("/api/tenants", input, validator, AppJsonContext.Default.TenantInput, services) + .ToResultAsync(errors => new TenantInputInvalid(input, errors)); + +// A response body that fails its schema is a failure too. +var tenant = await response.Content + .ReadFromJsonAndValidateAsync(tenantValidator, AppJsonContext.Default.Tenant, services, cancellationToken) + .ToResultAsync(errors => new TenantResponseInvalid(errors)); +``` + +- A **server's** validation problem reads back with `await response.TryReadZodValidationProblemAsync()`; build the + case from `problem.ToValidationErrors()` (`AsFailure()`), so a local and a server rejection are the same + case. +- `validator` is the generated `{Type}SchemaValidator` (an `IZodSchemaValidator`; create it with `new()` - + `AddZodSharp{Assembly}Schemas()` registers it in the `IZodSchemaFactory`, not as a service); pass the `JsonTypeInfo` from your own `JsonSerializerContext`. ## Carrying the errors @@ -104,6 +130,13 @@ Guidance: - **One carrier per rejection**, holding the whole `ImmutableArray` for that input. - Implementing the interface (rather than a helper that extracts errors) lets the HTTP mapping stay generic. +## Trimming, Native AOT and Blazor + +`Purview.Results.ZodSharp` is `IsAotCompatible` and uses no reflection. It works in a Native AOT application and in a +Blazor WebAssembly client, trimmed or with `RunAOTCompilation=true`, including the async forms (the shipped +assemblies do not use runtime async, which Mono does not support). Purview.ZodSharp.Http is AOT- and +WebAssembly-safe too. + ## Why there is no factory that returns a result `ToResult` takes `Func, TError>` on purpose. A lambda that returns diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 1288fe8..c2e38bf 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -12,16 +12,19 @@ out here: a runtime lifecycle change becomes an SDK bump instead of an edit in this file. --> Supported + + + universal preview - $(Features);runtime-async=on + + $(Features);runtime-async=on + + true diff --git a/src/src/AspNetCore/Extensions/Purview/Results/ResultHttpExtensions.cs b/src/src/AspNetCore/Extensions/Purview/Results/ResultHttpExtensions.cs index fb2e9db..baefe86 100644 --- a/src/src/AspNetCore/Extensions/Purview/Results/ResultHttpExtensions.cs +++ b/src/src/AspNetCore/Extensions/Purview/Results/ResultHttpExtensions.cs @@ -11,6 +11,17 @@ public static class ResultHttpExtensions { extension(Result result) { + /// + /// Wraps the result in an that is mapped onto a response when it executes. + /// + /// The response the result represents, mapped by the registered . + /// + /// This is the form to return from a minimal-API handler in a Native AOT host: the handler then returns an + /// , so ASP.NET Core resolves no JSON metadata for the result type when it builds the + /// endpoint. See . + /// + public ResultHttpResult ToHttpResult() => new(result); + /// /// Converts the result into an ASP.NET Core response using the mapper registered in dependency injection. /// @@ -41,4 +52,47 @@ public IResult ToHttpResult(IResultsHttpMapper mapper, HttpContext context) return mapper.Map(result, context); } } + + extension(Result result) + { + /// + /// Wraps the unit result in an that is mapped onto a response when it executes. + /// + /// The response the result represents, mapped by the registered . + /// + /// A success answers 204 No Content. This is the form to return from a minimal-API handler in a Native + /// AOT host; see . + /// + public ResultHttpResult ToHttpResult() => new(result); + + /// + /// Converts the unit result into an ASP.NET Core response using the mapper registered in dependency injection. + /// + /// The current request. + /// The response the result represents. + /// Thrown when is null. + public IResult ToHttpResult(HttpContext context) + { + ArgumentNullException.ThrowIfNull(context); + + return result.ToHttpResult(context.RequestServices.GetRequiredService(), context); + } + + /// + /// Converts the unit result into an ASP.NET Core response using the supplied mapper. + /// + /// The mapper that turns the result into a response. + /// The current request. + /// The response the result represents. + /// + /// Thrown when or is null. + /// + public IResult ToHttpResult(IResultsHttpMapper mapper, HttpContext context) + { + ArgumentNullException.ThrowIfNull(mapper); + ArgumentNullException.ThrowIfNull(context); + + return mapper.Map(result, context); + } + } } diff --git a/src/src/AspNetCore/ResultHttpResultT.cs b/src/src/AspNetCore/ResultHttpResultT.cs new file mode 100644 index 0000000..04cdcb4 --- /dev/null +++ b/src/src/AspNetCore/ResultHttpResultT.cs @@ -0,0 +1,105 @@ +using System.Reflection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Purview.Results.AspNetCore; + +/// +/// An that maps a onto a response when it executes. +/// +/// The type of the successful value. +/// The type of the error. +/// +/// +/// Returned by result.ToHttpResult(). Execution resolves the registered from +/// the request's services, so the response is exactly the one WithResultsHttp() would produce: the same +/// success handling, the same failure resolution order and the same unmapped-failure response. +/// +/// +/// Use it instead of returning the result directly when the host is published with Native AOT. ASP.NET Core's +/// request delegate generator resolves JSON metadata for a handler's declared return type when it builds the +/// endpoint, and a has none: the result is never serialized, and its error is +/// usually a union that System.Text.Json's source generator cannot describe. A handler declared to return this type +/// returns an , so nothing is resolved for the result itself; only +/// needs to be in the app's JsonSerializerContext. +/// +/// +/// It also describes the success response to OpenAPI: as JSON with +/// . Nothing is described when a +/// is set or when is itself an +/// , because the response is then not known ahead of time. Failure responses are whatever the +/// host's mappings produce, so they are not described here; add ProducesProblem(...) to the endpoint. +/// +/// +/// +/// +/// app.MapPost("/tenants", (TenantRegistration registration, TenantRegistrationService service) => +/// service.Register(registration).ToHttpResult()); +/// +/// +public sealed class ResultHttpResult : IResult, IEndpointMetadataProvider +{ + internal ResultHttpResult(Result result) + { + Result = result; + } + + /// + /// Gets the result this response represents. + /// + public Result Result { get; } + + /// + /// Thrown when is null. + /// + /// Thrown when no is registered; call AddResultsHttp(). + /// + public Task ExecuteAsync(HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + + return ResultHttpResultExecution.Execute(Result, httpContext); + } + + /// + static void IEndpointMetadataProvider.PopulateMetadata(MethodInfo method, EndpointBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + if (typeof(IResult).IsAssignableFrom(typeof(TValue))) + return; + + var options = builder.ApplicationServices.GetService>()?.Value; + if (options?.SuccessMapper is not null) + return; + + builder.Metadata.Add( + new ProducesResponseTypeMetadata( + options?.SuccessStatusCode ?? StatusCodes.Status200OK, + typeof(TValue), + ["application/json"] + ) + ); + } +} + +/// +/// The execution shared by and . +/// +static class ResultHttpResultExecution +{ + internal static Task Execute(IResultValue result, HttpContext httpContext) + { + var mapper = + httpContext.RequestServices.GetService() + ?? throw new InvalidOperationException( + $"No {nameof(IResultsHttpMapper)} is registered. Call services.AddResultsHttp() before mapping a " + + "result with ToHttpResult()." + ); + + return mapper.Map(result, httpContext).ExecuteAsync(httpContext); + } +} diff --git a/src/src/AspNetCore/ResultHttpResultTError.cs b/src/src/AspNetCore/ResultHttpResultTError.cs new file mode 100644 index 0000000..5192dfb --- /dev/null +++ b/src/src/AspNetCore/ResultHttpResultTError.cs @@ -0,0 +1,66 @@ +using System.Reflection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Purview.Results.AspNetCore; + +/// +/// An that maps a unit onto a response when it executes. +/// +/// The type of the error. +/// +/// +/// The unit-result counterpart of , returned by +/// result.ToHttpResult(): execution uses the registered , so a success +/// answers 204 No Content and a failure is mapped exactly as WithResultsHttp() maps it. Return it from +/// a handler in a Native AOT host, where a handler declared to return the result itself fails when the endpoint is +/// built. +/// +/// +/// It describes the 204 success to OpenAPI, unless a is set. +/// +/// +/// +/// +/// app.MapDelete("/tenants/{tenantId}", (string tenantId, TenantService service) => +/// service.Remove(tenantId).ToHttpResult()); +/// +/// +public sealed class ResultHttpResult : IResult, IEndpointMetadataProvider +{ + internal ResultHttpResult(Result result) + { + Result = result; + } + + /// + /// Gets the result this response represents. + /// + public Result Result { get; } + + /// + /// Thrown when is null. + /// + /// Thrown when no is registered; call AddResultsHttp(). + /// + public Task ExecuteAsync(HttpContext httpContext) + { + ArgumentNullException.ThrowIfNull(httpContext); + + return ResultHttpResultExecution.Execute(Result, httpContext); + } + + /// + static void IEndpointMetadataProvider.PopulateMetadata(MethodInfo method, EndpointBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + if (builder.ApplicationServices.GetService>()?.Value.SuccessMapper is not null) + return; + + builder.Metadata.Add(new ProducesResponseTypeMetadata(StatusCodes.Status204NoContent)); + } +} diff --git a/src/src/AspNetCore/Sdk/README.md b/src/src/AspNetCore/Sdk/README.md index 64f7017..d3b5b0e 100644 --- a/src/src/AspNetCore/Sdk/README.md +++ b/src/src/AspNetCore/Sdk/README.md @@ -30,7 +30,7 @@ builder.Services.AddResultsHttp(options => options var app = builder.Build(); -app.MapGet("/tenants/{id:int}", (int id) => GetTenant(id)).WithResultsHttp(); +app.MapGet("/tenants/{id}", (string id) => GetTenant(new TenantId(id))).WithResultsHttp(); ``` The handler returns a `Result`; the filter converts it into the configured response. @@ -55,11 +55,14 @@ union resolves to its leaf; or the error itself for a non-union error), then map 4. the fallback stage, in registration order: the `AddFallback(...)` delegates and the `AddFailureMapper()` mappers share one list, and whatever is registered first is consulted first; a fallback or mapper receives the leaf case and returns `null` to defer to the next entry -5. a `ProblemDetails` response using `UnmappedStatusCode` (`500`), `UnmappedTitle`, and an `errorType` - extension naming the unmapped case — or an `InvalidOperationException` when `ThrowOnUnmappedFailure` is set +5. a `ProblemDetails` response using `UnmappedStatusCode` (`500`) and `UnmappedTitle` — or an + `InvalidOperationException` when `ThrowOnUnmappedFailure` is set. The unmapped case's type name is always + logged, but it is written into the response's `errorType` extension only when + `IncludeErrorTypeInProblemDetails` is set, because it discloses internal namespaces to the caller. -An **uninitialized** result (`default`) is logged and answered with the unmapped-failure response, because an -endpoint returning `default` is a host bug rather than a domain outcome. +An **uninitialized** result (`default`) is logged and answered with the same `500`, always with +`errorType = "results_uninitialized"` (a fixed discriminator, not a type name), because an endpoint returning +`default` is a host bug rather than a domain outcome. `ThrowOnUnmappedFailure` does not apply to it. ## Options @@ -69,14 +72,17 @@ endpoint returning `default` is a host bug rather than a domain outcome. | `SuccessMapper` | `null` | Replaces the default success handling entirely | | `UnmappedStatusCode` | `500` | Status code for a failure with no mapping | | `UnmappedTitle` | *"The operation failed with an error that is not mapped to an HTTP response."* | Title of the unmapped `ProblemDetails` | -| `ThrowOnUnmappedFailure` | `false` | Throw instead of producing a problem response; useful during development | +| `ThrowOnUnmappedFailure` | `false` | Throw on an unmapped failure instead of producing a problem response; useful during development | +| `IncludeErrorTypeInProblemDetails` | `false` | Write the unmapped error's CLR type name into the `errorType` extension; enable per environment | | `IncludeTraceId` | `true` | Whether problem responses this package writes itself carry the request trace identifier | | `Map(Func)` | — | Maps a case (or the error itself) to a response | | `Map(Func)` | — | Same, with access to the request | | `AddFallback(Func)` | — | Consulted in order for unmapped failures, with the case value | | `AddFailureMapper()` | — | Same stage, for a mapper class resolved from dependency injection | -Registering the same type twice replaces the earlier mapping. +Registering the same type twice replaces the earlier mapping. A mapping matches the exact runtime type only, so +`Map` never matches; answer an interface or base-type shape with a failure mapper. The options +are startup configuration: they are frozen the first time a request reads them. ## Choosing an extension point @@ -111,15 +117,44 @@ an `InvalidOperationException` naming the registration that is missing. ## Converting a result by hand -When a filter is not appropriate, convert explicitly: +When a filter is not appropriate, convert explicitly. `ToHttpResult()` wraps the result in a `ResultHttpResult`, +an `IResult` that is mapped by the registered `IResultsHttpMapper` when it executes, so the response is exactly the +one `WithResultsHttp()` produces: ```csharp -app.MapGet("/tenants/{id:int}", (int id, HttpContext context) => - GetTenant(id).ToHttpResult(context)); +app.MapGet("/tenants/{id}", (string id) => GetTenant(new TenantId(id)).ToHttpResult()); +app.MapDelete("/tenants/{id}", (string id) => DeleteTenant(new TenantId(id)).ToHttpResult()); // 204 on success ``` -`ToHttpResult(HttpContext)` resolves `IResultsHttpMapper` from the request services; the -`ToHttpResult(IResultsHttpMapper, HttpContext)` overload takes one directly. +It is available for both `Result` and the unit `Result`, and it describes the success +response to OpenAPI (`TValue` with `SuccessStatusCode`, or `204` for a unit result). `ToHttpResult(HttpContext)` +maps immediately with the mapper resolved from the request services, and `ToHttpResult(IResultsHttpMapper, +HttpContext)` takes one directly. + +## Native AOT + +The package is `IsAotCompatible`, and a successful value reaches `TypedResults` with its static type (`Ok`, +never `Ok`). Two things are needed in a Native AOT host: + +- **Return `result.ToHttpResult()` from the handler, not the result.** Under `PublishAot` ASP.NET Core's request + delegate generator resolves JSON metadata for every handler's declared return type when it builds the endpoints, + and a `Result<...>` has none — it is never serialized, and a union error cannot be described by System.Text.Json's + source generator (`SYSLIB1227`). A handler that returns the result directly therefore fails every request with a + `NotSupportedException` at startup; `WithResultsHttp()` runs too late to prevent it. +- **Put the value types in your `JsonSerializerContext`**, with the request body types: + `builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default));`. + The problem responses resolve from ASP.NET Core's own context. + +Set the options in code (the configuration binder needs reflection unless its source generator is enabled), and if +you set `SuccessMapper`, cast its `object?` value to the value type before serializing it. + +## Observability + +The mapper counts under the meter `ResultsHttpTelemetry.MeterName` (`Purview.Results.AspNetCore`): +`purview.results.failures.mapped`, `purview.results.failures.unmapped` and `purview.results.uninitialized`, each +tagged `purview.results.error_type` (the case type name, or `unknown` for an uninitialized result), which is also +added to `Activity.Current`. Register it with `metrics.AddMeter(ResultsHttpTelemetry.MeterName)`. Log statements +carry stable event ids: `1000` for an uninitialized result and `1001` for an unmapped failure. ## Extensibility @@ -153,9 +188,11 @@ This package deliberately knows nothing about ZodSharp. ## Agent skills -This package ships the `purview-results-http-mapping` agent skill under `.agents/`. Repositories that import -`Purview.BuildSdk` get it mirrored into their own `.agents/` folder on the next restore or build, so AI agents -working there receive the guidance automatically. +The `purview-results-http-mapping` agent skill covers this package. No package ships skills: install them, with every other Purview.Results skill, from the git repository with the skills CLI: + +```bash +npx skills add purview-dev/results +``` ## License diff --git a/src/src/Examples.AspNetCore/Program.cs b/src/src/Examples.AspNetCore/Program.cs index 0bf93a8..6648632 100644 --- a/src/src/Examples.AspNetCore/Program.cs +++ b/src/src/Examples.AspNetCore/Program.cs @@ -15,6 +15,7 @@ // curl -i -X DELETE http://localhost:5215/tenants/hooli -> 204 No Content (unit result success) // curl -i -X DELETE http://localhost:5215/tenants/initech -> 404 Not Found (unit result failure) // curl -i http://localhost:5215/tenants/broken -> 500 Internal Server Error +// curl -i http://localhost:5215/explicit/tenants/initech -> 404 Not Found (ToHttpResult(), no filter) // // The billing endpoint returns Result, whose error union nests TenantError // and BillingError. The mapper resolves the innermost case, so the billing leaf mappings win and the mapping @@ -76,4 +77,9 @@ app.MapGet("/tenants/broken", () => default(Result)).WithResultsHttp(); +// The explicit form: ToHttpResult() returns an IResult that the same mapper maps when it executes, so no filter is +// needed. A Native AOT host must use this form, because the request delegate generator resolves JSON metadata for a +// handler's declared return type and a Result<...> has none. +app.MapGet("/explicit/tenants/{id}", (string id) => store.GetTenant(new TenantId(id)).ToHttpResult()); + app.Run(); diff --git a/src/src/Results/Results.csproj b/src/src/Results/Results.csproj index 0b35675..791daab 100644 --- a/src/src/Results/Results.csproj +++ b/src/src/Results/Results.csproj @@ -5,9 +5,10 @@ plus both analyzers, so a regression becomes a build warning here rather than a runtime failure in a consumer's published application. - Note the ASP.NET Core packages are NOT marked compatible: DefaultResultsHttpMapper serializes through - IResultValue.SuccessValue, which is typed `object?`, so TypedResults.Json binds Json and has - no resolvable JsonTypeInfo under AOT. That needs a generic entry point, not an annotation. + All four packages carry the same marking. The ASP.NET Core ones can because IResultValue.AcceptSuccess + hands the successful value to the mapper with its static type, so it binds Ok rather than + Ok and resolves from the host's own JsonSerializerContext. The packed-consumer gate + (scripts/test-packed-consumers.ps1) proves it by publishing and running a Native AOT web API. --> true diff --git a/src/src/Results/Sdk/README.md b/src/src/Results/Sdk/README.md index 529aa14..33aa576 100644 --- a/src/src/Results/Sdk/README.md +++ b/src/src/Results/Sdk/README.md @@ -57,16 +57,32 @@ Result failed = "not a number"; | `Bind(bind, mapError)` | Chain another operation that fails with a different error type, widening the existing error | | `MapError(map)` | Transform the error, preserving the value | | `Throw()` | Return the successful value, or throw `ResultException` carrying the error | +| `TryGetValue(out value)` / `TryGetError(out error)` | Probe without throwing, even for `default` | +| `GetValueOrDefault()` / `GetValueOrDefault(fallback)` / `GetValueOrDefault(fallbackFactory)` | The successful value, or `default`, a fallback or a lazily produced fallback | +| `GetErrorOrDefault(fallback)` | The error, or `fallback` | +| `OrElse(fallback)` | This result when successful, otherwise `fallback` | +| `Switch(success, failure)` | Invoke the matching action and discard the value | +| `Tap(success)` / `TapError(failure)` | Observe a success or a failure and return the result unchanged | +| `Ensure(predicate, errorFactory)` | Turn a successful value that fails the predicate into a failure | +| `MapAsync(map)` / `BindAsync(bind)` / `BindAsync(bind, mapError)` | Asynchronous `Map`, `Bind` and widening `Bind` | +| `DiscardValue()` | Turn a value result into a unit `Result`, preserving the error | | `ToString()` | `Success(value)`, `Failure(error)` or `Uninitialized` | +The members from `TryGetValue` down are extension methods in `ResultExtensions` (`Bind(bind, mapError)` and +`Throw()` live there too), not members on the result type. + `Result` is a `readonly record struct`. `default` is *uninitialized*: `IsInitialized`, -`IsSuccess` and `IsFailure` are all `false`, and `Value`, `Error`, `Match`, `Map`, `Bind` and `MapError` throw -`InvalidOperationException` rather than silently treating the result as a failure or a success. +`IsSuccess` and `IsFailure` are all `false`, and `Value`, `Error`, `Match`, `Map`, `Bind`, `MapError` and every +state-dependent extension throw `InvalidOperationException` rather than silently treating the result as a failure +or a success. Only `TryGetValue` and `TryGetError` never throw. `IResultValue` exposes a non-generic, read-only view (`IsInitialized`, `IsSuccess`, `SuccessValue`, `ErrorValue`) for infrastructure that cannot be generic over the value and error types — an ASP.NET Core endpoint filter, for example. Its accessors never throw: the one that does not describe the current state -returns `null`. +returns `null`. `AcceptSuccess(IResultValueVisitor, state)` hands the successful value to the +visitor's `VisitSuccess` with its static type intact, which is what keeps serialization trimming- and +Native AOT-safe where `SuccessValue` would erase the type to `object`. It never throws for a state: a failed or +uninitialized value result visits `default`, and a unit result visits the `Success` marker. `Throw()` is the deliberate escape hatch: it returns the successful value and throws a `ResultException` carrying the error on failure, so a boundary that must throw does not have to branch. The exception exposes the @@ -91,7 +107,7 @@ var deleted = DeleteTenant(tenantId); deleted.Match(() => "deleted", error => $"could not delete: {error}"); // success takes no value deleted.Map(() => 1); // attach a value to a success deleted.Bind(() => GetTenant(tenantId)); // continue with another operation -deleted.Ensure(() => _tenants.Count > 0, () => new TenantError()); // turn a success into a failure +deleted.Ensure(() => _tenants.Count > 0, () => new TenantNotFound(tenantId)); // turn a success into a failure ``` | Member | Purpose | @@ -180,10 +196,11 @@ ASP.NET Core + Zod and value-objects composition examples too. ## Agent skills -This package ships the `purview-results-core` and `purview-results-union-errors` agent skills, the -`purview-results-union-author` agent and the `migrate-error-returns-to-result-unions` prompt under `.agents/`. -Repositories that import `Purview.BuildSdk` get them mirrored into their own `.agents/` folder on the next -restore or build, so AI agents working there receive the guidance automatically. +The `purview-results-core` and `purview-results-union-errors` agent skills cover this package. No package ships skills: install them, with every other Purview.Results skill, from the git repository with the skills CLI: + +```bash +npx skills add purview-dev/results +``` ## License diff --git a/src/src/SourceGenerator.CodeFixes/SourceGenerator.CodeFixes.csproj b/src/src/SourceGenerator.CodeFixes/SourceGenerator.CodeFixes.csproj index 912473e..3047ffd 100644 --- a/src/src/SourceGenerator.CodeFixes/SourceGenerator.CodeFixes.csproj +++ b/src/src/SourceGenerator.CodeFixes/SourceGenerator.CodeFixes.csproj @@ -2,17 +2,17 @@ true netstandard2.0 Purview Results Source Generator Code Fixes - IDE code fixes for the Purview.Results source generator: rewrites a returned union case value into the generated AsFailure<TValue>() failed result. + IDE code fixes for the Purview.Results source generator: rewrites a returned union case value (CS0029) into the generated union-receiver factory, Union.Failure<TValue>(case) or Union.Failure(case). diff --git a/src/src/SourceGenerator.CodeFixes/UnionCaseResultCodeFixProvider.cs b/src/src/SourceGenerator.CodeFixes/UnionCaseResultCodeFixProvider.cs index 12ecf30..20407f2 100644 --- a/src/src/SourceGenerator.CodeFixes/UnionCaseResultCodeFixProvider.cs +++ b/src/src/SourceGenerator.CodeFixes/UnionCaseResultCodeFixProvider.cs @@ -79,12 +79,12 @@ public override async Task RegisterCodeFixesAsync(CodeFixContext context) if (unionType is null || (!isUnit && valueType is null)) continue; - // The expression must be a case of the union, otherwise AsFailure() would not exist for - // it and the rewrite would not compile. + // The expression must be a case of the union, otherwise the union's Failure factory would have no + // overload for it and the rewrite would not compile. if (!IsCaseOf(unionType, model.GetTypeInfo(expression, cancellationToken).Type)) continue; - // The helper is generated into the union's namespace, so the rewrite only binds where that + // The factory is generated into the union's namespace, so the rewrite only binds where that // namespace is reachable by simple name. if (!IsUnionVisibleBySimpleName(model, unionType, expression.SpanStart)) continue; diff --git a/src/src/SourceGenerator/AnalyzerReleases.Shipped.md b/src/src/SourceGenerator/AnalyzerReleases.Shipped.md index 333672c..4804b1a 100644 --- a/src/src/SourceGenerator/AnalyzerReleases.Shipped.md +++ b/src/src/SourceGenerator/AnalyzerReleases.Shipped.md @@ -13,5 +13,5 @@ RSG1005 | Purview.Results.Usage | Error | Generated helper class name collision RSG1006 | Purview.Results.Usage | Warning | Union case type is shared with another union RSG1007 | Purview.Results.Usage | Error | Unsupported union member provider RSG1008 | Purview.Results.Usage | Warning | Union inclusion case is ambiguous -RSG1009 | Purview.Results.Usage | Warning | Union case type is not a named type +RSG1009 | Purview.Results.Usage | Warning | Unsupported union case type RSG9000 | Purview.Results.Usage | Error | Unhandled exception in the results source generator diff --git a/src/src/SourceGenerator/README.md b/src/src/SourceGenerator/README.md index 4bd0adf..8719158 100644 --- a/src/src/SourceGenerator/README.md +++ b/src/src/SourceGenerator/README.md @@ -204,10 +204,10 @@ needs no generated code. The diagnostics live in one shared library (`Diagnostics/DiagnosticLibrary.cs` + `Diagnostics/ResultUnionDiagnostics.cs` + `Diagnostics/ResultDiagnostic.cs`) that both hosts consume: -- **`ResultsDiagnosticAnalyzer` reports the per-target rules** (`RSG1000`–`RSG1004`, `RSG1007`, `RSG1008`) in the +- **`ResultsDiagnosticAnalyzer` reports the per-target rules** (`RSG1000`–`RSG1004`, `RSG1007`–`RSG1009`) in the IDE and in build output. - **The generator reports the compilation-wide rules** (`RSG1005`, `RSG1006`) that need every opted-in union - in the compilation. + in the compilation, and `RSG9000` when the emitter fails unexpectedly. - **The generator never reports a rule the analyzer reports.** It still runs the same shared analysis, so the `IsBlocking` decision (`DiagnosticLibrary.IsBlocking`, the single blocking policy) comes from the same findings — the rule is simply never surfaced twice. @@ -223,6 +223,8 @@ The diagnostics live in one shared library (`Diagnostics/DiagnosticLibrary.cs` + | `RSG1006` | Warning | Generator | No (only the shared leaf case's `AsFailure` helper is skipped) | A **leaf** case type is shared with another union, so its per-case helper is generated once; the union-receiver `Failure(...)` factory is generated for every union and is the shared-case safe form. A union-typed case shared across unions is composition, not a shared leaf, and is not reported. | | `RSG1007` | Error | Analyzer | Yes | The union uses an `IUnionMembers` member provider, which the first implementation does not support. | | `RSG1008` | Warning | Analyzer | No (only the ambiguous case's inclusion factory is skipped) | A case is reachable through two different included unions, so no inclusion factory can name its construction path. List the case through the union that directly declares it instead. | +| `RSG1009` | Warning | Analyzer | No (only the unsupported case is skipped) | A case type is not a named type (for example an array), so no helper or factory can be declared against it. Wrap it in a record or struct case instead. | +| `RSG9000` | Error | Generator | — | The generator failed unexpectedly. The failure is reported instead of escaping, because an escaping exception surfaces as `CS8785` and discards *all* generated output for the compilation. Please report it. | Case-level findings never block the union: the remaining cases are still generated, and the skipped case is reported. @@ -245,7 +247,8 @@ The suppression is narrow on purpose: - The union's **case types** keep the warning, as does every other value type. A case declared as a plain `struct` still gets `CA1815`; a `record struct` case never gets it, because records synthesise equality. - Nothing is hidden: a suppressor can only suppress non-error, configurable diagnostics, and `RSG2000` is a - suppression id rather than a rule, so `RSG1000`–`RSG1008` remain the only diagnostics this package reports. + suppression id rather than a rule, so `RSG1000`–`RSG1009` and `RSG9000` remain the only diagnostics this + package reports. Every suppression is logged as an `Info` diagnostic against `RSG2000`, in the verbose build log and in an MSBuild binlog (and as a suppressed diagnostic in an `/errorlog` SARIF file), so a build can always be audited @@ -304,8 +307,10 @@ A fix is only offered when the rewrite will bind: The component is not packable on its own: it needs `Microsoft.CodeAnalysis.CSharp.Workspaces`, which only the IDE host provides, so `Purview.Results` packs it into `analyzers/dotnet/cs/` as a second analyzer assembly -(never IL-merged into the generator). The two projects stay independent — the code fix does not reference the -generator — so no reference cycle can form and Workspaces never enters the generator's dependencies. +(never IL-merged into the generator). The code fix does not reference the generator; the generator references the +code-fix project analyzer-style only (`OutputItemType="Analyzer"`, `ReferenceOutputAssembly="false"`), so it is +built alongside without becoming a compile-time dependency. No reference cycle can form and Workspaces never +enters the generator's dependencies. ## Tests @@ -342,11 +347,11 @@ ASP.NET Core + Zod examples as well. ## Agent skills -This package ships the `purview-results-union-errors` agent skill, the `purview-results-union-author` agent and -the `migrate-error-returns-to-result-unions` prompt under `.agents/`, so a repository that imports -`Purview.BuildSdk` receives them in its own `.agents/` folder on the next restore or build. The skill covers -union modelling, the generated helpers, the diagnostics table, and the language rules that make the helper -necessary. +The `purview-results-union-errors` agent skill covers union modelling, the generated helpers, the diagnostics table, and the language rules that make the helper necessary. No package ships skills: install them, with every other Purview.Results skill, from the git repository with the skills CLI: + +```bash +npx skills add purview-dev/results +``` ## License diff --git a/src/src/SourceGenerator/SourceGenerator.csproj b/src/src/SourceGenerator/SourceGenerator.csproj index 37f54de..8a9eaae 100644 --- a/src/src/SourceGenerator/SourceGenerator.csproj +++ b/src/src/SourceGenerator/SourceGenerator.csproj @@ -8,7 +8,7 @@ --> true diff --git a/src/src/ZodSharp.AspNetCore/CompatibilitySuppressions.xml b/src/src/ZodSharp.AspNetCore/CompatibilitySuppressions.xml new file mode 100644 index 0000000..e907dff --- /dev/null +++ b/src/src/ZodSharp.AspNetCore/CompatibilitySuppressions.xml @@ -0,0 +1,32 @@ + + + + + CP0001 + T:ZodSharp.Core.ErrorTypeAttribute + lib/net10.0/Purview.Results.ZodSharp.AspNetCore.dll + lib/net10.0/Purview.Results.ZodSharp.AspNetCore.dll + true + + + CP0001 + T:ZodSharp.ZodSchemaAttribute + lib/net10.0/Purview.Results.ZodSharp.AspNetCore.dll + lib/net10.0/Purview.Results.ZodSharp.AspNetCore.dll + true + + + CP0001 + T:ZodSharp.Core.ErrorTypeAttribute + lib/net11.0/Purview.Results.ZodSharp.AspNetCore.dll + lib/net11.0/Purview.Results.ZodSharp.AspNetCore.dll + true + + + CP0001 + T:ZodSharp.ZodSchemaAttribute + lib/net11.0/Purview.Results.ZodSharp.AspNetCore.dll + lib/net11.0/Purview.Results.ZodSharp.AspNetCore.dll + true + + diff --git a/src/src/ZodSharp.AspNetCore/Sdk/README.md b/src/src/ZodSharp.AspNetCore/Sdk/README.md index 0b1f06f..cd99601 100644 --- a/src/src/ZodSharp.AspNetCore/Sdk/README.md +++ b/src/src/ZodSharp.AspNetCore/Sdk/README.md @@ -80,6 +80,17 @@ A factory receives the failure's **full error set**, so a rule never hides the o fix, and the `int statusCode` overloads still render every error as an `HttpValidationProblemDetails`. A code, category or origin registered twice with different behaviour is rejected at configuration time. +A failure whose case does not carry validation errors (is not an `IValidationErrorCarrier`) is declined (`null`), +so other mappers and fallbacks still apply — this mapper never answers a failure it does not understand. + +## .NET 11 request validation + +On .NET 11, Purview.ZodSharp 3's `AddZodSharpValidation()` validates a `[ZodSchema]` endpoint parameter **before +the handler runs** and answers an invalid request with ZodSharp's own validation problem, so the handler never +returns its result and the rules above never see the failure. A host whose handlers validate and return results +either does not call `AddZodSharpValidation()` or registers it for type validation only (`validateParameters: +false`). `AddZodSharpBlazor()` validates form models only and does not turn parameter validation on. + ## API | Member | Purpose | @@ -91,9 +102,13 @@ category or origin registered twice with different behaviour is rejected at conf | `ImmutableArray.ToValidationProblem(HttpContext, int statusCode = 400)` | Creates the validation problem for a set of errors | | `ZodValidationProblems.ToProblem(errors, options, defaultStatusCode = 400, traceId = null)` | The underlying mapper, for hosts that resolve the options themselves | -The status code resolved by `ZodProblemDetailsOptions.StatusCodeSelector` wins; `statusCode` (or -`defaultStatusCode`, or a rule's `statusCode`) is only used when the resolved error type does not define one. The -trace identifier is included when `ResultsHttpOptions.IncludeTraceId` is `true` (the default). +The response is written by Purview.ZodSharp's `ZodProblemDetailsWriter` with the host's `ZodProblemDetailsOptions`, +so every option applies exactly as it does to a thrown `ZodException` - path naming, `ErrorType` message formatting, +the `issues` extension, `MergeParametersIntoExtensions` and the error type resolver. The status code follows +Purview.ZodSharp 3's precedence: `statusCode` (or `defaultStatusCode`, or a rule's `statusCode`) is the default, the +highest `ErrorType.HttpStatus` replaces it, `ZodProblemDetailsOptions.StatusCodeSelector` wins over both, and +`StatusCodeOverride` has the last word. The trace identifier is included when `ResultsHttpOptions.IncludeTraceId` is +`true` (the default). ## Examples @@ -108,7 +123,8 @@ dotnet run --project src/src/Examples.AspNetCore.Zod --urls http://localhost:521 The [repository README](https://github.com/purview-dev/results#examples) lists the Basic, ZodSharp and ASP.NET Core examples too. The [value-objects composition example](https://github.com/purview-dev/results/tree/main/src/src/Examples.ValueObjects.Zod) -shows a type-level `[ZodRule]` whose `Origin = "value_object"` is answered by `MapOrigin`. +is a console app with no HTTP layer: it shows a type-level `[ZodRule]` reporting `Origin = "value_object"` into a +result failure — the identity a `MapOrigin("value_object", …)` rule in this package keys on. ## Related packages @@ -119,9 +135,11 @@ shows a type-level `[ZodRule]` whose `Origin = "value_object"` is answered by `M ## Agent skills -This package ships the `purview-results-zodsharp-problems` agent skill under `.agents/`. Repositories that import -`Purview.BuildSdk` get it mirrored into their own `.agents/` folder on the next restore or build, so AI agents -working there receive the guidance automatically. +The `purview-results-zodsharp-problems` agent skill covers this package. No package ships skills: install them, with every other Purview.Results skill, from the git repository with the skills CLI: + +```bash +npx skills add purview-dev/results +``` ## License diff --git a/src/src/ZodSharp.AspNetCore/ZodValidationProblems.cs b/src/src/ZodSharp.AspNetCore/ZodValidationProblems.cs index 553573e..83bbe57 100644 --- a/src/src/ZodSharp.AspNetCore/ZodValidationProblems.cs +++ b/src/src/ZodSharp.AspNetCore/ZodValidationProblems.cs @@ -2,7 +2,6 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; -using ZodSharp; using ZodSharp.AspNetCore; using ZodSharp.Core; @@ -39,12 +38,15 @@ public static IResult ToProblem( { ArgumentNullException.ThrowIfNull(options); - var statusCode = options.StatusCodeSelector?.Invoke(errors) ?? defaultStatusCode; - - // ZodSharp maps ProblemDetails through validation results, thrown exceptions and its exception handler, and - // every one of them shares one mapper. The exception view is the public shape that accepts raw errors, so it - // is reused here rather than duplicating the mapping rules. - var problem = new ZodException(errors).ToHttpValidationProblemDetails(options.Registry, statusCode); + // ZodSharp writes every ProblemDetails - its exception handler, endpoint filter and typed results - through + // one ZodProblemDetailsWriter configured by ZodProblemDetailsOptions, so the same writer is used here rather + // than duplicating the mapping rules. Handing it the whole options object (instead of only the registry, as + // Purview.ZodSharp 2 required) keeps every option the host set - path naming, message formatting, issues, + // parameter merging and the status-code precedence: default, then the highest ErrorType.HttpStatus, then + // StatusCodeSelector, then StatusCodeOverride - identical to a thrown ZodException's response. The only + // difference is the default status, which a ZodResultsHttpOptions rule may supply. + ZodProblemDetailsWriter writer = new(WithDefaultStatusCode(options, defaultStatusCode)); + var problem = writer.Create(errors.IsDefault ? [] : errors.AsSpan()); if (!string.IsNullOrEmpty(traceId)) problem.Extensions["traceId"] = traceId; @@ -52,6 +54,38 @@ public static IResult ToProblem( return WriteProblem(problem, defaultStatusCode); } + /// + /// The options with as their default status, copied rather than mutated + /// because the instance is the host's shared, configured options. + /// + /// + /// Every settable member of is copied, which + /// ZodValidationProblemsTests.WithDefaultStatusCode_GivenADifferentStatus_CopiesEverySettableOption pins, + /// so an option a later ZodSharp version adds cannot be dropped silently. + /// + /// The host's options. + /// The default status to apply. + /// itself when its default already matches; otherwise a copy. + internal static ZodProblemDetailsOptions WithDefaultStatusCode( + ZodProblemDetailsOptions options, + int defaultStatusCode + ) => + options.DefaultStatusCode == defaultStatusCode + ? options + : new() + { + Registry = options.Registry, + ErrorTypeResolver = options.ErrorTypeResolver, + FormatMessages = options.FormatMessages, + PathNaming = options.PathNaming, + IncludeIssues = options.IncludeIssues, + MergeParametersIntoExtensions = options.MergeParametersIntoExtensions, + ProjectDataAnnotationsIssues = options.ProjectDataAnnotationsIssues, + DefaultStatusCode = defaultStatusCode, + StatusCodeSelector = options.StatusCodeSelector, + StatusCodeOverride = options.StatusCodeOverride, + }; + [UnconditionalSuppressMessage( "Trimming", "IL2026:RequiresUnreferencedCode", diff --git a/src/src/ZodSharp/CompatibilitySuppressions.xml b/src/src/ZodSharp/CompatibilitySuppressions.xml new file mode 100644 index 0000000..f6e003a --- /dev/null +++ b/src/src/ZodSharp/CompatibilitySuppressions.xml @@ -0,0 +1,32 @@ + + + + + CP0001 + T:ZodSharp.Core.ErrorTypeAttribute + lib/net10.0/Purview.Results.ZodSharp.dll + lib/net10.0/Purview.Results.ZodSharp.dll + true + + + CP0001 + T:ZodSharp.ZodSchemaAttribute + lib/net10.0/Purview.Results.ZodSharp.dll + lib/net10.0/Purview.Results.ZodSharp.dll + true + + + CP0001 + T:ZodSharp.Core.ErrorTypeAttribute + lib/net11.0/Purview.Results.ZodSharp.dll + lib/net11.0/Purview.Results.ZodSharp.dll + true + + + CP0001 + T:ZodSharp.ZodSchemaAttribute + lib/net11.0/Purview.Results.ZodSharp.dll + lib/net11.0/Purview.Results.ZodSharp.dll + true + + diff --git a/src/src/ZodSharp/Sdk/README.md b/src/src/ZodSharp/Sdk/README.md index b80dc3e..303aa6c 100644 --- a/src/src/ZodSharp/Sdk/README.md +++ b/src/src/ZodSharp/Sdk/README.md @@ -49,6 +49,7 @@ return await ReconcileCoreAsync(validated.Value, repositories, cancellationToken | `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 | +| `Task>.ToResultAsync(...)` / `.ToUnitResultAsync(...)` | The same, for APIs that return a `Task` - Purview.ZodSharp.Http's request and response validation | | `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 @@ -72,6 +73,26 @@ return await TenantInputSchemaValidator .ToUnitResultAsync(errors => new TenantInputInvalid(input, errors)); ``` +## Validation over HTTP + +`ToResultAsync` and `ToUnitResultAsync` also accept a `Task>`, the shape +[Purview.ZodSharp.Http](https://www.nuget.org/packages/Purview.ZodSharp.Http) returns. A client's request and response +validation therefore folds into a result like a local `Validate`, and a server's validation problem reads back into +the same case: + +```csharp +// Validated before it is sent: an invalid body is never sent, and its errors become the case. +var sent = await http + .PostAsJsonValidatedAsync("/api/tenants", input, validator, AppJsonContext.Default.TenantInput, services) + .ToResultAsync(errors => new TenantInputInvalid(input, errors)); + +// Rejected by the server: the problem Purview.ZodSharp.AspNetCore wrote reads back into ZodSharp errors. +if (sent.TryGetValue(out var response) && await response.TryReadZodValidationProblemAsync() is { } problem) + return new TenantInputInvalid(input, problem.ToValidationErrors()).AsFailure(); +``` + +`validator` is the generated `{Type}SchemaValidator`. This package takes no dependency on Purview.ZodSharp.Http; +reference it in the client. Both are trim-, Native AOT- and Blazor WebAssembly-safe. ## Examples [`src/src/Examples.Zod`](https://github.com/purview-dev/results/tree/main/src/src/Examples.Zod) is a @@ -93,9 +114,11 @@ ASP.NET Core + Zod and value-objects composition examples too. ## Agent skills -This package ships the `purview-results-zodsharp-validation` agent skill under `.agents/`. Repositories that -import `Purview.BuildSdk` get it mirrored into their own `.agents/` folder on the next restore or build, so AI -agents working there receive the guidance automatically. +The `purview-results-zodsharp-validation` agent skill covers this package. No package ships skills: install them, with every other Purview.Results skill, from the git repository with the skills CLI: + +```bash +npx skills add purview-dev/results +``` ## License diff --git a/src/src/ZodSharp/ZodSharp.csproj b/src/src/ZodSharp/ZodSharp.csproj index bcef141..6bdd186 100644 --- a/src/src/ZodSharp/ZodSharp.csproj +++ b/src/src/ZodSharp/ZodSharp.csproj @@ -5,8 +5,7 @@ this package. --> true diff --git a/src/src/ZodSharp/ZodValidationResultExtensions.cs b/src/src/ZodSharp/ZodValidationResultExtensions.cs index 2545bce..5ad2dcc 100644 --- a/src/src/ZodSharp/ZodValidationResultExtensions.cs +++ b/src/src/ZodSharp/ZodValidationResultExtensions.cs @@ -139,6 +139,7 @@ Func, TError> onFailure /// 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. + /// An overload accepts a , for APIs such as Purview.ZodSharp.Http that return one. /// /// /// Because the method is asynchronous, the for a null @@ -193,7 +194,9 @@ Func, TError> onFailure /// /// 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. + /// to be named. Use + /// + /// when the success has to carry it. /// /// /// Because the method is asynchronous, the for a null @@ -214,4 +217,109 @@ Func, TError> onFailure return validated.IsSuccess ? Result.Success() : Result.Failure(onFailure(validated.Errors)); } + + /// + /// Converts a task-based 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<Tenant, TenantError>(...), because a union case does not carry the union type that + /// contains it. + /// + /// + /// The validation result to convert, such as the one Purview.ZodSharp.Http's + /// HttpContent.ReadFromJsonAndValidateAsync or HttpClient.PostAsJsonValidatedAsync 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 form of + /// , + /// for APIs that return a task rather than a value task — Purview.ZodSharp.Http's request and response + /// validation in particular — so a response body that fails its schema, or a request that was never sent + /// because its body did not validate, folds into the result pipeline exactly as a local Validate does. + /// + /// + /// Because the method is asynchronous, the for a null + /// or surfaces when the returned task is awaited + /// rather than at the call site. + /// + /// + /// + /// + /// using var response = await http.GetAsync($"/api/tenants/{tenantId}", cancellationToken); + /// + /// return await response.Content + /// .ReadFromJsonAndValidateAsync(TenantSchema.Instance, AppJsonContext.Default.Tenant, cancellationToken) + /// .ToResultAsync<Tenant, TenantError>(errors => new TenantResponseInvalid(errors)); + /// + /// + /// + /// Thrown when or is null, when the returned task is + /// awaited. + /// + public static async ValueTask> ToResultAsync( + this Task> validation, + Func, TError> onFailure + ) + { + ArgumentNullException.ThrowIfNull(validation); + ArgumentNullException.ThrowIfNull(onFailure); + + var validated = await validation.ConfigureAwait(false); + + return validated.IsSuccess + ? Result.Success(validated.Value) + : Result.Failure(onFailure(validated.Errors)); + } + + /// + /// Converts a task-based 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<TenantRegistration, TenantError>(...), because a union case does not carry the + /// union type that contains it. + /// + /// The validation result to convert, such as one Purview.ZodSharp.Http 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 form of + /// . + /// Because the method is asynchronous, the for a null + /// or surfaces when the returned task is awaited + /// rather than at the call site. + /// + /// + /// Thrown when or is null, when the returned task is + /// awaited. + /// + public static async ValueTask> ToUnitResultAsync( + this Task> validation, + Func, TError> onFailure + ) + { + ArgumentNullException.ThrowIfNull(validation); + ArgumentNullException.ThrowIfNull(onFailure); + + var validated = await validation.ConfigureAwait(false); + + return validated.IsSuccess ? Result.Success() : Result.Failure(onFailure(validated.Errors)); + } } diff --git a/src/tests/AspNetCore.UnitTests/ResultHttpResultTests.cs b/src/tests/AspNetCore.UnitTests/ResultHttpResultTests.cs new file mode 100644 index 0000000..7c5288d --- /dev/null +++ b/src/tests/AspNetCore.UnitTests/ResultHttpResultTests.cs @@ -0,0 +1,234 @@ +using System.Net; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace Purview.Results.AspNetCore; + +public sealed class ResultHttpResultTests +{ + [Test] + public async Task ToHttpResult_GivenSuccessfulResult_ReturnsOkWithTheValue() + { + // Arrange + await using var app = await CreateHostAsync(app => + app.MapGet("/ok", () => Result.Success(7).ToHttpResult()) + ); + using var client = app.GetTestClient(); + + // Act + var response = await client.GetAsync(new Uri("/ok", UriKind.Relative)); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + await Assert.That(await response.Content.ReadAsStringAsync()).IsEqualTo("7"); + } + + [Test] + public async Task ToHttpResult_GivenMappedFailure_ReturnsTheMappedResponse() + { + // Arrange + await using var app = await CreateHostAsync(app => + app.MapGet("/missing", () => new ItemNotFound(7).AsFailure().ToHttpResult()) + ); + using var client = app.GetTestClient(); + + // Act + var response = await client.GetAsync(new Uri("/missing", UriKind.Relative)); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + await Assert.That(response.Content.Headers.ContentType?.MediaType).IsEqualTo("application/problem+json"); + } + + [Test] + public async Task ToHttpResult_GivenUninitializedResult_ReturnsTheUninitializedProblem() + { + // Arrange + await using var app = await CreateHostAsync(app => + app.MapGet("/broken", () => default(Result).ToHttpResult()) + ); + using var client = app.GetTestClient(); + + // Act + var response = await client.GetAsync(new Uri("/broken", UriKind.Relative)); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.InternalServerError); + await Assert.That(await response.Content.ReadAsStringAsync()).Contains("results_uninitialized"); + } + + [Test] + public async Task ToHttpResult_GivenSuccessfulUnitResult_ReturnsNoContent() + { + // Arrange + await using var app = await CreateHostAsync(app => + app.MapDelete("/items/{id:int}", (int id) => Result.Success().ToHttpResult()) + ); + using var client = app.GetTestClient(); + + // Act + var response = await client.DeleteAsync(new Uri("/items/7", UriKind.Relative)); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NoContent); + await Assert.That(await response.Content.ReadAsStringAsync()).IsEmpty(); + } + + [Test] + public async Task ToHttpResult_GivenFailedUnitResult_ReturnsTheMappedResponse() + { + // Arrange + await using var app = await CreateHostAsync(app => + app.MapDelete("/items/{id:int}", (int id) => new ItemNotFound(id).AsFailure().ToHttpResult()) + ); + using var client = app.GetTestClient(); + + // Act + var response = await client.DeleteAsync(new Uri("/items/7", UriKind.Relative)); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task ToHttpResult_GivenEndpointUnderWithResultsHttp_IsLeftToExecuteItself() + { + // Arrange + await using var app = await CreateHostAsync(app => + app.MapGet("/missing", () => new ItemNotFound(7).AsFailure().ToHttpResult()).WithResultsHttp() + ); + using var client = app.GetTestClient(); + + // Act + var response = await client.GetAsync(new Uri("/missing", UriKind.Relative)); + + // Assert + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task Result_GivenAWrappedResult_ReturnsIt() + { + // Arrange + var result = Result.Success(7); + + // Act + var httpResult = result.ToHttpResult(); + + // Assert + await Assert.That(httpResult.Result).IsEqualTo(result); + } + + [Test] + public async Task ExecuteAsync_GivenNoRegisteredMapper_ThrowsNamingTheMissingRegistration() + { + // Arrange + var context = ResultsHttpTestFactory.CreateContext(new ServiceCollection().BuildServiceProvider()); + var httpResult = Result.Success(7).ToHttpResult(); + + // Act & Assert + var exception = await Assert + .That(async () => await httpResult.ExecuteAsync(context)) + .Throws(); + await Assert.That(exception!.Message).Contains("AddResultsHttp()"); + } + + [Test] + public async Task ExecuteAsync_GivenNullContext_ThrowsArgumentNullException() + { + // Arrange + var httpResult = Result.Success().ToHttpResult(); + + // Act & Assert + await Assert.That(async () => await httpResult.ExecuteAsync(null!)).Throws(); + } + + [Test] + public async Task PopulateMetadata_GivenValueResult_DescribesTheValueWithTheSuccessStatusCode() + { + // Arrange + await using var app = await CreateHostAsync( + app => app.MapGet("/ok", () => Result.Success(7).ToHttpResult()), + options => options.SuccessStatusCode = StatusCodes.Status201Created + ); + + // Act + var produces = GetProducesMetadata(app); + + // Assert + await Assert.That(produces.Count).IsEqualTo(1); + await Assert.That(produces[0].StatusCode).IsEqualTo(StatusCodes.Status201Created); + await Assert.That(produces[0].Type).IsEqualTo(typeof(int)); + await Assert.That(produces[0].ContentTypes).IsEquivalentTo(["application/json"]); + } + + [Test] + public async Task PopulateMetadata_GivenSuccessMapper_DescribesNothing() + { + // Arrange + await using var app = await CreateHostAsync( + app => app.MapGet("/ok", () => Result.Success(7).ToHttpResult()), + options => options.SuccessMapper = (_, _) => TypedResults.NoContent() + ); + + // Act + var produces = GetProducesMetadata(app); + + // Assert + await Assert.That(produces).IsEmpty(); + } + + [Test] + public async Task PopulateMetadata_GivenUnitResult_DescribesNoContent() + { + // Arrange + await using var app = await CreateHostAsync(app => + app.MapDelete("/items/{id:int}", (int id) => Result.Success().ToHttpResult()) + ); + + // Act + var produces = GetProducesMetadata(app); + + // Assert + await Assert.That(produces.Count).IsEqualTo(1); + await Assert.That(produces[0].StatusCode).IsEqualTo(StatusCodes.Status204NoContent); + await Assert.That(produces[0].Type).IsNull(); + } + + static List GetProducesMetadata(WebApplication app) => + [ + .. ((IEndpointRouteBuilder)app) + .DataSources.SelectMany(static source => source.Endpoints) + .Single() + .Metadata.OfType(), + ]; + + static async Task CreateHostAsync( + Action configure, + Action? configureOptions = null + ) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + + builder.Services.AddResultsHttp(options => + { + options.Map(notFound => + TypedResults.Problem(statusCode: StatusCodes.Status404NotFound, title: $"Item {notFound.ItemId}.") + ); + configureOptions?.Invoke(options); + }); + + var app = builder.Build(); + + configure(app); + + await app.StartAsync(); + + return app; + } +} diff --git a/src/tests/Repository.UnitTests/InstalledSkillTests.cs b/src/tests/Repository.UnitTests/InstalledSkillTests.cs new file mode 100644 index 0000000..ca6dffa --- /dev/null +++ b/src/tests/Repository.UnitTests/InstalledSkillTests.cs @@ -0,0 +1,170 @@ +using System.Text.Json; + +namespace Purview.Results.Repository; + +/// +/// Guards the skills installed from other repositories. They are installed by the skills CLI into the +/// universal .agents/skills/ folder only, and that folder and the CLI's skills-lock.json +/// are committed, so the two must agree and no per-agent copy or instruction file may appear beside +/// them. +/// +/// +/// SkillTests validates this repository's own skills in skills/; these are someone else's, +/// so their content is not checked here, only that the installed set is the recorded set. +/// +public sealed class InstalledSkillTests +{ + static readonly Lazy RepositoryRoot = new(FindRepositoryRoot); + + [Test] + public async Task Lockfile_GivenEveryEntry_HasItsSkillInstalledUnderAgents(CancellationToken cancellationToken) + { + // Arrange + var recorded = await ReadLockfileSkillsAsync(cancellationToken); + + // Act + var missing = recorded + .Where(static name => !File.Exists(Path.Combine(AgentsSkillsFolder(), name, "SKILL.md"))) + .ToArray(); + + // Assert + await Assert.That(recorded).IsNotEmpty().Because("skills-lock.json records no installed skills"); + await Assert + .That(missing) + .IsEmpty() + .Because( + "skills-lock.json records skills that are not installed in .agents/skills/. Restore them with " + + $"`npx -y skills@1 experimental_install`: {string.Join(", ", missing)}" + ); + } + + [Test] + public async Task AgentsSkills_GivenEveryInstalledSkill_IsRecordedInTheLockfile(CancellationToken cancellationToken) + { + // Arrange + var recorded = await ReadLockfileSkillsAsync(cancellationToken); + + // Act + var unrecorded = Directory + .GetDirectories(AgentsSkillsFolder()) + .Select(static path => Path.GetFileName(path)) + .Where(name => !recorded.Contains(name, StringComparer.Ordinal)) + .ToArray(); + + // Assert + await Assert + .That(unrecorded) + .IsEmpty() + .Because( + "every folder in .agents/skills/ must be written by the skills CLI, which records it in " + + $"skills-lock.json: {string.Join(", ", unrecorded)}" + ); + } + + [Test] + public async Task AgentsSkills_GivenTheAuthoredSkills_HoldsNoCopyOfThem(CancellationToken cancellationToken) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + var authored = Directory + .GetDirectories(Path.Combine(RepositoryRoot.Value, "skills")) + .Select(static path => Path.GetFileName(path)) + .ToArray(); + + // Act + var copied = authored + .Where(static name => Directory.Exists(Path.Combine(AgentsSkillsFolder(), name))) + .ToArray(); + + // Assert + await Assert + .That(copied) + .IsEmpty() + .Because( + "this repository's own skills live in skills/ only; a copy in .agents/skills/ would drift from " + + $"the authored one: {string.Join(", ", copied)}" + ); + } + + [Test] + public async Task ClaudeFolder_GivenTheInstalledSkills_HoldsNoCopy(CancellationToken cancellationToken) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + var claudeSkills = Path.Combine(RepositoryRoot.Value, ".claude", "skills"); + + // Act + var exists = Directory.Exists(claudeSkills); + + // Assert + await Assert + .That(exists) + .IsFalse() + .Because( + "skills are installed into .agents/skills/ only. A .claude/skills/ folder means the CLI ran without " + + "`-a universal`; delete it and reinstall with `-a universal`" + ); + } + + [Test] + public async Task Repository_GivenAgentInstructions_HasNoClaudeMd(CancellationToken cancellationToken) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + string[] candidates = + [ + Path.Combine(RepositoryRoot.Value, "CLAUDE.md"), + Path.Combine(RepositoryRoot.Value, ".github", "CLAUDE.md"), + ]; + + // Act + var present = candidates.Where(File.Exists).ToArray(); + + // Assert + await Assert + .That(present) + .IsEmpty() + .Because( + "this repository is not agent-specific: AGENTS.md is its one instruction file, so a CLAUDE.md " + + $"must not be added: {string.Join(", ", present)}" + ); + } + + static string AgentsSkillsFolder() => Path.Combine(RepositoryRoot.Value, ".agents", "skills"); + + static async Task ReadLockfileSkillsAsync(CancellationToken cancellationToken) + { + await using var stream = File.OpenRead(Path.Combine(RepositoryRoot.Value, "skills-lock.json")); + using var lockfile = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + + return + [ + .. lockfile + .RootElement.GetProperty("skills") + .EnumerateObject() + .Select(static property => property.Name) + .Order(StringComparer.Ordinal), + ]; + } + + static string FindRepositoryRoot() + { + for ( + var directory = new DirectoryInfo(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent + ) + { + if ( + File.Exists(Path.Combine(directory.FullName, "AGENTS.md")) + && File.Exists(Path.Combine(directory.FullName, "skills-lock.json")) + && Directory.Exists(Path.Combine(directory.FullName, "src", "src")) + ) + return directory.FullName; + } + + throw new InvalidOperationException( + $"No repository root above '{AppContext.BaseDirectory}' carries AGENTS.md, skills-lock.json and src/src." + ); + } +} diff --git a/src/tests/ZodSharp.AspNetCore.UnitTests/ZodResultsFailureMapperTests.cs b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodResultsFailureMapperTests.cs index 7db8da3..75aa5c3 100644 --- a/src/tests/ZodSharp.AspNetCore.UnitTests/ZodResultsFailureMapperTests.cs +++ b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodResultsFailureMapperTests.cs @@ -245,7 +245,7 @@ public async Task Map_GivenAFailureFromAValueObjectRule_AnswersByOrigin() var mapper = services.GetRequiredService(); var context = CreateContext(services); - var validation = ValidatedTenantIdSchema.Validate(ValidatedTenantId.Hydrate(Guid.Empty)); + var validation = await ValidatedTenantIdSchema.ValidateAsync(ValidatedTenantId.Hydrate(Guid.Empty)); await Assert.That(validation.IsSuccess).IsFalse(); // Act @@ -273,9 +273,11 @@ public async Task Map_GivenAFailureFromANullableScalarRule_AnswersByItsCode() var mapper = services.GetRequiredService(); var context = CreateContext(services); - await Assert.That(NullableTenantAliasSchema.Validate(NullableTenantAlias.Hydrate(null)).IsSuccess).IsTrue(); + await Assert + .That((await NullableTenantAliasSchema.ValidateAsync(NullableTenantAlias.Hydrate(null))).IsSuccess) + .IsTrue(); - var validation = NullableTenantAliasSchema.Validate(NullableTenantAlias.Hydrate(" ")); + var validation = await NullableTenantAliasSchema.ValidateAsync(NullableTenantAlias.Hydrate(" ")); await Assert.That(validation.IsSuccess).IsFalse(); // Act @@ -302,7 +304,7 @@ public async Task Map_GivenAUnitResultFailureFromAValueObjectRule_AnswersTheVali var mapper = services.GetRequiredService(); var context = CreateContext(services); - var validation = ValidatedTenantIdSchema.Validate(ValidatedTenantId.Hydrate(Guid.Empty)); + var validation = await ValidatedTenantIdSchema.ValidateAsync(ValidatedTenantId.Hydrate(Guid.Empty)); // Act var result = validation.ToUnitResult(errors => new InputRejected( diff --git a/src/tests/ZodSharp.AspNetCore.UnitTests/ZodValidationProblemsTests.cs b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodValidationProblemsTests.cs index bd09331..cecadb3 100644 --- a/src/tests/ZodSharp.AspNetCore.UnitTests/ZodValidationProblemsTests.cs +++ b/src/tests/ZodSharp.AspNetCore.UnitTests/ZodValidationProblemsTests.cs @@ -105,10 +105,8 @@ public async Task ToProblem_GivenTheSameErrorsAShownException_ProducesTheSameRes ZodValidationProblems.ToProblem(errors, options), context ); - var exceptionProblem = new ZodException(errors).ToHttpValidationProblemDetails( - registry, - StatusCodes.Status400BadRequest - ); + // The options-based conversion is the writer ZodSharp's exception handler uses. + var exceptionProblem = new ZodException(errors).ToHttpValidationProblemDetails(options); // Assert await Assert.That(statusCode).IsEqualTo(exceptionProblem.Status); @@ -125,6 +123,131 @@ await Assert await Assert.That(issues.GetArrayLength()).IsEqualTo(1); } + [Test] + public async Task ToProblem_GivenAnErrorTypeMessageFormat_FormatsTheMessageLikeAThrownException() + { + // Arrange + ErrorTypeRegistry registry = new(); + registry.Register( + new ErrorType( + Code: InvalidNameCode, + Category: "invalid_value", + Description: "The name is not valid.", + HttpStatus: StatusCodes.Status422UnprocessableEntity, + MessageFormat: "The name '{Name}' is not valid.", + Parameters: [ErrorType.Param("Name")] + ) + ); + ZodProblemDetailsOptions options = new() { Registry = registry }; + var errors = CreateErrors(); + + // Act + var (_, body, _) = await ExecuteAsync(ZodValidationProblems.ToProblem(errors, options), CreateContext()); + var exceptionProblem = new ZodException(errors).ToHttpValidationProblemDetails(options); + + // Assert + using var document = JsonDocument.Parse(body); + var message = document.RootElement.GetProperty("errors").GetProperty("Name")[0].GetString(); + await Assert.That(message).IsEqualTo("The name 'operation' is not valid."); + await Assert.That(message).IsEqualTo(exceptionProblem.Errors["Name"][0]); + } + + [Test] + public async Task ToProblem_GivenMergeParametersIntoExtensions_WritesTheParametersAtTheRoot() + { + // Arrange + ZodProblemDetailsOptions merged = new() { MergeParametersIntoExtensions = true }; + + // Act + var (_, mergedBody, _) = await ExecuteAsync( + ZodValidationProblems.ToProblem(CreateErrors(), merged), + CreateContext() + ); + var (_, defaultBody, _) = await ExecuteAsync( + ZodValidationProblems.ToProblem(CreateErrors(), CreateOptions()), + CreateContext() + ); + + // Assert + using var mergedDocument = JsonDocument.Parse(mergedBody); + using var defaultDocument = JsonDocument.Parse(defaultBody); + await Assert.That(mergedDocument.RootElement.TryGetProperty("name", out var name)).IsTrue(); + await Assert.That(name.GetString()).IsEqualTo("operation"); + await Assert.That(defaultDocument.RootElement.TryGetProperty("name", out _)).IsFalse(); + } + + [Test] + public async Task ToProblem_GivenARuleStatusAndAStatusCodeSelector_LetsTheSelectorWin() + { + // Arrange - the default status a ZodResultsHttpOptions rule supplies is the lowest-precedence input, as it is + // for ZodProblemDetailsOptions.DefaultStatusCode. + ZodProblemDetailsOptions options = new() { StatusCodeSelector = _ => StatusCodes.Status409Conflict }; + + // Act + var (statusCode, _, _) = await ExecuteAsync( + ZodValidationProblems.ToProblem(CreateErrors(), options, StatusCodes.Status422UnprocessableEntity), + CreateContext() + ); + + // Assert + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status409Conflict); + } + + [Test] + public async Task ToProblem_GivenARuleStatus_UsesItAsTheDefault() + { + // Act + var (statusCode, _, _) = await ExecuteAsync( + ZodValidationProblems.ToProblem(CreateErrors(), CreateOptions(), StatusCodes.Status422UnprocessableEntity), + CreateContext() + ); + + // Assert + await Assert.That(statusCode).IsEqualTo(StatusCodes.Status422UnprocessableEntity); + } + + [Test] + public async Task WithDefaultStatusCode_GivenADifferentStatus_CopiesEverySettableOption() + { + // Arrange - every settable option set to a non-default value, so a member the copy misses shows up here. + ErrorTypeRegistry registry = new(); + Func resolver = _ => null; + Func, int> selector = _ => 418; + Func, int, int> statusOverride = (_, status) => status; + ZodProblemDetailsOptions options = new() + { + Registry = registry, + ErrorTypeResolver = resolver, + FormatMessages = false, + PathNaming = ZodPathNaming.Json, + IncludeIssues = false, + MergeParametersIntoExtensions = true, + ProjectDataAnnotationsIssues = false, + DefaultStatusCode = StatusCodes.Status400BadRequest, + StatusCodeSelector = selector, + StatusCodeOverride = statusOverride, + }; + + // Act + var copy = ZodValidationProblems.WithDefaultStatusCode(options, StatusCodes.Status422UnprocessableEntity); + + // Assert + await Assert.That(copy).IsNotSameReferenceAs(options); + await Assert.That(copy.DefaultStatusCode).IsEqualTo(StatusCodes.Status422UnprocessableEntity); + var settable = typeof(ZodProblemDetailsOptions) + .GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) + .Where(static property => + property.CanWrite && property.Name != nameof(ZodProblemDetailsOptions.DefaultStatusCode) + ); + foreach (var property in settable) + { + await Assert + .That(Equals(property.GetValue(copy), property.GetValue(options))) + .IsTrue() + .Because($"ZodProblemDetailsOptions.{property.Name} must be copied"); + } + } + [Test] public async Task ToValidationProblem_GivenValidationErrorCarrier_ReturnsValidationProblem() { diff --git a/src/tests/ZodSharp.UnitTests/ZodValidationResultExtensionsTests.cs b/src/tests/ZodSharp.UnitTests/ZodValidationResultExtensionsTests.cs index 77220a9..f10eb2d 100644 --- a/src/tests/ZodSharp.UnitTests/ZodValidationResultExtensionsTests.cs +++ b/src/tests/ZodSharp.UnitTests/ZodValidationResultExtensionsTests.cs @@ -293,4 +293,120 @@ await Assert .That(async () => await validation.ToUnitResultAsync(null!)) .Throws(); } + + [Test] + public async Task ToResultAsync_GivenSuccessfulTaskValidation_ReturnsTheValidatedValueWithoutInvokingTheFactory() + { + // Arrange + var validation = Task.FromResult(ValidationResult.Success(42)); + var factoryInvocations = 0; + + // Act + var result = await validation.ToResultAsync(errors => + { + factoryInvocations++; + return errors.Length; + }); + + // Assert + await Assert.That(result.IsSuccess).IsTrue(); + await Assert.That(result.Value).IsEqualTo(42); + await Assert.That(factoryInvocations).IsEqualTo(0); + } + + [Test] + public async Task ToResultAsync_GivenFailedTaskValidation_KeepsEveryError() + { + // Arrange + var validation = Task.FromResult( + ValidationResult.Failure([ + ValidationError.Create("first", "The first failure.", []), + ValidationError.Create("second", "The second failure.", []), + ]) + ); + + // Act + var result = await validation.ToResultAsync(errors => new OperationRejected( + new Operation(), + errors + )); + + // Assert + await Assert.That(result.IsFailure).IsTrue(); + await Assert.That(result.Error is OperationRejected { Errors.Length: 2 }).IsTrue(); + } + + [Test] + public async Task ToResultAsync_GivenNullTask_ThrowsArgumentNullException() + { + // Arrange + Task> validation = null!; + + // Act & Assert + await Assert + .That(async () => await validation.ToResultAsync(errors => errors.Length)) + .Throws(); + } + + [Test] + public async Task ToResultAsync_GivenNullFailureFactoryForTask_ThrowsArgumentNullException() + { + // Arrange + var validation = Task.FromResult(ValidationResult.Success(1)); + + // Act & Assert + await Assert + .That(async () => await validation.ToResultAsync(null!)) + .Throws(); + } + + [Test] + public async Task ToUnitResultAsync_GivenSuccessfulTaskValidation_ReturnsAUnitSuccessWithoutInvokingTheFactory() + { + // Arrange + var validation = Task.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.IsSuccess).IsTrue(); + await Assert.That(factoryInvocations).IsEqualTo(0); + } + + [Test] + public async Task ToUnitResultAsync_GivenFailedTaskValidation_ReturnsAUnitFailureCarryingTheCreatedError() + { + // Arrange + var validation = Task.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.IsFailure).IsTrue(); + await Assert.That(result.Error is OperationRejected).IsTrue(); + } + + [Test] + public async Task ToUnitResultAsync_GivenNullTask_ThrowsArgumentNullException() + { + // Arrange + Task> validation = null!; + + // Act & Assert + await Assert + .That(async () => await validation.ToUnitResultAsync(errors => "rejected")) + .Throws(); + } } diff --git a/src/tests/ZodSharp.UnitTests/ZodValidationResultIntegrationTests.cs b/src/tests/ZodSharp.UnitTests/ZodValidationResultIntegrationTests.cs index 6648da6..90389e1 100644 --- a/src/tests/ZodSharp.UnitTests/ZodValidationResultIntegrationTests.cs +++ b/src/tests/ZodSharp.UnitTests/ZodValidationResultIntegrationTests.cs @@ -4,6 +4,12 @@ namespace Purview.Results.ZodSharp; /// Integration tests that run the real ZodSharp-generated schemas of this test project and compose their outcomes /// into results whose error is the local union. /// +[System.Diagnostics.CodeAnalysis.SuppressMessage( + "Performance", + "CA1849:Call async methods when in an async method", + Justification = "These tests fold the synchronous schema's ValidationResult through the synchronous ToResult " + + "bridge, and one compares it with the asynchronous path; Purview.ZodSharp 3 added ValidateAsync beside it." +)] public class ZodValidationResultIntegrationTests { const string InvalidOperationResultCode = "invalid_operation_result";