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-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..4bb5cb6 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -17,7 +17,7 @@ "rollForward": false }, "purview.build": { - "version": "0.7.1", + "version": "0.8.1", "commands": [ "purview-build" ], diff --git a/.config/purview-build.json b/.config/purview-build.json index 1a770f3..5772e2d 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -26,6 +26,47 @@ "README.md", "purview-logo-light.png" ] + }, + // Every package ("*"). RequireExplicitContent (on by default) 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 proves every rule fails a package. + "ForbiddenContent": { + "*": [ + // The generator ships IL-merged, with Purview.SourceGeneratorFramework internalized into it; a loose copy + // under analyzers/ means the merge did not run. + "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.ValueObjects.SourceGenerator*", + "ref/**/Purview.ValueObjects.SourceGenerator*", + "runtimes/**/Purview.ValueObjects.SourceGenerator*" + ] } }, "Release": { 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/workflows/pr.yml b/.github/workflows/pr.yml index 28dfb99..dbd9ee1 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -15,7 +15,59 @@ jobs: with: # The shared workflow defaults to the 10.0.x SDK, which cannot build the net11.0 target this # repository gained (NETSDK1045). 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" run-pack: true validate-pack: true - secrets: inherit \ No newline at end of file + secrets: inherit + + # Every other job builds against ProjectReferences; this one builds what ships. It packs Purview.ValueObjects to an + # isolated feed and package cache, then publishes and runs consumers that reference the package only - a Native AOT + # console, a Blazor WebAssembly app (trimmed and RunAOTCompilation, run in headless Chrome), a Blazor Server app + # and a net8.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* and Purview.ZodSharp* only, for versions that are not on + # nuget.org yet. Unset, the gate restores them from nuget.org alone. + VALUEOBJECTS_DEPENDENCY_FEED: ${{ vars.VALUEOBJECTS_DEPENDENCY_FEED }} + steps: + - uses: actions/checkout@v7 + + # The SDK from global.json, plus the .NET 8 runtime the net8.0 consumer runs on. + - uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + dotnet-version: 8.0.x + + # First, before any toolchain is installed: the generator is built against the Purview.SourceGeneratorFramework + # version Directory.Packages.props pins, and the consumers use the pinned Purview.ZodSharp. Until both are on + # nuget.org (or the VALUEOBJECTS_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 package + 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 8e47abe..ca3988f 100644 --- a/.gitignore +++ b/.gitignore @@ -593,4 +593,14 @@ coverage/ !src/src/EventSourcing/build !assets/packages/* +# The packed-consumer fixtures under scripts/packed-consumers are real, hand-written Blazor apps, not build output. +# The web section above ignores *.html/*.css/*.js at any depth, which silently kept the WebAssembly consumer's +# wwwroot/index.html out of the repository: a fresh clone then published an app with no index.html and the gate's +# static file host answered 500 for /. +!scripts/packed-consumers/**/wwwroot/** + .tools/ + +# 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 5d43896..40b6cf4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,8 @@ a diagnostic analyzer, a code fix, tests, samples, and documentation for scalar | `global.json` | Required .NET SDK and Microsoft.Testing.Platform selection | | `package.json` | Authoritative repository/package version | | `Justfile` | Supported local workflow commands | +| `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)) | ## Standard workflow @@ -79,6 +81,8 @@ a diagnostic analyzer, a code fix, tests, samples, and documentation for scalar - `ScalarJsonConverterFactory` uses only reflection over `ScalarAttribute` + `Create`/`Hydrate`/constructor; keep it free of event-sourcing dependencies. - Keep the runtime package dependency-free (System.Text.Json is in-box for the supported TFMs). +- Purview.ZodSharp 3 ships `net10.0`/`net11.0` only. A project that references it selects + `PurviewTargetFrameworkSet=Supported`; the runtime package does not reference it and keeps `All`. ## Trimming and Native AOT (non-negotiable) @@ -92,7 +96,16 @@ a diagnostic analyzer, a code fix, tests, samples, and documentation for scalar suppressions accurate. Do not add reflection anywhere else in the runtime. - Never weaken or delete an AOT/trim annotation or suppression to silence a warning; fix the call site or move the reflection into the annotated factory. -- A feature is not complete until the generated path is confirmed clean under the trim/AOT analyzers. +- A feature is not complete until the generated path is confirmed clean under the trim/AOT analyzers. The in-repo + projects never trim-analyze generated code, so the proof is the packed-consumer gate (`just smoke-packed-consumers`, + `scripts/test-packed-consumers.ps1`): it publishes a Native AOT console and a Blazor WebAssembly app (trimmed and + `RunAOTCompilation`) from the packed package with `-warnaserror`, runs them, and leak-scans every output. +- Generated JSON converters read and write through `ValueObjectJson` (runtime), never through the + `JsonSerializer` overloads that take only `JsonSerializerOptions`. The generated Entity Framework Core + JSON-column converters likewise go through `ValueObjectJson.Serialize`/`Deserialize` with the type's converter; + only a value object with no usable converter (`VO1017`) keeps `JsonSerializer`. +- The runtime declares ``, so the platform compatibility analyzer runs for + Blazor WebAssembly. A new CA1416 warning is a defect, like a trim/AOT warning. ## Testing conventions @@ -121,17 +134,25 @@ a diagnostic analyzer, a code fix, tests, samples, and documentation for scalar Prefer the `Justfile` recipes or their equivalent commands: ```text -dotnet tool restore +./scripts/restore-dotnet-tools.ps1 just restore just build just test just lint-check just pack just pipeline-pr +just smoke-packed-consumers +just verify-pack-rules ``` +`just smoke-packed-consumers` is the packed-consumer gate (Native AOT, Blazor WebAssembly trimmed and AOT in a +headless browser, Blazor Server, net8.0, compiler-host leak scan). 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 generated code, the runtime, packaging or a dependency version changes. + The repository uses the shared `Purview.Build` pipeline (`purview-build.json` at the root). `just pipeline-pr` -runs `dotnet tool restore`, which installs the pinned `Purview.Build` from `.config/dotnet-tools.json`. +restores the local tools with `scripts/restore-dotnet-tools.ps1`, which installs the pinned `Purview.Build` from +`.config/dotnet-tools.json` and retries a cold-cache `dotnet tool restore` failure (see that script's help). Local CI-equivalent validation: @@ -167,17 +188,69 @@ Before handing work back: - Update all affected docs, samples, analyzer metadata, and release notes when applicable. - Run appropriate build, test, formatting, and pack checks in proportion to risk. - State exactly what validation ran. If a check was skipped or blocked, give the concrete reason and remaining risk. + +## Installed skills (`.agents/skills/`) + +Specialist guidance this repository depends on - the Purview engineering playbook, `Purview.BuildSdk` +configuration, the `Purview.SourceGeneratorFramework` generator and test APIs, and the ZodSharp integration - 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 | +| `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 a `VO1xxx` diagnostic or the analyzer | +| `purview-sourcegenerator-packaging` | `purview-dev/sourcegenerator-framework` | changing how the generator is 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 `[ZodSchema]` integration, custom rules, JSON, ASP.NET Core and `ZODSGEN` diagnostics | + +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 + +# 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: `Purview.BuildSdk` delivers skills through the skills CLI, which clones the *git* repository and only discovers a root `skills/` folder. -`.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`. -Install these skills into your agent's directories with: +Consumers install these skills with: ```bash npx skills add purview-dev/value-objects diff --git a/CHANGELOG.md b/CHANGELOG.md index f620ac3..a4dc66c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,132 @@ Released versions correspond to `v` GitHub releases. `package.json` is newest heading below is the version in development. Earlier releases are listed under [releases](https://github.com/purview-dev/value-objects/releases). -## Unreleased +## 1.3.0-prerelease.2 + +A prerelease of fixes and guidance: no target framework is dropped and the public API only grows. + +### Fixed + +- **The generated Entity Framework Core JSON-column converters are trim- and Native AOT-safe.** A complex value + object with `EFMapping = EntityFrameworkMapping.Json` (its own `EF.Converter`, or the inline converter a + consumer's registry emits for a value object from an assembly without EF Core) converted through + `JsonSerializer.Serialize(vo)`/`JsonSerializer.Deserialize(v)`, so every trimmed or AOT build got IL2026/IL3050 + from generated code. The converter now writes and reads through the type's JSON converter (its own + `[JsonConverter]`, otherwise the generated one) with `ValueObjectJson.Serialize`/`Deserialize`, and the column + content is unchanged. A value object with `GenerateJsonConverter = false` (`VO1017`), or whose `[JsonConverter]` + names a factory, keeps the `JsonSerializer` call. +- **ZodSharp issue paths for a request of value objects.** A strict value object that failed its ZodSharp schema + while a request was deserialized was reported at its own path (`Value`, `Field 'Value' ...`): a converter cannot + know where in the document it sits. The generated strict converters now throw the validator's exception inside a + `JsonException`, so System.Text.Json records the member (`$.customerEmail`), and Purview.ZodSharp + 3.0.0-prerelease.2 re-roots the errors there: `CustomerEmail.Value`, `Field 'CustomerEmail' ...`, + `Copies[1].Value`. Verified under the JIT on .NET 10 and 11 and under Native AOT; on .NET 10 it needs + `AddZodSharpProblemDetails(o => o.HandleBindingFailures = true)`. +- **ZodSharp Problem Details guidance.** The docs, the ZodSharp skill and the ASP.NET Core sample registered + `ScalarJsonConverterFactory` for strict value objects. Under Native AOT every request then returned `500`, and with + earlier ZodSharp builds the response never reached `ZodExceptionHandler` (a bare `400`, or a `500` in Development). + The guidance, skill and sample now validate the bound request with the default `Hydrate` mode, + `WithZodSharpValidation()` and the generated `AddZodSharp{Assembly}Schemas()` registration. That reports **every** + invalid member at its full path; `Strict` stops at the first. + +### Added + +- `ValueObjectJson.Serialize`/`Deserialize`, the reflection-free string helpers the + Entity Framework Core JSON-column converters call, and `ValueObjectJson.IsValidationFailure`/`ValidationFailed`, + the rule and exception the strict converters (generated and `ScalarJsonConverterFactory`) share. +- The runtime declares `browser` as a supported platform (``), so the + platform compatibility analyzer (CA1416) reports any API it calls that Blazor WebAssembly does not support. +- Guidance for `Purview.ZodSharp.Http` with value objects, and an account of each ZodSharp package under trimming + and Native AOT, in [ZodSharp Validation](docs/ZodSharp-Validation.md#typed-http-clients) and + [Trimming, Native AOT and Blazor](docs/Trimming-AOT-and-Blazor.md#zodsharp-packages). + +### Changed + +- **A generated strict converter throws `JsonException` for a value that fails validation**, with the validator's + exception (`ArgumentException` by default, `ZodException` with ZodSharp) as its `InnerException` and the + member's location in `Path`. It used to let the validator's exception escape unwrapped. This is what + `ScalarJsonConverterFactory` already did, so the two paths now agree, and ASP.NET Core answers the failure as bad + input. `Hydrate` (the default) is unchanged: it never validates. +- Built and tested against Purview.ZodSharp 3.0.0-prerelease.2. The ASP.NET Core sample uses the + `ToHttpValidationProblemDetails(ZodProblemDetailsOptions, HttpContext)` overload that replaces the registry-only + one this release made obsolete. + +### Upgrade notes + +- **Strict deserialization failures are `JsonException`s.** Code that deserializes a strict value object and catches + `ArgumentException` or `ZodException` should catch `JsonException` and read its `InnerException`; with ZodSharp, + `ZodJsonExceptions.TryGetErrors(exception, typeInfo, out var errors)` returns the errors under the member. +- **ZodSharp Problem Details:** take Purview.ZodSharp 3.0.0-prerelease.2 or later. On .NET 10 with strict value + objects, set `AddZodSharpProblemDetails(o => o.HandleBindingFailures = true)`. Remove any + `ScalarJsonConverterFactory` registration. To report every invalid member rather than the first, validate the + bound request instead: drop `Strict`, mark the request `[ZodSchema]`, register `AddZodSharp{Assembly}Schemas()` + and add `.WithZodSharpValidation()` (or, on `net11.0`, `AddZodSharpValidation()`). Either way, error keys change + from `Value` to the member path (`CustomerEmail.Value`). +- **`Purview.ZodSharp.Http` and a same-project `JsonSerializerContext`.** Pass a `JsonTypeInfo` built from options + that carry the generated converters, not the context's own property, which writes each value object as + `{"value": ...}`. + +## 1.3.0-prerelease.1 + +A minor prerelease: no target framework is dropped and the public API only grows. `Purview.ValueObjects` still +ships `net8.0`, `net9.0`, `net10.0` and `net11.0`, and has no dependency on ZodSharp. + +### Fixed + +- **The generated JSON converters are trim- and Native AOT-safe.** The scalar converter read and wrote its + underlying value through `JsonSerializer.Deserialize(ref reader, options)` and + `JsonSerializer.Serialize(writer, value, options)`, and the complex converter round-tripped a generated + `{Name}JsonModel` class the same way. Those overloads are `[RequiresUnreferencedCode]`/`[RequiresDynamicCode]`, + so every trimmed or AOT application - Blazor WebAssembly included - got IL2026/IL3050 from generated code, and the + model class needed metadata no source-generated context could provide. The converters now go through the new + `ValueObjectJson` runtime helper, which uses the options' metadata when they have it (so converters, naming + policies and number handling behave as before), the generated converter of a value-object-typed member, and the + built-in converter for a primitive a source-generated context never listed. The complex converter reads and + writes its members directly, honouring `PropertyNamingPolicy`, `PropertyNameCaseInsensitive` and + `DefaultIgnoreCondition`, skipping unknown members and defaulting missing ones, as before. + +### Added + +- `ValueObjectJson` (`Purview.ValueObjects.Serialization`, hidden from IntelliSense): the reflection-free JSON + helpers the generated converters call. +- **A packed-consumer gate** (`just smoke-packed-consumers`, `scripts/test-packed-consumers.ps1`, PR job + `packed-consumers`). It packs the package to an isolated feed and cache, then publishes and runs, from the package + alone: a Native AOT console; a Blazor WebAssembly app with a `` form and a + `` form over a model of value objects, published trimmed and with + `RunAOTCompilation=true` and run in a headless browser; a Blazor Server app whose static form is posted and whose + interactive page runs over a live circuit; and a `net8.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. +- [Trimming, Native AOT and Blazor](docs/Trimming-AOT-and-Blazor.md): target frameworks, source-generated JSON, + binding a value object in a Blazor form, and validating it with ZodSharp. + +### Changed + +- **The generated complex converter no longer emits a public `{Name}JsonModel` nested type.** It was an + implementation detail of the converter; code that referenced it should serialize the value object itself. +- Built and tested against Purview.SourceGeneratorFramework 1.2.2, Purview.ZodSharp 3.0.0-prerelease.1, + Purview.BuildSdk 1.2.0 and Purview.Skills.Testing 1.2.0, on the .NET 11 rc.3 SDK. The ZodSharp samples target + `net10.0`/`net11.0`. + +### Upgrade notes + +- **Upgrading to Purview.ZodSharp 3.** It ships `net10.0` and `net11.0` only, so a project that uses the ZodSharp + integration must target `net10.0` or later; the value object generator's output is unchanged and works with 2.x + and 3.x. Read ZodSharp's own upgrade notes for the behaviour changes - the ones a value object user meets first: + built-in rules now report `Origin` (`"string"` for a `Guid`) and `Category`; collection paths render as `"0"` and + `Items[0].Name` (`FullPath.ToDottedString()`); `MergeParametersIntoExtensions` defaults to `false`; generated + schemas gain `ValidateAsync`, so CA1849 flags a synchronous `Validate` inside an async method. +- **Source-generated JSON contexts.** A `JsonSerializerContext` in the project that declares the value objects + cannot see their generated `[JsonConverter]` attribute, so register the generated converters on the options (see + [Trimming, Native AOT and Blazor](docs/Trimming-AOT-and-Blazor.md#systemtextjson-source-generation)). +- **A nested scalar's ZodSharp messages name the owning member.** With ZodSharp 3, an error inside a value object + member (`Email.Value`) reads `Field 'Email' ...` rather than `Field 'Value' ...`, and Blazor shows it on the + owning member's field; its path is still `Email.Value`. Update any assertion on the old text. + +## 1.2.0 - 1.2.3 + +These changes shipped across the 1.2.x releases while they were still recorded under *Unreleased*. ### Added diff --git a/Directory.Packages.props b/Directory.Packages.props index 40ef604..36fed52 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,8 +6,8 @@ true 5.9.0 1.76.0 - 1.2.1 - 2.4.2 + 1.2.2 + 3.0.0-prerelease.2 @@ -25,7 +25,7 @@ - + diff --git a/Justfile b/Justfile index 09035bf..aa723e1 100644 --- a/Justfile +++ b/Justfile @@ -18,7 +18,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, pack, validate) [group('Pipeline')] @@ -99,6 +99,26 @@ pack *args: echo "Packing {{ BLUE }}{{ solution_file }}{{ NORMAL }} with {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}..." dotnet pack "{{ solution_file }}" --configuration "{{ build_configuration }}" --no-restore --output "{{ artifact_folder }}" {{ args }} +# Prove the packed package works for real consumers: packs Purview.ValueObjects 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 and a net8.0 console against it, 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. Purview.ZodSharp/Purview.SourceGeneratorFramework versions not yet on nuget.org restore from +# LOCAL_NUGET_FEED_PATH (or VALUEOBJECTS_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: injects a matching +# file into scratch copies of the packed packages and runs the real purview-build validation on them. 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 }} + # ----------------------------------------------------------------------------- # Formatting # ----------------------------------------------------------------------------- diff --git a/README.md b/README.md index 7154ff4..dcc05cf 100644 --- a/README.md +++ b/README.md @@ -63,26 +63,29 @@ expresses a nullable scalar). See ## JSON serialization -Scalar value objects serialize as their underlying value. Register the converter factory on your -`JsonSerializerOptions`: +Scalar value objects serialize as their underlying value; complex value objects serialize as an object of their +members. The generator emits a `[JsonConverter]` per value object, so no registration is needed: ```csharp -var options = new JsonSerializerOptions(); -options.Converters.Add(new ScalarJsonConverterFactory()); +var json = JsonSerializer.Serialize(EmailAddress.Create("demo@example.com")); // "demo@example.com" ``` -The generator also emits a `[JsonConverter]` per value object, so scalar/complex value objects serialize correctly -even when the factory is not registered. - -Use the same options for Entity Framework JSON columns: +The generated converters are reflection-free, so they are trim- and Native AOT-safe. `ScalarJsonConverterFactory` +covers a hand-written scalar that has no generated converter: ```csharp -modelBuilder - .Entity() - .Property(c => c.Email) - .HasColumnType("jsonb"); +var options = new JsonSerializerOptions(); +options.Converters.Add(new ScalarJsonConverterFactory()); ``` +It uses reflection and is annotated `[RequiresUnreferencedCode]`/`[RequiresDynamicCode]`, so a trimmed or AOT +application should rely on the generated converters instead (see +[Trimming, Native AOT and Blazor](docs/Trimming-AOT-and-Blazor.md), which also covers source-generated +`JsonSerializerContext`s). + +A complex value object mapped to an Entity Framework JSON column (`EFMapping = EntityFrameworkMapping.Json`) is +stored through the same generated converter. + ## Entity Framework Core When your project references `Microsoft.EntityFrameworkCore`, the generator emits an `EF` nested class per value @@ -167,7 +170,23 @@ var result = EmailAddressSchema.Validate(EmailAddress.Create("demo@example.com") ``` See [ZodSharp Validation](docs/ZodSharp-Validation.md), the `src/src/ZodSharpSample` project, and -the `src/src/ZodSharp.AspNetCoreSample` project (ASP.NET Core Problem Details for strict deserialization failures). +the `src/src/ZodSharp.AspNetCoreSample` project (ASP.NET Core Problem Details for a request of value objects, each +invalid member reported under its own path). +Purview.ZodSharp 3 targets `net10.0`/`net11.0` (this package still ships `net8.0`-`net11.0` and does not depend on +it); `Purview.ZodSharp.Blazor` validates Blazor forms over value objects - see +[Trimming, Native AOT and Blazor](docs/Trimming-AOT-and-Blazor.md#blazor). + +## Trimming, Native AOT and Blazor + +Generated code uses no reflection, so trimmed, Native AOT and Blazor WebAssembly applications get no IL2xxx/IL3xxx +warning from it. `just smoke-packed-consumers` proves it against the packed package: a Native AOT console, a Blazor +WebAssembly app (trimmed and `RunAOTCompilation`, run in a headless browser), a Blazor Server app and a `net8.0` +console, all built with warnings as errors and scanned for compiler-host assemblies. + +The generated Entity Framework Core mapping, JSON-column converters included, adds no warning either, though EF Core +itself is not fully trim- or AOT-compatible. The runtime declares `browser` as a supported platform, so the CA1416 +analyzer reports any API it calls that Blazor WebAssembly does not support. See +[Trimming, Native AOT and Blazor](docs/Trimming-AOT-and-Blazor.md). ## How it works @@ -222,4 +241,6 @@ Set `DisableValueObjectsSourceGenerator` to `true` in your project: is the merged, self-contained artifact the `Purview.SourceGeneratorFramework` merge pass produces, and that pass strips every `InternalsVisibleTo` declaration. - `src/tests` – unit and source-generator tests. +- `scripts` – the packed-consumer gate (`test-packed-consumers.ps1` and the consumers in `scripts/packed-consumers`), + the compiler-host leak scan and the pack-validation rule prover. - `docs` – design and usage guidance. diff --git a/docs/Entity-Framework.md b/docs/Entity-Framework.md index 7c94c2e..be4b3a0 100644 --- a/docs/Entity-Framework.md +++ b/docs/Entity-Framework.md @@ -138,7 +138,10 @@ What the mapping does: - **Complex value objects** (`[ValueObject]`) map as **EF Core complex types** (EF Core 8+) by default, producing a column per member — including nested scalar value objects (e.g. `Money.Currency` converts to its primitive). - Complex value objects with `[ValueObject(EFMapping = EntityFrameworkMapping.Json)]` map to a single JSON column using the - generated JSON converter. + generated JSON converter (or the type's own `[JsonConverter]`), through `ValueObjectJson` rather than the + reflection-based `JsonSerializer` overloads, so the column converter adds no trim or Native AOT warning. The + column content is what `JsonSerializer.Serialize(value)` would write. Only with `GenerateJsonConverter = false` + (`VO1017`), or a `[JsonConverter]` naming a factory, does it fall back to `JsonSerializer`. ## Nullable scalars and nullable columns @@ -570,6 +573,10 @@ value-object provider assemblies referenced by EF consumers: `src/tests/ValueObjects.EFCompatibility.IntegrationTests`, so version-specific API shifts (for example `ValueGeneratorFactory.Create`'s second parameter changing to `ITypeBase`) are caught by the build rather than by a consumer. +- **Trimming and Native AOT.** The generated mapping code raises no IL2xxx/IL3xxx warning of its own, but EF Core + itself is not fully trim- or AOT-compatible: its `DbContext` constructor and migrations APIs carry + `[RequiresUnreferencedCode]`/`[RequiresDynamicCode]`, and a trimmed or AOT application warns at those calls. + Follow EF Core's own guidance (compiled models and precompiled queries) for that part. - Value objects are immutable; EF tracks them by value like any struct/record. The generator emits a parameterless constructor for `[ValueObject]` types to support EF Core materialization. - **Materialization is a replay path.** Entity Framework Core rebuilds a value object with `Hydrate(...)`, diff --git a/docs/Getting-Started.md b/docs/Getting-Started.md index dc1698c..5337c88 100644 --- a/docs/Getting-Started.md +++ b/docs/Getting-Started.md @@ -293,18 +293,23 @@ public readonly partial record struct OrderStatus ## 5. JSON serialization Scalar value objects serialize as their underlying value. The generator emits a `[JsonConverter]` per value object, -so they round-trip with default options. For a shared options instance, register -`ScalarJsonConverterFactory`: +so they round-trip with any options and need no registration. The generated converters use no reflection, so they +are trim- and Native AOT-safe. `ScalarJsonConverterFactory` is only for a hand-written scalar that has no generated +converter, in a host that is neither trimmed nor AOT-compiled: ```csharp var options = new JsonSerializerOptions(); options.Converters.Add(new ScalarJsonConverterFactory()); ``` +A source-generated `JsonSerializerContext` in the project that declares the value objects is the exception: see +[Trimming, Native AOT and Blazor](Trimming-AOT-and-Blazor.md#systemtextjson-source-generation). + `ValueObjectDeserializationMode` controls which factory deserialization uses: - `Hydrate` (default) – reconstructs via `Hydrate`, skipping validation. -- `Strict` – reconstructs via `Create`, re-running validation. +- `Strict` – reconstructs via `Create`, re-running validation. A value that fails is reported as a `JsonException` + whose `InnerException` is the validator's exception and whose `Path` is where the value sat (`$.customerEmail`). ```csharp [Scalar(DeserializationMode = ValueObjectDeserializationMode.Strict)] @@ -418,10 +423,12 @@ through `Create` unchanged. See [Custom rules on scalars](ZodSharp-Validation.md#custom-rules-on-scalars) and [Reusing a normal rule for a scalar](ZodSharp-Validation.md#reusing-a-normal-rule-for-a-scalar). -In ASP.NET Core, `Purview.ZodSharp.AspNetCore` converts those `ZodException`s into standard Problem -Details responses — combine `ValueObjectDeserializationMode.Strict` with -`AddZodSharpProblemDetails()` + `UseExceptionHandler()` so invalid request bodies return -`HttpValidationProblemDetails` automatically. See the `src/src/ZodSharp.AspNetCoreSample` project. +In ASP.NET Core, `Purview.ZodSharp.AspNetCore` turns validation failures into standard Problem Details +responses. Mark the request model `[ZodSchema]`, bind it with the default `Hydrate` mode, and add +`.WithZodSharpValidation()` to the endpoint: every invalid value object member is reported under its own path +(`CustomerEmail.Value`). See +[ASP.NET Core Problem Details](ZodSharp-Validation.md#aspnet-core-problem-details) and the +`src/src/ZodSharp.AspNetCoreSample` project. See `ZodSharp-Validation.md` and the `src/src/ZodSharpSample` project. diff --git a/docs/Trimming-AOT-and-Blazor.md b/docs/Trimming-AOT-and-Blazor.md new file mode 100644 index 0000000..50a5121 --- /dev/null +++ b/docs/Trimming-AOT-and-Blazor.md @@ -0,0 +1,186 @@ +# Trimming, Native AOT and Blazor + +The code the generator writes into your project - the factories, equality, comparison, parsing and formatting +members, the JSON converters and the ZodSharp integration - is trim- and Native AOT-safe. It uses no reflection, +so a trimmed or AOT-compiled application, a Blazor WebAssembly client among them, gets no IL2xxx/IL3xxx warning +from it. + +That is proven against the packed package, not the repository's own projects: `just smoke-packed-consumers` +packs `Purview.ValueObjects`, then publishes and runs a Native AOT console, a Blazor WebAssembly app (trimmed, and +with `RunAOTCompilation=true`, loaded in a headless browser), a Blazor Server app and a `net8.0` console against +it, every one with warnings as errors, and scans every output for compiler-host assemblies. + +## Target frameworks + +| Package | Target frameworks | +| --- | --- | +| `Purview.ValueObjects` | `net8.0`, `net9.0`, `net10.0`, `net11.0` | +| `Purview.ZodSharp` 3.x (optional) | `net10.0`, `net11.0` | +| `Purview.ZodSharp.Blazor` 3.x (optional) | `net11.0` | + +`Purview.ValueObjects` has no dependency on ZodSharp. A project that adds the ZodSharp integration needs the +target frameworks ZodSharp supports; a project that does not keeps the full range. + +The runtime assembly declares `browser` as a supported platform, so the platform compatibility analyzer (CA1416) +reports any API it calls that is unsupported in Blazor WebAssembly. + +## The one reflection-based component + +`ScalarJsonConverterFactory` builds converters with reflection and expression trees, and is annotated +`[RequiresUnreferencedCode]`/`[RequiresDynamicCode]`, so registering it in a trimmed or AOT application warns at +your call site. You do not need it for a generated value object: each one carries `[JsonConverter]` pointing at +its generated converter. + +## System.Text.Json source generation + +The generated converters read and write through the `JsonSerializerOptions` they are called with, so naming +policies, number handling and your own converters apply as usual. With a source-generated `JsonSerializerContext` +there is one thing to know. + +**A context in the project that declares the value objects cannot see their `[JsonConverter]` attribute.** The +value object generator adds it, and one source generator never sees another's output, so the context describes +each value object as a plain object with a `Value` property. Register the generated converters on the options: + +```csharp +[JsonSerializable(typeof(Order))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +public sealed partial class AppJsonContext : JsonSerializerContext; + +JsonSerializerOptions options = new(AppJsonContext.Default.Options) +{ + Converters = + { + new EmailAddress.EmailAddressJsonConverter(), + new Money.MoneyJsonConverter(), + }, +}; + +var json = JsonSerializer.Serialize(order, (JsonTypeInfo)options.GetTypeInfo(typeof(Order))); +``` + +A value object declared in a **referenced library** needs none of this: the attribute is compiled into the library, +and the context in your application sees it. + +Inside a generated converter: + +- a member of a complex value object that is itself a value object is written with that value object's generated + converter, so `Money.Currency` (a `CurrencyCode`) does not have to be registered or listed; +- an underlying or member value of a primitive type (`string`, `Guid`, the numeric types, `bool`, the date and time + types, `Uri`, `Version`, and their nullable forms) falls back to the built-in System.Text.Json converter when the + context never listed it; +- anything else - an enum, or a custom underlying type - needs metadata, so add `[JsonSerializable(typeof(T))]` to + the context. The `NotSupportedException` thrown otherwise names the type. + +## ZodSharp packages + +A Native AOT minimal API with `[Scalar]` + `[ZodSchema]` value objects in `Strict` mode, built against ZodSharp +3.0.0-prerelease.2, raises no trim or AOT warning from any ZodSharp API it uses, under the build analyzers or ILC's +whole-program analysis: + +| Package | Under trimming and Native AOT | +| --- | --- | +| `Purview.ZodSharp` | Generated schemas use typed code, no reflection. Its reflection fallbacks sit behind the `ZodSharp.Reflection.IsSupported` feature switch, which is off when trimming or compiling ahead of time. | +| `Purview.ZodSharp.AspNetCore` | `AddZodSharpProblemDetails`, `ZodExceptionHandler` and `WithZodSharpValidation()` are safe; the first adds ZodSharp's ProblemDetails JSON metadata itself. Assembly scanning (`AddZodSharp(o => o.ScanAssemblies...)`) is not trim-safe: register the generated `AddZodSharp{Assembly}Schemas()` instead. Validating a bound request this way reports each value object member under its full path (`CustomerEmail.Value`), under Native AOT as under the JIT. | +| `Purview.ZodSharp.Http` | Safe, and runs in Blazor WebAssembly: every API takes a `JsonTypeInfo`. | +| `Purview.ZodSharp.Blazor`, `.Extensions.Validation` | Per their own guidance, safe with the generated `AddZodSharp{Assembly}Schemas()` registration; the model-walking fallback is annotated `[RequiresUnreferencedCode]`. The packed-consumer gate runs `Purview.ZodSharp.Blazor` under WebAssembly AOT. | + +A strict value object's failure is re-rooted under the request member by `ZodExceptionHandler` under Native AOT too +(on .NET 10 with `HandleBindingFailures = true`; see +[ZodSharp Validation](ZodSharp-Validation.md#strict-deserialization)). + +What does not work is `ScalarJsonConverterFactory`. Under Native AOT it cannot build its converters, and every request +it handles returns `500`. The generated converters need no registration. + +For `Purview.ZodSharp.Http` and for an ASP.NET Core app's own `JsonSerializerContext`, register the generated +converters when the value objects are declared in the same project, as for any +[source-generated context](#systemtextjson-source-generation). + +## Entity Framework Core + +The Entity Framework Core mapping the generator emits - converters, comparers, the `ConfigureValueObjects` +registry and the key value generators - raises no trim or AOT warning of its own. A JSON-mapped complex value object +(`EFMapping = EntityFrameworkMapping.Json`) is written to its column through its generated converter and +`ValueObjectJson`, not through `JsonSerializer`. Without the reflection-based metadata a trimmed or AOT application +turns off, a member of a JSON-mapped value object is written through its value object's converter or the built-in +converter for a primitive; an enum member, or a custom one, throws `NotSupportedException` naming the type. + +EF Core itself is not fully trim- or AOT-compatible: its `DbContext` constructor and migrations APIs are annotated +`[RequiresUnreferencedCode]`/`[RequiresDynamicCode]`, so a trimmed or AOT application warns at those calls. See EF +Core's guidance on compiled models and precompiled queries. + +## Blazor + +### Binding a value object in a form + +The built-in inputs bind primitives. A small input over a string-backed scalar binds the value object itself, and +uses `Hydrate` - which never validates - so the form's validator reports what is wrong with the value, through the +value object's own rules, instead of the input rejecting it before any rule runs: + +```csharp +public sealed class InputScalar : InputBase + where TScalar : struct, IScalarValueObject +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenElement(0, "input"); + builder.AddMultipleAttributes(1, AdditionalAttributes); + if (!string.IsNullOrEmpty(NameAttributeValue)) + builder.AddAttribute(2, "name", NameAttributeValue); + builder.AddAttribute(3, "class", CssClass); + builder.AddAttribute(4, "value", CurrentValueAsString); + builder.AddAttribute(5, "onchange", + EventCallback.Factory.CreateBinder(this, v => CurrentValueAsString = v, CurrentValueAsString)); + builder.CloseElement(); + } + + protected override string? FormatValueAsString(TScalar value) => value.Value; + + protected override bool TryParseValueFromString( + string? value, [MaybeNullWhen(false)] out TScalar result, [NotNullWhen(false)] out string? validationErrorMessage) + { + result = TScalar.Hydrate(value ?? string.Empty); + validationErrorMessage = null; + return true; + } +} +``` + +```razor + +``` + +In a **statically rendered** form, a posted value binds through the value object's generated `IParsable` +implementation, whose `TryParse` runs `TryCreate`: an invalid value is reported as a form mapping error on its +field ("The value 'abc' is not valid for 'Postcode'."), and a valid one arrives normalized. + +### Validating with ZodSharp + +Mark the form model and its value objects `[ZodSchema]` and register the generated schemas and ZodSharp for Blazor +(`Purview.ZodSharp.Blazor`, `net11.0`) in the client and the server: + +```csharp +builder.Services.AddZodSharpMyAppSchemas(); // generated: AddZodSharp{Assembly}Schemas() +builder.Services.AddZodSharpBlazor(); +``` + +```razor + + + + + + +``` + +Each value object member is validated by the value object's own generated schema, so the rules live with the value +object rather than being repeated on every form. + +An error inside a value object is located at `Email.Value`, but a struct cannot be a `FieldIdentifier` model, so +ZodSharp places it on the owning member's field: `` (or +``) shows it next to the value object's input, with `` and with +``, on Blazor Server and in WebAssembly alike. A scalar's messages name the owning +member, not its own `Value`: `Field 'Email' must be a valid email address.` A type-level rule such as +`[NonSentinel(Message = "Choose a plan.")]` is located at the member itself and keeps its own message. + +A form or request model may declare a value object member as `[Required]` and nullable (`EmailAddress?`): a missing +value is reported at the member, and a present one is validated by the value object's own schema. diff --git a/docs/ZodSharp-Validation.md b/docs/ZodSharp-Validation.md index a62c3db..80f9ee5 100644 --- a/docs/ZodSharp-Validation.md +++ b/docs/ZodSharp-Validation.md @@ -22,6 +22,15 @@ against the underlying value. ```text dotnet add package Purview.ZodSharp ``` +Purview.ZodSharp 3 targets `net10.0` and `net11.0` only, so a project that uses this integration must target +`net10.0` or later; `Purview.ValueObjects` itself still ships `net8.0` to `net11.0` and does not depend on ZodSharp. +The value object generator's output is the same for ZodSharp 2.x and 3.x. Two 3.x behaviour changes show up first in +value object code: built-in rules report a structured `Origin` (the Zod type name of the underlying value, so +`"string"` for a `Guid`) and `Category`, and generated schemas gain `ValidateAsync`, so CA1849 flags a synchronous +`{Type}Schema.Validate` inside an async method. ZodSharp's own changelog lists the rest (paths, codes, messages). + +For Blazor forms over value objects (`Purview.ZodSharp.Blazor`) and for trimmed and AOT applications, see +[Trimming, Native AOT and Blazor](Trimming-AOT-and-Blazor.md). ## 1. Generated validators on value objects @@ -492,41 +501,117 @@ result. ### ASP.NET Core Problem Details -In ASP.NET Core, `Purview.ZodSharp.AspNetCore` maps thrown `ZodException`s to standard -`HttpValidationProblemDetails` responses. This covers strict deserialization of value objects -(`ValueObjectDeserializationMode.Strict`) and any `Create`/`Parse` failure that bubbles up as a -`ZodException`. - -Wire the handler into the pipeline: +In ASP.NET Core, `Purview.ZodSharp.AspNetCore` turns validation failures into standard `HttpValidationProblemDetails` +responses. For a request body of value objects, bind it with the default `Hydrate` mode and validate the bound +request. The request's generated schema validates each value object member through that value object's own schema, so +every invalid member is reported under its own path: ```csharp +[Scalar(ZodSchemaMode = ZodSchemaMode.InsteadOfHooks)] +[ZodSchema] +public readonly partial record struct EmailAddress +{ + [Required, EmailAddress] + public string Value { get; } +} + +[ZodSchema] +public sealed partial record PlaceOrderRequest(EmailAddress CustomerEmail, OrderQuantity Quantity); + +builder.Services.AddZodSharpMyAppSchemas(); // generated: AddZodSharp{Assembly}Schemas(), trim- and AOT-safe builder.Services.AddZodSharpProblemDetails(); builder.Services.AddProblemDetails(); var app = builder.Build(); app.UseExceptionHandler(); + +app.MapPost("/orders", (PlaceOrderRequest request) => TypedResults.Ok()) + .WithZodSharpValidation(); ``` -Mark the value object for strict deserialization and ZodSharp validation so invalid request bodies throw -during model binding: +`{"customerEmail":"not-an-email","quantity":500}` returns `400 application/problem+json`: -```csharp -[Scalar( - ZodSchemaMode = ZodSchemaMode.InsteadOfHooks, - DeserializationMode = ValueObjectDeserializationMode.Strict)] -[ZodSchema] -public readonly partial record struct EmailAddress +```json { - [Required, EmailAddress] - public string Value { get; } + "errors": { + "CustomerEmail.Value": ["Field 'CustomerEmail' must be a valid email address."], + "Quantity.Value": ["Field 'Quantity' must be greater than or equal to 1 and less than or equal to 100."] + }, + "issues": [{ "path": "CustomerEmail.Value", "segments": ["CustomerEmail", "Value"], "code": "invalid_string", ... }] } +``` +On `net11.0`, `AddZodSharpValidation()` from `Purview.ZodSharp.Extensions.Validation` does the same for every endpoint +through ASP.NET Core's built-in validation pipeline. Both work under Native AOT with the generated registration. For +Native AOT (`WebApplication.CreateSlimBuilder` and a source-generated `JsonSerializerContext`), also register the +generated converters on the options, because a context in the project that declares the value objects cannot see +their `[JsonConverter]` attributes: + +```csharp builder.Services.ConfigureHttpJsonOptions(options => - options.SerializerOptions.Converters.Add(new ScalarJsonConverterFactory())); +{ + options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default); + options.SerializerOptions.Converters.Add(new EmailAddress.EmailAddressJsonConverter()); +}); +``` + +#### Strict deserialization + +With `ValueObjectDeserializationMode.Strict` the value object validates while it is deserialized. The generated +converter reports a failure as a `JsonException` whose inner exception is the validator's (`ZodException` here), so +System.Text.Json records where the value sat (`$.customerEmail`). `ZodExceptionHandler` joins the two, so the +response is the same contract as above, located under the member: `errors["CustomerEmail.Value"]`, +`Field 'CustomerEmail' ...`, and `Copies[1].Value` for a collection element. + +```csharp +// .NET 10: binding answers a body it cannot deserialize itself (a bare 400) unless it is told to throw. +builder.Services.AddZodSharpProblemDetails(options => options.HandleBindingFailures = true); + +// .NET 11: binding hands the exception to the ProblemDetails service, which ZodSharp rewrites; nothing to set. +builder.Services.AddZodSharpProblemDetails(); +``` + +`HandleBindingFailures` sets `RouteHandlerOptions.ThrowOnBadRequest` for the whole application, so every minimal-API +binding failure travels through `UseExceptionHandler()`. Malformed JSON is still a standard `400` ProblemDetails. This +needs Purview.ZodSharp 3.0.0-prerelease.2 or later, and works under Native AOT. + +One limit remains: binding stops at the first invalid member. The bound-request validation above reports every one, +so prefer it for request bodies, and keep `Strict` for payloads that are not validated any other way. + +`ScalarJsonConverterFactory` throws the same exception, but it uses reflection: under Native AOT it cannot build its +converters and every request returns `500`. The generated converters need no registration. + +### Typed HTTP clients + +`Purview.ZodSharp.Http` (`net10.0`/`net11.0`, no ASP.NET Core dependency, so it runs in Blazor WebAssembly) validates +on the client side of the wire. `PostAsJsonValidatedAsync`, `PutAsJsonValidatedAsync` and `PatchAsJsonValidatedAsync` +validate a body before sending it, so an invalid one is never sent. `ReadFromJsonAndValidateAsync` validates a +response, and `TryReadZodValidationProblemAsync` turns the server's ProblemDetails back into ZodSharp errors. Every +API takes a `JsonTypeInfo`, so the package is trim-, AOT- and WebAssembly-safe. + +When the value objects are declared in the same project as the `JsonSerializerContext`, do not pass the context's own +property (`AppJsonContext.Default.PlaceOrderRequest`). That context cannot see the generated converters and writes +`{"customerEmail":{"value":"a@example.com"}}`, which the server rejects. Build the type info from options that carry +the converters: + +```csharp +JsonSerializerOptions options = new(AppJsonContext.Default.Options) +{ + Converters = { new EmailAddress.EmailAddressJsonConverter(), new OrderQuantity.OrderQuantityJsonConverter() }, +}; +var requestInfo = (JsonTypeInfo)options.GetTypeInfo(typeof(PlaceOrderRequest)); + +var sent = await http.PostAsJsonValidatedAsync( + "orders", request, new PlaceOrderRequestSchemaValidator(), requestInfo); +if (!sent.IsSuccess) + return sent.Errors; // e.g. CustomerEmail.Value: invalid_string - nothing was sent + +using var response = sent.Value!; +if (await response.TryReadZodValidationProblemAsync() is { } problem) + return problem.ToValidationErrors(); // rejected by the server ``` -A `POST` body with an invalid email now returns `400 application/problem+json` with the structured issues -in the `issues` extension. +A value object from a referenced library needs none of this, and the context's own property works. Map error codes to HTTP statuses and formatted messages with `ErrorType` + `ErrorTypeRegistry`. Mark a static partial class with `[ErrorType]` on a `static readonly ErrorType` field and the bundled diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index e0b7f98..0000000 --- a/docs/index.md +++ /dev/null @@ -1,15 +0,0 @@ -# Purview Value Objects - -Strongly typed value objects with Entity Framework and ZodSharp validation integrations. - -[Get started](Getting-Started.md){ .md-button .md-button--primary } - -## Guides - -- [Value object design](Value-Object-Design.md) -- [Entity Framework integration](Entity-Framework.md) -- [ZodSharp validation](ZodSharp-Validation.md) - -## Reference - -- [Diagnostics](Diagnostics.md) — every `VO1xxx` rule, what triggers it, and how to fix it diff --git a/global.json b/global.json index 8b388e3..054e74d 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/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index 112ecd8..0000000 --- a/mkdocs.yml +++ /dev/null @@ -1,21 +0,0 @@ -site_name: Purview Value Objects -site_description: Developer documentation for Purview Value Objects -repo_url: https://github.com/purview-dev/value-objects -edit_uri: edit/main/docs/ -docs_dir: docs - -theme: - name: material - palette: - - media: "(prefers-color-scheme: light)" - scheme: default - primary: indigo - accent: cyan - - media: "(prefers-color-scheme: dark)" - scheme: slate - primary: indigo - accent: cyan - features: [navigation.instant, navigation.sections, navigation.top, search.highlight, search.suggest, content.code.copy] - -plugins: [search, techdocs-core] -markdown_extensions: [admonition, attr_list, md_in_html, pymdownx.details, pymdownx.superfences] diff --git a/package.json b/package.json index 46b4166..eb3ced0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "value-objects", - "version": "1.2.3", + "version": "1.3.0-prerelease.2", "license": "MIT", "author": { "name": "Kieron Lanning", diff --git a/scripts/assert-no-compiler-host-assemblies.ps1 b/scripts/assert-no-compiler-host-assemblies.ps1 new file mode 100644 index 0000000..ba126cd --- /dev/null +++ b/scripts/assert-no-compiler-host-assemblies.ps1 @@ -0,0 +1,159 @@ +#!/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.ValueObjects source generator, its code fix, the framework they are built on + (Purview.SourceGeneratorFramework), Roslyn, and the Purview.ZodSharp generator and code fixes 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.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.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..66b5091 --- /dev/null +++ b/scripts/packed-consumers/AotConsole/AotConsole.csproj @@ -0,0 +1,39 @@ + + + + Exe + net11.0 + PackedValueObjectsAot + PackedValueObjects + enable + enable + true + true + false + true + true + true + + + + + + + + + + + + diff --git a/scripts/packed-consumers/AotConsole/Models.cs b/scripts/packed-consumers/AotConsole/Models.cs new file mode 100644 index 0000000..119940c --- /dev/null +++ b/scripts/packed-consumers/AotConsole/Models.cs @@ -0,0 +1,94 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; +using Purview.ValueObjects.Serialization; +using ZodSharp; +using ZodSharp.Rules; + +namespace PackedValueObjects; + +/// A scalar validated by both generators: Create normalizes, then runs EmailAddressSchema. +[Scalar] +[ZodSchema] +public readonly partial record struct EmailAddress +{ + [Required] + [EmailAddress] + [StringLength(254, MinimumLength = 3)] + public string Value { get; } + + static partial void OnNormalize(ref string value) => value = value?.Trim().ToLowerInvariant()!; +} + +/// A Guid scalar rejected by the built-in, type-level [NonSentinel] rule. +[Scalar] +[ZodSchema] +[NonSentinel(Message = "CustomerId must not be empty.")] +public readonly partial record struct CustomerId +{ + public Guid Value { get; } +} + +/// A scalar validated by the value-object hook alone, with no ZodSharp involvement. +[Scalar] +public readonly partial record struct Quantity +{ + public int Value { get; } + + static partial void OnValidate(int value) + { + if (value is < 1 or > 100) + throw new ArgumentOutOfRangeException(nameof(value), value, "Quantity must be between 1 and 100."); + } +} + +/// A complex value object. +[ValueObject] +public readonly partial record struct Money +{ + public decimal Amount { get; } + + public string Currency { get; } + + partial void OnValidate(decimal amount, string currency) + { + if (amount < 0) + throw new ArgumentOutOfRangeException(nameof(amount), amount, "Amount cannot be negative."); + + if (currency is not { Length: 3 }) + throw new ArgumentException("Currency must be a three-letter code.", nameof(currency)); + } +} + +/// A request DTO over value objects, validated by the ZodSharp generator. +[ZodSchema] +public sealed class Order +{ + [Required] + public CustomerId? Customer { get; set; } + + // A [Required] nullable member of a string-backed scalar: the generated null test must not bind the scalar's + // operator ==(string?, EmailAddress), and a present address is validated by EmailAddress's own schema. + [Required] + public EmailAddress? ContactEmail { get; set; } + + [MinCount(1)] + public List Lines { get; set; } = []; +} + +[ZodSchema] +public sealed class OrderLine +{ + [Required] + [RegularExpression("^[A-Z]{3}-[0-9]{4}$")] + public string? Sku { get; set; } + + public Quantity Quantity { get; set; } + + public Money Price { get; set; } +} + +[JsonSerializable(typeof(Order))] +[JsonSerializable(typeof(EmailAddress))] +[JsonSerializable(typeof(Money))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +public sealed partial class ConsumerJsonContext : JsonSerializerContext { } diff --git a/scripts/packed-consumers/AotConsole/Program.cs b/scripts/packed-consumers/AotConsole/Program.cs new file mode 100644 index 0000000..42405c8 --- /dev/null +++ b/scripts/packed-consumers/AotConsole/Program.cs @@ -0,0 +1,270 @@ +// 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 System.Collections.Immutable; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using PackedValueObjects; +using ZodSharp; +using ZodSharp.Core; +using ZodSharp.Rules; + +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}"); + } +} + +void CheckPaths(ImmutableArray errors, string[] expected, string description) +{ + var actual = errors.IsDefault ? [] : errors.Select(static e => e.FullPath.ToDottedString()).Distinct().ToArray(); + var missing = expected.Except(actual).ToArray(); + var unexpected = actual.Except(expected).ToArray(); + Check( + missing.Length == 0 && unexpected.Length == 0, + missing.Length == 0 && unexpected.Length == 0 + ? description + : $"{description} (missing: [{string.Join(", ", missing)}], unexpected: [{string.Join(", ", unexpected)}])" + ); +} + +void Section(string name, Action body) +{ + Console.WriteLine(name); + try + { + body(); + } + catch (Exception ex) + { + Check(false, $"{name} threw {ex.GetType().Name}: {ex.Message}"); + } +} + +static T? Catch(Action action) + where T : Exception +{ + try + { + action(); + return null; + } + catch (T ex) + { + return ex; + } +} + +// The value objects are declared in this project, so the System.Text.Json source generator - which never sees +// another generator's output - cannot see the [JsonConverter] attribute the value object generator adds, and would +// describe each value object as an object with a Value property. Registering the generated converters on the +// options fixes that; a value object declared in a referenced library needs nothing, because the attribute is then +// compiled into it. +JsonSerializerOptions JsonOptions = new(ConsumerJsonContext.Default.Options) +{ + Converters = + { + new EmailAddress.EmailAddressJsonConverter(), + new CustomerId.CustomerIdJsonConverter(), + new Quantity.QuantityJsonConverter(), + new Money.MoneyJsonConverter(), + }, +}; + +static Order ValidOrder() => + new() + { + Customer = CustomerId.Create(Guid.NewGuid()), + ContactEmail = EmailAddress.Create("buyer@example.com"), + Lines = + [ + new() + { + Sku = "ABC-1234", + Quantity = Quantity.Create(2), + Price = Money.Create(9.99m, "GBP"), + }, + ], + }; + +Section( + "native image", + () => + // A native image compiles nothing at run time; `dotnet run` of the same project would JIT. + Check(System.Runtime.JitInfo.GetCompiledMethodCount() == 0, "the process is a Native AOT image (no JIT)") +); + +Section( + "generated scalar factories", + () => + { + var email = EmailAddress.Create(" Buyer@Example.COM "); + Check(email.Value == "buyer@example.com", $"Create normalizes before validating (got '{email.Value}')"); + Check( + EmailAddress.Hydrate("not-an-email").Value == "not-an-email", + "Hydrate is replay-safe and does not validate" + ); + Check(!EmailAddress.TryCreate("not-an-email", out _), "TryCreate returns false for a schema failure"); + Check(Quantity.Create(5).Value == 5, "a hook-validated scalar creates"); + Check( + Catch(() => Quantity.Create(0)) is not null, + "a hook-validated scalar rejects an invalid value with ArgumentOutOfRangeException" + ); + Check(Money.Create(1.5m, "EUR").Amount == 1.5m, "a complex value object creates"); + Check( + Catch(() => Money.Create(-1m, "EUR")) is not null, + "a complex value object runs its OnValidate hook" + ); + Check(EmailAddress.Create("a@b.co") == EmailAddress.Create("A@B.CO"), "equality is by normalized value"); + } +); + +Section( + "Create routes through the ZodSharp schema", + () => + { + var email = Catch(() => EmailAddress.Create("not-an-email")); + Check(email is { Errors.Length: > 0 }, "EmailAddress.Create throws ZodException for an invalid address"); + if (email is not null) + CheckPaths(email.Errors, ["Value"], "the scalar's member rule reports the Value path"); + + var customer = Catch(() => CustomerId.Create(Guid.Empty)); + ValidationError? error = customer is null ? null : customer.Errors.Single(); + Check( + error is { Code: NonSentinelRule.ErrorCode, Message: "CustomerId must not be empty." }, + $"a type-level [NonSentinel] rejects Guid.Empty (got {error?.Code}: {error?.Message})" + ); + Check( + error?.Path.IsEmpty == true, + "a type-level rule reports an empty path: the scalar is validated as a unit" + ); + Check( + error?.Origin == ZodTypeNames.String, + $"ZodSharp 3 reports the rule's structured origin (got '{error?.Origin}')" + ); + } +); + +Section( + "generated DTO validation over value objects", + () => + { + Check(OrderSchema.Validate(ValidOrder()).IsSuccess, "a valid order passes"); + + Order invalid = new() + { + Customer = null, + ContactEmail = EmailAddress.Hydrate("not-an-email"), + Lines = + [ + new() + { + Sku = "abc", + Quantity = Quantity.Hydrate(1), + Price = Money.Hydrate(1m, "GBP"), + }, + ], + }; + var errors = OrderSchema.Validate(invalid).Errors; + foreach (var e in errors) + Console.WriteLine($" {e.FullPath.ToDottedString()}: {e.Code} - {e.Message}"); + + CheckPaths( + errors, + ["Customer", "ContactEmail.Value", "Lines[0].Sku"], + "a missing value object, a nested value object's own rule and a collection element report typed paths" + ); + + var nested = errors.Where(static e => e.FullPath.ToDottedString() == "ContactEmail.Value").ToArray(); + Check( + nested is [{ Message: "Field 'ContactEmail' must be a valid email address." }], + $"a nested scalar's message names the owning member (got '{string.Join("', '", nested.Select(static e => e.Message))}')" + ); + + var missingEmail = ValidOrder(); + missingEmail.ContactEmail = null; + var required = OrderSchema.Validate(missingEmail).Errors; + Check( + required is [{ Code: RequiredRule.ErrorCode }] + && required[0].FullPath.ToDottedString() == "ContactEmail", + $"[Required] on a nullable scalar member rejects a missing address at the member (got {string.Join(", ", required.Select(static e => $"{e.Code} at {e.FullPath.ToDottedString()}"))})" + ); + + var empty = ValidOrder(); + empty.Lines = []; + var tooFew = OrderSchema.Validate(empty).Errors.Single(); + Check( + tooFew is { Code: "too_small", Minimum: 1 } && tooFew.FullPath.ToDottedString() == "Lines", + $"[MinCount] reports too_small at the member (got {tooFew.Code} at {tooFew.FullPath.ToDottedString()})" + ); + } +); + +Section( + "System.Text.Json with a source-generated context", + () => + { + var emailInfo = (JsonTypeInfo)JsonOptions.GetTypeInfo(typeof(EmailAddress)); + var moneyInfo = (JsonTypeInfo)JsonOptions.GetTypeInfo(typeof(Money)); + var orderInfo = (JsonTypeInfo)JsonOptions.GetTypeInfo(typeof(Order)); + + var json = JsonSerializer.Serialize(EmailAddress.Create("demo@example.com"), emailInfo); + Check(json == "\"demo@example.com\"", $"a scalar serializes as its underlying value (got {json})"); + + var moneyJson = JsonSerializer.Serialize(Money.Create(12.5m, "USD"), moneyInfo); + Check( + moneyJson == """{"amount":12.5,"currency":"USD"}""", + $"a complex value object writes its members (got {moneyJson})" + ); + Check( + JsonSerializer.Deserialize(moneyJson, moneyInfo) == Money.Create(12.5m, "USD"), + "a complex value object round-trips" + ); + + var order = ValidOrder(); + var orderJson = JsonSerializer.Serialize(order, orderInfo); + Console.WriteLine($" {orderJson}"); + var roundTripped = JsonSerializer.Deserialize(orderJson, orderInfo); + Check( + roundTripped is not null + && roundTripped.ContactEmail == order.ContactEmail + && roundTripped.Customer == order.Customer + && roundTripped.Lines.Single().Quantity == Quantity.Create(2), + "a DTO of value objects round-trips through the generated converters" + ); + + var validator = new OrderSchemaValidator(); + var invalid = validator.DeserializeAndValidate( + """{"customer":"3f2504e0-4f89-11d3-9a0c-0305e82c3301","contactEmail":"not-an-email","lines":[]}""", + orderInfo + ); + CheckPaths( + invalid.Errors, + ["ContactEmail.Value", "Lines"], + "DeserializeAndValidate validates the value objects it read" + ); + } +); + +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/BlazorServer/BlazorServer.csproj b/scripts/packed-consumers/BlazorServer/BlazorServer.csproj new file mode 100644 index 0000000..e2c9634 --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/BlazorServer.csproj @@ -0,0 +1,35 @@ + + + + net11.0 + PackedValueObjectsServer + PackedValueObjectsServer + 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..51c8636 --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/Components/App.razor @@ -0,0 +1,13 @@ + + + + + + Packed Purview.ValueObjects 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..17152d9 --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/Components/Pages/Interactive.razor @@ -0,0 +1,6 @@ +@page "/interactive" +@rendermode InteractiveServer + +

Register (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..497e00e --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/Components/Pages/Register.razor @@ -0,0 +1,45 @@ +@page "/register" + +@* Statically rendered: the gate posts this form and reads the response. The value objects bind from the posted + strings through their generated IParsable implementation, so an unparseable value is a form mapping error and a + parsed one is validated by through the value objects' own schemas. *@ + +

Register (static server rendering)

+ + + + + + + + + + + + + + + + + @* The plan is chosen elsewhere; it travels with the form so its own rule still applies. *@ + + + + + + +@if (_registered) +{ +

Registered @Model!.Name <@Model.Email.Value> at @Model.Postcode.Value.

+} + +@code { + [SupplyParameterFromForm] + Registration? Model { get; set; } + + bool _registered; + + protected override void OnInitialized() => Model ??= new() { Plan = PlanId.Create(Guid.NewGuid()) }; + + void Submit() => _registered = true; +} 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..299250c --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/Components/_Imports.razor @@ -0,0 +1,7 @@ +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using ZodSharp.Blazor +@using PackedValueObjectsBlazor +@using PackedValueObjectsServer.Components diff --git a/scripts/packed-consumers/BlazorServer/Program.cs b/scripts/packed-consumers/BlazorServer/Program.cs new file mode 100644 index 0000000..4f4522b --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/Program.cs @@ -0,0 +1,16 @@ +using PackedValueObjectsServer.Components; + +var builder = WebApplication.CreateBuilder(args); +builder.Services.AddRazorComponents().AddInteractiveServerComponents(); + +// The generated, trim-safe registration of this assembly's [ZodSchema] types (the form model and the value objects), +// then ZodSharp for Blazor forms. +builder.Services.AddZodSharpPackedValueObjectsServerSchemas(); +builder.Services.AddZodSharpBlazor(); + +var app = builder.Build(); +app.UseAntiforgery(); +app.MapStaticAssets(); +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..f61eb2d --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj @@ -0,0 +1,40 @@ + + + + net11.0 + PackedValueObjectsWasm + PackedValueObjectsWasm + 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..b730068 --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/Pages/Index.razor @@ -0,0 +1,5 @@ +@page "/" + +

Register

+ + diff --git a/scripts/packed-consumers/BlazorWasm/Program.cs b/scripts/packed-consumers/BlazorWasm/Program.cs new file mode 100644 index 0000000..1f9a354 --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/Program.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using PackedValueObjectsWasm; + +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 (the form model and the value objects), +// then ZodSharp for Blazor forms, which also makes validate them. +builder.Services.AddZodSharpPackedValueObjectsWasmSchemas(); +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..8b369d3 --- /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 PackedValueObjectsBlazor +@using PackedValueObjectsWasm diff --git a/scripts/packed-consumers/BlazorWasm/wwwroot/index.html b/scripts/packed-consumers/BlazorWasm/wwwroot/index.html new file mode 100644 index 0000000..ddf8e0f --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/wwwroot/index.html @@ -0,0 +1,22 @@ + + + + + + + Packed Purview.ValueObjects Blazor WebAssembly consumer + + + + + +
Loading...
+ + + + diff --git a/scripts/packed-consumers/Net8Console/Net8Console.csproj b/scripts/packed-consumers/Net8Console/Net8Console.csproj new file mode 100644 index 0000000..1f2879d --- /dev/null +++ b/scripts/packed-consumers/Net8Console/Net8Console.csproj @@ -0,0 +1,28 @@ + + + + Exe + net8.0 + PackedValueObjectsNet8 + PackedValueObjectsNet8 + enable + enable + true + true + + + + + + + + + + diff --git a/scripts/packed-consumers/Net8Console/Program.cs b/scripts/packed-consumers/Net8Console/Program.cs new file mode 100644 index 0000000..ac3defd --- /dev/null +++ b/scripts/packed-consumers/Net8Console/Program.cs @@ -0,0 +1,60 @@ +// Packed-consumer gate, consumer (d): the net8.0 target of the runtime package, with the trim and AOT analyzers on +// (IsAotCompatible), so the generated code is analyzed for that target too. +using System.Text.Json; +using System.Text.Json.Serialization; +using Purview.ValueObjects.Serialization; + +var failures = 0; +void Check(bool condition, string description) +{ + Console.WriteLine($" {(condition ? "pass" : "FAIL")} {description}"); + if (!condition) + failures++; +} + +Console.WriteLine($"net8.0 consumer on {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}"); +Check(Environment.Version.Major == 8, "running on the .NET 8 runtime"); + +var sku = Sku.Create(" abc-1234 "); +Check(sku.Value == "ABC-1234", $"Create normalizes (got '{sku.Value}')"); +Check(!Sku.TryCreate("nope", out _), "TryCreate rejects an invalid value"); + +JsonSerializerOptions options = new(Net8JsonContext.Default.Options) +{ + Converters = { new Sku.SkuJsonConverter(), new Price.PriceJsonConverter() }, +}; +var price = Price.Create(9.5m, sku); +var json = JsonSerializer.Serialize(price, options.GetTypeInfo(typeof(Price))); +Check(json == """{"amount":9.5,"sku":"ABC-1234"}""", $"a complex value object serializes its members (got {json})"); +Check( + JsonSerializer.Deserialize(json, options.GetTypeInfo(typeof(Price))) is Price roundTripped && roundTripped == price, + "and round-trips" +); + +return failures == 0 ? 0 : 1; + +[Scalar] +public readonly partial record struct Sku +{ + public string Value { get; } + + static partial void OnNormalize(ref string value) => value = value?.Trim().ToUpperInvariant()!; + + static partial void OnValidate(string value) + { + if (value is not { Length: 8 }) + throw new ArgumentException("A SKU is eight characters.", nameof(value)); + } +} + +[ValueObject] +public readonly partial record struct Price +{ + public decimal Amount { get; } + + public Sku Sku { get; } +} + +[JsonSerializable(typeof(Price))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +partial class Net8JsonContext : JsonSerializerContext; diff --git a/scripts/packed-consumers/Shared/InputScalar.cs b/scripts/packed-consumers/Shared/InputScalar.cs new file mode 100644 index 0000000..a1510c4 --- /dev/null +++ b/scripts/packed-consumers/Shared/InputScalar.cs @@ -0,0 +1,52 @@ +// Shared by the Blazor consumers (copied in by scripts/test-packed-consumers.ps1). +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Forms; +using Microsoft.AspNetCore.Components.Rendering; +using Purview.ValueObjects; + +namespace PackedValueObjectsBlazor; + +/// +/// A text input bound to a string-backed scalar value object. What is typed is kept with Hydrate - which +/// never validates - so the form's validator reports what is wrong with it through the value object's own schema, +/// instead of the input rejecting it as unparseable before any rule runs. +/// +/// The scalar value object. +public sealed class InputScalar : InputBase + where TScalar : struct, IScalarValueObject +{ + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenElement(0, "input"); + builder.AddMultipleAttributes(1, AdditionalAttributes); + // The name a statically rendered form posts the value under, exactly as InputText writes it. + if (!string.IsNullOrEmpty(NameAttributeValue)) + builder.AddAttribute(2, "name", NameAttributeValue); + builder.AddAttribute(3, "class", CssClass); + builder.AddAttribute(4, "value", CurrentValueAsString); + builder.AddAttribute( + 5, + "onchange", + EventCallback.Factory.CreateBinder( + this, + value => CurrentValueAsString = value, + CurrentValueAsString + ) + ); + builder.CloseElement(); + } + + protected override string? FormatValueAsString(TScalar value) => value.Value; + + protected override bool TryParseValueFromString( + string? value, + [MaybeNullWhen(false)] out TScalar result, + [NotNullWhen(false)] out string? validationErrorMessage + ) + { + result = TScalar.Hydrate(value ?? string.Empty); + validationErrorMessage = null; + return true; + } +} diff --git a/scripts/packed-consumers/Shared/Models.cs b/scripts/packed-consumers/Shared/Models.cs new file mode 100644 index 0000000..9c1a2a7 --- /dev/null +++ b/scripts/packed-consumers/Shared/Models.cs @@ -0,0 +1,61 @@ +// Shared by the Blazor consumers: scripts/test-packed-consumers.ps1 copies this folder into BlazorWasm and +// BlazorServer before building them, so both validate exactly the same value objects. +using System.ComponentModel.DataAnnotations; +using Purview.ValueObjects.Serialization; +using ZodSharp; +using ZodSharp.Rules; + +namespace PackedValueObjectsBlazor; + +/// A scalar whose generated Create normalizes, then validates through EmailAddressSchema. +[Scalar] +[ZodSchema] +public readonly partial record struct EmailAddress +{ + [Required] + [EmailAddress] + [StringLength(254, MinimumLength = 3)] + public string Value { get; } + + static partial void OnNormalize(ref string value) => value = value?.Trim().ToLowerInvariant()!; +} + +/// A scalar validated by a pattern on its underlying value. +[Scalar] +[ZodSchema] +public readonly partial record struct PostalCode +{ + [Required] + [RegularExpression("^[0-9]{5}$")] + public string Value { get; } +} + +/// +/// A Guid scalar rejected by the built-in, type-level [NonSentinel] rule. Every publish carries it, the +/// WebAssembly AOT one included, so the gate proves the Mono AOT compiler compiles a type-level rule. +/// +[Scalar] +[ZodSchema] +[NonSentinel(Message = "Choose a plan.")] +public readonly partial record struct PlanId +{ + public Guid Value { get; } +} + +/// +/// The form model. Its value object members are validated by their own generated schemas, so the rules live with +/// the value objects and the form only declares what is specific to it. +/// +[ZodSchema] +public sealed class Registration +{ + [Required] + [StringLength(50, MinimumLength = 2)] + public string? Name { get; set; } + + public EmailAddress Email { get; set; } + + public PostalCode Postcode { get; set; } + + public PlanId Plan { get; set; } +} diff --git a/scripts/packed-consumers/Shared/RegistrationSelfTest.razor b/scripts/packed-consumers/Shared/RegistrationSelfTest.razor new file mode 100644 index 0000000..7f66b21 --- /dev/null +++ b/scripts/packed-consumers/Shared/RegistrationSelfTest.razor @@ -0,0 +1,174 @@ +@* Shared by the Blazor consumers (copied in by scripts/test-packed-consumers.ps1). Two forms over the same model of + value objects - one validated by , one by - and a self-test that runs once + the component is interactive and reports, in #self-test, what each validator placed where. The gate loads the page + in a headless browser and reads that element. *@ +@namespace PackedValueObjectsBlazor +@using System.Text +@using Microsoft.AspNetCore.Components.Forms +@using ZodSharp.Blazor + +

ZodValidator

+ + + + + + + + + + + + + + + + + + + +

DataAnnotationsValidator

+ + + + + + + + + + + + + + + + + + + +
@_report
+ +@code { + readonly Registration _zodModel = new(); + readonly Registration _annotationsModel = new(); + EditContext _zodContext = default!; + EditContext _annotationsContext = default!; + string _result = "running"; + string _report = string.Empty; + + protected override void OnInitialized() + { + _zodContext = new(_zodModel); + _annotationsContext = new(_annotationsModel); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + return; + + StringBuilder report = new(); + var failures = 0; + try + { + failures += await SelfTestAsync("ZodValidator", _zodContext, _zodModel, report); + failures += await SelfTestAsync("DataAnnotationsValidator", _annotationsContext, _annotationsModel, report); + } + 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(); + } + + static async Task SelfTestAsync(string name, EditContext context, Registration model, StringBuilder report) + { + var failures = 0; + void Check(bool condition, string description) + { + report.AppendLine($"{(condition ? "pass" : "FAIL")} {name}: {description}"); + if (!condition) + failures++; + } + + const string EmailMessage = "Field 'Email' must be a valid email address."; + const string PostcodeMessage = "Field 'Postcode' must match the regular expression '^[0-9]{5}$'."; + const string PlanMessage = "Choose a plan."; + + string[] Messages(string field) => [.. context.GetValidationMessages(new FieldIdentifier(model, field))]; + + // Hydrate keeps what was "typed" without validating, exactly as InputScalar does. + model.Name = "x"; + model.Email = EmailAddress.Hydrate("not-an-email"); + model.Postcode = PostalCode.Hydrate("abc"); + model.Plan = PlanId.Hydrate(Guid.Empty); + + var invalid = await context.ValidateAsync(); + string[] all = [.. context.GetValidationMessages()]; + report.AppendLine($"{name}: invalid form -> valid={invalid}, {all.Length} message(s)"); + foreach (var message in all) + report.AppendLine($" * {message}"); + foreach (var field in new[] { "Name", "Email", "Postcode", "Plan" }) + { + foreach (var message in Messages(field)) + report.AppendLine($" {field}: {message}"); + } + + Check(!invalid, "an invalid registration fails validation"); + Check(Messages("Name").Length == 1, "the form's own rule is placed on Name"); + + // An error inside a value object (Email.Value) is placed on the owning member's field, and a scalar's + // messages name that member rather than its own Value. + Check( + Messages("Email").SequenceEqual([EmailMessage]), + $"EmailAddress's own rule is placed on Email and reads \"{EmailMessage}\"" + ); + Check( + Messages("Postcode").SequenceEqual([PostcodeMessage]), + $"PostalCode's own rule is placed on Postcode and reads \"{PostcodeMessage}\"" + ); + + // A type-level rule's error is located at the member itself (Plan) and keeps the rule's own message. + Check( + Messages("Plan").SequenceEqual([PlanMessage]), + $"PlanId's type-level [NonSentinel] rule is placed on Plan and reads \"{PlanMessage}\"" + ); + Check(all.Length == 4, "every error has exactly one visible message (Name, Email, Postcode, Plan)"); + Check( + !all.Any(static m => m.Contains("'Value'", StringComparison.Ordinal)), + "no message names a value object's own Value member" + ); + + // Only the value objects are invalid: the form must still be rejected, or invalid data is submitted. + model.Name = "Ada Lovelace"; + model.Plan = PlanId.Create(Guid.NewGuid()); + var onlyValueObjectsInvalid = await context.ValidateAsync(); + string[] valueObjectMessages = [.. context.GetValidationMessages()]; + report.AppendLine( + $"{name}: only value objects invalid -> valid={onlyValueObjectsInvalid}, {valueObjectMessages.Length} message(s)" + ); + Check(!onlyValueObjectsInvalid, "a form whose only errors are inside value objects is rejected"); + Check( + valueObjectMessages.Length == 2 + && Messages("Email").SequenceEqual([EmailMessage]) + && Messages("Postcode").SequenceEqual([PostcodeMessage]), + "and both of those errors are shown on their own fields" + ); + + model.Email = EmailAddress.Create(" Ada@Example.COM "); + model.Postcode = PostalCode.Create("12345"); + + var valid = await context.ValidateAsync(); + string[] remaining = [.. context.GetValidationMessages()]; + report.AppendLine($"{name}: valid form -> valid={valid}, {remaining.Length} message(s)"); + Check(valid && remaining.Length == 0, "a valid registration passes and clears every message"); + Check(model.Email.Value == "ada@example.com", "Create normalized the address before validating it"); + + return failures; + } +} 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..ea57e04 --- /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.ValueObjects 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()) "vo-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.ValueObjects.$version.nupkg" } + if (-not $core) { throw "Purview.ValueObjects.$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 27 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..5b28f2b --- /dev/null +++ b/scripts/test-packed-consumers.ps1 @@ -0,0 +1,763 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Proves Purview.ValueObjects works for real consumers - Native AOT, Blazor WebAssembly (trimmed and AOT) and Blazor + Server - built from the .nupkg alone, alongside the Purview.ZodSharp packages it integrates with. + +.DESCRIPTION + Modelled on purview-dev/zodsharp's scripts/test-packed-consumers.ps1. Every in-repository build references the + generator and runtime as ProjectReferences, which exercises none of what ships: the packed runtime, the merged + analyzer under analyzers/, the buildTransitive imports, and the generated code 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, and how the generated JSON converters reached + every trimmed consumer with IL2026/IL3050. + + This gate consumes the package exactly as a user does: + + 1. Preflight: checks the build-time Purview.SourceGeneratorFramework and the Purview.ZodSharp packages the + 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.ValueObjects + from the isolated feed only: + (a) AotConsole - a Native AOT console over value objects, [ZodSchema] DTOs and System.Text.Json source + generation. Published with -warnaserror, then run; it exits non-zero on any mismatch. + (b) BlazorWasm - a Blazor WebAssembly app with a form and a form over + a model of value objects. Published trimmed with -warnaserror, then with RunAOTCompilation=true. Each publish + is served and loaded in a headless Chromium-family browser, where the page validates both forms and reports + the outcome in the DOM. + (c) BlazorServer - a Blazor Web App. Published and started: a statically rendered form is posted invalid and + valid, and an interactive-server page is prerendered and then run over a live circuit in the browser. + (d) Net8Console - a net8.0 console without ZodSharp, built and (when the runtime is installed) run: the runtime + package still serves every target framework it ships. + 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* and Purview.ZodSharp* from, in addition + to nuget.org - for versions that are not on nuget.org yet. Used for those packages only. Defaults to + $env:VALUEOBJECTS_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:VALUEOBJECTS_DEPENDENCY_FEED ? $env:VALUEOBJECTS_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('//PurviewSGFVersion')?.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 PurviewSGFVersion 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.ValueObjects to src/src/ValueObjects). +$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 -ireplace '^purview\.', '' + $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 } +} + +$workspace = Join-Path ([System.IO.Path]::GetTempPath()) "vo-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.ValueObjects $version ($Configuration), RID $RuntimeIdentifier" -ForegroundColor Cyan + Write-Host "Purview.ZodSharp $zodSharpVersion, 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 and ZodSharp only; everything else comes from nuget.org. + $sgfPatterns = @('Purview.SourceGeneratorFramework', 'Purview.SourceGeneratorFramework.*') + $zodSharpPatterns = @('Purview.ZodSharp', 'Purview.ZodSharp.*') + $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.ValueObjects from the package 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.ValueObjects') } + $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 and Purview.ZodSharp $zodSharpVersion" { + $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 or Purview.ZodSharp $zodSharpVersion (Directory.Packages.props) " + + "could not be restored from $from. The generator is built against the first and the consumers use the " + + 'second, 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 VALUEOBJECTS_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 ($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\.ValueObjects\.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 + } + + $consumerProperties = @( + "-p:ValueObjectsPackageVersion=$version" + "-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') '^PackedValueObjectsAot\.deps\.json$' 'the build deps.json' + + Invoke-Dotnet 'aot-console-publish' (@( + 'publish', $project, '--configuration', 'Release', '--runtime', $RuntimeIdentifier, '--output', $publish, + '-warnaserror' + ) + $consumerProperties) + + & (Join-Path $publish "PackedValueObjectsAot$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) { + $root = Join-Path $publish 'wwwroot' + $framework = Join-Path $root '_framework' + # Without it the static file host has nothing to serve and answers 500 for /, so name it here rather than in the + # browser step. The repository ignores *.html (see .gitignore), so a consumer's index.html must stay committed. + Assert-Contains $root '^index\.html$' 'the app''s index.html' + Assert-Contains $framework '^Purview\.ValueObjects\.[a-z0-9]+\.wasm$' 'Purview.ValueObjects' + 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 '^PackedValueObjectsWasm\.[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. The + # model is the same one the trimmed publish runs, type-level [ZodRule] (PlanId's [NonSentinel]) included. + $directory = Join-Path $consumers 'wasm-aot' + 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, 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 "PackedValueObjectsServer$exe") @() $publish $port 'blazor-server' + try { + $session = $null + $page = Wait-Http $process "$base/register" + if ($page.StatusCode -ne 200) { throw "/register answered $($page.StatusCode)." } + $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-Registration([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) + } + + $invalid = Send-Registration @{ Name = 'x'; Email = 'not-an-email'; Postcode = 'abc'; Plan = [guid]::NewGuid().ToString() } + $messages = @([regex]::Matches($invalid, '
]*>(.*?)
') | ForEach-Object { $_.Groups[1].Value -replace '<[^>]+>', ' ' }) + $messages | ForEach-Object { Write-Host " invalid post: $_" } + foreach ($expected in @( + @{ Text = "Field 'Name' must contain at least 2 characters."; What = "the form's own rule, through " } + @{ Text = "The value 'not-an-email' is not valid for 'Email'."; What = "EmailAddress rejecting the posted address (its generated TryParse runs Create)" } + @{ Text = "The value 'abc' is not valid for 'Postcode'."; What = 'PostalCode rejecting the posted postcode' } + # The unparsed members stay default, so each value object's own [Required] on Value fails - inside the + # struct (Email.Value) - and places it on the owning member's field, named after it. + @{ Text = 'Email is required'; What = "EmailAddress's own rule placed on the Email field" } + @{ Text = 'Postcode is required'; What = "PostalCode's own rule placed on the Postcode field" } + )) { + if (-not ($messages | Where-Object { $_.Contains($expected.Text) })) { + throw "The invalid post does not show $($expected.What): '$($expected.Text)'." + } + } + if ($invalid.Contains('id="registered"')) { throw 'The invalid post was accepted.' } + if (-not $invalid.Contains('invalid_length')) { throw "The does not show the rule's code (invalid_length)." } + + $valid = Send-Registration @{ Name = 'Ada Lovelace'; Email = ' Ada@Example.COM '; Postcode = '12345'; Plan = [guid]::NewGuid().ToString() } + $registered = [regex]::Match($valid, '

]*>(.*?)

').Groups[1].Value + Write-Host " valid post: $registered" + if ($registered -ne 'Registered Ada Lovelace at 12345.') { + throw "The valid post did not register the normalized value objects (got '$registered')." + } + + $interactive = Invoke-WebRequest "$base/interactive" + if ($interactive.StatusCode -ne 200 -or -not $interactive.Content.Contains('id="zod-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 } + + # The server publishes the WebAssembly debugging proxy only when it hosts a WebAssembly client, which this one + # does not, so nothing needs allowing. + Assert-NoLeaks @((Join-Path $consumers 'BlazorServer/bin'), $publish) + } + + Invoke-Step '(d) net8.0 console without ZodSharp: build and run' { + $project = Join-Path $consumers 'Net8Console/Net8Console.csproj' + Invoke-Dotnet 'net8-console-build' (@('build', $project, '--configuration', 'Release', '-warnaserror') + $consumerProperties) + $bin = Join-Path $consumers 'Net8Console/bin/Release/net8.0' + + $net8 = [bool] (& dotnet --list-runtimes 2>$null | Select-String -Pattern '^Microsoft\.NETCore\.App 8\.') + if ($net8) { + & dotnet (Join-Path $bin 'PackedValueObjectsNet8.dll') | Out-Host + if ($LASTEXITCODE -ne 0) { throw "The net8.0 console exited with ${LASTEXITCODE}." } + } + else { + Write-Host ' the .NET 8 runtime is not installed: built for net8.0 but NOT run.' -ForegroundColor Yellow + if ($env:GITHUB_ACTIONS) { Write-Host '::warning::The net8.0 consumer was built but not run: no .NET 8 runtime.' } + } + + Assert-NoLeaks @((Join-Path $consumers 'Net8Console/bin')) + } +} +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 ($package in $shipping) { + & dotnet restore $package.Project -nr:false | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Failure "Restoring $($package.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..90f6427 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,161 @@ +{ + "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-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-value-objects-efcore/SKILL.md b/skills/purview-value-objects-efcore/SKILL.md index 8800c71..d1ce2ae 100644 --- a/skills/purview-value-objects-efcore/SKILL.md +++ b/skills/purview-value-objects-efcore/SKILL.md @@ -54,7 +54,7 @@ generated `ModelCustomizer` that runs it after `OnModelCreating`, and works for | --- | --- | --- | | `[Scalar]` | one column of the underlying primitive | via `{Type}.EF.Converter` + `ValueComparer`; `EmailAddress` is a `TEXT` column | | `[ValueObject]` (default) | an EF Core complex type — one column per member | EF Core 8+ only; nested scalars convert too | -| `[ValueObject(EFMapping = EntityFrameworkMapping.Json)]` | a single JSON column | a value converter, so it works on every EF Core version | +| `[ValueObject(EFMapping = EntityFrameworkMapping.Json)]` | a single JSON column | a value converter, so it works on every EF Core version; writes through the generated JSON converter, with no reflection | The provider-to-model direction always goes through `Hydrate(...)`. **Materialization is a replay path**: neither `OnValidate` nor a ZodSharp schema runs when a row is read. That matches @@ -314,6 +314,15 @@ is left unmapped — use `EFMapping = Json` or configure the property manually. (converters, the key value generator, the convention, `ValueObjectSequentialGuid`) works on EF Core 7 and later. +## Trimming and Native AOT + +The generated mapping raises no IL2xxx/IL3xxx warning of its own. A JSON column goes through +`ValueObjectJson` and the type's generated converter, not `JsonSerializer`, unless the type turned +its converter off (`VO1017`). EF Core itself is not fully trim- or AOT-compatible — its `DbContext` +constructor and migrations APIs are annotated — so a trimmed or AOT app warns there; that is EF's +surface, not the generator's. Without reflection-based JSON metadata, an enum or custom member of a +JSON-mapped value object throws `NotSupportedException`. + ## Diagnostics `VO1009` (EF option set without an EF reference), `VO1010` (underlying type not mappable), `VO1017` diff --git a/skills/purview-value-objects-zodsharp/SKILL.md b/skills/purview-value-objects-zodsharp/SKILL.md index ad82684..3a28585 100644 --- a/skills/purview-value-objects-zodsharp/SKILL.md +++ b/skills/purview-value-objects-zodsharp/SKILL.md @@ -23,6 +23,18 @@ ZodSharp source generator is active in any project that references the package. ```text dotnet add package Purview.ZodSharp ``` +**Purview.ZodSharp 3** targets `net10.0`/`net11.0` only, so a project using the integration needs +`net10.0`+ (Purview.ValueObjects itself still ships `net8.0`-`net11.0`). The generated integration works +with 2.x and 3.x. In 3.x built-in rules report `Origin`/`Category`, paths render as `Items[0].Name` +(`FullPath.ToDottedString()`), and generated schemas gain `ValidateAsync` (CA1849 flags a synchronous +`Validate` in async code). + +**Blazor** (`Purview.ZodSharp.Blazor`, `net11.0`): register `AddZodSharp{Assembly}Schemas()` and +`AddZodSharpBlazor()`, bind a scalar with an `InputBase` that uses `Hydrate`, and validate with +`` or ``. An error inside a value object member +(`Email.Value`) is shown on the owning member's field (`For="() => model.Email"`) and named after it +(`Field 'Email' must be ...`), on Blazor Server and WebAssembly (trimmed or AOT) alike; a type-level +`[ZodRule]` is located at the member. Repository guide: `docs/Trimming-AOT-and-Blazor.md`. Four ways to use it, in rough order of how often you want them: @@ -253,35 +265,57 @@ reconstructs codes or messages — it forwards `result.Errors` into a `ZodExcept ### ASP.NET Core Problem Details -`Purview.ZodSharp.AspNetCore` maps a thrown `ZodException` to `HttpValidationProblemDetails`: +Bind request bodies with the default `Hydrate` mode and validate the **bound request**: its generated +schema validates each value object member through that value object's own schema, so every invalid member +is reported under its own path (`CustomerEmail.Value`, message `Field 'CustomerEmail' ...`). ```csharp +[ZodSchema] +public sealed partial record PlaceOrderRequest(EmailAddress CustomerEmail, OrderQuantity Quantity); + +builder.Services.AddZodSharpMyAppSchemas(); // generated AddZodSharp{Assembly}Schemas(), AOT-safe builder.Services.AddZodSharpProblemDetails(); builder.Services.AddProblemDetails(); var app = builder.Build(); app.UseExceptionHandler(); +app.MapPost("/orders", (PlaceOrderRequest request) => TypedResults.Ok()).WithZodSharpValidation(); ``` -Make the value object validate during model binding: +An invalid body returns `400 application/problem+json` with `errors` and the structured `issues`, under +the JIT and Native AOT. On `net11.0`, `AddZodSharpValidation()` (`Purview.ZodSharp.Extensions.Validation`) +does it for every endpoint. Under Native AOT (`CreateSlimBuilder` and a source-generated context), also +register the generated converters (`new EmailAddress.EmailAddressJsonConverter()`) on the HTTP JSON options. + +`ValueObjectDeserializationMode.Strict` gives the same contract at the same path: the generated converter +throws `JsonException(inner: ZodException)`, System.Text.Json records `$.customerEmail`, and +`ZodExceptionHandler` re-roots the errors (`CustomerEmail.Value`, `Copies[1].Value`). On .NET 10 set +`AddZodSharpProblemDetails(o => o.HandleBindingFailures = true)`, or binding answers a bare `400`; .NET 11 +needs nothing. Requires Purview.ZodSharp 3.0.0-prerelease.2+. It still stops at the first invalid member, so +prefer bound-request validation for request bodies. Don't register `ScalarJsonConverterFactory`: under Native +AOT every request fails. + +### Typed HTTP clients (`Purview.ZodSharp.Http`) + +`PostAsJsonValidatedAsync`/`PutAsJsonValidatedAsync`/`PatchAsJsonValidatedAsync` validate a body before +sending (an invalid one is never sent), `ReadFromJsonAndValidateAsync` validates a response, and +`TryReadZodValidationProblemAsync` turns the server's ProblemDetails back into ZodSharp errors. Every API +takes a `JsonTypeInfo`, so it is trim-, AOT- and WebAssembly-safe. **Do not pass the context's own +property** (`AppJsonContext.Default.Order`) when the value objects are declared in the same project: that +context cannot see their converters and writes `{"customerEmail":{"value":"..."}}`, which the server rejects. +Build the type info from options that carry the generated converters: ```csharp -[Scalar( - ZodSchemaMode = ZodSchemaMode.InsteadOfHooks, - DeserializationMode = ValueObjectDeserializationMode.Strict)] -[ZodSchema] -public readonly partial record struct EmailAddress +JsonSerializerOptions options = new(AppJsonContext.Default.Options) { - [Required, EmailAddress] - public string Value { get; } -} + Converters = { new EmailAddress.EmailAddressJsonConverter() }, +}; +var orderInfo = (JsonTypeInfo)options.GetTypeInfo(typeof(PlaceOrderRequest)); -builder.Services.ConfigureHttpJsonOptions(options => - options.SerializerOptions.Converters.Add(new ScalarJsonConverterFactory())); +var sent = await http.PostAsJsonValidatedAsync("orders", request, new PlaceOrderRequestSchemaValidator(), orderInfo); ``` -An invalid body now returns `400 application/problem+json` with the structured issues in the `issues` -extension. +Value objects from a referenced library need none of this; the context's own property works. Map error codes to HTTP statuses with `[ErrorType]` on a `static readonly ErrorType` field; the bundled `ErrorTypeGenerator` emits `Create{Field}(...)` and a `[DoesNotReturn]` `Throw{Field}(...)`. diff --git a/skills/purview-value-objects/SKILL.md b/skills/purview-value-objects/SKILL.md index 9636d32..a42b1fa 100644 --- a/skills/purview-value-objects/SKILL.md +++ b/skills/purview-value-objects/SKILL.md @@ -205,6 +205,14 @@ Both attributes default to `ValueObjectDeserializationMode.Hydrate`, so a JSON r re-validate. Choose `Strict` only when round-trip fidelity genuinely requires re-running validation on read — and then make sure a `Create` overload exists to re-validate through, or you get `VO1007`. +The generated `[JsonConverter]`s use no reflection, so trimmed, Native AOT and Blazor WebAssembly apps +get no IL2026/IL3050 from them (`ScalarJsonConverterFactory` is the reflection-based exception). A +source-generated `JsonSerializerContext` **in the project that declares the value objects** cannot see the +generated `[JsonConverter]` attribute (one generator never sees another's output) and describes each value +object as an object with a `Value` property: register the generated converters on its options +(`new JsonSerializerOptions(AppJsonContext.Default.Options) { Converters = { new EmailAddress.EmailAddressJsonConverter() } }`). +A value object from a referenced library needs nothing. Repository guide: `docs/Trimming-AOT-and-Blazor.md`. + ## Assembly-level defaults Repeat nothing per type that the assembly can say once: diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 7b698eb..ae66414 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -8,6 +8,11 @@ itself, and anything carrying a single Entity Framework Core 10 pin could not restore for net8.0/net9.0. Roslyn components are excluded by the SDK, which targets them at netstandard2.0. --> All + + universal diff --git a/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs b/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs index 0b4d42d..084c3cb 100644 --- a/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs +++ b/src/src/SourceGenerator/Common/TypeLibrary.Constants.cs @@ -40,6 +40,12 @@ public static partial class TypeLibrary public const string StringNormalizeAttributeFullTypeName = SerializationNamespace + ".StringNormalizeAttribute"; + /// + /// The runtime's trim- and Native AOT-safe JSON helpers, which every generated JSON converter reads and writes + /// through instead of the reflection-based JsonSerializer overloads. + /// + public const string ValueObjectJsonGlobalName = "global::" + SerializationNamespace + ".ValueObjectJson"; + // ZodSharp's runtime and generator-emitted types. The ZodSharp attribute and schema types are produced // by the ZodSharp source generator, so these names are matched and emitted as qualified text rather than // resolved as symbols (the value object generator must keep working when ZodSharp is not referenced). diff --git a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.EF.cs b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.EF.cs index c291ef7..5902c5f 100644 --- a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.EF.cs +++ b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.EF.cs @@ -20,8 +20,14 @@ static void EmitEF(CodeWriter writer, ComplexValueObjectModel model, bool emitEF ValueObjectEmitterHelpers.EFConverterBaseType(model.TypeModel.FullyQualifiedName, "global::System.String"), model.TypeModel.FullyQualifiedName, "global::System.String", - "vo => global::System.Text.Json.JsonSerializer.Serialize(vo)", - $"v => global::System.Text.Json.JsonSerializer.Deserialize<{model.TypeModel.FullyQualifiedName}>(v)!", + ValueObjectEFConverterEmitter.ToJsonProviderExpression( + model.TypeModel.FullyQualifiedName, + model.EFJsonConverterTypeName + ), + ValueObjectEFConverterEmitter.FromJsonProviderExpression( + model.TypeModel.FullyQualifiedName, + model.EFJsonConverterTypeName + ), ValueObjectEmitterHelpers.EFJsonReaderWriterType("global::System.String"), model.IsEF8Referenced ); diff --git a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.Serialization.cs b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.Serialization.cs index b3a6cd0..7670768 100644 --- a/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.Serialization.cs +++ b/src/src/SourceGenerator/ValueObject/ComplexValueObjectEmitter.Serialization.cs @@ -2,18 +2,28 @@ namespace Purview.ValueObjects.SourceGenerator.ValueObject; static partial class ComplexValueObjectEmitter { + /// + /// Emits the complex value object's JSON converter. It reads and writes the object member by member through the + /// runtime's ValueObjectJson helpers, with no reflection: the previous converter deserialized a generated + /// {Name}JsonModel class through JsonSerializer.Deserialize<T>(ref reader, options), which is + /// [RequiresUnreferencedCode]/[RequiresDynamicCode] (IL2026/IL3050 in every trimmed or Native AOT + /// consumer) and needs metadata for a type no source-generated context could list. + /// + /// + /// Member names follow the options' PropertyNamingPolicy and PropertyNameCaseInsensitive, unknown + /// members are skipped, a missing member keeps its default, and DefaultIgnoreCondition is honoured when + /// writing - the behaviour the model-class converter had. + /// static void EmitJsonConverter(CodeWriter writer, ComplexValueObjectModel model) { if (!model.Options.GenerateJsonConverter) return; - var modelTypeName = $"{model.TypeModel.Name}JsonModel"; - var jsonModelIdentity = new TypeIdentity(model.TypeModel.Name, model.TypeModel.Namespace).Nested( - modelTypeName, - 0 - ); - var hydrateArgs = string.Join(", ", model.Properties.Select(static property => $"model.{property.Name}")); + var helper = TypeLibrary.ValueObjectJsonGlobalName; + var properties = model.Properties.ToArray(); + var hydrateArgs = string.Join(", ", properties.Select(static (_, index) => MemberLocal(index))); var valueObjectType = ValueObjectType(model); + var unableToDeserialize = $"\"Unable to deserialize {model.TypeModel.Name}.\""; writer.Class( new($"{model.TypeModel.Name}JsonConverter") @@ -24,6 +34,26 @@ static void EmitJsonConverter(CodeWriter writer, ComplexValueObjectModel model) }, body => { + for (var index = 0; index < properties.Length; index++) + { + var property = properties[index]; + if (property.JsonConverterExpression is not { } converter) + continue; + + body.Field( + new FieldDeclarationOptions( + MemberConverterField(index), + TypeLibrary.System.Text.Json.Serialization.JsonConverter.MakeGeneric(property.Type) + ) + { + Accessibility = TypeDeclarationAccessibility.Private, + IsStatic = true, + IsReadOnly = true, + Initializer = converter, + } + ); + } + body.Method( new("Read", valueObjectType, TypeDeclarationAccessibility.Public) { @@ -37,20 +67,55 @@ static void EmitJsonConverter(CodeWriter writer, ComplexValueObjectModel model) }, methodBody => { - methodBody.Assignment( - "var", - "model", - $"global::System.Text.Json.JsonSerializer.Deserialize<{modelTypeName}>(ref reader, options)" - ); methodBody.IfBlock( - "model is null", + "reader.TokenType != global::System.Text.Json.JsonTokenType.StartObject", ifBody => - ifBody.Throw( - TypeLibrary.System.Text.Json.JsonException, - $"Unable to deserialize {model.TypeModel.Name}." - ) + ifBody.Line($"throw new global::System.Text.Json.JsonException({unableToDeserialize});") + ); + + for (var index = 0; index < properties.Length; index++) + methodBody.Line($"{properties[index].TypeName} {MemberLocal(index)} = default!;"); + + methodBody.While( + "reader.Read()", + loop => + { + loop.IfBlock( + "reader.TokenType == global::System.Text.Json.JsonTokenType.EndObject", + ifBody => + ValueObjectEmitterHelpers.EmitJsonFactoryReturn( + ifBody, + $"{model.HydrateFactoryName}({hydrateArgs})", + model.Options.DeserializationMode + == ValueObjectSymbolInspector.StrictModeName, + model.TypeModel.FullyQualifiedName + ) + ); + + loop.Line("var jsonName = reader.GetString();"); + loop.Line("reader.Read();"); + + for (var index = 0; index < properties.Length; index++) + { + var property = properties[index]; + var condition = $"{helper}.IsMember(jsonName, \"{property.Name}\", options)"; + var statement = + $"{MemberLocal(index)} = {helper}.Read<{property.TypeName}>(ref reader, options{ConverterArgument(property, index)})!;"; + + if (index == 0) + loop.IfBlock(condition, branch => branch.Line(statement)); + else + loop.ElseIf(condition, branch => branch.Line(statement)); + } + + if (properties.Length == 0) + loop.Line("reader.Skip();"); + else + loop.Else(static branch => branch.Line("reader.Skip();")); + } ); - methodBody.Return($"{model.HydrateFactoryName}({hydrateArgs})"); + + methodBody.Line($"throw new global::System.Text.Json.JsonException({unableToDeserialize});"); } ); @@ -67,48 +132,26 @@ static void EmitJsonConverter(CodeWriter writer, ComplexValueObjectModel model) }, methodBody => { - methodBody.Assignment( - "var", - "model", - new ObjectCreationOptions(jsonModelIdentity) - { - WriteInitializerMembersOnSeparateLines = false, - InitializerMembers = - [ - .. model.Properties.Select(static property => new ObjectInitializerMemberOptions( - property.Name, - $"value.{property.Name}" - )), - ], - } - ); - methodBody.MethodCallOn( - $"{TypeLibrary.System.Text.Json.JsonSerializer}", - "Serialize", - "writer", - "model", - "options" - ); - } - ); - } - ); - - writer.Class( - new(modelTypeName) { IsPartial = false, IsSealed = true }, - body => - { - foreach (var property in model.Properties) - { - body.Property( - new(property.Name, property.Type, TypeDeclarationAccessibility.Public) + methodBody.Line("writer.WriteStartObject();"); + for (var index = 0; index < properties.Length; index++) { - HasSetter = true, - Initializer = "default!", + var property = properties[index]; + methodBody.Line( + $"{helper}.WriteMember<{property.TypeName}>(writer, \"{property.Name}\", value.{property.Name}, options{ConverterArgument(property, index)});" + ); } - ); - } + + methodBody.Line("writer.WriteEndObject();"); + } + ); } ); } + + static string ConverterArgument(ComplexPropertyModel property, int index) => + property.JsonConverterExpression is null ? string.Empty : $", {MemberConverterField(index)}"; + + static string MemberLocal(int index) => $"member{index}"; + + static string MemberConverterField(int index) => $"Member{index}Converter"; } diff --git a/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs b/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs index 43dd2ba..60e0557 100644 --- a/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs +++ b/src/src/SourceGenerator/ValueObject/ComplexValueObjectModelBuilder.cs @@ -238,12 +238,159 @@ out var efCtorArgs zodSchema.HasSchema, zodSchema.SchemaClassName, isEFReferenced, - ValueObjectSymbolInspector.IsEF8Referenced(compilation) + ValueObjectSymbolInspector.IsEF8Referenced(compilation), + isEFReferenced + ? ResolveEFJsonConverterTypeName( + typeSymbol, + typeModel.Value.FullyQualifiedName, + hasJsonConverterAttribute, + valueObjectOptions.GenerateJsonConverter + ) + : null ); return GeneratorResult.Create(model, diagnosticsList.ToImmutableArray()); } + /// + /// The expression constructing the JSON converter a member serializes through when its type is a value object + /// (or a nullable one), or for any other type. A type that declares + /// [JsonConverter(typeof(C))] - every compiled value object does, because the generator emits it - uses + /// C; a value object declared in this compilation, whose attribute this generator has not emitted yet, + /// uses the {Name}JsonConverter it is about to emit. The expression is only a fallback: the generated + /// converter prefers the metadata of the options it is called with. + /// + static string? ResolveMemberJsonConverter( + ITypeSymbol memberType, + IAssemblySymbol compilationAssembly, + ValueObjectDefaultsAttributeData assemblyDefaults + ) + { + var isNullableValueType = + memberType is INamedTypeSymbol { OriginalDefinition.SpecialType: SpecialType.System_Nullable_T }; + var type = isNullableValueType ? ((INamedTypeSymbol)memberType).TypeArguments[0] : memberType; + if (type is not INamedTypeSymbol { IsGenericType: false } named) + return null; + + var converterTypeName = + ResolveDeclaredJsonConverterTypeName(named) + ?? ResolveGeneratedJsonConverterTypeName(named, compilationAssembly, assemblyDefaults); + if (converterTypeName is null) + return null; + + var converter = $"new {converterTypeName}()"; + return isNullableValueType ? $"{TypeLibrary.ValueObjectJsonGlobalName}.Nullable({converter})" : converter; + } + + /// + /// The converter an Entity Framework Core JSON column of this value object serializes through, or + /// to keep the reflection-based JsonSerializer call. It is the converter + /// JsonSerializer.Serialize(value) would pick: the author's own [JsonConverter] when there is one + /// (and nothing when that is a factory or otherwise cannot be constructed here), otherwise the generated + /// {Name}JsonConverter. + /// + static string? ResolveEFJsonConverterTypeName( + INamedTypeSymbol typeSymbol, + string fullyQualifiedName, + bool hasJsonConverterAttribute, + bool generateJsonConverter + ) + { + if (hasJsonConverterAttribute) + return ResolveDeclaredJsonConverterTypeName(typeSymbol); + + return generateJsonConverter ? $"{fullyQualifiedName}.{typeSymbol.Name}JsonConverter" : null; + } + + /// + /// The fully qualified name of the concrete JsonConverter<T> a type's own + /// [JsonConverter(typeof(C))] names, or when it has none, or names a factory or a + /// converter that cannot be constructed without arguments. + /// + internal static string? ResolveDeclaredJsonConverterTypeName(INamedTypeSymbol type) + { + foreach (var attribute in type.GetAttributes()) + { + if ( + !TypeLibrary.System.Text.Json.Serialization.JsonConverterAttribute.Equals(attribute.AttributeClass) + || attribute.ConstructorArguments.Length != 1 + || attribute.ConstructorArguments[0].Value is not INamedTypeSymbol converterType + ) + { + continue; + } + + // Only a concrete JsonConverter for exactly this type, constructed without arguments, can be passed + // as the member's converter; a JsonConverterFactory or anything else is left to the options. + var constructible = + !converterType.IsAbstract + && !converterType.IsGenericType + && converterType.InstanceConstructors.Any(static ctor => + ctor.Parameters.Length == 0 && ctor.DeclaredAccessibility == Accessibility.Public + ); + if (constructible && IsJsonConverterOf(converterType, type)) + return ValueObjectSymbolInspector.ToTypeName(converterType); + + return null; + } + + return null; + } + + static string? ResolveGeneratedJsonConverterTypeName( + INamedTypeSymbol type, + IAssemblySymbol compilationAssembly, + ValueObjectDefaultsAttributeData assemblyDefaults + ) + { + // A compiled value object already carries the [JsonConverter] this generator emitted, which + // ResolveDeclaredJsonConverter reads; only a type in this compilation has not been given it yet. + if (!SymbolEqualityComparer.Default.Equals(type.ContainingAssembly, compilationAssembly)) + return null; + + var attributes = type.GetAttributes(); + bool generateJsonConverter; + if (ScalarAttributeParser.Find(attributes) is { } scalarAttribute) + { + generateJsonConverter = ValueObjectDefaultsHelper + .Apply(ScalarAttributeParser.Parse(scalarAttribute), assemblyDefaults, attributes) + .GenerateJsonConverter; + } + else if ( + ValueObjectSymbolInspector.HasAttribute( + attributes, + TypeLibrary.Purview.ValueObjects.Serialization.ValueObjectAttribute + ) + ) + { + generateJsonConverter = ValueObjectDefaultsHelper + .Apply(ValueObjectAttributeData.FromAttributeData(attributes), assemblyDefaults, attributes) + .GenerateJsonConverter; + } + else + { + return null; + } + + return generateJsonConverter ? $"{ValueObjectSymbolInspector.ToTypeName(type)}.{type.Name}JsonConverter" : null; + } + + static bool IsJsonConverterOf(INamedTypeSymbol converterType, INamedTypeSymbol valueType) + { + for (var current = converterType.BaseType; current is not null; current = current.BaseType) + { + if ( + current is { Name: "JsonConverter", Arity: 1 } + && current.ContainingNamespace.ToDisplayString() == "System.Text.Json.Serialization" + ) + { + return SymbolEqualityComparer.Default.Equals(current.TypeArguments[0], valueType); + } + } + + return false; + } + /// /// Builds the complex member models and resolves their built-in string normalization. The option is /// only applied by the generated Create, so a hand-written OnNormalize hook or a @@ -275,7 +422,8 @@ List diagnostics TypeReference.Create(property.Type), normalizeTrim, normalizeCasing, - normalizeExplicit + normalizeExplicit, + ResolveMemberJsonConverter(property.Type, typeSymbol.ContainingAssembly, assemblyDefaults) ) ); } diff --git a/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs b/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs index 4d5e4a1..17fac32 100644 --- a/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs +++ b/src/src/SourceGenerator/ValueObject/Models/ComplexValueObjectModel.cs @@ -36,14 +36,30 @@ sealed record class ComplexValueObjectModel( bool HasZodSchemaValidation, string? ZodSchemaClassName, bool IsEFReferenced, - bool IsEF8Referenced + bool IsEF8Referenced, + // The JsonConverter an Entity Framework Core JSON column of this value object serializes through, or null + // when there is none to call without reflection. + string? EFJsonConverterTypeName ); +/// A member of a complex value object. +/// The member's name. +/// The member's fully qualified type, with its nullable annotation. +/// The member's type. +/// True when the generated Create trims the member. +/// The casing the generated Create applies to the member. +/// True when the normalization was set on the member rather than defaulted. +/// +/// The expression that constructs the JSON converter a value-object-typed member serializes through, or +/// . The generated converter passes it to ValueObjectJson, so a member that is itself +/// a value object still round-trips when a source-generated JsonSerializerContext never listed its type. +/// readonly record struct ComplexPropertyModel( string Name, string TypeName, TypeReference Type, bool NormalizeTrim, string NormalizeCasing, - bool NormalizeExplicit + bool NormalizeExplicit, + string? JsonConverterExpression ); diff --git a/src/src/SourceGenerator/ValueObject/Models/EFValueObjectDescriptors.cs b/src/src/SourceGenerator/ValueObject/Models/EFValueObjectDescriptors.cs index 3e09d3b..6508597 100644 --- a/src/src/SourceGenerator/ValueObject/Models/EFValueObjectDescriptors.cs +++ b/src/src/SourceGenerator/ValueObject/Models/EFValueObjectDescriptors.cs @@ -35,7 +35,9 @@ readonly record struct EFScalarDescriptor( /// Compile-time description of an Entity Framework Core-enabled complex value object, used to emit the /// assembly-level ValueObjectEFExtensions registry. When the declaring assembly emitted an /// EF nested class (), the registry references {TypeName}.EF.Converter -/// for JSON columns; otherwise the JSON converter is emitted inline. +/// for JSON columns; otherwise the JSON converter is emitted inline, serializing through +/// when the type's [JsonConverter] names one that can be called without +/// reflection. /// readonly record struct EFComplexDescriptor( string TypeName, @@ -43,5 +45,6 @@ readonly record struct EFComplexDescriptor( bool IsEF8Referenced, bool HasComparer, bool HasJsonConverter, - bool HasEFMembers + bool HasEFMembers, + string? JsonConverterTypeName = null ); diff --git a/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs b/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs index 6375496..33cc6c7 100644 --- a/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs +++ b/src/src/SourceGenerator/ValueObject/ReferencedEFValueObjectDiscovery.cs @@ -222,7 +222,9 @@ efMapping is not null isEF8Referenced, options.GenerateEFComparer, efMapping is not null && ValueObjectSymbolInspector.IsEFMappingJson(efMapping), - HasEFMembers: false + HasEFMembers: false, + // The compiled type carries the [JsonConverter] its own generator emitted (or its author's). + JsonConverterTypeName: ComplexValueObjectModelBuilder.ResolveDeclaredJsonConverterTypeName(type) ) ); return; diff --git a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Equality.cs b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Equality.cs index 9d1f54f..06be68f 100644 --- a/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Equality.cs +++ b/src/src/SourceGenerator/ValueObject/ScalarValueObjectEmitter.Equality.cs @@ -287,8 +287,8 @@ static void EmitJsonConverter(CodeWriter writer, ScalarValueObjectModel model) return; var valueObjectType = ValueObjectType(model); - var factoryMethod = - model.Options.DeserializationMode == ValueObjectSymbolInspector.StrictModeName ? "Create" : "Hydrate"; + var isStrict = model.Options.DeserializationMode == ValueObjectSymbolInspector.StrictModeName; + var factoryMethod = isStrict ? "Create" : "Hydrate"; writer.Class( new($"{model.TypeModel.Name}JsonConverter") @@ -312,14 +312,19 @@ static void EmitJsonConverter(CodeWriter writer, ScalarValueObjectModel model) }, methodBody => { + // Through the runtime's ValueObjectJson rather than JsonSerializer.Deserialize(ref + // reader, options): that overload is [RequiresUnreferencedCode]/[RequiresDynamicCode], so + // every trimmed or Native AOT consumer got IL2026/IL3050 from this generated code. The + // helper resolves the same metadata from the options, and falls back to the built-in + // converter for a primitive that a source-generated context never listed. methodBody.Assignment( "var", "value", writeValue => writeValue.MethodCall( - "Deserialize", + "Read", [new MethodCallArgumentOptions("reader", ParameterModifier.Ref), "options"], - receiver: $"{TypeLibrary.System.Text.Json.JsonSerializer}", + receiver: TypeLibrary.ValueObjectJsonGlobalName, genericArguments: [model.ScalarTypeReference] ) ); @@ -338,7 +343,12 @@ [new MethodCallArgumentOptions("reader", ParameterModifier.Ref), "options"], ); } - methodBody.Return($"{factoryMethod}(value)"); + ValueObjectEmitterHelpers.EmitJsonFactoryReturn( + methodBody, + $"{factoryMethod}(value)", + isStrict, + model.TypeModel.FullyQualifiedName + ); } ); @@ -355,8 +365,8 @@ [new MethodCallArgumentOptions("reader", ParameterModifier.Ref), "options"], }, methodBody => methodBody.MethodCallOn( - $"{TypeLibrary.System.Text.Json.JsonSerializer}", - "Serialize", + TypeLibrary.ValueObjectJsonGlobalName, + "Write", "writer", $"value.{model.ScalarPropertyName}", "options" diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectEFConverterEmitter.cs b/src/src/SourceGenerator/ValueObject/ValueObjectEFConverterEmitter.cs index 20ae341..328e3ac 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectEFConverterEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectEFConverterEmitter.cs @@ -53,6 +53,26 @@ hydrateCastTypeName is null ? $"v => {valueObjectTypeName}.Hydrate(v)" : $"v => {valueObjectTypeName}.Hydrate(({hydrateCastTypeName})v)"; + /// + /// Builds the model-to-provider lambda of a JSON column. With it writes + /// through that converter with the runtime's ValueObjectJson, which is trim- and Native AOT-safe; + /// without one (the value object opted out of its generated converter, or names a factory) it falls back to the + /// reflection-based JsonSerializer.Serialize. + /// + public static string ToJsonProviderExpression(string valueObjectTypeName, string? jsonConverterTypeName) => + jsonConverterTypeName is null + ? "vo => global::System.Text.Json.JsonSerializer.Serialize(vo)" + : $"vo => {TypeLibrary.ValueObjectJsonGlobalName}.Serialize<{valueObjectTypeName}, {jsonConverterTypeName}>(vo)"; + + /// + /// Builds the provider-to-model lambda of a JSON column; the counterpart of + /// . + /// + public static string FromJsonProviderExpression(string valueObjectTypeName, string? jsonConverterTypeName) => + jsonConverterTypeName is null + ? $"v => global::System.Text.Json.JsonSerializer.Deserialize<{valueObjectTypeName}>(v)!" + : $"v => {TypeLibrary.ValueObjectJsonGlobalName}.Deserialize<{valueObjectTypeName}, {jsonConverterTypeName}>(v)!"; + public static void EmitConverterClass(CodeWriter writer, EFConverterDefinition definition) { var nullableObject = PurviewTypeLibrary.System.Object.MakeNullable(writer); diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs b/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs index 24d3655..0b33939 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectEFRegistryEmitter.cs @@ -745,8 +745,14 @@ Dictionary Fields ValueObjectEmitterHelpers.EFConverterBaseType(descriptor.TypeName, "global::System.String"), descriptor.TypeName, "global::System.String", - "vo => global::System.Text.Json.JsonSerializer.Serialize(vo)", - $"v => global::System.Text.Json.JsonSerializer.Deserialize<{descriptor.TypeName}>(v)!" + ValueObjectEFConverterEmitter.ToJsonProviderExpression( + descriptor.TypeName, + descriptor.JsonConverterTypeName + ), + ValueObjectEFConverterEmitter.FromJsonProviderExpression( + descriptor.TypeName, + descriptor.JsonConverterTypeName + ) ) ); fields[descriptor] = fieldName; diff --git a/src/src/SourceGenerator/ValueObject/ValueObjectEmitterHelpers.cs b/src/src/SourceGenerator/ValueObject/ValueObjectEmitterHelpers.cs index d779681..f106949 100644 --- a/src/src/SourceGenerator/ValueObject/ValueObjectEmitterHelpers.cs +++ b/src/src/SourceGenerator/ValueObject/ValueObjectEmitterHelpers.cs @@ -47,6 +47,34 @@ public static string EFJsonReaderWriterType(string providerTypeName) return $"global::Microsoft.EntityFrameworkCore.Storage.Json.{singleton}"; } + /// + /// Emits a generated JSON converter's return {factoryCall};. Under strict deserialization the factory is + /// Create, and a validation failure leaves the converter as the JsonException + /// ValueObjectJson.ValidationFailed builds, with the validator's exception inside: System.Text.Json then + /// records where the value sat (JsonException.Path), which Purview.ZodSharp uses to report the errors under + /// that member, and hosts answer it as bad input. ScalarJsonConverterFactory throws the same exception. + /// + public static void EmitJsonFactoryReturn( + CodeWriter body, + string factoryCall, + bool isStrict, + string valueObjectTypeName + ) + { + if (!isStrict) + { + body.Return(factoryCall); + return; + } + + var helper = TypeLibrary.ValueObjectJsonGlobalName; + body.Block("try", tryBlock => tryBlock.Return(factoryCall)); + body.Block( + $"catch (global::System.Exception exception) when ({helper}.IsValidationFailure(exception))", + catchBlock => catchBlock.Throw($"{helper}.ValidationFailed(typeof({valueObjectTypeName}), exception)") + ); + } + /// /// ZodSharp refinement emission shared by the scalar and complex value object emitters. /// diff --git a/src/src/ValueObjects/Sdk/README.md b/src/src/ValueObjects/Sdk/README.md index ae68742..88a00ae 100644 --- a/src/src/ValueObjects/Sdk/README.md +++ b/src/src/ValueObjects/Sdk/README.md @@ -11,7 +11,8 @@ incremental source generator produces: and format overloads), and implicit conversions (with explicit extraction for nullable scalars) - The underlying value's standard interfaces — `IEquatable`, `ISpanFormattable`, `IUtf8SpanFormattable`, `IParsable`, `ISpanParsable`, and `IUtf8SpanParsable` — mirrored per type -- JSON converters (scalar value objects serialize as their underlying value) +- JSON converters (scalar value objects serialize as their underlying value), with no reflection: generated code is + trim- and Native AOT-safe, including in Blazor WebAssembly - Contextual creation via `IContextualValueObject<,>` + `ValueObjectContext` **Use cases** @@ -21,6 +22,10 @@ incremental source generator produces: (value converters, comparers, complex-type mapping) plus a `ConfigureValueObjects` extension for automatic mapping. Queries use the value object type directly — no `.Value` required. - **Domain models** – the F#-style single-case union pattern in C#. +- **Blazor and Native AOT** – trimmed, AOT-compiled and Blazor WebAssembly apps; validate forms over value objects + with Purview.ZodSharp 3 (`net10.0`+). See the + [Trimming, Native AOT and Blazor](https://github.com/purview-dev/value-objects/blob/main/docs/Trimming-AOT-and-Blazor.md) + guide. ## Install @@ -98,18 +103,21 @@ intentionally not mirrored. ## JSON serialization -Scalar value objects serialize as their underlying value. Register the converter factory on your -`JsonSerializerOptions`: +Scalar value objects serialize as their underlying value; complex value objects serialize as an object of their +members. The generator emits a `[JsonConverter]` per value object, so no registration is needed. That generated +converter is reflection-free and trim- and Native AOT-safe. + +`ScalarJsonConverterFactory` covers a hand-written scalar that has no generated converter. It uses reflection, so +register it only in a host that is neither trimmed nor AOT-compiled: ```csharp var options = new JsonSerializerOptions(); options.Converters.Add(new ScalarJsonConverterFactory()); ``` -The generator also emits a `[JsonConverter]` per value object, so scalar/complex value objects serialize -correctly even when the factory is not registered. That generated converter is reflection-free and trim- and -Native AOT-safe; register `ScalarJsonConverterFactory` only for a hand-written scalar, in a host that is neither -trimmed nor AOT-compiled. +A source-generated `JsonSerializerContext` in the project that declares the value objects cannot see the generated +`[JsonConverter]` attributes, so register the generated converters on its options +(`new EmailAddress.EmailAddressJsonConverter()`); a value object from a referenced library needs nothing. ## Entity Framework Core @@ -130,6 +138,16 @@ Queries compare the value object type directly — no `.Value` required — or t (`c.Email == "..."`, `m.Id == guid`). See the Entity Framework guide for automatic + manual mapping, keys and indexes, generated key values, query filters, schema/migrations, assembly defaults, and the opt-out levels. +## Trimming, Native AOT and Blazor + +Generated code uses no reflection, so trimmed, Native AOT and Blazor WebAssembly applications get no +IL2xxx/IL3xxx warning from it, including the Entity Framework Core JSON-column converters (EF Core itself is +not fully trim- or AOT-compatible). The runtime declares `browser` as a supported platform. The package targets +`net8.0`-`net11.0`; the optional Purview.ZodSharp 3 integration needs `net10.0`+, and `Purview.ZodSharp.Blazor` +validates Blazor forms over value objects. See the +[Trimming, Native AOT and Blazor](https://github.com/purview-dev/value-objects/blob/main/docs/Trimming-AOT-and-Blazor.md) +guide. + ## Validation with ZodSharp Validate value objects with [Purview.ZodSharp](https://www.nuget.org/packages/Purview.ZodSharp), a C# port of Zod: diff --git a/src/src/ValueObjects/Serialization/ScalarJsonConverterFactory.cs b/src/src/ValueObjects/Serialization/ScalarJsonConverterFactory.cs index 2095c06..8bfef15 100644 --- a/src/src/ValueObjects/Serialization/ScalarJsonConverterFactory.cs +++ b/src/src/ValueObjects/Serialization/ScalarJsonConverterFactory.cs @@ -37,15 +37,12 @@ namespace Purview.ValueObjects.Serialization; /// for a hand-written scalar that has no generated converter, and only in a host that is neither trimmed /// nor AOT-compiled. /// -/// -/// -/// Creates the factory. -/// -/// -/// The trimming and Native AOT requirement is declared here because this is where a host opts in, and -/// because an override of cannot carry the attributes -/// itself (IL2046/IL3051 require them to match the unannotated base). Registering this factory in a -/// trimmed or AOT-compiled host will warn at the construction site instead of failing at runtime. +/// +/// The trimming and Native AOT requirement is declared on the constructor because that is where a host opts +/// in, and because an override of cannot carry the +/// attributes itself (IL2046/IL3051 require them to match the unannotated base). Registering this factory in +/// a trimmed or AOT-compiled host will warn at the construction site instead of failing at runtime. +/// /// [method: RequiresUnreferencedCode( "ScalarJsonConverterFactory reads ScalarAttribute, resolves the scalar member and the " @@ -158,29 +155,18 @@ public override TScalarObject Read(ref Utf8JsonReader reader, Type typeToConvert { return _create(scalar!); } - catch (Exception exception) when (IsValidationFailure(exception)) + catch (Exception exception) when (ValueObjectJson.IsValidationFailure(exception)) { // Surfaced as JsonException so System.Text.Json attaches Path and LineNumber, and so hosts // treat it as bad input. Under Strict the factory is Create, which throws the validation // exception of whatever validator the value object uses - ArgumentException by default, or // ZodException with ZodSharp validation. Letting those escape unwrapped meant ASP.NET Core // saw an unhandled exception and answered 500 instead of 400, with no indication of which - // property failed. - throw new JsonException( - $"Cannot deserialize {typeof(TScalarObject).Name}: the value failed validation.", - exception - ); + // property failed. The generated converters throw the same exception. + throw ValueObjectJson.ValidationFailed(typeof(TScalarObject), exception); } } - /// - /// Whether is a validation failure from the value object's factory - /// rather than something that must not be reported as malformed input. - /// - static bool IsValidationFailure(Exception exception) => - exception - is not (OperationCanceledException or OutOfMemoryException or StackOverflowException or JsonException); - public override void Write(Utf8JsonWriter writer, TScalarObject value, JsonSerializerOptions options) => JsonSerializer.Serialize(writer, _getScalar(value), options); diff --git a/src/src/ValueObjects/Serialization/ValueObjectJson.cs b/src/src/ValueObjects/Serialization/ValueObjectJson.cs new file mode 100644 index 0000000..b58839a --- /dev/null +++ b/src/src/ValueObjects/Serialization/ValueObjectJson.cs @@ -0,0 +1,441 @@ +using System.Buffers; +using System.ComponentModel; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace Purview.ValueObjects.Serialization; + +/// +/// Trim- and Native AOT-safe JSON plumbing for the converters the value object generator emits. Generated +/// <Type>JsonConverter classes read and write their underlying values and members through these +/// helpers rather than through the reflection-based overloads. +/// +/// +/// +/// A value of type T in the helpers below is resolved in this order: +/// +/// +/// +/// +/// The converter the generator supplies for a member that is itself a value object, unless the options +/// register a converter for that type. A source-generated in the +/// project that declares the value object cannot see the [JsonConverter] attribute the value object +/// generator adds (one source generator never sees another's output), so the context would otherwise +/// describe the value object as a plain object with a Value property. +/// +/// +/// +/// +/// The the caller's provide - the +/// reflection resolver in an ordinary application, or a source-generated . +/// This honours every converter, naming policy and number-handling option exactly as +/// JsonSerializer.Deserialize<T>(ref reader, options) would. +/// +/// +/// +/// +/// The built-in System.Text.Json converter for a primitive (, , the +/// numeric types, , , , +/// , , , , +/// and their nullable forms) when the options cannot describe it - typically a +/// source-generated context that never listed the primitive because it only saw the value object. +/// +/// +/// +/// +/// Anything else (an enum, or a custom underlying type) needs metadata: add +/// [JsonSerializable(typeof(T))] to the application's . The +/// thrown otherwise names the type. +/// +/// +/// These members exist for generated code. They are public so a consumer's generated converters can call them, +/// and hidden from IntelliSense because application code has no reason to. +/// +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public static class ValueObjectJson +{ + /// Reads a value of type from . + /// The type to read. + /// The reader, positioned on the value. + /// The options the enclosing converter was called with. + /// + /// The converter of a value-object-typed member, used unless register one for . + /// + /// The value read. + /// No metadata, supplied converter or built-in converter applies. + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "CA1045:Do not pass types by reference", + Justification = "Mirrors JsonConverter.Read, which receives the reader by reference." + )] + public static T? Read( + ref Utf8JsonReader reader, + JsonSerializerOptions options, + JsonConverter? converter = null + ) + { + ArgumentNullException.ThrowIfNull(options); + + if (!PrefersSupplied(converter, options) && TryGetTypeInfo(options) is { } typeInfo) + return JsonSerializer.Deserialize(ref reader, typeInfo); + + converter ??= BuiltInConverter.Instance ?? throw CreateNotSupported(typeof(T)); + if (reader.TokenType == JsonTokenType.Null && !converter.HandleNull) + { + return default(T) is null + ? default + : throw new JsonException($"The JSON value null could not be converted to {typeof(T)}."); + } + + return converter.Read(ref reader, typeof(T), options); + } + + /// Writes to . + /// The type to write. + /// The writer. + /// The value to write. + /// The options the enclosing converter was called with. + /// + /// The converter of a value-object-typed member, used unless register one for . + /// + /// No metadata, supplied converter or built-in converter applies. + public static void Write( + Utf8JsonWriter writer, + T value, + JsonSerializerOptions options, + JsonConverter? converter = null + ) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(options); + + if (!PrefersSupplied(converter, options) && TryGetTypeInfo(options) is { } typeInfo) + { + JsonSerializer.Serialize(writer, value, typeInfo); + return; + } + + converter ??= BuiltInConverter.Instance ?? throw CreateNotSupported(typeof(T)); + if (value is null && !converter.HandleNull) + { + writer.WriteNullValue(); + return; + } + + converter.Write(writer, value, options); + } + + /// + /// Serializes to a JSON string through : the trim- and + /// Native AOT-safe equivalent of JsonSerializer.Serialize(value) for a type whose + /// [JsonConverter] is . The generated Entity Framework Core JSON-column + /// converters use it. + /// + /// + /// While reflection-based serialization is enabled the options are , + /// so the output matches JsonSerializer.Serialize(value) exactly. Trimmed and Native AOT applications turn + /// it off, and then a member is written through its value object's converter or the built-in converter for a + /// primitive; any other member type (an enum, for example) throws . + /// + /// The type to write. + /// The converter that writes . + /// The value to write. + /// The JSON text. + public static string Serialize(T value) + where TConverter : JsonConverter, new() + { + ArrayBufferWriter buffer = new(); + using (Utf8JsonWriter writer = new(buffer)) + Write(writer, value, StringOptions.Instance, ConverterCache.Instance); + + return Encoding.UTF8.GetString(buffer.WrittenSpan); + } + + /// + /// Deserializes through : the trim- and Native AOT-safe + /// equivalent of JsonSerializer.Deserialize<T>(json) for a type whose [JsonConverter] is + /// . The generated Entity Framework Core JSON-column converters use it. + /// + /// The options are chosen as for . + /// The type to read. + /// The converter that reads . + /// The JSON text. + /// The value read. + /// is not a single JSON value. + public static T? Deserialize(string json) + where TConverter : JsonConverter, new() + { + ArgumentNullException.ThrowIfNull(json); + + Utf8JsonReader reader = new(Encoding.UTF8.GetBytes(json)); + if (!reader.Read()) + throw new JsonException($"The JSON value could not be converted to {typeof(T)}: the input is empty."); + + var value = Read(ref reader, StringOptions.Instance, ConverterCache.Instance); + + // The reader rejects a second top-level value itself; this only guards a converter that stopped short. + if (reader.Read()) + throw new JsonException($"The JSON value could not be converted to {typeof(T)}: unexpected trailing data."); + + return value; + } + + /// + /// Writes a member of a complex value object: its name (with the options' naming policy applied) and its value, + /// honouring . + /// + /// The member type. + /// The writer, positioned inside an object. + /// The member's CLR name. + /// The member's value. + /// The options the enclosing converter was called with. + /// An optional converter for the member, as for . + public static void WriteMember( + Utf8JsonWriter writer, + string memberName, + T value, + JsonSerializerOptions options, + JsonConverter? converter = null + ) + { + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(options); + + var ignore = options.DefaultIgnoreCondition; + if ( + (ignore == JsonIgnoreCondition.WhenWritingNull && value is null) + || (ignore == JsonIgnoreCondition.WhenWritingDefault && EqualityComparer.Default.Equals(value, default)) + ) + { + return; + } + + writer.WritePropertyName(GetMemberName(memberName, options)); + Write(writer, value, options, converter); + } + + /// + /// The JSON name of a complex value object member: with the options' + /// applied. + /// + /// The member's CLR name. + /// The options the enclosing converter was called with. + /// The name written to, and expected in, JSON. + public static string GetMemberName(string memberName, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + return options.PropertyNamingPolicy?.ConvertName(memberName) ?? memberName; + } + + /// + /// True when a JSON property name read by a complex value object converter names + /// , honouring the naming policy and + /// . + /// + /// The property name read from JSON. + /// The member's CLR name. + /// The options the enclosing converter was called with. + /// when the names match. + public static bool IsMember(string? jsonName, string memberName, JsonSerializerOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + return string.Equals( + jsonName, + GetMemberName(memberName, options), + options.PropertyNameCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal + ); + } + + /// + /// Whether , thrown by a value object's factory during strict deserialization, is a + /// validation failure - and so is reported as - rather than something that must + /// not be reported as bad input. + /// + /// The exception the factory threw. + /// for a validation failure. + public static bool IsValidationFailure(Exception exception) => + exception + is not (OperationCanceledException or OutOfMemoryException or StackOverflowException or JsonException); + + /// + /// The exception a strict converter throws when the value it read fails validation: a + /// whose is the validator's exception (ArgumentException by default, + /// ZodException with ZodSharp validation). + /// + /// + /// System.Text.Json fills in ($.customerEmail) as the exception leaves the + /// converter, which is the only way the member that held the value can be known; Purview.ZodSharp reads it to + /// report a ZodException's errors under that member (CustomerEmail.Value). Hosts treat a + /// as bad input, so ASP.NET Core answers it with 400 rather than 500. + /// + /// The value object being read. + /// The validation failure. + /// The exception to throw. + public static JsonException ValidationFailed(Type valueObjectType, Exception innerException) + { + ArgumentNullException.ThrowIfNull(valueObjectType); + + return new($"Cannot deserialize {valueObjectType.Name}: the value failed validation.", innerException); + } + + /// Adapts a converter for to . + /// The underlying value type. + /// The converter for the underlying type. + /// A converter that reads and writes JSON null as . + public static JsonConverter Nullable(JsonConverter converter) + where T : struct + { + ArgumentNullException.ThrowIfNull(converter); + + return new NullableConverter(converter); + } + + static bool PrefersSupplied(JsonConverter? converter, JsonSerializerOptions options) + { + if (converter is null) + return false; + + // A converter the application registered for the value object wins, exactly as it would for a member the + // serializer resolved itself. + foreach (var registered in options.Converters) + { + if (registered.CanConvert(typeof(T))) + return false; + } + + return true; + } + + static JsonTypeInfo? TryGetTypeInfo(JsonSerializerOptions options) + { + // Options with no resolver resolve through reflection only while it is enabled; once trimming or Native AOT + // turns it off, asking would throw, so those options can only use the supplied or built-in converters. + if (options.TypeInfoResolver is null && !JsonSerializer.IsReflectionEnabledByDefault) + return null; + + return options.TryGetTypeInfo(typeof(T), out var typeInfo) ? typeInfo as JsonTypeInfo : null; + } + + static NotSupportedException CreateNotSupported(Type type) => + new( + $"No JSON metadata is available for '{type}'. A value object converter reads and writes it through the " + + "JsonSerializerOptions it is called with; when those come from a source-generated JsonSerializerContext, " + + $"add [JsonSerializable(typeof({type}))] to that context." + ); + + static class ConverterCache + where TConverter : JsonConverter, new() + { + public static readonly TConverter Instance = new(); + } + + /// The options and use. + static class StringOptions + { + public static readonly JsonSerializerOptions Instance = Create(); + + // JsonSerializerOptions.Default resolves metadata through reflection. It is only read while reflection-based + // serialization is enabled, which trimmed and Native AOT applications turn off by default + // (JsonSerializerIsReflectionEnabledByDefault), and the trimmer then removes the branch - the same rule + // TryGetTypeInfo follows for the options a converter is called with. + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "Trimming", + "IL2026:RequiresUnreferencedCode", + Justification = "Only reached while JsonSerializer.IsReflectionEnabledByDefault is true." + )] + [System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage( + "AOT", + "IL3050:RequiresDynamicCode", + Justification = "Only reached while JsonSerializer.IsReflectionEnabledByDefault is true." + )] + static JsonSerializerOptions Create() + { + if (JsonSerializer.IsReflectionEnabledByDefault) + return JsonSerializerOptions.Default; + + JsonSerializerOptions options = new() { TypeInfoResolver = JsonTypeInfoResolver.Combine() }; + options.MakeReadOnly(); + return options; + } + } + + sealed class NullableConverter(JsonConverter converter) : JsonConverter + where T : struct + { + public override bool HandleNull => true; + + public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + reader.TokenType == JsonTokenType.Null ? null : converter.Read(ref reader, typeof(T), options); + + public override void Write(Utf8JsonWriter writer, T? value, JsonSerializerOptions options) + { + if (value is { } inner) + converter.Write(writer, inner, options); + else + writer.WriteNullValue(); + } + } + + /// + /// The built-in System.Text.Json converter for a primitive, or . Each test is against a + /// constant , so the JIT and ILC fold the chain to the one branch that applies. + /// + static class BuiltInConverter + { + public static readonly JsonConverter? Instance = (JsonConverter?)Create(); + + static object? Create() + { + if (typeof(T) == typeof(string)) + return JsonMetadataServices.StringConverter; + if (typeof(T) == typeof(Uri)) + return JsonMetadataServices.UriConverter; + if (typeof(T) == typeof(Version)) + return JsonMetadataServices.VersionConverter; + if (typeof(T) == typeof(byte[])) + return JsonMetadataServices.ByteArrayConverter; + + return CreateNumber() ?? CreateOther(); + } + + static object? CreateNumber() => + CreateValueType(JsonMetadataServices.ByteConverter) + ?? CreateValueType(JsonMetadataServices.SByteConverter) + ?? CreateValueType(JsonMetadataServices.CharConverter) + ?? CreateValueType(JsonMetadataServices.Int16Converter) + ?? CreateValueType(JsonMetadataServices.UInt16Converter) + ?? CreateValueType(JsonMetadataServices.Int32Converter) + ?? CreateValueType(JsonMetadataServices.UInt32Converter) + ?? CreateValueType(JsonMetadataServices.Int64Converter) + ?? CreateValueType(JsonMetadataServices.UInt64Converter) + ?? CreateValueType(JsonMetadataServices.Int128Converter) + ?? CreateValueType(JsonMetadataServices.UInt128Converter) + ?? CreateValueType(JsonMetadataServices.SingleConverter) + ?? CreateValueType(JsonMetadataServices.DoubleConverter) + ?? CreateValueType(JsonMetadataServices.DecimalConverter) + ?? CreateValueType(JsonMetadataServices.HalfConverter); + + static object? CreateOther() => + CreateValueType(JsonMetadataServices.BooleanConverter) + ?? CreateValueType(JsonMetadataServices.GuidConverter) + ?? CreateValueType(JsonMetadataServices.DateTimeConverter) + ?? CreateValueType(JsonMetadataServices.DateTimeOffsetConverter) + ?? CreateValueType(JsonMetadataServices.DateOnlyConverter) + ?? CreateValueType(JsonMetadataServices.TimeOnlyConverter) + ?? CreateValueType(JsonMetadataServices.TimeSpanConverter); + + static object? CreateValueType(JsonConverter converter) + where TValue : struct + { + if (typeof(T) == typeof(TValue)) + return converter; + + return typeof(T) == typeof(TValue?) ? new NullableConverter(converter) : null; + } + } +} diff --git a/src/src/ValueObjects/ValueObjects.csproj b/src/src/ValueObjects/ValueObjects.csproj index 0840f96..6dd13b7 100644 --- a/src/src/ValueObjects/ValueObjects.csproj +++ b/src/src/ValueObjects/ValueObjects.csproj @@ -26,6 +26,14 @@ true + + + + + -/// A scalar value object validated by the ZodSharp-generated schema and deserialized in -/// mode, so invalid JSON input throws a -/// ZodException that the registered exception handler converts to Problem Details. +/// A scalar value object validated by the ZodSharp-generated schema. It deserializes with the default +/// , so binding never throws; the endpoint filter validates the +/// bound request, and an invalid email is reported at CustomerEmail.Value. /// -[Scalar(ZodSchemaMode = ZodSchemaMode.InsteadOfHooks, DeserializationMode = ValueObjectDeserializationMode.Strict)] +[Scalar(ZodSchemaMode = ZodSchemaMode.InsteadOfHooks)] [ZodSchema] readonly partial record struct EmailAddress { @@ -18,9 +18,9 @@ readonly partial record struct EmailAddress } /// -/// A scalar value object validated by the ZodSharp-generated schema and deserialized strictly. +/// A scalar value object validated by the ZodSharp-generated schema. /// -[Scalar(ZodSchemaMode = ZodSchemaMode.InsteadOfHooks, DeserializationMode = ValueObjectDeserializationMode.Strict)] +[Scalar(ZodSchemaMode = ZodSchemaMode.InsteadOfHooks)] [ZodSchema] readonly partial record struct OrderQuantity { @@ -29,6 +29,8 @@ readonly partial record struct OrderQuantity } /// -/// The request DTO bound by POST /orders. Its value object members are deserialized strictly. +/// The request DTO bound by POST /orders. Its generated schema validates each value object member through +/// that value object's own schema, so every invalid member is reported, under its own path. /// -sealed record PlaceOrderRequest(EmailAddress CustomerEmail, OrderQuantity Quantity); +[ZodSchema] +sealed partial record PlaceOrderRequest(EmailAddress CustomerEmail, OrderQuantity Quantity); diff --git a/src/src/ZodSharp.AspNetCoreSample/Program.cs b/src/src/ZodSharp.AspNetCoreSample/Program.cs index 3f1809c..a1fa481 100644 --- a/src/src/ZodSharp.AspNetCoreSample/Program.cs +++ b/src/src/ZodSharp.AspNetCoreSample/Program.cs @@ -1,15 +1,16 @@ -using Purview.ValueObjects.Serialization; using ZodSharp; using ZodSharp.AspNetCore; using ZodSharp.Core; var builder = WebApplication.CreateBuilder(args); -// Register the ScalarJsonConverterFactory so value objects serialize as their scalar value and -// deserialize through the configured ValueObjectDeserializationMode (Strict here). -builder.Services.ConfigureHttpJsonOptions(options => - options.SerializerOptions.Converters.Add(new ScalarJsonConverterFactory()) -); +// No JSON registration: each value object carries a generated [JsonConverter] that serializes it as its +// scalar value and, in the default Hydrate mode, binds it without validating. Do not register +// ScalarJsonConverterFactory: it adds reflection and is not trimming- or Native AOT-compatible. + +// Registers the ZodSharp validators generated for this assembly, trim- and Native AOT-safe (assembly +// scanning is not). WithZodSharpValidation() below resolves the request's validator from them. +builder.Services.AddZodSharpPurviewValueObjectsZodSharpAspNetCoreSampleSchemas(); // Registers ZodExceptionHandler as an IExceptionHandler. Requires UseExceptionHandler() in the // pipeline (added below), otherwise the handler is never invoked. @@ -26,14 +27,17 @@ app.UseExceptionHandler(); -// Binds a request containing strict scalar value objects. Invalid JSON (for example an invalid -// email or a quantity outside 1-100) throws a ZodException during deserialization, which the -// exception handler turns into a 400 HttpValidationProblemDetails response. +// Binds a request of value objects, then validates it with PlaceOrderRequestSchema, which validates each +// member through that value object's own schema. An invalid email or a quantity outside 1-100 is a 400 +// HttpValidationProblemDetails response listing every invalid member under its own path +// (CustomerEmail.Value, Quantity.Value). Strict deserialization reports the same path (on .NET 10 with +// AddZodSharpProblemDetails(o => o.HandleBindingFailures = true)), but stops at the first invalid member. app.MapPost( - "/orders", - (PlaceOrderRequest request) => - Results.Ok(new { Email = request.CustomerEmail.Value, Quantity = request.Quantity.Value }) -); + "/orders", + (PlaceOrderRequest request) => + Results.Ok(new { Email = request.CustomerEmail.Value, Quantity = request.Quantity.Value }) + ) + .WithZodSharpValidation(); // Throws a ZodException carrying a registered error code. The handler resolves the ErrorType from // the registry and returns a 409 Conflict response whose message is formatted from the error's @@ -47,7 +51,7 @@ // with ErrorType resolution, without relying on the exception-handling middleware. app.MapGet( "/orders/{orderId}/email", - (string orderId, string email) => + (string orderId, string email, HttpContext httpContext) => { try { @@ -56,7 +60,10 @@ } catch (ZodException ex) { - var problem = ex.ToHttpValidationProblemDetails(ErrorTypeRegistry.Default); + var problem = ex.ToHttpValidationProblemDetails( + new ZodProblemDetailsOptions { Registry = ErrorTypeRegistry.Default }, + httpContext + ); return Results.Problem( problem.Detail, statusCode: problem.Status, diff --git a/src/src/ZodSharp.AspNetCoreSample/README.md b/src/src/ZodSharp.AspNetCoreSample/README.md index 3fb0cee..292ebe2 100644 --- a/src/src/ZodSharp.AspNetCoreSample/README.md +++ b/src/src/ZodSharp.AspNetCoreSample/README.md @@ -12,12 +12,15 @@ dotnet run --project src/src/ZodSharp.AspNetCoreSample ## What it shows -- **Strict deserialization of value objects** — `EmailAddress` and `OrderQuantity` are annotated with - `[Scalar]` + `[ZodSchema]` and `ValueObjectDeserializationMode.Strict`. Their generated `Create` runs - the ZodSharp-generated schema, so deserializing an invalid value throws a `ZodException`. +- **Validating a request of value objects** — `EmailAddress` and `OrderQuantity` are `[Scalar]` + + `[ZodSchema]`, and `PlaceOrderRequest` is `[ZodSchema]`. The request binds with the default `Hydrate` mode, + and `.WithZodSharpValidation()` validates it with `PlaceOrderRequestSchema`, which validates each member + through that value object's own schema. Every invalid member is reported under its own path + (`CustomerEmail.Value`, `Quantity.Value`) in an `HttpValidationProblemDetails` response with the structured + `issues` extension. The validators are registered with the generated, trim-safe + `AddZodSharpPurviewValueObjectsZodSharpAspNetCoreSampleSchemas()`; nothing is registered on the JSON options. - **Automatic exception handling** — `AddZodSharpProblemDetails()` registers `ZodExceptionHandler` and - `app.UseExceptionHandler()` catches the thrown `ZodException`, converting it to a - `HttpValidationProblemDetails` response with the structured issues in the `issues` extension. + `app.UseExceptionHandler()` catches a thrown `ZodException` (below), converting it to the same contract. - **Mapping error types to status codes** — `ErrorTypes.SaveFailed` (a `[ErrorType]`-generated partial) is registered in `ErrorTypeRegistry.Default`; `ErrorTypes.ThrowSaveFailed(...)` throws a `ZodException` carrying the `aggregate_save_failed` code, which is returned as a `409 Conflict` with a message @@ -37,13 +40,13 @@ curl -i -X POST http://localhost:5000/orders \ -d '{"customerEmail":"demo@example.com","quantity":2}' ``` -Send an invalid email — the strict value object throws and the handler returns a 400 Problem Details -payload: +Send an invalid email and quantity — the filter returns a 400 Problem Details payload with +`errors["CustomerEmail.Value"]` and `errors["Quantity.Value"]`: ```bash curl -i -X POST http://localhost:5000/orders \ -H "Content-Type: application/json" \ - -d '{"customerEmail":"not-an-email","quantity":2}' + -d '{"customerEmail":"not-an-email","quantity":500}' ``` Simulate an optimistic-concurrency failure (returns `409 Conflict` with a formatted message): diff --git a/src/src/ZodSharp.AspNetCoreSample/ZodSharp.AspNetCoreSample.csproj b/src/src/ZodSharp.AspNetCoreSample/ZodSharp.AspNetCoreSample.csproj index 9beb905..487733b 100644 --- a/src/src/ZodSharp.AspNetCoreSample/ZodSharp.AspNetCoreSample.csproj +++ b/src/src/ZodSharp.AspNetCoreSample/ZodSharp.AspNetCoreSample.csproj @@ -1,6 +1,9 @@  false + + Supported $(NoWarn);CA1506 diff --git a/src/src/ZodSharpSample/ZodSharpSample.csproj b/src/src/ZodSharpSample/ZodSharpSample.csproj index a10a067..68f46f1 100644 --- a/src/src/ZodSharpSample/ZodSharpSample.csproj +++ b/src/src/ZodSharpSample/ZodSharpSample.csproj @@ -2,6 +2,9 @@ Exe false + + Supported diff --git a/src/tests/Repository.UnitTests/InstalledSkillTests.cs b/src/tests/Repository.UnitTests/InstalledSkillTests.cs new file mode 100644 index 0000000..99d11c6 --- /dev/null +++ b/src/tests/Repository.UnitTests/InstalledSkillTests.cs @@ -0,0 +1,170 @@ +using System.Text.Json; + +namespace Purview.ValueObjects.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/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs index bfb93c4..a41f3fc 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/ValueObjectEFSourceGeneratorTests.cs @@ -409,10 +409,83 @@ public readonly partial record struct Money await Assert.That(converterType.TypeArguments[1].SpecialType).IsEqualTo(SpecialType.System_String); var moneyRecord = Normalize(result.Generated().GetRecord("Money", "Testing").Node.ToString()); - await Assert.That(moneyRecord).Contains("JsonSerializer.Serialize(vo)"); + await Assert + .That(moneyRecord) + .Contains("ValueObjectJson.Serialize(vo)"); + await Assert + .That(moneyRecord) + .Contains("ValueObjectJson.Deserialize(v)"); + await Assert.That(moneyRecord).DoesNotContain("JsonSerializer.Serialize("); + await Assert.That(moneyRecord).DoesNotContain("JsonSerializer.Deserialize<"); await Assert.That(moneyRecord).Contains("JsonStringReaderWriter.Instance"); } + [Test] + public async Task ComplexEFGeneration_JsonMappingWithDeclaredConverter_SerializesThroughDeclaredConverter( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.ValueObject(EFMapping = Purview.ValueObjects.Serialization.EntityFrameworkMapping.Json)] + [System.Text.Json.Serialization.JsonConverter(typeof(MoneyConverter))] + public readonly partial record struct Money + { + public decimal Amount { get; } + } + + public sealed class MoneyConverter : System.Text.Json.Serialization.JsonConverter + { + public override Money Read(ref System.Text.Json.Utf8JsonReader reader, System.Type typeToConvert, System.Text.Json.JsonSerializerOptions options) => + Money.Hydrate(reader.GetDecimal()); + + public override void Write(System.Text.Json.Utf8JsonWriter writer, Money value, System.Text.Json.JsonSerializerOptions options) => + writer.WriteNumberValue(value.Amount); + } + } + """; + + using var result = await GenerateAsync( + source, + ValueObjectsEFGeneratorTestOptions.Default.Compile(), + cancellationToken + ); + + var moneyRecord = Normalize(result.Generated().GetRecord("Money", "Testing").Node.ToString()); + await Assert + .That(moneyRecord) + .Contains("ValueObjectJson.Serialize(vo)"); + await Assert.That(moneyRecord).DoesNotContain("JsonSerializer.Serialize("); + } + + [Test] + public async Task ComplexEFGeneration_JsonMappingWithoutGeneratedConverter_FallsBackToJsonSerializer( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.ValueObject(EFMapping = Purview.ValueObjects.Serialization.EntityFrameworkMapping.Json, GenerateJsonConverter = false)] + public readonly partial record struct Money + { + public decimal Amount { get; } + } + } + """; + + using var result = await GenerateAsync( + source, + ValueObjectsEFGeneratorTestOptions.Default.Compile(), + cancellationToken + ); + + var moneyRecord = Normalize(result.Generated().GetRecord("Money", "Testing").Node.ToString()); + await Assert.That(moneyRecord).Contains("global::System.Text.Json.JsonSerializer.Serialize(vo)"); + await Assert.That(moneyRecord).DoesNotContain("ValueObjectJson.Serialize<"); + } + [Test] public async Task ComplexEFGeneration_NoneMapping_EmitsNoEFMembers(CancellationToken cancellationToken) { @@ -988,7 +1061,10 @@ public readonly partial record struct LocalId await Assert.That(registry).Contains("[typeof(global::Shared.Audit)]"); await Assert.That(registry).Contains("ValueConverter"); await Assert.That(registry).Contains("JsonStringReaderWriter.Instance"); - await Assert.That(registry).Contains("JsonSerializer.Serialize(vo)"); + await Assert + .That(registry) + .Contains("ValueObjectJson.Serialize(vo)"); + await Assert.That(registry).DoesNotContain("JsonSerializer.Serialize("); await Assert.That(registry).DoesNotContain("global::Shared.Audit.EF.Converter"); var compilationErrors = result diff --git a/src/tests/SourceGenerator.UnitTests/Generators/ScalarAutomaticPropertyTests.cs b/src/tests/SourceGenerator.UnitTests/Generators/ScalarAutomaticPropertyTests.cs index e0f4ac3..01003c7 100644 --- a/src/tests/SourceGenerator.UnitTests/Generators/ScalarAutomaticPropertyTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Generators/ScalarAutomaticPropertyTests.cs @@ -477,17 +477,21 @@ static partial void OnValidate(System.Guid value) public static class Harness { + public sealed record Holder(StrictId Id); + public static bool RejectsEmpty() { try { - System.Text.Json.JsonSerializer.Deserialize( - "\"00000000-0000-0000-0000-000000000000\""); + System.Text.Json.JsonSerializer.Deserialize( + "{\"Id\":\"00000000-0000-0000-0000-000000000000\"}"); return false; } - catch (System.ArgumentException) + catch (System.Text.Json.JsonException ex) + when (ex.InnerException is System.ArgumentException && ex.Path == "$.Id") { - // Strict re-validates through Create, which throws the OnValidate exception. + // Strict re-validates through Create. Its OnValidate exception leaves the converter inside a + // JsonException, so System.Text.Json records where the value sat. return true; } } @@ -508,6 +512,41 @@ public static bool RejectsEmpty() await Assert.That(rejects).IsTrue(); } + [Test] + public async Task ScalarGeneration_GivenDeserializationMode_WrapsOnlyStrictValidationFailures( + CancellationToken cancellationToken + ) + { + const string source = """ + namespace Testing + { + [Purview.ValueObjects.Serialization.Scalar( + DeserializationMode = Purview.ValueObjects.Serialization.ValueObjectDeserializationMode.Strict + )] + public readonly partial record struct StrictId { } + + [Purview.ValueObjects.Serialization.Scalar] + public readonly partial record struct HydratedId { } + } + """; + + using var result = await GenerateAsync( + source, + ValueObjectsGeneratorTestOptions.Default.Compile(), + cancellationToken + ); + + var strict = result.Generated().GetRecord("StrictId", "Testing").Node.ToString(); + var hydrated = result.Generated().GetRecord("HydratedId", "Testing").Node.ToString(); + + await Assert.That(strict).Contains("ValueObjectJson.IsValidationFailure(exception)"); + await Assert + .That(strict) + .Contains("ValueObjectJson.ValidationFailed(typeof(global::Testing.StrictId), exception)"); + await Assert.That(hydrated).DoesNotContain("ValidationFailed"); + await Assert.That(hydrated).Contains("return Hydrate(value);"); + } + [Test] public async Task ScalarGeneration_GivenAssemblyDefaults_AppliesAndTypeOverrideWins( CancellationToken cancellationToken diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/StrictDeserializationTests.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/StrictDeserializationTests.cs new file mode 100644 index 0000000..3016bde --- /dev/null +++ b/src/tests/ValueObjects.IntegrationTests/Serialization/StrictDeserializationTests.cs @@ -0,0 +1,134 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json; +using ZodSharp; +using ZodSharp.Core; + +namespace Purview.ValueObjects.Serialization; + +/// +/// A strict value object's generated converter reports a validation failure as a whose +/// inner exception is the validator's, so System.Text.Json records where the value sat. Purview.ZodSharp joins the +/// two (), so a 's errors are located under the member that +/// held the value - exactly as validating the deserialized model would report them. +/// +public sealed class StrictDeserializationTests +{ + [Test] + public async Task Deserialize_GivenInvalidStrictZodMember_ReportsErrorsUnderTheMember() + { + // Arrange + const string json = """{"CustomerEmail":"not-an-email","Copies":[]}"""; + + // Act + var exception = CaptureJsonException(json); + + // Assert + await Assert.That(exception.Path).IsEqualTo("$.CustomerEmail"); + await Assert.That(exception.InnerException).IsTypeOf(); + + var found = ZodJsonExceptions.TryGetErrors(exception, RequestTypeInfo(), out var errors); + await Assert.That(found).IsTrue(); + await Assert + .That(errors.Select(static e => e.FullPath.ToDottedString())) + .IsEquivalentTo(["CustomerEmail.Value"]); + } + + [Test] + public async Task Deserialize_GivenInvalidStrictZodElement_ReportsErrorsUnderTheElement() + { + // Arrange + const string json = """{"CustomerEmail":"a@example.com","Copies":["b@example.com","not-an-email"]}"""; + + // Act + var exception = CaptureJsonException(json); + + // Assert + ZodJsonExceptions.TryGetErrors(exception, RequestTypeInfo(), out var errors); + await Assert.That(errors.Select(static e => e.FullPath.ToDottedString())).IsEquivalentTo(["Copies[1].Value"]); + } + + [Test] + public async Task Deserialize_GivenInvalidStrictComplexValueObject_WrapsTheValidatorException() + { + // Arrange + const string json = """{"Price":{"Amount":-1,"Currency":"USD"}}"""; + + // Act + var exception = CaptureJsonException(json); + + // Assert + await Assert.That(exception.Path).IsEqualTo("$.Price"); + await Assert.That(exception.InnerException).IsAssignableTo(); + await Assert.That(exception.Message).Contains(nameof(StrictPrice)); + } + + [Test] + public async Task Deserialize_GivenValidStrictValues_RoundTrips() + { + // Arrange + const string json = """{"CustomerEmail":"a@example.com","Copies":["b@example.com"]}"""; + + // Act + var request = JsonSerializer.Deserialize(json); + + // Assert + await Assert.That(request!.CustomerEmail.Value).IsEqualTo("a@example.com"); + await Assert.That(request.Copies.Single().Value).IsEqualTo("b@example.com"); + } + + [Test] + public async Task Deserialize_GivenHydrateMode_DoesNotValidate() + { + // Act - the default mode is the replay path: an invalid value is hydrated, never rejected. + var email = JsonSerializer.Deserialize("\"not-an-email\""); + + // Assert + await Assert.That(email.Value).IsEqualTo("not-an-email"); + } + + static JsonException CaptureJsonException(string json) + { + try + { + JsonSerializer.Deserialize(json); + } + catch (JsonException ex) + { + return ex; + } + + throw new InvalidOperationException("Expected a JsonException."); + } + + // The root contract maps the JSON names in JsonException.Path back to member names - from a source-generated + // context, as a trimmed or Native AOT application would supply it. + static System.Text.Json.Serialization.Metadata.JsonTypeInfo RequestTypeInfo() => + StrictDeserializationJsonContext.Default.StrictOrderRequest; +} + +/// A strict scalar validated by its ZodSharp schema. +[Scalar(ZodSchemaMode = ZodSchemaMode.InsteadOfHooks, DeserializationMode = ValueObjectDeserializationMode.Strict)] +[ZodSchema] +public readonly partial record struct StrictZodEmail +{ + [Required, EmailAddress] + public string Value { get; } +} + +public sealed record StrictOrderRequest(StrictZodEmail CustomerEmail, IReadOnlyList Copies); + +/// A strict complex value object validated by its OnValidate hook. +[ValueObject(DeserializationMode = ValueObjectDeserializationMode.Strict)] +public readonly partial record struct StrictPrice(decimal Amount, string Currency) +{ + partial void OnValidate(decimal amount, string currency) + { + if (amount < 0) + throw new ArgumentOutOfRangeException(nameof(amount), "The amount must not be negative."); + } +} + +public sealed record StrictPriceHolder(StrictPrice Price); + +[System.Text.Json.Serialization.JsonSerializable(typeof(StrictOrderRequest))] +sealed partial class StrictDeserializationJsonContext : System.Text.Json.Serialization.JsonSerializerContext; diff --git a/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaIntegrationTests.cs b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaIntegrationTests.cs index c10097a..6a36a88 100644 --- a/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaIntegrationTests.cs +++ b/src/tests/ValueObjects.IntegrationTests/Serialization/ZodSchemaIntegrationTests.cs @@ -89,8 +89,10 @@ public async Task AssetId_GivenNonSentinelRule_ReportsInvalidValueThroughCreate( var error = exception!.Errors.Single(); await Assert.That(error.Code).IsEqualTo(NonSentinelRule.ErrorCode); await Assert.That(error.Message).IsEqualTo("AssetId must not be empty."); - // Built-in rules own their code but report no structured origin. - await Assert.That(error.Origin).IsNull(); + // Purview.ZodSharp 3 gives every built-in rule a structured identity: the origin is the Zod type name + // of the underlying value (a Guid is a string in Zod), and the category is the Zod v4 issue code. + await Assert.That(error.Origin).IsEqualTo(ZodTypeNames.String); + await Assert.That(error.Category).IsEqualTo(ZodIssueCategories.InvalidValue); // A scalar is validated as a unit, so the adapted rule reports an empty path. await Assert.That(error.Path).IsEmpty(); @@ -162,7 +164,7 @@ public async Task CorrelationId_GivenNonSentinelCodeOverride_ReportsTheOverride( } [Test] - public async Task ContactDto_GivenShippedMemberAttributes_ValidatesMembers() + public async Task ContactDto_GivenShippedMemberAttributes_ValidatesMembers(CancellationToken cancellationToken) { // Arrange — [Email], [E164], [UUID], and [MinLengthZod] all ship in ZodSharp.Rules. ContactDto valid = new() @@ -174,7 +176,7 @@ public async Task ContactDto_GivenShippedMemberAttributes_ValidatesMembers() }; // Act - var validResult = ContactDtoSchema.Validate(valid); + var validResult = await ContactDtoSchema.ValidateAsync(valid, services: null, cancellationToken); // Assert await Assert.That(validResult.IsSuccess).IsTrue(); @@ -187,7 +189,7 @@ public async Task ContactDto_GivenShippedMemberAttributes_ValidatesMembers() Code = "ab", }; - var invalidResult = ContactDtoSchema.Validate(invalid); + var invalidResult = await ContactDtoSchema.ValidateAsync(invalid, services: null, cancellationToken); await Assert.That(invalidResult.IsSuccess).IsFalse(); var codes = invalidResult.Errors.Select(error => error.Code).ToHashSet(StringComparer.Ordinal); diff --git a/src/tests/ValueObjects.UnitTests/Serialization/SourceGeneratedJsonContextTests.cs b/src/tests/ValueObjects.UnitTests/Serialization/SourceGeneratedJsonContextTests.cs new file mode 100644 index 0000000..99c2aff --- /dev/null +++ b/src/tests/ValueObjects.UnitTests/Serialization/SourceGeneratedJsonContextTests.cs @@ -0,0 +1,246 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace Purview.ValueObjects.Serialization; + +/// +/// The generated JSON converters must not depend on reflection, so they keep working with the source-generated +/// a trimmed or Native AOT application serializes through. +/// +/// +/// A context declared in the project that declares the value objects cannot see the [JsonConverter] +/// attribute the value object generator adds, so it describes as an object with a +/// Value property. The application registers the root value objects' generated converters on the options +/// (); , a member of , is +/// deliberately not registered, so these tests prove the converter is handed for a +/// value-object-typed member wins over the context's object metadata. +/// +public sealed class SourceGeneratedJsonContextTests +{ + static readonly JsonSerializerOptions ContextOptions = new(ContextOnlyJsonContext.Default.Options) + { + Converters = + { + new ContextMoney.ContextMoneyJsonConverter(), + new ContextReference.ContextReferenceJsonConverter(), + }, + }; + + static readonly JsonSerializerOptions IgnoreNullOptions = new(ContextOptions) + { + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + // A resolver that describes nothing, as a trimmed application's would for a type it never listed. + static readonly JsonSerializerOptions EmptyResolverOptions = new() + { + TypeInfoResolver = JsonTypeInfoResolver.Combine(), + }; + + [Test] + public async Task ComplexValueObject_GivenContextWithoutMemberConverters_RoundTrips() + { + // Arrange + ContextOrder order = new() + { + Total = ContextMoney.Create(19.99m, ContextCurrencyCode.Create("usd")), + Reference = ContextReference.Create("REF-1"), + }; + + // Act + var json = JsonSerializer.Serialize(order, ContextOptions); + var roundTripped = JsonSerializer.Deserialize(json, ContextOptions); + + // Assert + await Assert + .That(json) + .IsEqualTo("""{"total":{"amount":19.99,"currency":"USD","fallback":null},"reference":"REF-1"}"""); + await Assert.That(roundTripped!.Total).IsEqualTo(order.Total); + await Assert.That(roundTripped.Reference).IsEqualTo(order.Reference); + } + + [Test] + public async Task ComplexValueObject_GivenNullableValueObjectMember_RoundTripsValueAndNull() + { + // Arrange + var withFallback = ContextMoney.Create( + 5m, + ContextCurrencyCode.Create("GBP"), + ContextCurrencyCode.Create("EUR") + ); + + // Act + var json = JsonSerializer.Serialize(withFallback, ContextOptions); + var roundTripped = JsonSerializer.Deserialize(json, ContextOptions); + + // Assert + await Assert.That(json).IsEqualTo("""{"amount":5,"currency":"GBP","fallback":"EUR"}"""); + await Assert.That(roundTripped).IsEqualTo(withFallback); + } + + [Test] + public async Task ComplexValueObject_GivenUnknownAndMissingMembers_SkipsAndDefaults() + { + // Act - "extra" is skipped, including its nested object, and the missing "fallback" stays null. + var money = JsonSerializer.Deserialize( + """{"extra":{"nested":[1,2]},"amount":1.5,"currency":"CHF"}""", + ContextOptions + ); + + // Assert + await Assert.That(money.Amount).IsEqualTo(1.5m); + await Assert.That(money.Currency).IsEqualTo(ContextCurrencyCode.Create("CHF")); + await Assert.That(money.Fallback).IsNull(); + } + + [Test] + public async Task ComplexValueObject_GivenWhenWritingNull_OmitsNullMember() + { + // Act + var json = JsonSerializer.Serialize( + ContextMoney.Create(2m, ContextCurrencyCode.Create("USD")), + IgnoreNullOptions + ); + + // Assert + await Assert.That(json).IsEqualTo("""{"amount":2,"currency":"USD"}"""); + } + + [Test] + public async Task ComplexValueObject_GivenNonObjectToken_ThrowsJsonException() + { + // Act & Assert + await Assert.That(() => JsonSerializer.Deserialize("42", ContextOptions)).Throws(); + } + + [Test] + public async Task Read_GivenResolverWithoutMetadataForPrimitive_UsesBuiltInConverter() + { + // Arrange + var id = Guid.NewGuid(); + Utf8JsonReader reader = new(JsonSerializer.SerializeToUtf8Bytes(id.ToString())); + reader.Read(); + + // Act + var read = ValueObjectJson.Read(ref reader, EmptyResolverOptions); + + // Assert + await Assert.That(read).IsEqualTo(id); + } + + [Test] + public async Task Read_GivenResolverWithoutMetadataForNullablePrimitive_ReadsNull() + { + // Arrange + Utf8JsonReader reader = new("null"u8); + reader.Read(); + + // Act + var read = ValueObjectJson.Read(ref reader, EmptyResolverOptions); + + // Assert + await Assert.That(read).IsNull(); + } + + [Test] + public async Task Read_GivenResolverWithoutMetadataForCustomType_ThrowsNotSupportedNamingTheType() + { + // Arrange + Utf8JsonReader reader = new("""{"x":1}"""u8); + reader.Read(); + + // Act + NotSupportedException? exception = null; + try + { + ValueObjectJson.Read(ref reader, EmptyResolverOptions); + } + catch (NotSupportedException ex) + { + exception = ex; + } + + // Assert + await Assert.That(exception).IsNotNull(); + await Assert.That(exception!.Message).Contains(typeof(ContextOrder).FullName!); + } + + [Test] + public async Task Serialize_GivenComplexValueObject_MatchesJsonSerializerAndRoundTrips() + { + // Arrange - the string helpers back the generated Entity Framework Core JSON-column converters, which used to + // call JsonSerializer.Serialize(value)/Deserialize(json); the column content must not change. + var money = ContextMoney.Create(19.99m, ContextCurrencyCode.Create("usd"), ContextCurrencyCode.Create("eur")); + var expected = JsonSerializer.Serialize(money); + + // Act + var json = ValueObjectJson.Serialize(money); + var roundTripped = ValueObjectJson.Deserialize(json); + + // Assert + await Assert.That(json).IsEqualTo(expected); + await Assert.That(json).IsEqualTo("""{"Amount":19.99,"Currency":"USD","Fallback":"EUR"}"""); + await Assert.That(roundTripped).IsEqualTo(money); + } + + [Test] + public async Task Deserialize_GivenEmptyInput_ThrowsJsonException() + { + // Act & Assert + await Assert + .That(() => ValueObjectJson.Deserialize(" ")) + .Throws(); + } + + [Test] + public async Task Deserialize_GivenTrailingValue_ThrowsJsonException() + { + // Act & Assert + await Assert + .That(() => + ValueObjectJson.Deserialize( + """{"Amount":1,"Currency":"USD"} 2""" + ) + ) + .Throws(); + } +} + +[Scalar] +public readonly partial record struct ContextCurrencyCode +{ + public string Value { get; } + + static partial void OnNormalize(ref string value) => value = value?.Trim().ToUpperInvariant()!; +} + +[Scalar] +public readonly partial record struct ContextReference +{ + public string Value { get; } +} + +[ValueObject] +public readonly partial record struct ContextMoney +{ + public decimal Amount { get; } + + public ContextCurrencyCode Currency { get; } + + public ContextCurrencyCode? Fallback { get; } + + public static ContextMoney Create(decimal amount, ContextCurrencyCode currency) => Create(amount, currency, null); +} + +public sealed class ContextOrder +{ + public ContextMoney Total { get; set; } + + public ContextReference Reference { get; set; } +} + +[JsonSerializable(typeof(ContextOrder))] +[JsonSerializable(typeof(ContextMoney))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +sealed partial class ContextOnlyJsonContext : JsonSerializerContext;