diff --git a/.agents/skills/purview-architecture-testing/SKILL.md b/.agents/skills/purview-architecture-testing/SKILL.md new file mode 100644 index 0000000..55d0ca8 --- /dev/null +++ b/.agents/skills/purview-architecture-testing/SKILL.md @@ -0,0 +1,184 @@ +--- +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" + repository: purview-dev/engineering-playbook +--- + +# 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/foundations/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/foundations/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..0c43596 --- /dev/null +++ b/.agents/skills/purview-architecture/SKILL.md @@ -0,0 +1,177 @@ +--- +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" + repository: purview-dev/engineering-playbook +--- + +# 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/governance/Adopting-This-At-Your-Scale.md) diff --git a/.agents/skills/purview-architecture/references/contract-strategy.md b/.agents/skills/purview-architecture/references/contract-strategy.md new file mode 100644 index 0000000..62b5a17 --- /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/data/Contracts-And-Data-Exchange.md) + — `Create` and `Hydrate`, and the schema as the source of truth for a wire shape +- [zodsharp](https://github.com/purview-dev/zodsharp) — authoritative on `[ZodSchema]` and JSON Schema + export diff --git a/.agents/skills/purview-architecture/references/project-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..dc4e67c --- /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/foundations/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..bb24fa3 --- /dev/null +++ b/.agents/skills/purview-buildsdk-adoption/SKILL.md @@ -0,0 +1,119 @@ +--- +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 + +Nothing the SDK does during a restore or a build touches agent skills. Installing, updating and +cleaning them up are separate targets a person runs by name — `InstallPurviewSkills`, +`UpdatePurviewSkills`, `CheckPurviewSkills`, `FixPurviewSkills` — and the ones that change the +repository refuse to run inside a build (`PRSKL0009`). Do not hook them into one. The skills CLI does +the installing, through `bun x` by default; with `PurviewSkillsAgents` unset it auto-detects agents. + +If the repository uses `just`, add recipes for them — see `purview-buildsdk-skills-justfile`. For what each +target does and the `PRSKL*` diagnostics, see `purview-buildsdk-skill-installation`. + +## Constraints + +- 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..e081eac --- /dev/null +++ b/.agents/skills/purview-buildsdk-configuration/SKILL.md @@ -0,0 +1,127 @@ +--- +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`. + +The skill targets read their settings when they run, so these can go in `Directory.Build.props` or +on the command line, and a repository-wide choice belongs in `Directory.Build.props`: + +| Property | Default | Controls | +| -- | -- | -- | +| `PurviewSkillsRunner` | `bun` (`bun x skills`) | Which package runner launches the skills CLI | +| `PurviewSkillsAgents` | empty — the CLI auto-detects | Agents to install to; when set, the only agent directories `CheckPurviewSkills` allows | +| `PurviewSkillsDryRun` | `false` | Install and update print commands instead of running them | + +On a command line, `dotnet msbuild -p:` splits values on `,` and `;` — write +`-p:PurviewSkillsAgents=claude-code%2Ccodex`. For `just` recipes, see `purview-buildsdk-skills-justfile`. + +## Guidance when changing SDK configuration + +1. Preserve existing defaults unless the task explicitly changes product behaviour. +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..21ddc7f --- /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`, +`PurviewSkillsDryRun`, and the `PurviewSkillsAgentDirectory` item group. + +## 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`, `PurviewSkillsDryRun` +- `PurviewBuildSdkVersionForGlobalJson`, `CurrentYear`, `AutoGeneratedAssemblyInfoFile`, + `AutoGeneratedAssemblyIdentifierFile` diff --git a/.agents/skills/purview-buildsdk-project-detection/SKILL.md b/.agents/skills/purview-buildsdk-project-detection/SKILL.md 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..2be4c08 --- /dev/null +++ b/.agents/skills/purview-buildsdk-repository-review/SKILL.md @@ -0,0 +1,128 @@ +--- +name: purview-buildsdk-repository-review +description: Review a repository that uses Purview.BuildSdk for naming, placement, test-structure and agent-skill-management alignment, and report what the SDK can and cannot do automatically as a result. Use when asked to audit, rationalise or health-check a repo against the SDK's conventions, when onboarding an existing codebase onto Purview.BuildSdk, or when deciding whether a layout quirk is worth changing. +license: MIT +metadata: + package: Purview.BuildSdk + 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. Check how agent skills are managed + +Only relevant when the repository has a `skills-lock.json`, an agent skills directory, or a package +reference that ships skills: + +- **Anything that runs `InstallPurviewSkills`, `UpdatePurviewSkills` or `FixPurviewSkills` + implicitly** — an `AfterTargets`/`BeforeTargets` hook, a `*DependsOn` property, a git hook, a + `just` recipe dependency — is structural debt: the SDK fails it with `PRSKL0009`, and skill changes + should only happen when somebody asks. +- **Hand-written skill scripts** (`update-agent-skills.ts`, `ensure-universal-skills.ts`, + `package.json` `skills:*` entries) are an incremental opportunity: the SDK targets replace them, and + pin package skills to their package's commit where the scripts install latest. See + `purview-buildsdk-skills-justfile`. +- **Drift**: run `dotnet msbuild -t:RecommendPurviewSkills` and quote any `PRSKL0010`/`PRSKL0011`. +- **Layout**: if `PurviewSkillsAgents` is set, run `-t:CheckPurviewSkills` and quote any `PRSKL0015`. + If it is not set, every agent directory is legitimate — that is acceptable variance, not a finding. + +## 7. Explain consequences, not just mismatches + +For each finding, name the specific automatic behaviour that is lost: `RootNamespace`, +`AssemblyName`, `PackageId`, `TargetProjectName`, the automatic target-project reference, the +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..5c2694d --- /dev/null +++ b/.agents/skills/purview-buildsdk-skill-installation/SKILL.md @@ -0,0 +1,248 @@ +--- +name: purview-buildsdk-skill-installation +description: Install, update, list, check and diagnose the agent skills a Purview.BuildSdk package ships. Use for the InstallPurviewSkills, UpdatePurviewSkills, CheckPurviewSkills, FixPurviewSkills, ListPurviewSkills, RecommendPurviewSkills and VerifyPurviewSkills MSBuild targets, for updating skills after a package upgrade, for choosing agents with PurviewSkillsAgents, for any PRSKL diagnostic, for choosing a package runner (bun x, npx, pnpm dlx, bunx, yarn dlx, deno), for skills-lock.json, or when a package ships skills but no agent can see them. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Installing the skills a package ships + +For *authoring* skills, use `purview-buildsdk-skill-tests`; for `just` recipes that run these +targets, `purview-buildsdk-skills-justfile`. + +## The one thing to understand first + +**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 +dotnet msbuild -t:UpdatePurviewSkills # re-add every skills-lock.json source: refresh, and add new skills +dotnet msbuild -t:CheckPurviewSkills # only the PurviewSkillsAgents directories exist +dotnet msbuild -t:FixPurviewSkills # remove the CLI's links from any other agent directory +``` + +They run **only when named**. None has `BeforeTargets` or `AfterTargets`, nothing in the SDK depends +on them, and `InstallPurviewSkills`, `UpdatePurviewSkills` and `FixPurviewSkills` refuse to run when +something else pulls them into a build, a restore or a design-time build (`PRSKL0009`). Do not hook +them into a build, a `*DependsOn` property or a git hook. If skills are changing on their own, +something has been wired up that should not be. + +Name **the solution**. Skills belong to the repository, so each target runs once however many +projects the solution holds: every project hands the work to the same elected leader, and MSBuild +runs it once. Package resolution reads every project in the solution, so a package only one project +references is still found. A single project works too, but sees only its own packages. For `just` +recipes that run these targets, see `purview-buildsdk-skills-justfile`. + +## How a skill is resolved, and why the version is always right + +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. + +## Updating + +```bash +# after bumping the PackageReference +dotnet restore +dotnet msbuild -t:UpdatePurviewSkills +``` + +`UpdatePurviewSkills` re-adds every unique source recorded in `skills-lock.json` with `skills add`: + +- a source a restored package ships is re-added at **that package's commit**, naming every skill + that package version ships — the same pinned `add` `PRSKL0011` recommends, so the skills match the + package version referenced; +- every other source is re-added at its latest with `--skill "*"`. + +That is the whole update. `skills add` reinstalls every skill it selects, so one re-add both +refreshes the installed skills and adds any the source has gained; `skills update` would only +re-fetch what the lockfile already lists, and is never run. `-p:PurviewSkillsDryRun=true` prints every +command and runs none. Each failed source is named (`PRSKL0008`). + +`InstallPurviewSkills` also works as an update for package skills alone: it re-adds each at its +package's commit. Do not reach for a bare `skills update` instead; see below. + +## Choosing agents + +`PurviewSkillsAgents` is **empty by default**, which passes no `--agent` and lets the CLI auto-detect +the agents on the machine. Set it to choose — in a project file `universal`, +or on a command line with the separator escaped or the value quoted, because `dotnet msbuild -p:` +itself splits on `,` and `;`: + +```bash +dotnet msbuild -t:UpdatePurviewSkills -p:PurviewSkillsAgents=claude-code%2Ccodex +``` + +Each agent becomes its own `--agent` flag; the CLI does not split a comma-separated value. + +When `PurviewSkillsAgents` is set, `CheckPurviewSkills` fails (`PRSKL0015`) for any agent skill +directory at the repository root that belongs to an agent it does not name. `.agents/skills` is always +allowed — it is the CLI's canonical store — and the authored `skills/` folder is never touched. +`FixPurviewSkills` removes links (symlinks, junctions) from those directories, never their targets, +and then any folder left empty; a real file is kept and reported, because it is not provably the +CLI's. The agent-to-directory map is the `PurviewSkillsAgentDirectory` item group, taken from the +CLI's own agent table; an agent it does not know is `PRSKL0016`, fixed by adding an item: + +```xml + +``` + +With `PurviewSkillsAgents` empty, both targets say so and do nothing: every agent directory is then a +legitimate destination. + +## Installing from an explicit source + +For a skill you want but have no `PackageReference` for: + +```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 Bun or Node, or set `PurviewSkillsCommand` | +| `PRSKL0005` | An authored skill is malformed | See `purview-buildsdk-skill-tests` | +| `PRSKL0006` | Retired, with `PurviewSkillsUpdateMode` | Remove the property; update no longer runs `skills update` | +| `PRSKL0007` | `UpdatePurviewSkills` found no `skills-lock.json` | Install first with `-t:InstallPurviewSkills` | +| `PRSKL0008` | Re-adding a lockfile source failed | Read the CLI output above it | +| `PRSKL0009` | Install, update or fix was pulled into a build, a restore or a design-time build | Remove the hook; run the target on its own | +| `PRSKL0010` | A skill a referenced package ships is missing | Warning. `bun x skills add ...` | +| `PRSKL0011` | An installed skill is pinned to a different ref than the referenced package | Warning. Install the package's own commit | +| `PRSKL0012` | A skill cannot be verified | Message | +| `PRSKL0013` | An orphaned lockfile entry | Message | +| `PRSKL0014` | Recorded in the lockfile but never restored | Message | +| `PRSKL0015` | An agent skill directory exists for an agent `PurviewSkillsAgents` does not name | `-t:FixPurviewSkills`, then remove anything it kept by hand | +| `PRSKL0016` | `PurviewSkillsAgents` names an agent with no known skills directory | Warning. Add a `PurviewSkillsAgentDirectory` item | +| `PRSKL0017` | Projects in the solution reference one skill-shipping package at different versions | Warning. Align the versions; only one version's skills can be installed | + +## Nothing about skills runs during a build + +This used to be the opposite: a `RecommendPurviewSkillsOnBuild` hook ran on `AfterTargets="Build"`. +It was removed after measurement — on a 39-project solution it read and regex-scanned every project's +`project.assets.json` (≈500 KB, 256 libraries each), made thousands of failing `Directory.Exists` +calls, read the lockfile and walked the repository root, on every build, to print advice that a +repository managing its own skills had already acted on. All of its inputs are repository-scoped, so +the per-project cost bought nothing. + +The work is now paid only when somebody names a target: + +```bash +dotnet msbuild -t:RecommendPurviewSkills # report, never fail +dotnet msbuild -t:VerifyPurviewSkills # report, and fail on PRSKL0010/0011 +``` + +`EvaluatePurviewSkillState` joins the skills each restored package ships against the CLI's own +`skills-lock.json`. Both inputs are local files, so it stays offline and spawns no process. + +`VerifyPurviewSkills` is the gate to call from CI. It escalates only `PRSKL0010` (missing) and +`PRSKL0011` (drifted) to errors: both are factual, both are fixed by one command, and neither can fire +without a referenced package that really ships skills. `PRSKL0012` stays a warning because a package +published without repository metadata is not the consumer's to fix, and `PRSKL0013`/`PRSKL0014` stay +messages. Failing over something the caller cannot clear is how a gate gets switched off. + +Three things shape the output: + +- Missing and drifted are **warnings** (fixable in one command); unverifiable and orphaned are + **messages**. `VerifyPurviewSkills` turns missing and drifted into failures, and promotes + unverifiable to a warning. +- A correctly installed set produces no output at all. +- There is no deduplication stamp any more. `EnablePurviewSkillRecommendations`, + `PurviewSkillRecommendationsInCI` and `PurviewSkillRecommendationStampPath` were retired with the + hook: a stamp existed so a per-build hook could report one state once per repository, and a target + a person invokes should answer every time it is asked. + +Orphan detection is **owner-scoped on purpose**: a lockfile cannot record who asked for an entry, so +only an entry under an owner the project references — but from a repository it no longer references +— is reported. A skill from any other owner is the developer's own, and is never mentioned. + +### Drift never recommends a bare `skills update` + +Re-adding a source without a ref installs the *newest* published skill, which is only correct when +the newest skill happens to describe the referenced package version. `PRSKL0011` names the referenced +package's own commit in a pinned `skills add` instead — which is what `-t:InstallPurviewSkills` and +`-t:UpdatePurviewSkills` run for package skills. + +## Package runners + +The CLI is published to npm, and every runner spells "run this npm package" differently. +`PurviewSkillsRunner` selects one, defaulting to `bun` (`bun x skills`). + +| Runner | Invocation | +| -- | -- | +| `bun` *(default)* | `bun x skills@1` | +| `npx` | `npx -y skills@1` | +| `bunx` | `bunx 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 `bun x skills experimental_install`. + +The lockfile is read **directly** rather than by parsing `skills list --json`, because that +listing omits the `ref` — and the ref is the whole compatibility question. Reading a file also needs +no Node tooling, which is what keeps recommendations working on a machine that can build .NET but +has no Node. There is deliberately no competing lockfile. diff --git a/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md b/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md new file mode 100644 index 0000000..d556174 --- /dev/null +++ b/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md @@ -0,0 +1,57 @@ +# 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 why the explicit-only guard (`PRSKL0009`) is a dependency target with +its own `Error` rather than a `Condition` on `InstallPurviewSkills`, `UpdatePurviewSkills` or +`FixPurviewSkills`. + +## The managed port + +`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-skills-justfile/SKILL.md b/.agents/skills/purview-buildsdk-skills-justfile/SKILL.md new file mode 100644 index 0000000..2da36a2 --- /dev/null +++ b/.agents/skills/purview-buildsdk-skills-justfile/SKILL.md @@ -0,0 +1,177 @@ +--- +name: purview-buildsdk-skills-justfile +description: Add or update Justfile recipes that run Purview.BuildSdk's agent-skill targets - skills-update, skills-check, skills-fix, skills-install, skills-recommend and skills-verify. Use when a repository wants a just recipe to update, install, check or clean up its agent skills, when replacing hand-written skill scripts (update-agent-skills.ts, ensure-universal-skills.ts, package.json skills:* scripts) with the SDK targets, or when an existing skills recipe runs the wrong project, the wrong runner or the wrong agents. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Justfile recipes for the agent-skill targets + +The recipes are thin: each one names one SDK target. The behaviour lives in the targets, which are +covered by `purview-buildsdk-skill-installation`; this skill is about wiring them into a `Justfile` +correctly. + +## The rule that matters most + +**Every skill recipe is run by a person, on purpose.** `InstallPurviewSkills`, `UpdatePurviewSkills` +and `FixPurviewSkills` change the repository, and the SDK refuses to run them inside a build, a +restore or a design-time build (`PRSKL0009`). Keep the Justfile consistent with that: + +- never make a skills recipe a dependency of `build`, `test`, `default` or any other recipe; +- never call `skills-update`, `skills-install` or `skills-fix` from a git hook (`lefthook.yml`, + `.husky/`) or a CI job that commits; +- `skills-verify` is the one recipe CI may call: it is read-only and fails on missing or drifted skills. + +## Recipes + +Put them in their own file beside the `Justfile`, and import it from the `Justfile`: + +```just +# Justfile +import "Justfile.skills.just" +``` + +The only line that differs between repositories is `skills_project`. Work it out from the repository +as described in [Choosing `skills_project`](#choosing-skills_project) — do not ask for it, and do not +leave a placeholder: + +```just +skills_project := solution_file # or a path, when the Justfile has no such variable + +# Update agent skills: re-add every skills-lock.json source, refreshing installed skills and adding new ones +[group('Agent skills')] +skills-update *args: + dotnet msbuild "{{ skills_project }}" "-t:UpdatePurviewSkills;FixPurviewSkills" -v:n {{ args }} + +# Check that only the skill directories of the agents in PurviewSkillsAgents exist +[group('Agent skills')] +skills-check *args: + dotnet msbuild "{{ skills_project }}" -t:CheckPurviewSkills -v:n {{ args }} + +# Remove the skills CLI's links from agent directories PurviewSkillsAgents does not select +[group('Agent skills')] +skills-fix *args: + dotnet msbuild "{{ skills_project }}" -t:FixPurviewSkills -v:n {{ args }} + +# Install the skills the referenced packages ship (pass -p:PurviewSkillsSource=owner/repo for others) +[group('Agent skills')] +skills-install *args: + dotnet msbuild "{{ skills_project }}" -t:InstallPurviewSkills -v:n {{ args }} + +# Report missing, drifted or unverifiable skills without changing anything +[group('Agent skills')] +skills-recommend *args: + dotnet msbuild "{{ skills_project }}" -t:RecommendPurviewSkills -v:n {{ args }} + +# Fail when a referenced package's skills are missing or drifted - the CI gate +[group('Agent skills')] +skills-verify *args: + dotnet msbuild "{{ skills_project }}" -t:VerifyPurviewSkills -v:n {{ args }} +``` + +Only add the recipes the repository will use. `skills-update` and `skills-check` are the usual pair. + +### Choosing `skills_project` + +Take the first of these that applies: + +1. **The `Justfile` already names the solution in a variable** — `solution_file`, `solution`, `sln` + or similar, typically used by `build`, `test` and `restore`. Reference it: + `skills_project := solution_file`. An imported file shares the importing file's variables, so the + skill recipes then follow the solution if it is ever renamed or moved, with nothing to keep in step. +2. **An existing recipe runs `dotnet build`, `test` or `restore` on a literal solution path.** Use that + path. If several recipes repeat it, offer to lift it into a `solution_file` variable and reference + that, but do not restructure the `Justfile` unasked. +3. **No `Justfile`, or one that never names a solution.** Search for `*.slnx` and `*.sln`, ignoring + `bin/`, `obj/`, `node_modules/` and `artifacts/`. If there is exactly one, use its path relative to + the `Justfile`. With several, prefer the one the CI workflow or README builds; ask only if that + still leaves more than one. +4. **No solution at all.** Name a project that imports the SDK and references every package that + ships skills, and say in a comment that it sees only its own packages. + +An existing `Justfile.skills.just` pointed at a hand-picked project gets the same treatment: replace +the project with the solution, chosen as above. Remove any comment explaining why that project was +picked, because the reason no longer holds. + +### Details that break a recipe if missed + +- **Name the solution.** Every skill target runs once for the whole solution, however many projects + it holds: each project hands the work to the same elected leader, and MSBuild runs it once. Package + resolution reads every project in the solution, so a package only a test project references is + still pinned. There is no project to choose and nothing to keep in step as projects come and go. + A single project also works, but sees only that project's packages. (SDK releases before this + behaviour ran the target once per project; there, name one project that references every package + that ships skills.) +- **Restore first.** The targets read `project.assets.json` and never restore on their own + (`PRSKL0001`). A fresh clone needs `dotnet restore` (or `just restore`) before `skills-update`. +- **Quote a multi-target `-t:`.** `"-t:UpdatePurviewSkills;FixPurviewSkills"` — unquoted, `sh` treats + the `;` as the end of the command and runs `FixPurviewSkills` as a separate, failing command. +- **Keep `*args` last** so a caller can pass properties: `just skills-update -p:PurviewSkillsDryRun=true`. +- `FixPurviewSkills` after `UpdatePurviewSkills` does nothing unless `PurviewSkillsAgents` is set, so + it is safe in the recipe either way. + +## Runner and agents belong in MSBuild, not in the recipe + +Set repository-wide choices once in `Directory.Build.props`, not as flags repeated in each recipe, +so a plain `dotnet msbuild -t:...` behaves the same as the recipe: + +```xml + + + bun + + universal + +``` + +One-off overrides go on the command line. `dotnet msbuild -p:` splits values on `,` and `;`, so +escape the separator: `just skills-update -p:PurviewSkillsAgents=claude-code%2Ccodex`. + +Useful properties to pass through `*args`: + +| Property | Effect | +| -- | -- | +| `PurviewSkillsDryRun=true` | Print every CLI command; run none | +| `PurviewSkillsRunner=npx` | Launch the CLI through another runner (`bunx`, `pnpm`, `yarn`, `deno`) | +| `PurviewSkillsSource=owner/repo#ref` | `skills-install` from an explicit source with no package behind it | + +## Replacing hand-written skill scripts + +Repositories often carry their own scripts for this. The SDK targets replace them: + +| Hand-written | Replace with | +| -- | -- | +| `scripts/update-agent-skills.ts`, `"skills:update"` | `skills-update` (`UpdatePurviewSkills`) | +| `--update` / `--no-update` | Nothing: the re-add refreshes installed skills and adds new ones, so there is no separate `skills update` step to switch | +| `--dry-run` | `-p:PurviewSkillsDryRun=true` | +| `scripts/ensure-universal-skills.ts check`, `"skills:check"` | `skills-check` with `PurviewSkillsAgents=universal` | +| `scripts/ensure-universal-skills.ts fix`, `"skills:fix"` | `skills-fix` | +| a hard-coded `-a universal` | `PurviewSkillsAgents` — or leave it empty to auto-detect | + +Two behaviours differ, deliberately — mention them when migrating: + +- A source a referenced package ships is re-added **at that package's commit**, not at latest, so its + skills keep matching the package version. Other sources are re-added at latest, as before. +- Update is `skills add`, not `skills update`, so it also installs skills a source has **gained**: a + package source gets every skill its referenced version ships, and any other source is re-added with + `--skill "*"`. + +After switching, delete the scripts and their `package.json` entries, and remove `package.json` +itself if nothing else uses it. Run `just skills-update -p:PurviewSkillsDryRun=true` and compare the +printed commands with what the old script ran before deleting anything. + +## Updating an existing Justfile + +1. Read the whole `Justfile` first: match its group names, comment style, and any `set shell` + (a PowerShell shell still needs the `-t:` value quoted). +2. Keep recipe names the repository already uses; change only what they run. If `skills-check` + already means "report", rename it to `skills-recommend` rather than silently changing its meaning, + and say so. A recipe pointed at a hand-picked "small" project, to avoid running once per project, + can point at the solution instead. +3. Check nothing depends on a changing recipe: `just --list`, then search the Justfile, CI workflows + and hook configuration for the recipe name. +4. Verify: `just --list` shows the group, `just skills-update -p:PurviewSkillsDryRun=true` prints the + commands, and `just skills-check` passes or names a real problem. diff --git a/.agents/skills/purview-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-component-compatibility/SKILL.md b/.agents/skills/purview-component-compatibility/SKILL.md new file mode 100644 index 0000000..b017681 --- /dev/null +++ b/.agents/skills/purview-component-compatibility/SKILL.md @@ -0,0 +1,116 @@ +--- +name: purview-component-compatibility +description: >- + Use when developing, reviewing or releasing a published component - a NuGet package, or an npm + package - and choosing which dependency version to declare, when a consumer reports a version + conflict or an unexpected upgrade, or when a component's supported target frameworks change. Applies + the golden rule that a published component declares the LOWEST compatible version rather than the + latest, walks the Purview component hierarchy to find each floor, and verifies it by building + against it. Not for applications: they take the latest compatible version instead. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" + repository: purview-dev/engineering-playbook +--- + +# Purview component compatibility + +A published component makes a promise with every version it declares: *this is the least I require of +you*. Raise that number and every consumer is pulled up with you, whether or not they wanted it. + +**The golden rule: declare the lowest version that compiles, passes your tests, and is supported on +every target framework you claim.** The version you happen to have installed is never the answer, and +neither is the latest. + +## Which direction? + +| What you are publishing | Declare | Why | +| -- | -- | -- | +| an application, a test suite, a tool — it runs, and nobody installs it | the **latest** compatible version | you own the runtime, and a version you skip is a fix you did not take | +| a component — a NuGet or npm package somebody else installs | the **lowest** compatible version | dependency resolution takes the highest minimum in the graph, so your floor becomes theirs | + +This skill is about the second row. If the repository only ever runs, stop here and take the latest. + +## Why a floor matters + +NuGet and npm resolve a graph to the **highest** minimum among all constraints. If a package declares +`Testcontainers` 4.16 because 4.16 is what its author had installed, every consumer is forced onto 4.16, +including the one whose own constraint is 4.0 and whose runtime is pinned to it. The consumer cannot +refuse an upgrade the package never needed to ask for, and the symptom appears in *their* build, not +yours. + +## The workflow + +### 1. Name the component and its frameworks + +The floor is per target framework, not per package. Write down every `TargetFramework` the component +publishes; a multi-targeting component has a different floor on each, and the one that matters is the +oldest framework still supported, not the newest one you build against. + +### 2. Read the hierarchy, do not remember it + +The dependencies — and their minimums — are in the published `.nuspec`, not in anyone's memory: + +```bash +node scripts/dependencies.mjs Purview.Containers +node scripts/dependencies.mjs Purview.Architecture.Testing --tfm net11.0 +node scripts/dependencies.mjs Purview.Containers --walk +``` + +A bare version is a **minimum**; a bracketed one (`[1.2.3]`, `[1.0,2.0)`) is a range. The script prints +the distinction, because it is the whole rule: a minimum you raise is a minimum every consumer inherits. + +`references/purview-components.md` records which Purview package depends on which, and which target +frameworks each family supports. Where the two disagree the registry wins — that file is the map, the +`.nuspec` is the territory. + +### 3. Fix the floor, per framework + +For each dependency, find the oldest version that: + +- exposes every API the component calls (the compiler decides this, not the changelog); +- passes the component's own tests and its consumer-verification suite; +- is supported on every framework in that dependency group. + +Then declare that version. Not `latest`, not floating (`1.*`), and never the version that resolved on +your machine by accident. + +### 4. Prove the floor + +A floor nobody builds against is a guess. Add a CI leg that restores the component with every dependency +held at its declared floor and runs the suite — the ordinary leg keeps resolving the latest, which is +what a consumer will actually get. `references/lowest-compatible.md` has the mechanics. + +### 5. Keep the ceiling visible, and the floor written down + +A component's README carries a badge for the current release and a sentence naming the floor it +supports. Badges are generated from the registry, so they cannot go stale; a version written into prose +is wrong within one release. + +## Pitfalls that produce a floor nobody meant + +| Pitfall | What it looks like | The fix | +| -- | -- | -- | +| Declaring what is installed | `dotnet add package X` writes the latest; right for an application, wrong for a component | edit the version down, then prove it in step 4 | +| Transitive uplift | a dependency pulls in a newer transient package than your floor allows | override it deliberately — the persistence sample does this for a security-patched package — and comment why | +| One framework's floor | a `net10.0-windows` group needs a higher floor than `net10.0` | read each group; never average them | +| Prerelease in the graph | a family that ships prereleases depends on them at the same version | pin the family to one property so the floor moves together | +| Analyzer and source-generator packages | compile-time only, so they rarely constrain a consumer | still declare a real floor; they appear in the graph and in transitive audits | +| Floating versions | `1.*` resolves differently on every restore | banned: a floor is a number | + +## Checks before you answer + +- Is each declared version the lowest that passes, or the one that happened to be installed? +- Does every declared target framework have a floor that compiles on that framework? +- Was the floor proved by a build, or only by your machine? +- Does any page state a version number? It should be a badge or a link to the registry. +- If a floor was raised, is that a breaking change the version number admits to? + +## See also + +`references/lowest-compatible.md` for the CI mechanics and the override patterns, and +`references/purview-components.md` for the Purview hierarchy. The rule itself, and the application-side +half of it, is stated in the Engineering Playbook's Dependency Versions page; the +`purview-engineering-principles` skill summarises it as one of the eleven conventions. + diff --git a/.agents/skills/purview-component-compatibility/references/lowest-compatible.md b/.agents/skills/purview-component-compatibility/references/lowest-compatible.md new file mode 100644 index 0000000..ccdb3b6 --- /dev/null +++ b/.agents/skills/purview-component-compatibility/references/lowest-compatible.md @@ -0,0 +1,93 @@ +# Proving the floor + +The rule is "declare the lowest compatible version". This file is the mechanics: how to find the floor, +how to prove it in a build, and the patterns that keep it from silently rising. + +## What "compatible" means, precisely + +A version is a valid floor when all three hold: + +1. **It compiles against every API the component uses.** The compiler decides this, not a changelog. If + you cannot say which member forced the version up, you have not found the floor. +2. **The component's own tests pass against it**, including the consumer-verification suite, which + builds a throwaway consumer rather than the component's own test project. +3. **It is supported on every target framework in that dependency group.** A floor is per group, and a + multi-targeting component can have several. + +## Finding it + +```bash +dotnet list package --outdated # what you declared against what exists +dotnet list package --include-transitive # what actually resolved +dotnet nuget why # which path pulled a version in +``` + +Then work downward: lower the declared version, build, and read the compiler. The first version that +compiles *and* passes is the floor. Do not stop at the first failure and pick the version above it — +that is how a floor ends up one minor above what the code needs. + +## Making the floor a build leg + +Keep one property per family (so a family cannot be bumped halfway) and make each conditional on a floor +switch. Nothing here is NuGet-specific magic: it is MSBuild, and central package management already +gives you the single place to put it. + +```xml + + LATEST + FLOOR + +``` + +```bash +dotnet restore -p:DependencyFloor=true +dotnet build -p:DependencyFloor=true --no-restore +dotnet test -p:DependencyFloor=true --no-build +``` + +`LATEST` and `FLOOR` are literals in the file; the point is that they are two properties, not two files. +Run the ordinary leg at `LATEST` and a second, slower leg at `FLOOR`, and give the floor leg its own CI +job so a failure names the floor rather than the build. + +Where a component multi-targets, add the framework dimension and run the pair per framework: + +```bash +for tfm in net8.0 net9.0 net10.0; do + dotnet build -p:DependencyFloor=true -f "$tfm" +done +``` + +## Transitive pins + +A floor can be raised by something you never declared. Transitive pinning puts those versions where you +can see them: + +```xml + + true + +``` + +Then an override is a deliberate line with a reason beside it — the persistence sample in this +repository does exactly that for a security-patched package the SQL client drags in. An override without +a comment is indistinguishable from an accident, and the next person deletes it. + +## The ceiling, and the API surface + +- The floor leg proves the **bottom**; the ordinary leg proves the **top**. Both, or you know neither. +- An API-compatibility check against the previous release catches the other direction of the same + mistake: a floor can stay put while the public surface grows a member that only the newest dependency + provides. `Microsoft.DotNet.ApiCompat.Task` wires that into the build; `Microsoft.DotNet.ApiCompat.Tool` + runs it from a pipeline. +- A prerelease dependency is a floor with an expiry: it cannot be consumed by anyone who has not opted + into prereleases. Say so in the package's README rather than letting the error message explain it. + +## What not to do + +| Temptation | Why it is wrong | +| -- | -- | +| `dotnet add package X` and commit | it writes the latest; correct for an application, wrong for a component | +| floating versions (`1.*`) | resolves differently on every restore, so the floor is nobody's decision | +| "it restores on my machine" | that proves one graph, on one date, with one SDK | +| raising a floor in a patch release | the consumer's build changes without their version changing | +| one floor leg for all frameworks | the framework with the lowest floor is the one nobody tested | diff --git a/.agents/skills/purview-component-compatibility/references/purview-components.md b/.agents/skills/purview-component-compatibility/references/purview-components.md new file mode 100644 index 0000000..6e13b65 --- /dev/null +++ b/.agents/skills/purview-component-compatibility/references/purview-components.md @@ -0,0 +1,60 @@ +# The Purview component hierarchy + +Which Purview package depends on which, and which target frameworks each family supports, so a harvest +or a floor decision starts from the shape rather than from a memory of it. + +Every row was read from the registry with `scripts/dependencies.mjs`, which reads the published +`.nuspec`. **The registry is the authority; this file is the map.** Re-run the script before acting on +any row — a family that multi-targets changes its floors on every release. + +## The families + +| Family | Packages | Target frameworks | Depends on | +| -- | -- | -- | -- | +| Build SDK | `Purview.BuildSdk` | `netstandard2.0` (an MSBuild SDK) | nothing | +| Test-support rule sets | `Purview.Skills.Testing`, `Purview.Architecture.Testing`, `Purview.Architecture.Testing.Cli` | `net10.0`; `Architecture.Testing` also `net11.0` | `TUnit.Core`, `TUnit.Assertions`, `JsonSchema.Net` | +| Telemetry | `Purview.Telemetry.SourceGenerator` | `netstandard2.0` (an analyzer) | nothing | +| Containers | `Purview.Containers` + `.Core`, `.Docker`, `.Wsl`, `.MsSql`, `.PostgreSql` | `net10.0`; `.Wsl` adds `net10.0-windows10.0.19041` | its own family at the same version, `Microsoft.Extensions.Telemetry.Abstractions`, `Testcontainers` | +| Results | `Purview.Results`, `.AspNetCore`, `.ZodSharp`, `.ZodSharp.AspNetCore` | `net10.0`, `net11.0` | nothing; the adapters depend on the core and on ZodSharp | +| ZodSharp | `Purview.ZodSharp`, `.AspNetCore`, `.SystemTextJson`, `.NewtonsoftJson` | `net9.0`, `net10.0`, `net11.0` | `Microsoft.Extensions.Options`, `Microsoft.Extensions.DependencyInjection.Abstractions` | +| Value Objects | `Purview.ValueObjects` | `net8.0`, `net9.0`, `net10.0`, `net11.0` | nothing | +| Source Generator Framework | `Purview.SourceGeneratorFramework`, `.Testing`, `.Testing.TUnit` | `netstandard2.0` | Roslyn (`Microsoft.CodeAnalysis.CSharp`), `System.Collections.Immutable`, `System.Reflection.Metadata` | +| Aspire ResourceKit | `Purview.Aspire.ResourceKit` | `net8.0`, `net9.0`, `net10.0` | `Aspire.Hosting`, OpenTelemetry exporter and hosting, `Microsoft.Extensions.*` | +| Astroc4 | `@purview-dev/astroc4` (npm, not NuGet) | an Astro/Starlight plugin | `likec4`, `@astrojs/starlight`, `astro` as peers | + +## Reading the shape + +Three patterns are worth internalising, because each one changes what "the floor" means: + +- **A family ships at one version.** `Purview.Containers.Core` is required at the umbrella's own version, + and `Purview.Results.ZodSharp` requires `Purview.Results` and `Purview.ZodSharp` at their current ones. + Declare the family with one property, so its floor moves as a unit. +- **An analyzer has no consumer-side floor worth arguing about.** `Purview.BuildSdk`, + `Purview.Telemetry.SourceGenerator` and `Purview.SourceGeneratorFramework` target `netstandard2.0` and do + their work at compile time. They still appear in a transitive audit, so they still get a real version. +- **A multi-targeting component has a different floor per framework**, and ZodSharp shows it most + clearly: the `net11.0` group declares older `Microsoft.Extensions.*` minimums than the `net10.0` group, + because the lower framework carries the newer requirements. Reading one group and applying it to + another is how a floor is set wrong in the direction that breaks consumers. + +## What the script prints + +```text +Purview.Containers 1.0.0-prerelease.11 + net10.0: Purview.Containers.Core >= 1.0.0-prerelease.11 + net10.0: Purview.Containers.Docker >= 1.0.0-prerelease.11 + net10.0: Purview.Containers.Wsl >= 1.0.0-prerelease.11 + net10.0: Microsoft.Extensions.Telemetry.Abstractions >= 10.11.0 + net10.0: Testcontainers >= 4.16.0 + Purview.Containers.Core 1.0.0-prerelease.11 + net10.0: Microsoft.Extensions.Telemetry.Abstractions >= 10.11.0 +``` + +A bare version is a minimum; brackets would mean a range. The numbers above are what the registry said +when this page was written — the shape is the part that lasts, which is why the command is here and the +versions are not presented as targets. + +## Related components that are not Purview packages + +`AspireC4.Hosting` is published under its own name and versions on its own schedule, so it is not part of +a Purview family floor. Read it the same way, with the same script. diff --git a/.agents/skills/purview-component-compatibility/scripts/dependencies.mjs b/.agents/skills/purview-component-compatibility/scripts/dependencies.mjs new file mode 100644 index 0000000..3069ef6 --- /dev/null +++ b/.agents/skills/purview-component-compatibility/scripts/dependencies.mjs @@ -0,0 +1,239 @@ +#!/usr/bin/env node +/** + * Prints a package's dependency groups exactly as NuGet declares them, so the floor a component + * inherits can be read rather than guessed. + * + * The golden rule of package development is to declare the LOWEST compatible version, and that decision + * needs the real graph: which dependency, at what minimum, on which target framework. This script reads + * it from the registry - the `.nuspec` the published package carries - instead of from a table that + * would be stale by the next release. + * + * Zero dependencies: it uses the platform `fetch`, so it runs anywhere Node 18 or later does. + * + * node dependencies.mjs Purview.Containers + * node dependencies.mjs Purview.Architecture.Testing --tfm net11.0 + * node dependencies.mjs Purview.Containers --walk # recurse into Purview.* dependencies + * node dependencies.mjs Purview.Results --json + * + * An npm package is not a NuGet id; read it with `npm view versions --json`. + * + * Exit codes: 0 on success, 1 when the package, version or network is not available. + */ + +/** The NuGet flat-container root: the same host that serves the package's own `.nuspec`. */ +const REGISTRY = "https://api.nuget.org/v3-flatcontainer"; + +/** How deep `--walk` follows dependencies before it stops. */ +const MAX_DEPTH = 3; + +/** True when a version string carries a prerelease label. */ +function isPrerelease(version) { + return version.includes("-"); +} + +/** Every published version of a package, oldest first. */ +async function versionsOf(id) { + const response = await fetch(`${REGISTRY}/${id.toLowerCase()}/index.json`); + if (!response.ok) { + throw new Error(`${id} is not on nuget.org (HTTP ${response.status}).`); + } + + const body = await response.json(); + return body.versions; +} + +/** The version to describe: the one asked for, else the newest stable, else the newest anything. */ +function chooseVersion(available, requested) { + if (requested) { + const match = available.find((version) => version.toLowerCase() === requested.toLowerCase()); + if (!match) { + throw new Error(`'${requested}' is not a published version. Newest is ${available.at(-1)}.`); + } + + return match; + } + + const stable = available.filter((version) => !isPrerelease(version)); + return stable.at(-1) ?? available.at(-1); +} + +/** The package's `.nuspec`, which is what a consumer's restore actually reads. */ +async function readNuspec(id, version) { + const name = id.toLowerCase(); + const response = await fetch(`${REGISTRY}/${name}/${version.toLowerCase()}/${name}.nuspec`); + if (!response.ok) { + throw new Error(`No .nuspec for ${id} ${version} (HTTP ${response.status}).`); + } + + return response.text(); +} + +/** The value of one XML attribute on a tag, or null. */ +function attribute(tag, name) { + const match = new RegExp(`${name}\\s*=\\s*"([^"]*)"`).exec(tag); + return match ? match[1] : null; +} + +/** + * How a nuspec version string constrains a consumer: a bare version is a MINIMUM, and a bracketed one is + * a range. This is the distinction the whole rule turns on, because a minimum you raise is a minimum + * every consumer inherits. + */ +function classify(range) { + if (!range) { + return "any"; + } + + return range.startsWith("[") || range.startsWith("(") ? "range" : "minimum"; +} + +/** The dependency elements inside a fragment, normalized. */ +function dependenciesIn(fragment) { + const dependencies = []; + const pattern = /]*)\/?>/g; + let match; + + while ((match = pattern.exec(fragment)) !== null) { + const tag = match[1]; + const version = attribute(tag, "version"); + dependencies.push({ id: attribute(tag, "id"), version, constraint: classify(version) }); + } + + return dependencies; +} + +/** The dependency groups a nuspec declares, one per target framework. */ +function dependencyGroups(nuspec) { + const groups = []; + const pattern = /]*)>([\s\S]*?)<\/group>/g; + let match; + + while ((match = pattern.exec(nuspec)) !== null) { + groups.push({ + framework: attribute(match[1], "targetFramework") ?? "(any)", + dependencies: dependenciesIn(match[2]), + }); + } + + if (groups.length === 0) { + const flat = dependenciesIn(nuspec); + if (flat.length > 0) { + groups.push({ framework: "(any)", dependencies: flat }); + } + } + + return groups; +} + +/** A printable name for a target framework, spelled the way the ecosystem usually writes it. */ +function shortFramework(framework) { + return framework + .replace(/^\.NETStandard/i, "netstandard") + .replace(/^\.NETCoreApp/i, "net") + .replace(/^\.NETFramework/i, "netframework") + .toLowerCase(); +} + +/** Prints one package's groups, recursing into Purview components when asked. */ +async function describe(id, requestedVersion, options, depth, seen) { + const version = chooseVersion(await versionsOf(id), requestedVersion); + const groups = dependencyGroups(await readNuspec(id, version)); + const indent = " ".repeat(depth); + + console.log(`${indent}${id} ${version}`); + + for (const group of groups) { + const framework = shortFramework(group.framework); + if (options.tfm && framework !== options.tfm.toLowerCase()) { + continue; + } + + if (group.dependencies.length === 0) { + console.log(`${indent} ${framework}: no dependencies`); + continue; + } + + for (const dependency of group.dependencies) { + const relation = dependency.constraint === "minimum" ? ">=" : dependency.constraint; + console.log( + `${indent} ${framework}: ${dependency.id} ${relation} ${dependency.version ?? "any"}`, + ); + } + + if (options.walk && depth < MAX_DEPTH) { + for (const dependency of group.dependencies) { + const isPurview = /^purview\./i.test(dependency.id ?? ""); + if (!(options.all || isPurview) || seen.has(dependency.id.toLowerCase())) { + continue; + } + + seen.add(dependency.id.toLowerCase()); + await describe(dependency.id, null, options, depth + 1, seen); + } + } + } + + return { id, version, groups }; +} + +/** Parses the command line. An unknown argument is a mistake worth failing on. */ +function parseArguments(argv) { + const options = { packages: [], tfm: null, walk: false, all: false, json: false }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + + if (argument === "--walk" || argument === "-w") { + options.walk = true; + } else if (argument === "--all") { + options.all = true; + } else if (argument === "--json") { + options.json = true; + } else if (argument === "--tfm") { + options.tfm = argv[(index += 1)]; + if (!options.tfm) { + throw new Error("--tfm needs a value, e.g. --tfm net10.0."); + } + } else if (argument.startsWith("-")) { + throw new Error(`Unknown argument '${argument}'.`); + } else { + options.packages.push(argument); + } + } + + if (options.packages.length === 0) { + throw new Error( + "Name at least one package, e.g. `node dependencies.mjs Purview.Containers`.\n" + + "A package may carry a version: `Purview.Containers@1.0.0-prerelease.11`.", + ); + } + + return options; +} + +/** The command. */ +async function main() { + const options = parseArguments(process.argv.slice(2)); + const results = []; + + for (const entry of options.packages) { + const separator = entry.indexOf("@", 1); + const id = separator === -1 ? entry : entry.slice(0, separator); + const version = separator === -1 ? null : entry.slice(separator + 1); + + results.push(await describe(id, version, options, 0, new Set([id.toLowerCase()]))); + + if (!options.walk && !options.json) { + console.log(""); + } + } + + if (options.json) { + console.log(JSON.stringify(results, null, "\t")); + } +} + +main().catch((error) => { + console.error(`error: ${error.message}`); + process.exitCode = 1; +}); diff --git a/.agents/skills/purview-engineering-principles/SKILL.md b/.agents/skills/purview-engineering-principles/SKILL.md new file mode 100644 index 0000000..24fe707 --- /dev/null +++ b/.agents/skills/purview-engineering-principles/SKILL.md @@ -0,0 +1,198 @@ +--- +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, tiering verification by risk, the version policy for applications and for published + components, and the conditional persistence rules. Each rule names the failure it prevents and + whether a build or a reviewer enforces it. +metadata: + product: Purview engineering playbook + repository: purview-dev/engineering-playbook +--- + +# Purview engineering principles + +Eleven rules, each with the failure it prevents and the mechanism that makes it true. The distinction +that matters throughout: **a convention a build can check is checked by the build; a convention only +a reviewer can check is labelled a review obligation and never described as enforced.** A rule that +claims enforcement it does not have is worse than no rule, because people stop looking. + +## 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`, as plain Markdown. The repository owns no +site configuration at all: `_Sidebar.md` is the navigation, `Home.md` is the hub, and the site that +publishes them lives in one place outside the repository. + +Documentation that lives elsewhere is updated elsewhere. A page with no owner and no review path is +wrong within one release. + +*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. + +## 11. A version is a range, not a number + +An application takes the latest version its runtime supports. A published component declares the +**lowest** version that compiles, passes its tests and satisfies the contract it documents, on each +target framework it claims — because dependency resolution is a set of lower bounds, so the floor a +component declares becomes the floor every consumer inherits. + +A page states no release number. It shows a badge generated from the registry, or links to the +registry, so a version cannot be wrong within a release. + +*Enforced by:* a lowest-compatible CI leg that restores against the declared floor, and an +API-compatibility check per target framework. Nothing enforces the floor of a package that has no +consumer yet — that tier arrives with the first release. + +*At your scale:* with one application there is nothing to earn yet; take the latest and let +`dotnet list package --outdated` report the rest. The golden rule starts at the first published package. + +## Applying these in review + +Three questions catch most of it: + +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. + +## The conditional persistence rules + +Three further rules apply only when a repository actually stores data: a value known before storage is +derived with UUIDv5, every other GUID key uses one strategy per store, and EF Core is the persistence +boundary. They are not part of the eleven because a stateless CLI or a pure library must not inherit them. +`purview-persistence` owns them and stays silent when persistence is not real. + +## Related skills + +`purview-architecture` for project-shape decisions, `purview-architecture-testing` for turning a +boundary into a failing build, `purview-telemetry` and `purview-persistence` for the per-concern +guidance, and `purview-component-compatibility` for deciding and verifying a published component's +lowest compatible dependency versions. diff --git a/.agents/skills/purview-org-harvest/SKILL.md b/.agents/skills/purview-org-harvest/SKILL.md new file mode 100644 index 0000000..2c05ea1 --- /dev/null +++ b/.agents/skills/purview-org-harvest/SKILL.md @@ -0,0 +1,197 @@ +--- +name: purview-org-harvest +description: >- + Use when a Purview repository's conventions may have fallen behind the organisation - + inventorying every public purview-dev repository at main, deciding which repository is + best-in-class for each concern before adopting anything, and recording the result in a dated + snapshot. Always reads the remote: a local clone is a cache, and it may be stale, renamed or + missing entirely. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" + repository: purview-dev/engineering-playbook +--- + +# Purview organisation harvest + +Seventeen public repositories, each with its own wiki, its own `skills/` folder and its own `AGENTS.md`. +They are not equally up to date, and the one you have cloned is not necessarily the one that has the +answer. Two failures follow, and this skill exists to prevent both. + +**Copying from a stale clone.** A clone is a cache with no expiry. The convention you read there may +be two months behind the one the owner now documents, and nothing about the local file says so. + +**Copying from the wrong owner.** "How do we ship?" is answered by `build`. "How do we keep a boundary +honest?" is answered by `architests`. Asking a library that does neither produces a confident answer +with no evidence, which is worse than no answer. + +So: establish membership, read at `main`, name one owner per concern, write down what you found. + +## 1. Scope + +Name two things before reading anything: + +| Input | Example | +| -- | -- | +| The repository being changed | `containers` | +| The concern in question | "how a test reaches a real database" | + +A harvest without a named concern becomes a tour, and a tour produces a list of differences nobody +acted on. The concern is what makes "best-in-class" a decision rather than a preference. + +## 2. Discover membership + +`gh repo list` is the membership authority. A catalogue, a README table, an `llms.txt` and a local +folder listing are all convenience indexes: they are maintained by hand, so they under-count, and the +omission is silent. + +```bash +gh auth status +gh repo list purview-dev --limit 200 \ + --json name,visibility,isArchived,pushedAt,defaultBranchRef +``` + +Drop `isArchived` and anything that is not `PUBLIC`. **If `gh auth status` fails, stop.** Degrading to +the local clone is the exact failure this skill prevents, and it degrades silently. + +The filter denies by default: a repository must be exactly `PUBLIC` and exactly not archived to be +recorded, so a visibility you have never seen fails closed. `gh repo list` also only lists what your +token can see, which is why the token's visibility and the file's contents are two separate questions: +the section below is about the second one. + +A local clone is admissible only when its `HEAD` equals the SHA the snapshot recorded: + +```bash +git -C ../containers rev-parse HEAD +``` + +That is a speed optimisation, never a substitute. + +## 3. Read it at `main` + +Every repository is read at `main`, and the commit is recorded. The snapshot is what makes the claim +checkable a month later, when nobody remembers which commit the analysis saw. + +```bash +node scripts/inventory.mjs # drift between the organisation and the snapshot +node scripts/inventory.mjs --write # regenerate .config/org-inventory.json +node scripts/inventory.mjs --repo containers +``` + +The script is deterministic and dependency-free: it shells out to `gh`, so it reuses the CLI's own +authentication, and it runs the same on Windows and on a Linux agent — it is Node, not a shell script, +so there is no PowerShell edition and no Bash edition to keep in step. It writes +`.config/org-inventory.json` in the repository you run it from — generated, never hand-edited. + +To inspect a decision without a network call, replay a recorded harvest with `--from` and `--details`. +That is the same script, not a variant, so what you review is what runs. + +## 4. Harvest in two tiers + +Seventeen repositories is not a reading list; it is a budget. Two tiers keep it affordable. + +| Tier | Call | Answers | +| -- | -- | -- | +| 1 — shape | `gh api repos/purview-dev//git/trees/main?recursive=1`, for all seventeen | Which folders exist, how many pages the wiki has, whether `skills/` is authored, whether `AGENTS.md` exists. Cheap enough for everything. | +| 2 — content | `gh api -H 'Accept: application/vnd.github.raw' repos/purview-dev//contents/`, **only for the owner of the concern** | What the convention actually says. | + +Tier 1 tells you where the evidence is. Tier 2 reads it, in the one repository that wrote it down. + +```bash +gh api repos/purview-dev/architests/git/trees/main?recursive=1 --jq '.tree[].path' +gh api -H 'Accept: application/vnd.github.raw' \ + repos/purview-dev/architests/contents/docs/wiki/Architecture-As-Tests.md +``` + +More invocations, and what each one is for: `references/harvest-commands.md`. + +## 5. Classify: one owner per concern + +Pick exactly one owner per concern, from `references/best-in-class.md`. Two signs the classification +is wrong: + +- **Two owners for one concern.** `build-sdk` and this playbook's `purview-engineering-principles` + both answer "review this repository". That overlap is a finding to resolve, not a tie to break by + preferring whichever you read first. +- **An owner that has never run the thing it documents.** Check the tree before trusting the page. + +## 6. Divergence register + +Write the differences down before changing anything, with an adopt-or-reject for each. A divergence +with no decision recorded is the same divergence next quarter. + +| Column | Answers | +| -- | -- | +| Concern | What is being compared | +| Owner | The repository the convention comes from, and the path it is stated at | +| Here | What this repository does now | +| Decision | Adopt, or reject with the reason | +| Mechanism | The build rule, test or review obligation that keeps it true | + +Rejecting is a legitimate outcome and is usually the right one at a smaller scale. What is not +legitimate is adopting a convention whose failure the repository cannot have. + +## 7. Adopt + +A convention arrives as a set of changes that land together, or it is a claim with no mechanism: + +```text +the page states the rule, why, how it is enforced, and what a smaller repository does instead +src/ demonstrates it, or says why it does not +the snapshot records the commit it was read at +the skill points at the owner, and never paraphrases it +``` + +**Link to the owner; do not restate it.** A paraphrase is a second source of truth, and the copy with +no owner is the one that loses. + +## 8. Verify + +```bash +just build # zero warnings +just test # architecture, skill and documentation rules +just lint-check +node scripts/inventory.mjs --print +``` + +If the harvest changed what the snapshot should record, regenerate it and commit the diff. The +snapshot being current is a review obligation rather than a build failure — a build that goes red on +a Tuesday because a clock passed is a build people disable. + +## Private repositories + +A private repository may inform a maintainer's judgement. It is never evidence, and it is never +written down. This is not a preference, and it is not a heuristic an agent gets to weigh against a +good argument. Five statements, in order of how often they are broken: + +1. A committed snapshot contains public, non-archived repositories only. The writer denies by default + and refuses to persist anything else, and a test asserts it. +2. A private repository may be read when `gh` is authenticated for it, and what it teaches may shape + a rule. +3. Nothing published names it: not the snapshot, not the page, not the skill, not a workflow, not a + config, not a commit message. Attribute the rule to "a Purview application repository", or to + nothing. +4. **If a rule cannot be justified without the private source, it is not published.** +5. **If you cannot say where a name came from, do not write the name.** A repository the snapshot + does not list is either private or gone, and both fail the same test. + +The fourth is the one an agent rationalises around, because the evidence is often excellent. A rule +whose only justification is out of reach of the people it is published to cannot be reviewed by them, +which makes it folklore with a citation they cannot follow. + +## Checks before you answer + +- Did you read membership from the organisation rather than from a catalogue or a folder? +- Is every claim about a repository true at the SHA the snapshot recorded, not at your clone? +- Did you name exactly one owner per concern, and resolve any overlap rather than duplicating it? +- Did you reject anything? A harvest with no rejections usually means nothing was understood. +- Does every change you propose have a mechanism, or is it labelled a review obligation? +- Is every repository you named public? + +## See also + +- `references/best-in-class.md` — which repository owns which concern, and where to read it +- `references/harvest-commands.md` — the `gh` invocations, and the trap in each one +- `purview-engineering-principles` — the conventions themselves, once you know which owner to read +- `purview-architecture` — the project-shape decisions a harvest most often touches diff --git a/.agents/skills/purview-org-harvest/references/best-in-class.md b/.agents/skills/purview-org-harvest/references/best-in-class.md new file mode 100644 index 0000000..9ab69cc --- /dev/null +++ b/.agents/skills/purview-org-harvest/references/best-in-class.md @@ -0,0 +1,77 @@ +# Best-in-class, by concern + +One owner per concern. Read the owner, not the nearest repository that happens to have a wiki. + +Every row was read from the organisation with `gh repo list purview-dev` and +`gh api repos//git/trees/main?recursive=1`. The page counts and skill counts are the shape at +the time of writing, not a promise: they are what the snapshot records, and the snapshot is +regenerated rather than trusted. + +## The matrix + +| Concern | Owner | Read it at | +| -- | -- | -- | +| Repository shape, MSBuild defaults, analyzers, skill authoring | `build-sdk` | `docs/wiki`, `templates/`, `skills/purview-buildsdk-*` | +| Pipeline, release, pack validation | `build` | `docs/wiki`, `skills/purview-build-*` | +| Architecture policy: dependency direction, naming rules | `architests` | `docs` (nine pages; `Policy-Reference.md` is the reference, `Extending.md` the how-to), `skills/purview-architests-*` | +| Local dependencies in tests | `containers` | `docs/wiki`, `skills/purview-containers-*` | +| TypeScript contracts generated from C# | `zodsharp` | `docs/wiki`, `skills/purview-zodsharp-*` | +| Logging, tracing, metrics | `telemetry-sourcegenerator` | `docs/wiki`, `skills/purview-telemetry-*` | +| Event streams and event storage | `event-sourcing` | `docs/wiki` — **no skills folder** | +| Operation outcomes without exceptions | `results` | `docs/wiki`, `skills/purview-results-*` | +| Validated identity types | `value-objects` | `docs` — **no `docs/wiki`** | +| Aspire hosting resources | `aspire-resourcekit` | `docs/wiki`, `skills/purview-resourcekit-*` | +| Architecture diagrams generated from source | `aspirec4` | `docs/wiki`, `skills/purview-aspirec4-*` | +| LikeC4 diagrams inside an Astro or Starlight site | `astroc4` | `docs/wiki`, `skills/purview-astroc4-*` — an npm package, not .NET | +| Source generator authoring | `sourcegenerator-framework` | `docs/wiki`, `skills/purview-sourcegenerator-*` | +| Skill-set design, and the dated-snapshot pattern | `pm-skills` | `skills/pm-*` — **no documentation folder at all** | +| OpenAPI generated from XML comments | `openapi-xmlcomments` | `docs/wiki`, `skills/purview-openapi-xmlcomments-*` | +| The reusable conventions themselves | `engineering-playbook` | `docs/wiki`, `skills/purview-*` | +| The public project catalogue | `purview-dev.github.io` | `src/src/data/projects/*.yml` | + +`purview-dev.github.io` is the catalogue, and a catalogue is an index rather than a membership list: +an entry is added by hand, so a repository missing from it is silent. `gh repo list` is the authority. + +## The overlaps worth knowing about + +**`build-sdk` and `engineering-playbook` both answer "review this repository".** They are not +duplicates, and the split is worth stating because a harvest will hit it immediately: + +- `build-sdk` owns *what the tooling does* — project detection, the props and targets, the analyzer + set, the packaged test-support rule sets. +- `engineering-playbook` owns *why a repository is shaped that way, and what it costs* — the rule, + the failure it prevents, and what a smaller repository does instead. + +A question about a property name goes to `build-sdk`. A question about whether a project should exist +goes here. + +**`containers` and `aspire-resourcekit` both look like "local dependencies".** `containers` owns the +test-time resource — a real SQL Server, PostgreSQL or queue — and `aspire-resourcekit` owns the +development-time orchestration. A test that needs a database is `containers`; an AppHost that needs +one is `aspire-resourcekit`. + +**`aspirec4` and `astroc4` both draw LikeC4 diagrams.** `aspirec4` owns the diagram generated from a +running Aspire resource graph; `astroc4` owns putting a `.c4` model inside an Astro or Starlight site — +the fence transform and the LikeC4 syntax highlighting. A question about the .NET host is `aspirec4`; a +question about the documentation site is `astroc4`, which is the one owner here that is not a .NET +package, so it is read for its TypeScript conventions rather than for its MSBuild ones. + +## Reading a repository you have not cloned + +Tier 1 first — the shape, for everything: + +```bash +gh api repos/purview-dev//git/trees/main?recursive=1 --jq '.tree[].path' +``` + +Then Tier 2 — the content, for the one owner: + +```bash +gh api -H 'Accept: application/vnd.github.raw' \ + repos/purview-dev//contents/docs/wiki/_Sidebar.md +``` + +`_Sidebar.md` is the fastest index of a wiki, because the repository's own documentation rule says a +page that is not in it does not exist. Where a repository publishes no wiki, its `AGENTS.md` is the +next best entry point, and where it has neither, read `skills/*/SKILL.md` — the trigger in each +`description` says what the repository thinks it is for. diff --git a/.agents/skills/purview-org-harvest/references/harvest-commands.md b/.agents/skills/purview-org-harvest/references/harvest-commands.md new file mode 100644 index 0000000..f4b4653 --- /dev/null +++ b/.agents/skills/purview-org-harvest/references/harvest-commands.md @@ -0,0 +1,121 @@ +# Harvest commands + +Every invocation here is safe to run read-only, and each one is followed by the trap that cost +somebody time. Both shells are shown because the harvest runs on a developer's Windows machine and on +a Linux agent, and the quoting rules are not the same. + +## Membership + +```bash +gh auth status +gh repo list purview-dev --limit 200 --json name,visibility,isArchived,pushedAt,defaultBranchRef +``` + +```powershell +gh auth status +gh repo list purview-dev --limit 200 --json name,visibility,isArchived,pushedAt,defaultBranchRef | + ConvertFrom-Json | Sort-Object name | + Format-Table name, visibility, isArchived, pushedAt +``` + +**Trap: the default limit is 30.** Without `--limit` the list is silently short, and a short list is +worse than a failed one because the omission never announces itself. + +**Trap: `gh repo list` shows what your token can see.** An organisation member sees private +repositories too; filter to `visibility -eq 'PUBLIC'` and `-not isArchived` before recording anything. + +## The SHA a convention was read at + +```bash +gh api repos/purview-dev//git/ref/heads/main --jq .object.sha +``` + +**Trap: a renamed repository resolves by redirect.** Asking for a repository under its former name +answers `purview-dev/purview-dev.github.io` and exits 0, so a stale name never fails. Compare the +returned `full_name` with the name you asked for. + +## Tier 1: the shape + +```bash +gh api repos/purview-dev//git/trees/main?recursive=1 --jq '.tree[].path' +``` + +**Trap: `truncated`.** GitHub caps a recursive tree, and the response says so with `"truncated": true` +rather than erroring. A truncated tree under-counts skills and pages, so check the flag before +believing a zero. + +**Trap: the tree lists blobs and trees.** Filter to `type == "blob"` when counting files, or a folder +whose name matches your pattern is counted as its contents. + +## Tier 2: the content + +```bash +gh api -H 'Accept: application/vnd.github.raw' \ + repos/purview-dev//contents/docs/wiki/_Sidebar.md +``` + +**Trap: `contents` returns JSON unless you ask for raw.** Without the `Accept` header the response is +a base64 `content` field, and the analysis quietly proceeds on a JSON document. + +**Trap: `contents` caps at 1000 files for a directory listing.** Use the tree API for enumerating and +`contents` for reading one file. + +## Windows PowerShell, specifically + +**Embedded double quotes are stripped from a native command's arguments.** This looks correct and is +not: + +```powershell +gh repo list purview-dev --jq '.[] | [.name, (.pushedAt // "-")] | @tsv' +``` + +`gh` receives `// -` and answers `failed to parse jq expression ... unexpected token`. Either keep the +jq program free of string literals, or do the shaping in PowerShell: + +```powershell +$repos = gh repo list purview-dev --limit 200 --json name,visibility | ConvertFrom-Json +$repos | Sort-Object name | ForEach-Object { '{0}|{1}' -f $_.name, $_.visibility } +``` + +**A filtered result is a scalar when it is one item.** `(... | Where-Object { ... }).Count` returns +nothing rather than `1`, so `.Count` in a summary line reads as empty. Wrap it: `@(...).Count`. + +## The snapshot + +```bash +node skills/purview-org-harvest/scripts/inventory.mjs +node skills/purview-org-harvest/scripts/inventory.mjs --write +node skills/purview-org-harvest/scripts/inventory.mjs --repo containers --write +``` + +It resolves the snapshot path from `git rev-parse --show-toplevel`, so it writes into the repository +you are standing in rather than into the one the script was installed from. That is what lets another +repository adopt it unchanged. + +`--repo` merges on write, so reading one repository does not erase the other sixteen. + +## Replaying a harvest, with no network + +Two flags replace the two `gh` calls. This is how the repository's own tests exercise the script on +every platform without touching GitHub, and how a reviewer reproduces what a harvest decided: + +```bash +node skills/purview-org-harvest/scripts/inventory.mjs \ + --from recorded-membership.json \ + --details recorded-details.json \ + --print +``` + +`--from` takes exactly what `gh repo list --json name,visibility,isArchived,pushedAt,defaultBranchRef` +printed. `--details` takes an object keyed by repository name, each with the `commitSha` a tier-1 +harvest found and the `paths` the tree contained. A repository that is in the membership and missing +from the details is an error, not a silent omission — a replay that quietly skipped a repository would +look like a clean run. + +Two behaviours worth knowing before you trust a run: + +- **A snapshot carrying a byte-order mark is accepted.** It is the one file in this workflow a person + is likely to open and re-save, and a BOM is not a corruption. +- **A snapshot containing anything that is not public and unarchived is refused**, and nothing is + written. This includes an entry a hand edit left behind, which the merge would otherwise carry + forward: delete it and re-run `--write`. diff --git a/.agents/skills/purview-org-harvest/scripts/inventory.mjs b/.agents/skills/purview-org-harvest/scripts/inventory.mjs new file mode 100644 index 0000000..affc46f --- /dev/null +++ b/.agents/skills/purview-org-harvest/scripts/inventory.mjs @@ -0,0 +1,466 @@ +#!/usr/bin/env node +/** + * Snapshots every public, non-archived `purview-dev` repository at `main` into + * `.config/org-inventory.json`, and reports the drift between that snapshot and the organisation. + * + * The snapshot is what makes the harvest rule checkable offline: `OrgInventoryTests` reads the file + * the pipeline has, never the network. This script is the only thing that writes it, so a repository + * cannot appear in the snapshot by being remembered. + * + * Zero dependencies: it shells out to `gh` and so reuses the CLI's own authentication rather than + * asking for a token. + * + * node inventory.mjs # print the drift against the snapshot (default) + * node inventory.mjs --write # rewrite the snapshot from the organisation + * node inventory.mjs --repo containers # narrow to one repository; merges on --write + * node inventory.mjs --out # write somewhere else, e.g. while adopting the skill + * node inventory.mjs --help + * + * Exit codes: 0 on success, including when drift is found - a stale snapshot is a review obligation, + * not a red pull request for whoever happened to push next. 1 when `gh` is missing, unauthenticated, + * or the organisation cannot be read. + */ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +/** The organisation this playbook describes. */ +const ORG = "purview-dev"; + +/** The branch a convention is read at. A local clone is a cache; this is the authority. */ +const BRANCH = "main"; + +/** Bumped when a field is added or its meaning changes. */ +const SCHEMA_VERSION = 1; + +/** Past this age the snapshot is reported as stale. Advisory: nothing fails on a clock. */ +const STALE_AFTER_DAYS = 30; + +/** Files a repository may put at its root to guide an agent. Recorded in this order. */ +const AGENT_GUIDANCE_FILES = ["AGENTS.md", "CLAUDE.md", ".github/copilot-instructions.md", "llms.txt"]; + +/** Directories a published documentation site is read from, most specific first. */ +const DOCS_DIRECTORIES = ["docs/wiki", "docs"]; + +/** + * The concern each repository is best-in-class for, so a harvest reads one owner instead of sixteen + * repositories. `references/best-in-class.md` is the long form of this table and is the file to edit + * first; this map only records the answer in the snapshot. + */ +const CONCERNS = new Map([ + ["architests", ["architecture policy", "dependency and naming rules"]], + ["aspire-resourcekit", ["Aspire hosting resources"]], + ["aspirec4", ["architecture diagrams from source"]], + ["astroc4", ["architecture diagrams in an Astro site"]], + ["build", ["pipeline", "release"]], + ["build-sdk", ["repository shape", "analyzers and style", "MSBuild defaults", "skill authoring"]], + ["containers", ["local dependencies in tests"]], + ["engineering-playbook", ["the conventions themselves"]], + ["event-sourcing", ["event streams", "event storage"]], + ["openapi-xmlcomments", ["OpenAPI from XML comments"]], + ["pm-skills", ["skill-set design"]], + ["purview-dev.github.io", ["the public project catalogue"]], + ["results", ["operation outcomes"]], + ["sourcegenerator-framework", ["source generator authoring"]], + ["telemetry-sourcegenerator", ["logging", "tracing", "metrics"]], + ["value-objects", ["validated identity types"]], + ["zodsharp", ["TypeScript contracts from C#"]], +]); + +/** Runs `gh` and returns stdout, turning a CLI failure into an actionable message. */ +function gh(args) { + try { + return execFileSync("gh", args, { + encoding: "utf8", + maxBuffer: 256 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + const detail = (error.stderr ?? "").toString().trim() || error.message; + throw new Error( + `gh ${args.join(" ")} failed.\n${detail}\n\n` + + "This script reads the organisation, not a local clone. Run `gh auth status`, then retry.", + ); + } +} + +/** Calls one GitHub REST endpoint and parses the JSON response. */ +function api(endpoint) { + return JSON.parse(gh(["api", endpoint])); +} + +/** The root of the repository the command was run in, so the skill works from any repository. */ +function repositoryRoot() { + try { + return execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + } catch { + throw new Error("Not inside a git repository. Run this from a clone, or pass --out ."); + } +} + +/** Membership, as the organisation reports it. The catalogue is a convenience index, not the list. */ +function readMembership(source) { + const listed = source + ? JSON.parse(readFileSync(source, "utf8")) + : JSON.parse( + gh([ + "repo", + "list", + ORG, + "--limit", + "200", + "--json", + "name,visibility,isArchived,pushedAt,defaultBranchRef", + ]), + ); + + return ( + listed + // Deny by default. The filter is "exactly public and not archived" rather than "not + // private", so a visibility value this script has never seen fails closed instead of + // falling through into a file that is committed to a public repository. + .filter((repository) => repository.visibility === "PUBLIC" && repository.isArchived === false) + .sort((left, right) => left.name.localeCompare(right.name, "en")) + ); +} + +/** The recorded commit and file list for each repository, when a run replays a previous harvest. */ +function readDetails(source) { + return new Map(Object.entries(JSON.parse(readFileSync(source, "utf8")))); +} + +/** The Markdown pages directly inside a docs directory. */ +function countPages(paths, directory) { + const prefix = `${directory}/`; + return paths.filter((path) => { + if (!path.startsWith(prefix)) { + return false; + } + + const remainder = path.slice(prefix.length); + return remainder.endsWith(".md") && !remainder.includes("/"); + }).length; +} + +/** A skill is a folder under `skills/` that carries a `SKILL.md`. */ +function authoredSkills(paths) { + const names = paths + .filter((path) => /^skills\/[^/]+\/SKILL\.md$/.test(path)) + .map((path) => path.split("/")[1]); + + return [...new Set(names)].sort((left, right) => left.localeCompare(right, "en")); +} + +/** Everything the snapshot records about one repository, read at `BRANCH`. */ +function describeRepository(repository, details) { + // The filter above already excluded these, and this is the second lock on the same door: a + // private repository must never reach the snapshot, so the guard is here as well as there. + if (repository.visibility !== "PUBLIC" || repository.isArchived !== false) { + throw new Error( + `${ORG}/${repository.name} is ${repository.visibility} and archived ${repository.isArchived}. ` + + "The snapshot records public, unarchived repositories only, so nothing was written.", + ); + } + + const defaultBranch = repository.defaultBranchRef?.name ?? BRANCH; + if (defaultBranch !== BRANCH) { + throw new Error( + `${ORG}/${repository.name} has default branch '${defaultBranch}', not '${BRANCH}'. ` + + "The snapshot pins one branch, so either the rule or the repository has to change.", + ); + } + + const recorded = details?.get(repository.name); + if (details && !recorded) { + throw new Error( + `${repository.name} has no recorded detail, so --details cannot describe it. ` + + "Replay a membership the details file was captured with, or run without --details.", + ); + } + + const commitSha = recorded?.commitSha ?? api(`repos/${ORG}/${repository.name}/git/ref/heads/${BRANCH}`).object.sha; + const paths = recorded?.paths ?? readTree(repository.name); + const docsDir = DOCS_DIRECTORIES.find((candidate) => + paths.some((path) => path.startsWith(`${candidate}/`)), + ); + + return { + name: repository.name, + visibility: repository.visibility.toLowerCase(), + archived: repository.isArchived, + commitSha, + pushedAt: repository.pushedAt, + docsDir: docsDir ?? null, + docsPages: docsDir ? countPages(paths, docsDir) : 0, + skills: authoredSkills(paths), + agentGuidance: AGENT_GUIDANCE_FILES.filter((file) => paths.includes(file)), + concerns: CONCERNS.get(repository.name) ?? [], + }; +} + +/** The blob paths of one repository at `BRANCH`. */ +function readTree(name) { + const tree = api(`repos/${ORG}/${name}/git/trees/${BRANCH}?recursive=1`); + + if (tree.truncated) { + process.stderr.write( + `warning: ${name}'s tree is truncated, so its skills and docs are undercounted\n`, + ); + } + + return tree.tree.filter((entry) => entry.type === "blob").map((entry) => entry.path); +} + +/** The snapshot document. */ +function snapshotOf(repositories) { + return { + schemaVersion: SCHEMA_VERSION, + org: ORG, + branch: BRANCH, + fetchedAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), + repositories, + }; +} + +/** The snapshot on disk, or null when the repository has not committed one yet. */ +function readSnapshot(path) { + if (!existsSync(path)) { + return null; + } + + // The snapshot is the one file in this workflow a person is likely to open and save by hand, and + // an editor that writes a byte-order mark produces a file JSON.parse rejects with a message that + // names neither the file nor the cause. + return JSON.parse(readFileSync(path, "utf8").replace(/^\uFEFF/, "")); +} + +/** A field's value in the form the drift report prints. */ +function render(value) { + if (value === null || value === undefined) { + return "none"; + } + + if (Array.isArray(value)) { + return value.length === 0 ? "none" : value.join(", "); + } + + return String(value); +} + +/** A field's value in the form the drift report compares: structured values compare as one string. */ +function fingerprint(value) { + return JSON.stringify(value ?? null); +} + +/** The fields whose change is worth reporting, in the order a reader cares about them. */ +const COMPARED_FIELDS = [ + "commitSha", + "docsDir", + "docsPages", + "skills", + "agentGuidance", + "concerns", + "visibility", + "archived", +]; + +/** The differences between the organisation and the snapshot. */ +function drift(existing, observed) { + const findings = []; + const before = new Map((existing?.repositories ?? []).map((entry) => [entry.name, entry])); + const after = new Map(observed.map((entry) => [entry.name, entry])); + + for (const [name, entry] of after) { + if (!before.has(name)) { + findings.push(`${name}: not in the snapshot`); + continue; + } + + const previous = before.get(name); + for (const field of COMPARED_FIELDS) { + if (fingerprint(previous[field]) === fingerprint(entry[field])) { + continue; + } + + findings.push(`${name}: ${field} ${render(previous[field])} -> ${render(entry[field])}`); + } + } + + for (const name of before.keys()) { + if (!after.has(name)) { + findings.push(`${name}: in the snapshot but no longer a public repository`); + } + } + + return findings; +} + +/** How long ago the snapshot was taken, in whole days, or null when it carries no usable date. */ +function ageInDays(fetchedAt) { + const fetched = Date.parse(fetchedAt ?? ""); + return Number.isNaN(fetched) ? null : Math.floor((Date.now() - fetched) / 86_400_000); +} + +/** Parses the command line. Unknown arguments are a mistake worth failing on. */ +function parseArguments(argv) { + const options = { write: false, repos: [], out: null, from: null, details: null, help: false }; + + for (let index = 0; index < argv.length; index++) { + const argument = argv[index]; + if (argument === "--write") { + options.write = true; + } else if (argument === "--print") { + options.write = false; + } else if (argument === "--help" || argument === "-h") { + options.help = true; + } else if (argument === "--repo") { + const value = argv[++index]; + if (!value) { + throw new Error("--repo needs a repository name."); + } + + options.repos.push(value); + } else if (argument === "--out") { + const value = argv[++index]; + if (!value) { + throw new Error("--out needs a path."); + } + + options.out = value; + } else if (argument === "--from") { + const value = argv[++index]; + if (!value) { + throw new Error("--from needs a path to a recorded `gh repo list` response."); + } + + options.from = value; + } else if (argument === "--details") { + const value = argv[++index]; + if (!value) { + throw new Error("--details needs a path to a recorded detail file."); + } + + options.details = value; + } else { + throw new Error(`Unknown argument '${argument}'. Run with --help.`); + } + } + + return options; +} + +/** The documented usage. */ +const HELP = `Snapshot every public, non-archived ${ORG} repository at ${BRANCH}. + + node inventory.mjs print the drift between the organisation and the snapshot + node inventory.mjs --write rewrite the snapshot from the organisation + node inventory.mjs --repo narrow to one repository; merges when writing + node inventory.mjs --out write somewhere other than .config/org-inventory.json + node inventory.mjs --from replay a recorded "gh repo list --json" response instead of + asking the organisation + node inventory.mjs --details replay recorded commits and file lists, so a run touches no + network at all; pair it with --from + node inventory.mjs --help this text + +The snapshot is generated, never hand-edited. A stale snapshot is a review obligation rather than a +build failure, so drift prints and the exit code stays 0.`; + +/** Runs the command. */ +function main() { + const options = parseArguments(process.argv.slice(2)); + if (options.help) { + console.log(HELP); + return; + } + + const snapshotPath = options.out ?? join(repositoryRoot(), ".config", "org-inventory.json"); + const existing = readSnapshot(snapshotPath); + + let observed = readMembership(options.from); + if (options.repos.length > 0) { + const known = new Set(observed.map((repository) => repository.name)); + for (const name of options.repos) { + if (!known.has(name)) { + throw new Error( + `'${name}' is not a public, non-archived ${ORG} repository. ` + + `Run without --repo to include all ${observed.length}.`, + ); + } + } + + observed = observed.filter((repository) => options.repos.includes(repository.name)); + } + + const details = options.details ? readDetails(options.details) : null; + const described = observed.map((repository) => describeRepository(repository, details)); + + if (options.write) { + const merged = new Map((existing?.repositories ?? []).map((entry) => [entry.name, entry])); + for (const entry of described) { + merged.set(entry.name, entry); + } + + const written = snapshotOf( + [...merged.values()].sort((left, right) => left.name.localeCompare(right.name, "en")), + ); + + // The merge carries forward whatever the file already held, which is how a hand-edited entry + // would survive a regeneration. Refuse to write a snapshot that records anything else. + const notPublic = written.repositories.filter( + (entry) => entry.visibility !== "public" || entry.archived !== false, + ); + if (notPublic.length > 0) { + throw new Error( + "Refusing to write a snapshot that records a repository which is not public and " + + `unarchived: ${notPublic.map((entry) => entry.name).join(", ")}. ` + + "Delete the entries and re-run --write.", + ); + } + + mkdirSync(dirname(snapshotPath), { recursive: true }); + writeFileSync(snapshotPath, `${JSON.stringify(written, null, "\t")}\n`, "utf8"); + console.log( + `wrote ${written.repositories.length} repositories to ${snapshotPath} at ${written.fetchedAt}`, + ); + return; + } + + const findings = drift(existing, described); + const age = ageInDays(existing?.fetchedAt); + + console.log(`${ORG}: ${observed.length} public, non-archived repositories at ${BRANCH}`); + if (existing) { + console.log( + `snapshot: ${existing.repositories?.length ?? 0} repositories, fetched ${existing.fetchedAt}` + + (age === null ? "" : ` (${age} day${age === 1 ? "" : "s"} old)`), + ); + } else { + console.log(`no snapshot at ${snapshotPath} - run with --write to create one`); + } + + if (findings.length === 0) { + console.log("no drift"); + } else { + console.log(`${findings.length} difference${findings.length === 1 ? "" : "s"}:`); + for (const finding of findings) { + console.log(` ${finding}`); + } + } + + if (age !== null && age > STALE_AFTER_DAYS) { + console.log( + `warning: the snapshot is older than ${STALE_AFTER_DAYS} days. ` + + "Re-run with --write and review the diff.", + ); + } +} + +try { + main(); +} catch (error) { + console.error(`error: ${error.message}`); + process.exitCode = 1; +} diff --git a/.agents/skills/purview-persistence/SKILL.md b/.agents/skills/purview-persistence/SKILL.md new file mode 100644 index 0000000..7d6ad91 --- /dev/null +++ b/.agents/skills/purview-persistence/SKILL.md @@ -0,0 +1,149 @@ +--- +name: purview-persistence +description: >- + Use when a .NET project actually persists data and you are choosing a storage approach, integrating + or abstracting EF Core, deriving a well-known identifier, or selecting a GUID primary-key strategy. + Gives provider-specific guidance — SQL Server and PostgreSQL differ and must not be treated alike — + and stays silent for projects that do not persist anything. +license: MIT +metadata: + author: Purview-Dev + version: "1.2" + repository: purview-dev/engineering-playbook +--- + +# 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. EF Core integration + +When EF Core is the store, keep the integration in four places: + +- **`DbContext` is the boundary.** Use it directly; do not add `IRepository` or a unit-of-work wrapper + for testability. `Purview.Containers` gives you a real database in tests. +- **Choose the provider where the context is registered.** Carry the choice as context-options data, not + a `Database.ProviderName` branch at model-build time. +- **Map value objects once.** Configure converters and comparers in the model, not per property. A + value-object key needs a converter-aware value generator. +- **Keep design time and runtime in step.** Both paths make the same provider, strategy and + `ReplaceService` choices, or the migration snapshot describes a different model. + +Migrations are code, reviewed and applied by a deliberate step; the API does not migrate at startup in +production. The full rule, composition sample and checks are in `references/ef-core-integration.md`. + +## 4. Identifiers — derived or generated + +Two decisions live here, and they are independent. Ask them in this order. + +### The deterministic-identifier gate + +Derive an identifier only when its **value must be known before storage** — a system tenant, a seeded +reference row, an installation's service principal, or an ingested object whose provider key is stable +and never recycled. Then derive it with UUIDv5 over an installation-scoped namespace and configure +`ValueGeneratedNever()`, so the sequential convention below leaves it alone. A project with random keys +and no seeding has no use for UUIDv5. An outbox row that merely needs an id before `SaveChanges` takes +the generated strategy below, not a derived one. + +The full rule, catalogue, naming rules, migration caveat and tests are in +`references/well-known-identifiers.md`. + +### The generated-key decision + +Resolve these before recommending anything; most are answered by the repository: + +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. + +**No NuGet package provides this pattern.** A reviewed generator such as `FastGuid` supplies a +store-ordered implementation, but the strategy witness, options extension, value generator with +value-converter support, and model-finalising convention are repository-local: copy them, do not depend +on a package that claims to own the wiring. Introduce it only for a measured requirement: a second +provider, or an identifier needed before `SaveChanges`. Until the second provider exists, a built-in +capability is the answer. + +## 5. Value objects as keys + +`Purview.ValueObjects` gives strongly-typed identifiers that stop an `OrderId` being passed where a +`CustomerId` belongs. They need an EF Core value converter; configure it once in the model rather +than per property. + +This is about type safety at the boundary, not about the generation strategy — the two decisions are +independent. + +## 6. Migrations and schema + +- Migrations are code: reviewed, tested against a real container, and applied by a deliberate step — + not by the application at startup in production. +- 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 the project uses EF Core, are you recommending the `DbContext` directly rather than a repository + wrapper added for testability? +- Does the design-time path make the same provider and strategy choices as the runtime path? +- Does every generated value have one owner — application, store or derivation — and no second writer? +- If you are deriving an identifier, is its value genuinely known before storage, and is it keyed + `ValueGeneratedNever()`? +- If you are recommending a GUID strategy, do you know the provider *and* its version? +- If you are proposing the strategy/convention, is there a second provider or a pre-save identifier? +- Are you proposing an abstraction you can justify by something other than testability? +- Have you preferred a built-in capability over a custom generator? + +## 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/ef-core-integration.md b/.agents/skills/purview-persistence/references/ef-core-integration.md new file mode 100644 index 0000000..77c7573 --- /dev/null +++ b/.agents/skills/purview-persistence/references/ef-core-integration.md @@ -0,0 +1,83 @@ +# EF Core integration + +Read this when the project actually uses EF Core. The identifier decisions are in `guid-generation.md` +and `well-known-identifiers.md`; this page is the rest of the integration. + +## The rule + +1. **`DbContext` is the boundary.** Read and write through it directly, or through a narrow query type + when a query deserves a name. Do not add `IRepository` or a unit-of-work wrapper for testability. + `DbContext` already is both, and a real container is a more faithful test than a substitute. +2. **The provider is chosen where the context is registered.** Carry the choice as context-options data; + never branch on `Database.ProviderName` while the model is being built. The model is built once and + cached, so that branch serves one provider's model to the other. +3. **Map a value object once.** Configure the converter and comparer in the model, not on each property. + The `Purview.ValueObjects` generator emits the converters when EF Core is referenced. A value-object + key also needs a converter-aware value generator, or the property receives a raw `Guid`. +4. **One owner per generated value.** The application supplies it, the store generates it, or it is + derived — never two. A derived identifier is `ValueGeneratedNever()`; a store default or computed + column is left to the store. +5. **Design time and runtime agree.** The design-time factory or host registration makes the same + provider, strategy and `ReplaceService` choices as the application. Otherwise the migration snapshot + describes a model the application does not run. +6. **The model cache key covers every input that varies the model.** Provider, strategy, tenant and + design time. EF's service-provider cache and model cache are separate; both need a key that matches + the decision. +7. **Migrations are code.** Persistence owns the definitions; a deliberate step applies them. The API + does not migrate at startup in production. +8. **Integration tests use a real store.** The in-memory provider does not enforce foreign keys, unique + indexes, computed columns or SQL semantics. Use `Purview.Containers` and a `.IntegrationTests` + project. +9. **The connection string is configuration.** Keep it out of source, out of `appsettings.json` in the + repository, and out of telemetry. A redaction test is worth more than a convention. + +## The composition + +The provider, the strategy and the service replacements belong together, where the context is +registered: + +```csharp +services.AddDbContext>((serviceProvider, options) => +{ + ConfigureStore(options, connectionString); + + options + .UseSequentialGuids() + .ReplaceService() + .ReplaceService>(); +}); + +services.AddSingleton>(); +``` + +- `ConfigureStore` is the only place that names `UseSqlServer` or `UseNpgsql`. +- `UseSequentialGuids` adds an options extension; the model reads it instead of asking about the provider. +- `IModelCacheKeyFactory` is replaced because the service-provider hash alone does not key the model + cache. +- The application-facing factory is registered separately, because EF's internal registration is not + reachable from an outbox writer or a correlation-id generator. + +## Migrations + +- Generation and application are separate: `dotnet ef migrations add` in development, a controlled + `Migrator` step in a deployment. +- A migration and `HasData` are compiled and replayed, so they need a literal GUID. A derived identifier + cannot be computed there; print the constant from the derivation and guard it with a test. +- Expect the schema to be ahead of or behind the code during a rollout, and design the change to be + compatible in both directions. +- Never let the API apply migrations at startup in production: a deploy then mutates the schema without + review and without a clean rollback. + +## Checks to write + +| Check | Shape | +| -- | -- | +| Model covers every GUID key | Each non-foreign-key GUID key is generated on add with the expected factory | +| Foreign keys are not generated | A foreign-key GUID keeps the value copied from its principal | +| Explicit configuration wins | A `ValueGeneratedNever()` key keeps its supplied value through `SaveChanges` | +| Two strategies, one context type | Different strategies produce different service providers and models | +| Design time matches runtime | Both models produce the same migration snapshot | +| Real store semantics | A unique index, a foreign key and a computed column behave as the model says in a container | + +The Engineering Playbook's *EF Core Integration* page states the same rules with the sample as evidence; +the runnable implementation is `samples/Acme.Persistence` in that repository, run with `just samples`. diff --git a/.agents/skills/purview-persistence/references/guid-generation.md b/.agents/skills/purview-persistence/references/guid-generation.md new file mode 100644 index 0000000..fb644bb --- /dev/null +++ b/.agents/skills/purview-persistence/references/guid-generation.md @@ -0,0 +1,103 @@ +# 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()` as a store default, or EF Core's client-side `SequentialGuidValueGenerator` | +| SQL Server | Yes | Yes | EF Core `SequentialGuidValueGenerator`, or a store-ordered `FastGuid` strategy when both providers share a model | +| SQL Server | Either | No | `Guid.NewGuid()` | +| PostgreSQL | Yes | Yes | `Guid.CreateVersion7()` with `ValueGeneratedNever()` | +| PostgreSQL | No | Yes | `uuidv7()` on 18+, otherwise generate v7 in the application | +| PostgreSQL | Either | No | `Guid.NewGuid()` or `gen_random_uuid()` | +| SQL Server **and** PostgreSQL, one context type | Either | Yes | A store-specific strategy carried as context-options data. One strategy per provider; see below. | +| Any | Either | Either, but must be unpredictable | Random v4 | + +The two-provider row is the one that needs code: an `ISequentialGuidProvider` witness, an options +extension and a model-finalising convention. Copy them from the Engineering Playbook's *GUID Key +Strategy* page. Never expect one byte-ordered generator to order for both stores. + +A value whose identity is known before storage does not belong in this table. Derive it with UUIDv5 and +key it `ValueGeneratedNever()`; see `well-known-identifiers.md`. + +## Prefer built-in capability + +In order of preference: + +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 — `FastGuid` is the one the sample uses for store-ordered values. +4. A bespoke generator — only against a measured requirement the first three cannot meet. + +A hand-written sequential GUID generator is easy to get subtly wrong — endianness, clock regression, +counter rollover within a millisecond, and thread safety are all live hazards, and the failure mode +is duplicate or badly-ordered keys found in production. + +## No NuGet package provides this + +No NuGet package provides the strategy/convention pattern. It is repository-local: copy the strategy +witness, options extension, value generator and model-finalising convention from the Engineering +Playbook's *GUID Key Strategy* page. Do not depend on a package that claims to own it, and do not +create one without a measured requirement the built-in options cannot meet. + +The abstraction is earned by a second provider, or by an identifier needed before `SaveChanges` on +both — not by anticipation. Until then a built-in capability is the answer. diff --git a/.agents/skills/purview-persistence/references/postgresql.md b/.agents/skills/purview-persistence/references/postgresql.md new file mode 100644 index 0000000..603987d --- /dev/null +++ b/.agents/skills/purview-persistence/references/postgresql.md @@ -0,0 +1,142 @@ +# 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. + +### Application-generated, store-ordered strategy + +When one context type serves both PostgreSQL and SQL Server, or the identifier must exist before +`SaveChanges` on both, use a store-specific strategy carried as context-options data. The PostgreSQL +implementation returns `FastGuid.NewPostgreSqlGuid()`; a model-finalising convention applies it to +every GUID key and leaves explicitly keyed derived identifiers alone. + +```csharp +public sealed class PostgreSqlSequentialGuidProvider : ISequentialGuidProvider +{ + public static string Name => "PostgreSql"; + + public static Guid NewGuid() => FastGuid.NewPostgreSqlGuid(); + + public static Guid NewGuid(DateTime timestampUtc) => + FastGuid.NewPostgreSqlGuid(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static DateTime GetTimestamp(Guid value) => FastGuid.PostgreSql.GetTimestamp(value); + + public static Guid MinValueFor(DateTime timestampUtc) => + FastGuid.PostgreSql.MinGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static Guid MaxValueFor(DateTime timestampUtc) => + FastGuid.PostgreSql.MaxGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); +} +``` + +- PostgreSQL compares `uuid` in plain big-endian byte order, so both `FastGuid.NewPostgreSqlGuid()` + and `Guid.CreateVersion7()` are genuinely sequential here. `FastGuid` is also correct for SQL Server, + which is the point of the strategy abstraction. +- Set `ValueGeneratedNever()` through the convention; never let EF fetch a store-generated value that + does not exist. + +#### RFC caveat + +`FastGuid.NewPostgreSqlGuid()` values are **not** RFC 9562 UUIDs. They are ordinary GUIDs with a +PostgreSQL-ordered layout, so PostgreSQL's `uuid_extract_timestamp()` will not read them even though +they are valid `uuid` values. Use the provider's `GetTimestamp` (above) instead. If another system +needs a standard timestamp, generate `Guid.CreateVersion7()` there and accept that the layout differs. + +The full sample is the Engineering Playbook's *GUID Key Strategy* page. The abstraction is earned by +the second provider, not by anticipation. + +### Database-generated UUIDv7 — PostgreSQL 18+ + +PostgreSQL 18 added a native `uuidv7()` function. Earlier versions do not have it; on 13–17 you have +`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()` | +| One model serving PostgreSQL and SQL Server | A store-specific `FastGuid` strategy carried as context-options data — never one byte-ordered generator for both | +| Key must be unpredictable | `Guid.NewGuid()` or `gen_random_uuid()` — v7 leaks creation time | +| Low or moderate volume | v4 — the locality difference will not be your bottleneck | + +## 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..2262972 --- /dev/null +++ b/.agents/skills/purview-persistence/references/sqlserver.md @@ -0,0 +1,144 @@ +# 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 does **not** map `ValueGeneratedOnAdd` to `NEWSEQUENTIALID()` by default. For a `Guid` key on +SQL Server the provider generates the value client-side with `SequentialGuidValueGenerator` (below). +Configure a store default explicitly when the database must own the value: + +```csharp +builder.Property(o => o.Id).HasDefaultValueSql("NEWSEQUENTIALID()"); +``` + +Trade-offs: + +- 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. + +### Application-generated, store-ordered strategy + +When one context type serves both SQL Server and PostgreSQL, or the identifier must exist before +`SaveChanges` on both, a single built-in generator is not enough: the two stores order GUIDs +differently. The answer is a store-specific strategy carried as context-options data — a +`static abstract` witness whose SQL Server implementation returns `FastGuid.NewSqlServerGuid()`, plus a +value generator and a model-finalising convention that applies it to every GUID key. + +```csharp +public sealed class SqlServerSequentialGuidProvider : ISequentialGuidProvider +{ + public static string Name => "SqlServer"; + + public static Guid NewGuid() => FastGuid.NewSqlServerGuid(); + + public static Guid NewGuid(DateTime timestampUtc) => + FastGuid.NewSqlServerGuid(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static DateTime GetTimestamp(Guid value) => FastGuid.SqlServer.GetTimestamp(value); + + public static Guid MinValueFor(DateTime timestampUtc) => + FastGuid.SqlServer.MinGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static Guid MaxValueFor(DateTime timestampUtc) => + FastGuid.SqlServer.MaxGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); +} +``` + +- The increasing part must sit where SQL Server sorts it. The trailing six bytes dominate + `uniqueidentifier`; a byte-ordered UUIDv7 does not. +- The generator must hand the value through the property's value converter, or a `readonly record + struct OrderId` key receives a raw `Guid`. +- Apply it at convention precedence, so explicit `ValueGeneratedNever()`, a store default and a + computed column all win. + +The full sample is the Engineering Playbook's *GUID Key Strategy* page. The abstraction is earned by +the second provider; on one SQL Server provider `NEWSEQUENTIALID()` or EF Core's generator is enough. + +### `Guid.NewGuid()` — random + +Correct, portable and unpredictable; poor index locality. + +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`, or a store-ordered `FastGuid` strategy when both providers share a model | +| Key must be unpredictable (public surface, security boundary) | `Guid.NewGuid()`, with a non-clustered PK | +| Low or moderate volume | `Guid.NewGuid()` — do not optimise what does not hurt | +| You want a sortable timestamp in the key and accept the locality cost | `Guid.CreateVersion7()` | + +## 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-persistence/references/well-known-identifiers.md b/.agents/skills/purview-persistence/references/well-known-identifiers.md new file mode 100644 index 0000000..372d585 --- /dev/null +++ b/.agents/skills/purview-persistence/references/well-known-identifiers.md @@ -0,0 +1,93 @@ +# Deriving well-known identifiers with UUIDv5 + +Read this only when an identifier's **value must be known before storage**: a system tenant, a seeded +reference row, an installation's service principal, or an ingested object whose provider key is stable +and never recycled. A project with random keys and no seeding has no use for UUIDv5 — say nothing and +move on. An outbox row that merely needs an id before `SaveChanges` takes a generated strategy instead. + +## The rule + +1. Derive the value: `Uuid5.Create(namespace, name)` is RFC 4122 version 5 (SHA-1), byte-identical for + the same namespace and name in every process, language and restart. +2. Scope the namespace to the isolation boundary — one installation or one deployment. Never a global + constant shared by every deployment. +3. Fix the name as a canonical constant of the identity kind. Not a display name, not user data, not a + secret. +4. Once persisted, the value is authoritative. Never recompute it and update the row. +5. Key it explicitly with `ValueGeneratedNever()`, so a sequential-GUID convention never generates over + a value the application already knows. + +## The catalogue + +Write the known identities down in one place. + +| Identity | Namespace | Name | Derived when | Immutable | +| -- | -- | -- | -- | -- | +| System tenant of an installation | installation id | `system-tenant` | first bootstrap | yes | +| System principal of an installation | installation id | `system-service-principal` | first bootstrap | yes | +| Seeded reference row | installation id | `:` | seed step | yes | +| Ingested external object (optional) | installation id | `:` | first ingest | yes | + +Derive the ingested row only when the provider guarantees the external key is stable and never +recycled. If it can be recycled, keep the local key sequential and make the external key a unique +column. + +## Naming rules + +- Lowercase ASCII, hyphen-separated, no whitespace, no locale-dependent casing. +- Never from a display name, email, URL, title, or anything a user can edit. +- Never from a secret, and never as a capability token. A name is guessable. +- Renaming a name creates a new identity. Renaming a persisted one is a data migration. + +## Bootstrap sequence + +Read the persisted identity; if present it wins, and a configured value must match or the operation +fails. If absent, insert it. Derive dependants from the persisted id. Save once. A second run changes +nothing. + +The guard matters as much as the insert: + +```csharp +if (persisted is not null && persisted.Id != configured) +{ + throw new InvalidOperationException( + $"Configured installation id '{configured.Value}' does not match the persisted id '{persisted.Id.Value}'."); +} +``` + +## Migrations cannot call the helper + +`HasData` and migration SQL need a literal GUID. A small script prints ready-to-paste constants from +the same derivation, and a test asserts every printed constant equals `Uuid5.Create(...)`. Changing the +namespace or a name after a migration has shipped is a data migration, not an edit. + +## Checks to write + +| Check | Shape | +| -- | -- | +| RFC 4122 conformance | Fixed vectors for DNS/URL/OID/X500 and the empty name; exact GUIDs | +| Determinism | Same pair twice equal; different names differ | +| Version and variant bits | Byte 6 high nibble `0x5`; byte 8 high bits `10` | +| Cross-language equality | Python `uuid.uuid5` or Node `crypto` reproduces the vectors | +| Catalogue uniqueness | The catalogue's constants are distinct | +| Bootstrap idempotency | Seeding twice changes zero rows; a mismatched configured id throws | +| Explicit keying | The key is `ValueGenerated.Never`, and the sequential convention leaves it alone | + +```text +uuid5(6ba7b810-9dad-11d1-80b4-00c04fd430c8, "example.com") = cfbff0d1-9375-5685-968c-48ce8b15ae17 +uuid5(6ba7b811-9dad-11d1-80b4-00c04fd430c8, "www.example.com") = b63cdfa4-3df9-568e-97ae-006c5b8fd652 +uuid5(6ba7b812-9dad-11d1-80b4-00c04fd430c8, "1.2.3.4") = 67448b45-6d15-536b-bef7-8a78c3d10ac6 +uuid5(6ba7b814-9dad-11d1-80b4-00c04fd430c8, "CN=Bob") = 0840e90d-9ca0-5f14-970e-26294d44cb1d +``` + +The hash path is big-endian. `Guid.ToByteArray()` and `new Guid(byte[])` use a mixed-endian layout and +produce a different hash; that is the most common way a "UUIDv5" implementation is wrong. + +**UUIDv5 is not sequential.** Never use it for a high-write table's primary key. It is for a small, +known set of identities; everything else takes `guid-generation.md`. + +RFC 4122 is the version-5 specification; RFC 9562 is the current UUID specification and retains the +algorithm. The .NET BCL has no UUIDv5 API, so the helper is a small hand-written function. + +The Engineering Playbook's *Well-Known Identifiers* page carries the full helper, catalogue and +bootstrap samples, and their test shapes. diff --git a/.agents/skills/purview-sourcegenerator-attribute-data-models/SKILL.md b/.agents/skills/purview-sourcegenerator-attribute-data-models/SKILL.md new file mode 100644 index 0000000..714ddbf --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-attribute-data-models/SKILL.md @@ -0,0 +1,146 @@ +--- +name: purview-sourcegenerator-attribute-data-models +description: >- + Use when declaring or fixing a [Generate] attribute-data model in Purview.SourceGeneratorFramework - + mapping constructor and named arguments with [Argument]/[Property], defaults, reusing a shape with + [NestedModel], reading an ADM0001-ADM0015 diagnostic, or testing that a model reads the values an + attribute application supplies. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Attribute-data models + +`AttributeDataModelGenerator` turns a `readonly partial record struct` marked `[Generate(typeof(TAttribute))]` +into a parser for `TAttribute`'s `AttributeData`: `Exists`, `Empty`, `TargetAttribute`, the guarded +`FromAttributeData`/`TryFromAttributeData`/`AllAttributeData` overloads, and `FromAttributeDataUnchecked`. +The generated model is a value type, so it is safe to hold in an incremental pipeline (an `AttributeData` +is not). + +The one thing to keep in mind: **the generated code looks names up case-sensitively at run time, and a +name it cannot find quietly yields the default value.** A mapping mistake compiles and produces `null`/`0`. +The naming analyzer (`ADM0011`–`ADM0015`) exists to report those mistakes where they are declared; do not +suppress it without understanding the finding, and test the model against real applications. + +## Mapping each property + +Each primary-constructor parameter becomes a property, PascalCased (`message_template` → `MessageTemplate`). +Its sources are read in this order, first hit wins: + +| Declaration | Reads | +| --- | --- | +| *(no mapping attribute)* | the named argument with the property's name: `[Log(EventId = 7)]` | +| `[Property]` / `[Property(Name = "X")]` | the named argument `X` (default: the property name) | +| `[Argument("messageTemplate")]` | the constructor parameter **named** `messageTemplate` | +| `[Argument]` | the constructor parameter named after the property, camel-cased: `MessageTemplate` → `messageTemplate` | +| `[Argument(1)]` | the constructor argument at **position** 1 | +| `[Argument(...)] [Property]` | the named argument first, then the constructor argument | +| `[NestedModel]` | another generated model, read from the same attribute (below) | +| `[GenericTypeArgument]` | a generic type argument of the attribute class, as a `TypeIdentity` | +| `[Exclude]` | nothing; the parameter is left out of the model | + +Methodology: + +- **Map a constructor parameter by name, not by index.** A name survives a new overload or a reordered + parameter list; an index silently starts reading a different parameter. +- **Map both sources when the attribute accepts both.** `[Argument("name")] [Property] string? Name` reads + `[A(name: "x")]`, `[A("x")]` and `[A(Name = "x")]`. The named argument is read first because it is assigned + after the constructor runs, so it is the attribute instance's effective value. +- **Only map `[Property]` to a public settable property or field.** A property with a private or `internal` + setter can never be a named argument; read it through the constructor parameter that sets it (`ADM0012`). +- **Match case exactly.** `[Argument("MessageTemplate")]` never matches a parameter called + `messageTemplate` (`ADM0011`). +- **Give every property that might be absent a `DefaultValue`**: `[Property(DefaultValue = -1)] int EventId`. + A missing argument takes it; without one, it takes `default(T)`. `Empty` always uses `default(T)`. +- **Use `IsEnum = true` with a `string` property** when the enum type is not available to the generator; a + bare member-name default (`DefaultValue = "Inherit"`) is expanded to the fully-qualified member. +- **Never name a property `Exists`, `Empty`, `TargetAttribute`, `AllAttributeData`, `FromAttributeData`, + `TryFromAttributeData` or `FromAttributeDataUnchecked`** — they are generated (`ADM0014`, generation is + skipped). Keyword names (`Event`, `Class`) are fine. + +## Reusing a shape with `[NestedModel]` + +A `[NestedModel]` parameter is populated by the nested model's `FromAttributeDataUnchecked`, from the +**outer** attribute's arguments, with no `TargetAttribute` check. The outer attribute does not have to derive +from the nested model's target; it only has to use the same constructor parameter and property names. + +```csharp +[Generate(typeof(LogAttribute))] +readonly partial record struct LogAttributeData( + [Argument("level")] [Property(DefaultValue = 99)] int Level, + [Argument("messageTemplate")] [Property] string? MessageTemplate, + [Argument("eventId")] [Property(DefaultValue = -1)] int EventId, + [Property] string? Name +); + +// InfoAttribute has messageTemplate, eventId and Name, but no level. +[Generate(typeof(InfoAttribute))] +readonly partial record struct InfoAttributeData([NestedModel] LogAttributeData Log); +``` + +- The nested model's `Exists` is always `true`; a mapping the outer attribute does not declare (`level` + here) takes its `DefaultValue`. `ADM0013` (info) lists such mappings, so an intended absence is visible + and an accidental one is caught. +- Map nested properties **by name**. A constructor index means a different parameter on each attribute + (`ADM0015`). +- `MatchByInheritance = true` is for using a model on its own against derived attributes. A nested model + does not need it. +- Call `FromAttributeData`/`TryFromAttributeData` from generator code. `FromAttributeDataUnchecked` skips the + type check and is for nested models. + +## Diagnostics + +| Rule | Severity | Meaning and fix | +| --- | --- | --- | +| `ADM0001` | Error | The `[Generate]` target cannot be resolved. Use `typeof`, a resolvable full name, or a type-library `…FullName` constant. | +| `ADM0002` | Error | Unsupported property type (array, pointer, function pointer). | +| `ADM0003` | Error | `[Argument("name")]`/`[Argument(index)]` names no constructor parameter of the target. | +| `ADM0004` | Error | A `[NestedModel]` type is not itself a `[Generate]` model. | +| `ADM0005` | Error | A `DefaultValue` cannot be emitted for the property type. | +| `ADM0006` | Error | A non-nullable reference property has no default. Add a `DefaultValue` or make it nullable. | +| `ADM0007` | Error | `AutoDiscover` needs the `typeof` target overload. | +| `ADM0008` | Error | A `[GenericTypeArgument]` property must be `TypeIdentity`. | +| `ADM0009` | Error | An `IsEnum` property must be `string`/`string?`. | +| `ADM0010` | Error | An `ISymbol`/`Type` property is not cacheable; use `TypeIdentity` or a string. | +| `ADM0011` | Warning | A mapped name matches an attribute member only when case is ignored, so it is never read. Fix the case. | +| `ADM0012` | Warning | The mapped named argument is not a public settable member of the attribute, or a bare `[Argument]` implies a constructor parameter it lacks. Map the member that actually carries the value. | +| `ADM0013` | Info | A nested model property reads only names the outer attribute lacks, so it always takes its default. Expected for a deliberately absent value; otherwise rename to match. | +| `ADM0014` | Error | A property has a generated member's name. Rename the parameter. | +| `ADM0015` | Warning | A nested model reads a constructor index that names a different parameter on the outer attribute. Map it by name. | + +## Testing a model + +Test the attribute side and the model side separately, against what Roslyn actually produces. Use +`AttributeDataFactory` from `Purview.SourceGeneratorFramework.Testing` to compile an application, and the +`AttributeDataAssertions` from the TUnit package to pin each argument by ordinal, parameter name or named +argument, including how it was supplied (`AttributeArgumentForm`): + +```csharp +var options = new MyTestOptions { AdditionalNamespaces = ["My.Attributes"] }; +var attribute = AttributeDataFactory.FromUsage("[Info(\"custom {Value}\", Name = \"N\")]", options); + +await Assert.That(attribute).HasConstructorArgument("messageTemplate", "custom {Value}"); +await Assert.That(attribute).HasConstructorArgument("eventId", -1, AttributeArgumentForm.Defaulted); +await Assert.That(attribute).HasNamedArgument("Name", "N"); + +var model = InfoAttributeData.FromAttributeData(attribute); +await Assert.That(model.Log.MessageTemplate).IsEqualTo("custom {Value}"); +await Assert.That(model.Log.Level).IsEqualTo(99); +``` + +Cover, for each model: positional, by-name and named-argument applications; an application omitting every +optional argument (proves the `DefaultValue`s); for a nested model, an outer attribute that lacks a mapped +member; and the nested target's own `FromAttributeData` returning `Empty` for the outer attribute. +`FromUsage` places the application on a declaration its `AttributeUsage` allows and throws on a compile +error, so a misspelt argument name fails the test. `FromSource` reads applications from a full source. + +Generated-code assertions (`Contains("FromAttributeDataUnchecked(attributeData)")`) prove *what* the +generator emits; the tests above prove the model *reads the right values*. Prefer the latter. + +## Related skills + +`purview-sourcegenerator-testing` for the runner layer, `AttributeDataFactory` and `AttributeArguments`; +`purview-sourcegenerator-tunit-tests` for the assertion extensions; `purview-sourcegenerator-pipeline` for +holding models in an incremental pipeline; `purview-sourcegenerator-diagnostics` for the other analyzer +families. diff --git a/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md b/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md 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..b80ebe7 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md @@ -0,0 +1,156 @@ +--- +name: purview-sourcegenerator-diagnostics +description: >- + Use when a Purview.SourceGeneratorFramework build reports a PSGFR, PSGF, PRSGD, TLB or ADM diagnostic, + when choosing which code fix applies, or when a source generator, diagnostic analyzer or code fix + provider is silently not running at all. +metadata: + 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 analyzers. They are documented on their +own wiki pages, `Type-Library.md` and `Attribute-Data-Models.md`. + +The attribute-data-model rules come from three analyzers: + +| Rules | Analyzer | Covers | +| --- | --- | --- | +| `ADM0001`–`ADM0009` | `AttributeDataModelValidationAnalyzer` | the target, property types, defaults, explicit constructor members | +| `ADM0010` | `AttributeDataModelSymbolPropertyAnalyzer` | non-cacheable `ISymbol`/`Type` properties | +| `ADM0011`–`ADM0015` | `AttributeDataModelNamingAnalyzer` | mapped names the generated code would never find, generated-member name clashes, `[NestedModel]` mappings the outer attribute does not line up with | + +Treat the naming rules as real defects: the generated code reads names case-sensitively and silently falls +back to a default, so a naming mistake never fails the build on its own. `ADM0013` is `Info` because a +nested model may deliberately read a value the outer attribute does not have; check each one. The +`purview-sourcegenerator-attribute-data-models` skill has the full table and the fix for each rule. + +## Code fixes + +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, `purview-sourcegenerator-testing` for proving a pipeline actually caches, and +`purview-sourcegenerator-attribute-data-models` for the `ADM` rules. diff --git a/.agents/skills/purview-sourcegenerator-packaging/SKILL.md b/.agents/skills/purview-sourcegenerator-packaging/SKILL.md new file mode 100644 index 0000000..a0e7f80 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-packaging/SKILL.md @@ -0,0 +1,249 @@ +--- +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 copied beside the generator's `bin` (a plain copy, not copy-to-output + content). A dependent component that references the generator as an assembly gets it copied + beside its own `bin` by ordinary assembly-reference resolution, which is what lets Visual Studio + load the dependent code-fix provider without a `FileNotFoundException` for the framework assembly. +- A project that references a component only as an analyzer (`ReferenceOutputAssembly="false"`) + never receives the framework assembly, and neither does any application downstream of it. Since + 1.2.2 it no longer leaks into app build output, `deps.json`, publish output, single-file bundles or + Blazor WebAssembly `_framework`, where it broke Native AOT and WebAssembly AOT publishing. +- Visual Studio's project system resolves an `OutputItemType=Analyzer` project reference to the + referenced project's **unmerged** `bin` assembly and adds it to the compiler's analyzers — the + command-line build does not. `Purview.BuildSdk` removes that item before adding the resolved + 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..a6da554 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-testing/SKILL.md @@ -0,0 +1,444 @@ +--- +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, testing attribute-data + models against real AttributeData with AttributeDataFactory, and asserting incremental pipeline caching + stage by stage. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# 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. + +## Testing attribute applications and attribute-data models + +A generator that reads attributes has two halves to test: what Roslyn gives it for an application, and what +its parsing (usually a generated attribute-data model) makes of that. `AttributeDataFactory` produces the +real `AttributeData` without running a generator; `AttributeArguments` describes it. + +```csharp +var options = new MyTestOptions { AdditionalNamespaces = ["My.Attributes"] }; + +// One application. Placed on a class, or on the first declaration its AttributeUsage allows. +AttributeData attribute = AttributeDataFactory.FromUsage("[Log(\"text\", eventId: 7, Name = \"N\")]", options); + +// Every application on a type, or on one of its members. +var onMember = AttributeDataFactory.FromSource(source, "Sample.IEvents", "Raised", options); + +var arguments = AttributeArguments.From(attribute); +arguments.TryGetConstructorArgument(0, out var byOrdinal); // #0 message: "text" +arguments.TryGetConstructorArgument("eventId", out var byName); // Form = NamedParameter +arguments.TryGetNamedArgument("Name", out var named); // Form = NamedArgument +arguments.ToString(); // [LogAttribute(#0 message: "text", #1 eventId: 7 (by name), Name = "N")] + +var model = LogAttributeData.FromAttributeData(attribute); +``` + +- References and usings come from the options: `AdditionalAssemblyTypes` for the assembly declaring the + attribute, `AdditionalNamespaces` for its namespace, or declare the attribute in `AdditionalSources`. +- A compilation error throws `InvalidOperationException` listing the errors, so a misspelt parameter name + fails the test rather than producing an application with a missing argument. +- `AttributeArgumentForm` tells `Positional`, `NamedParameter` (`message: "x"`), `Defaulted` (an optional + parameter that was not written) and `NamedArgument` (`Name = "x"`) apart, which `AttributeData` alone + cannot. +- `AttributeArgument.ValueEquals` compares numbers across types, enums by value or member name, `typeof` + by `Type`/`TypeIdentity`/name, and arrays element-wise. Names are case-sensitive, as generated models are. +- The TUnit assertions over these (`HasConstructorArgument`, `HasNamedArgument`, …) are in + `purview-sourcegenerator-tunit-tests`; what to cover for a model is in + `purview-sourcegenerator-attribute-data-models`. + +## Best practices + +- **Test attribute parsing against real `AttributeData`** (`AttributeDataFactory`), not only by asserting + on emitted parsing code: emitted code can look right and still read the wrong name. +- **Deterministic output**: `WriteAutoGeneratedHeader` is timestamp-free; assert with + `ContainsGeneratedCode`/`GeneratesCode` (whitespace-flattened) or `CodeQuery`, never with timestamps. +- **Generator references**: to use a generated type in the test project AND pass the generator type to a + runner, reference the generator project twice — once `OutputItemType="Analyzer"` and once as a normal + reference. +- **Multi-target**: build generators against the Roslyn version that supports the test matrix; the + framework is built against Roslyn 5.0 (net8.0/net9.0 assets keep a .NET 8–10 matrix loading); keep + `System.Collections.Immutable` version pinned to the shared one. +- **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..2a3a061 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md @@ -0,0 +1,246 @@ +--- +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 (including + attribute-argument assertions for testing attribute-data models), 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. +- **`AttributeDataAssertions`** — on an `AttributeData` (typically from `AttributeDataFactory`): + `HasConstructorArgument(ordinal, expected)`, `HasConstructorArgument(parameterName, expected[, form])`, + `DoesNotHaveConstructorArgument(parameterName)` (defaulted or absent), `HasNamedArgument(name, expected)`, + `DoesNotHaveNamedArgument(name)`. Each returns the matched `AttributeArgument`, and a failure lists every + argument of the application with how it was supplied. Pin the attribute side with these before asserting + on the model, so a failure says which half is wrong: + ```csharp + var attribute = AttributeDataFactory.FromUsage("[Info(\"custom {Value}\")]", options); + + await Assert.That(attribute).HasConstructorArgument("messageTemplate", "custom {Value}"); + await Assert.That(attribute).HasConstructorArgument("eventId", -1, AttributeArgumentForm.Defaulted); + await Assert.That(attribute).DoesNotHaveNamedArgument("Name"); + + await Assert.That(InfoAttributeData.FromAttributeData(attribute).Log.MessageTemplate).IsEqualTo("custom {Value}"); + ``` +- **`TypeIdentityAssertions`** — `HasSymbol(TypeIdentity)` / `HasSymbol("Namespace.Type")`. +- **`GeneratedCodeAssertionsExtensions`** — `GeneratesCode(expected)`, `ContainsGeneratedCode(expected)` + (whitespace-flattened string comparison). + +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..d2f42d7 --- /dev/null +++ b/.agents/skills/purview-telemetry/SKILL.md @@ -0,0 +1,124 @@ +--- +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" + repository: purview-dev/engineering-playbook +--- + +# 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/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/.config/dotnet-tools.json b/.config/dotnet-tools.json index 6a86ac3..bc9bc27 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -1,27 +1,27 @@ { - "version": 1, - "isRoot": true, - "tools": { - "csharpier": { - "version": "1.3.0", - "commands": [ - "csharpier" - ], - "rollForward": false - }, - "dotnet-inspect": { - "version": "0.27.0", - "commands": [ - "dotnet-inspect" - ], - "rollForward": false - }, - "purview.build": { - "version": "0.9.0", - "commands": [ - "purview-build" - ], - "rollForward": false - } - } + "version": 1, + "isRoot": true, + "tools": { + "csharpier": { + "version": "1.4.0", + "commands": [ + "csharpier" + ], + "rollForward": false + }, + "dotnet-inspect": { + "version": "0.27.0", + "commands": [ + "dotnet-inspect" + ], + "rollForward": false + }, + "purview.build": { + "version": "0.9.0", + "commands": [ + "purview-build" + ], + "rollForward": false + } + } } \ No newline at end of file diff --git a/.config/purview-build.json b/.config/purview-build.json index b008b1f..55fd8ab 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -4,7 +4,7 @@ "TestRoot": "src/tests", "TestPatterns": "*Tests.csproj", "TestProjects": "*Tests*", - "TestFilter": "/*/*/*/*[Category=Unit]" + "TestFilter": "/*/*/*/*" }, "PackValidation": { // Explicit because Purview.Build defaults RequiredCompilerFlags to empty. These packages are diff --git a/.csharpierignore b/.csharpierignore deleted file mode 100644 index 5b31612..0000000 --- a/.csharpierignore +++ /dev/null @@ -1,2 +0,0 @@ -# CSharpier ignores bin/ and obj/ by default. Nothing else in this repository is excluded: the -# [GenerateUnion] declarations are ordinary C#, unlike a 'union' declaration which CSharpier cannot parse. diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 6ec1df1..b15796b 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -16,19 +16,33 @@ jobs: name: Build and test uses: purview-dev/build/.github/workflows/purview-build.yml@main with: - # This repository targets net11.0 and pins the .NET 11 SDK in global.json. Without the matching - # SDK the runner cannot resolve global.json ("A compatible .NET SDK was not found"). - # Keep in sync with `sdk.version` in global.json. - dotnet-version: "11.0.100" - # Mirrors Build:TestFilter in purview-build.json: every test project is a *.UnitTests project - # whose tests the SDK categorises as Unit. - test-filter: "/*/*/*/*[Category=Unit]" # The runtime project is packable and carries the merged generator and code fix, so pack and # validate the produced package. run-pack: true validate-pack: true secrets: inherit + # Agent skills are committed to .agents/skills and recorded in skills-lock.json. Verify the installed set + # on every PR; installing or updating is a deliberate local step and never part of a build. + skills: + name: Verify agent skills + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + # Installs the SDK global.json pins. `actions/setup-dotnet`'s `global-json-file` input is not a + # substitute: when it cannot install the pin it falls back to the LTS channel, reports success and + # leaves the first dotnet command to fail with "A compatible .NET SDK was not found". + - uses: purview-dev/build/actions/dotnet-sdk@main + + - name: Verify the installed skills match the lockfile + shell: pwsh + run: | + ./scripts/restore-dotnet-tools.ps1 + dotnet restore src/src/Unions/Unions.csproj + dotnet msbuild src/src/Unions/Unions.csproj -t:CheckPurviewSkills + dotnet msbuild src/src/Unions/Unions.csproj -t:VerifyPurviewSkills + # Every other job builds against ProjectReferences; this one builds what ships. It packs Purview.Unions # to an isolated feed, then builds and runs a net10.0 and a net11.0 console against the package only, # leak-scans their output for compiler-host assemblies, and proves every ForbiddenContent rule rejects a @@ -39,10 +53,15 @@ jobs: steps: - uses: actions/checkout@v7 - # The SDK from global.json, plus the .NET 10 runtime the net10.0 consumer runs on. + # The SDK global.json pins (11.0.100), through the organisation's one SDK-setup action. See the + # skills job for why `actions/setup-dotnet`'s `global-json-file` input is not used. + - uses: purview-dev/build/actions/dotnet-sdk@main + + # The net10.0 consumer has to execute, so it needs the .NET 10 runtime as well as the pinned SDK. + # This is a second runtime for that consumer, not a second opinion on the SDK: global.json still + # selects 11.0.100 for every dotnet command. - uses: actions/setup-dotnet@v6 with: - global-json-file: global.json dotnet-version: 10.0.x - name: Proves the .NET 10 polyfill path and the .NET 11 BCL path diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 89dd624..66a3307 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,10 +29,7 @@ jobs: - uses: purview-dev/build/actions/purview-release@main with: release-mode: NuGet - # The shared action defaults to the 10.0.x SDK, but this repository targets net11.0 and pins - # the .NET 11 SDK in global.json; without it the release job cannot resolve global.json. - # Keep in sync with `sdk.version` in global.json (and with .github/workflows/pr.yml). - dotnet-version: "11.0.100" + # The action installs the SDK global.json pins; a dotnet-version input would be ignored. trusted-publishing: true # The nuget.org profile name, not the sign-in email. nuget-user: kieronlanning diff --git a/.gitignore b/.gitignore index 811d242..df356b4 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ project.lock.json project.fragment.lock.json artifacts/ BenchmarkDotNet.Artifacts/ +.bdn/ *_i.c *_p.c diff --git a/AGENTS.md b/AGENTS.md index b75ce95..b5901b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ The repository builds `Purview.Unions`: a dependency-free .NET package that gene | `src/tests/SourceGenerator.UnitTests/` | generation, the compiler experiments, diagnostics, code fixes, cache tests | | `src/tests/Repository.UnitTests/` | skills conformance, build configuration and agent instructions | | `docs/wiki/` | design, evidence and follow-ups | -| `skills/` | the three skills published to consumers | +| `skills/` | the four skills published to consumers | Do not add a second implementation of a union rule. Discovery, validation and the diagnostic catalogue live once in `src/src/SourceGenerator`; the analyzer and the generator both consume them. @@ -84,14 +84,17 @@ The compiler's contract is not ours to redefine. Every generated union must sati ## Diagnostics rules - Prefix `UNI`. Every reported rule is in `AnalyzerReleases.Unshipped.md` (RS2008). Suppression - descriptors are not release-tracked. + descriptors are not release-tracked. `UNI0000` and `UNI9001` are suppression descriptors, not rules: they + suppress `CA1034` and `CA1515` on a union's public nested `IUnionMembers` provider. - The blocking policy lives beside the descriptors (`DiagnosticLibrary.IsBlocking`); there is one policy for both hosts. - A rule is reported once. The analyzer reports per-target findings; the generator applies the same findings to `GeneratorResult.ShouldProcess` and reports only `UNI9000`. - Do not suppress warnings with `NoWarn`, `#pragma warning disable` or `SuppressMessage` in product code. - The only suppression kept repo-wide is `Purview.BuildSdk`'s `PDS0006` for `CA1815` on a union - declaration, plus the deliberate `RSEXPERIMENTAL006` at the `ITypeSymbol.IsUnion` call in a test. + The only suppressions kept repo-wide are `Purview.BuildSdk`'s `PDS0006` for `CA1815` on a union + declaration, this package's `UNI0000` `DiagnosticSuppressor` for `CA1034` and `UNI9001` for `CA1515` on a + union's `IUnionMembers` provider, and the deliberate `RSEXPERIMENTAL006` at the `ITypeSymbol.IsUnion` call + in a test. ## Packaging rules @@ -108,14 +111,16 @@ The compiler's contract is not ours to redefine. Every generated union must sati ## Skills rules - Authored skills live in `skills//SKILL.md` and carry the `purview-unions` prefix. -- Never install them into `.agents/skills/`; `Repository.UnitTests` enforces that. Never add `.claude/` or +- Installed upstream skills live in `.agents/skills/` only and are recorded in `skills-lock.json`; the + authored skills are never copied there. `Repository.UnitTests` enforces both. Never add `.claude/` or `CLAUDE.md`. -- `skills-lock.json` records the installed set; `just skills-update` restores it. +- `just skills-check` reports drift; `just skills-update` restores the recorded set. -The three published skills, authored for consumers of the package: +The four published skills, authored for consumers of the package: | Skill | Answers | | --- | --- | +| `purview-unions-quickstart` | install, compiler requirements, a first union, and the no-generator fallback | | `purview-unions-modelling` | which declaration surface to use, how to model cases, and why `Value` boxes while patterns do not | | `purview-unions-diagnostics` | what a `UNI0001`–`UNI0016`/`UNI9000` finding means and how to fix it | | `purview-unions-interop` | Results, Telemetry, ZodSharp, JSON, trimming and AOT, including the follow-ups | @@ -133,7 +138,7 @@ The three published skills, authored for consumers of the package: ## Performance rules - The benchmark project is `src/src/Benchmarks`; run it with `just perf-tests` (Release, net10.0) or - `just perf-tests -f net11.0`. Never benchmark a Debug build. + `just perf-tests-net11`. Never benchmark a Debug build. - Benchmarks are evidence for the README's allocation claims, not a CI gate: shared-runner timings are noise. Add or change a suite when a hot path or a claim changes, and record the results in `docs/wiki/Performance.md`. @@ -147,13 +152,16 @@ The three published skills, authored for consumers of the package: | `just restore` | restore the solution | | `just build` | build the solution | | `just test` | run every test project on both target frameworks | -| `just perf-tests` | run the BenchmarkDotNet suite (Release; `-f net11.0` for .NET 11) | +| `just perf-tests` | run the BenchmarkDotNet suite on .NET 10 (Release) | +| `just perf-tests-net11` | run the BenchmarkDotNet suite on .NET 11 (Release) | | `just lint-check` / `just lint-fix` | CSharpier check / format | | `just pipeline-pr` | restore, build, lint and test | | `just pipeline-pack-validate` | pack and validate package contents | | `just verify-pack-rules` | prove every forbidden-content rule rejects a package | | `just smoke-packed-consumers` | pack, then build and run consumers of the packages | +| `just test-telemetry-integration` | prove `Purview.Telemetry` 6.0.0 reads a generated union (needs the local prerelease feed) | | `just skills-update` | install/refresh the skills recorded in `skills-lock.json` | +| `just skills-check` | report drift between the installed skills and `skills-lock.json` | | `just scrub` | remove build output and restart from a clean tree | ## Guardrails diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b2b5bf..55e14ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,43 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- A `just test-telemetry-integration` gate that packs `Purview.Unions` and runs a `net10.0` and a `net11.0` + consumer against `Purview.Telemetry.SourceGenerator` 6.0.0, asserting the generated telemetry accessor uses + `TryGetValue` for every case. +- A consumer `purview-unions-quickstart` skill covering install, compiler requirements, a first union and + the no-generator fallback. +- An [Agent Skills](docs/wiki/Agent-Skills.md) wiki page that maps each published skill to the task it + answers. +- A `UNI0000` `DiagnosticSuppressor` that suppresses `CA1034` and a `UNI9001` suppressor that suppresses + `CA1515` on the public nested `IUnionMembers` provider the C# union contract requires. Both ship with the + package's analyzer, so a surface-C union needs no suppression attribute. +- A `UNI0005` code fix that makes the case type and every containing type public. + +### Fixed + +- The Example app's internal unions build again: `UNI0005` now allows a case type that is at least as + accessible as the union, so an internal union can use internal case types. + +### Changed + +- **`Purview.Telemetry` v6 supports `[GenerateUnion]`.** The telemetry generator reads the source-visible case + list and emits the accessor against the generated `HasValue`/`TryGetValue` pattern. `just + test-telemetry-integration` proves both generators in one compilation on `net10.0` and `net11.0`. +- **`Purview.Results` now supports `[GenerateUnion]` unions.** Its `[GenerateResult]` discovery recognises the + attribute by metadata identity and reads the source-visible case list, so a generated union no longer needs + hand-written `[Union]` or constructors for Results. The Interop documentation and cross-generator test surface + reflect the closed Results gap; the Telemetry surface-A gap remains. +- **`UNI0005` is relative to the union's accessibility.** A public union still needs public case types, but an + internal union can use internal case types; the diagnostic message and documentation now say “at least as + accessible as the union”. +- **.NET 11 GA / C# 15 GA.** The repository compiles with `LangVersion=latest` instead of `preview`. A + `net11.0` consumer needs no language-version switch; a `net10.0` consumer sets `latest` because its default + is C# 14. The packed-consumer gate now proves both. +- The README, `AGENTS.md` and wiki now name all four published skills, and the documentation links and + changelog guidance match the repository. + ## [0.1.0-prerelease.1] - 2026-11-10 ### Added diff --git a/Directory.Packages.props b/Directory.Packages.props index 8c31211..a39c9a4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -26,11 +26,11 @@ Purview.SourceGeneratorFramework.Testing.TUnit 2.0.0 depends on TUnit 1.81.16, so the central version is the framework's floor rather than the older version its siblings pinned. --> - 1.81.16 + 1.83.0 - - 0.15.8 + + 0.16.0-preview.2 - + diff --git a/Justfile b/Justfile index 2a8b013..a5ac971 100644 --- a/Justfile +++ b/Justfile @@ -93,12 +93,18 @@ test filter=default_test_filter *args: test-unit *args: just test "/*/*/*/*[Category=Unit]" {{ args }} -# Run the performance benchmarks (Release, net10.0 by default; pass `-f net11.0` to measure .NET 11) +# Run the performance benchmarks on .NET 10 [group('Build and Test')] perf-tests *args: - echo "Running performance benchmarks for {{ BLUE }}{{ benchmarks_project }}{{ NORMAL }}" + echo "Running performance benchmarks for {{ BLUE }}{{ benchmarks_project }}{{ NORMAL }} on net10.0" dotnet run --project {{ benchmarks_project }} -c Release -f net10.0 {{ args }} +# Run the performance benchmarks on .NET 11 +[group('Build and Test')] +perf-tests-net11 *args: + echo "Running performance benchmarks for {{ BLUE }}{{ benchmarks_project }}{{ NORMAL }} on net11.0" + dotnet run --project {{ benchmarks_project }} -c Release -f net11.0 {{ args }} + # Restore dependencies for the solution [group('Build and Test')] restore *args: @@ -120,6 +126,15 @@ smoke-packed-consumers *args: echo "Running the packed consumer gate..." pwsh -NoProfile -File scripts/test-packed-consumers.ps1 {{ args }} +# Prove Purview.Telemetry reads a Purview.Unions generated union as a telemetry parameter. Packs Purview.Unions, +# then builds and runs a net10.0 and a net11.0 consumer against it and Purview.Telemetry.SourceGenerator 6.0.0 +# (read from LOCAL_NUGET_FEED_PATH / TELEMETRY_PRERELEASE_FEED), asserting the generated telemetry accessor uses +# TryGetValue and leak-scanning the output. See scripts/test-telemetry-integration.ps1 for the options. +[group('Build and Test')] +test-telemetry-integration *args: + echo "Running the telemetry integration gate..." + pwsh -NoProfile -File scripts/test-telemetry-integration.ps1 {{ args }} + # Prove every PackValidation ForbiddenContent rule in .config/purview-build.json rejects a package. Reads artifacts/, # so pack first (`just pipeline-pack-validate`); `just pipeline-local-release` moves its packages to the local feed, so # pass -PackagesFolder after it. diff --git a/README.md b/README.md index e1ccea0..a9babfb 100644 --- a/README.md +++ b/README.md @@ -28,15 +28,16 @@ Console.WriteLine(value switch ## Package -| Package | Purpose | -| --- | --- | -| `Purview.Unions` | The `[GenerateUnion]` attribute, the source generator and code fixes, and the hand-written `Union` | +| Package | Purpose | Targets | +| --- | --- | --- | +| [`Purview.Unions`](src/src/Unions/Sdk/README.md) | The `[GenerateUnion]` attribute, the source generator and code fixes, and the hand-written `Union` | `net10.0`, `net11.0` | ```bash dotnet add package Purview.Unions ``` -The package targets `net10.0` and `net11.0` and carries the merged generator and its code fixes under +The package README is the same file that ships inside the `.nupkg` +(`src/src/Unions/Sdk/README.md`). The package carries the merged generator and its code fixes under `analyzers/dotnet/cs`, so one `PackageReference` is all a consumer needs. ## The four surfaces @@ -90,6 +91,11 @@ union member there instead of on the union. The generator emits the storage and provider owns its one-line `Create` bodies, because a source generator cannot add bodies to an interface you declared. +The provider must be **public**, so a strict analyzer set reports `CA1034` (nested types should not be +visible) and `CA1515` (public types should be internal). The package's `UNI0000` and `UNI9001` suppression +descriptors handle both for an `IUnionMembers` provider nested in a union; no attribute is needed. The nested +shape is mandated by the language, not a design choice. + ### D — hand-written `Union` ```csharp @@ -118,12 +124,12 @@ What the compiler uses for patterns is the **non-boxing access pattern** the gen | `value is null` (`HasValue`) | No | | `value.Value` | **Yes**, for a value-type case | -`tests/SourceGenerator.UnitTests/GeneratedUnionRuntimeTests` asserts exactly this with +`src/tests/SourceGenerator.UnitTests/GeneratedUnionRuntimeTests.cs` asserts exactly this with `GC.GetAllocatedBytesForCurrentThread()`. The committed BenchmarkDotNet suite in `src/src/Benchmarks` measures the same operations, the generic surfaces and the generator itself, with an `object`-backed baseline. Run `just perf-tests` (Release, `net10.0`) or -`just perf-tests -f net11.0`; the recorded results are on the [Performance](docs/wiki/Performance.md) page. +`just perf-tests-net11`; the recorded results are on the [Performance](docs/wiki/Performance.md) page. ## Declaration kinds @@ -135,16 +141,19 @@ takes `int?`, and `TryGetValue(out int)` returns false for null — a null value ## Requirements -- A **C# 15 compiler**: the .NET 11 SDK (or later) with `LangVersion=preview` in the project that declares a - union. -- Target `net10.0` or `net11.0`. On `net10.0` the generator emits internal `[Embedded]` polyfills for +- A **C# 15 compiler**: the .NET 11 SDK (or later). C# 15 is GA, so `LangVersion=preview` is not required. +- Target `net10.0` or `net11.0`. A `net11.0` project gets C# 15 by default, so it needs no language-version + switch. A `net10.0` project defaults to C# 14, so set `latest` in the project + that declares a union. On `net10.0` the generator also emits internal `[Embedded]` polyfills for `UnionAttribute`/`IUnion`; on `net11.0` the BCL types are used. - Do **not** set `EnablePreviewFeatures`. It emits `RequiresPreviewFeatures` assembly-wide and makes `CA2252` - fire on every member a consumer touches. `LangVersion=preview` is the only switch needed. + fire on every member a consumer touches. `LangVersion=latest` is the only switch needed, and only on + `net10.0`. The generated union is `CA1815`-clean: `Purview.BuildSdk`'s `PDS0006` suppresses the warning for a union -declaration, because a union is matched by its active case rather than compared by value. No `#pragma` is -needed. +declaration, because a union is matched by its active case rather than compared by value. Surface C is +`CA1034`- and `CA1515`-clean: the package's `UNI0000` and `UNI9001` suppression descriptors cover the +language-mandated public nested `IUnionMembers` provider. No `#pragma` is needed. ## Diagnostics @@ -154,7 +163,7 @@ needed. | `UNI0002` | Error | Unsupported declaration kind | | `UNI0003` | Error | The union declares no cases | | `UNI0004` | Error | Duplicate case type | -| `UNI0005` | Error | A case type is not public | +| `UNI0005` | Error | A case type is less accessible than the union | | `UNI0006` | Error | More cases than the tag can address | | `UNI0007` | Error | `[Union]` is required but missing (fallback path) | | `UNI0008` | Error | Two case types are implicitly convertible | @@ -168,8 +177,8 @@ needed. | `UNI0016` | Error | The declaration collides with a required member | | `UNI9000` | Error | The generator failed unexpectedly | -The package also ships code fixes that add the missing `partial` modifier (`UNI0001`) and, on the documented -fallback path, the `[Union]` attribute (`UNI0007`). +The package also ships code fixes that add the missing `partial` modifier (`UNI0001`), make an inaccessible +case type public (`UNI0005`) and, on the documented fallback path, add the `[Union]` attribute (`UNI0007`). ## Documentation @@ -179,12 +188,13 @@ fallback path, the `[Union]` attribute (`UNI0007`). - [Interop](docs/wiki/Interop.md) — Results, Telemetry, ZodSharp and the follow-ups - [Trimming and AOT](docs/wiki/Trimming-AOT.md) - [Performance](docs/wiki/Performance.md) — the benchmark suite and the measured allocation results +- [Agent Skills](docs/wiki/Agent-Skills.md) — what each published skill covers and when to load it - [Release Flow](docs/wiki/Release-Flow.md) — the PR and release gates ## Agent skills -The `purview-unions-modelling`, `purview-unions-diagnostics` and `purview-unions-interop` skills cover this -package. Install them from the repository: +The `purview-unions-quickstart`, `purview-unions-modelling`, `purview-unions-diagnostics` and +`purview-unions-interop` skills cover this package. Install them from the repository: ```bash npx skills add purview-dev/unions diff --git a/docs/wiki/Agent-Skills.md b/docs/wiki/Agent-Skills.md new file mode 100644 index 0000000..f2d581a --- /dev/null +++ b/docs/wiki/Agent-Skills.md @@ -0,0 +1,51 @@ +# Agent Skills + +This repository authors **agent skills** so AI agents working with `Purview.Unions` get the guidance +automatically. They live at `skills//SKILL.md` and are installed from this git repository with the +skills CLI — they are not shipped inside the NuGet package. A skill can keep task-shaped detail in a +`references/` folder, which an agent loads only when it is needed. + +## Skills published by this repository + +| Skill | Covers | +| --- | --- | +| `skills/purview-unions-quickstart` | install, compiler requirements, a first union and the no-generator fallback | +| `skills/purview-unions-modelling` | choosing a declaration surface, shaping the cases, generics and nullable value types | +| `skills/purview-unions-diagnostics` | every `UNI0001`–`UNI0016`/`UNI9000` finding and the fix for it | +| `skills/purview-unions-interop` | Results, Telemetry, ZodSharp, JSON, trimming and Native AOT | + +## When to load which skill + +- Installing the package or writing your first union → `purview-unions-quickstart` +- Choosing between the `typeof` list, partial factories, an `IUnionMembers` provider or `Union` → + `purview-unions-modelling` +- A `UNI` diagnostic, a missing generated member, or a union conversion that will not compile → + `purview-unions-diagnostics` +- A union crossing into Results, Telemetry, ZodSharp, JSON, a trimmed application or Native AOT → + `purview-unions-interop` + +## Install + +```bash +npx skills add purview-dev/unions +``` + +The CLI clones this repository, so the skills always match the branch you install from. + +## Upstream skills + +The Purview engineering playbook, `Purview.BuildSdk` and the source-generator framework publish their own +skills (engineering conventions, SDK configuration and project layout, generator authoring and testing) +through the same CLI. Those are owned by the repository that publishes them — changes belong there, not +here. + +This repository installs them into the universal `.agents/skills/` folder only (`-a universal`) and commits +that folder together with the CLI's `skills-lock.json`, so a clone has them without running anything. +Nothing is installed into `.claude/` or any other per-agent folder, and this repository's own skills are +never copied into `.agents/skills/` — two copies would drift apart. + +## Related + +- [Getting Started](Getting-Started.md) — install, declare and use a union. +- [Union Design](Union-Design.md) — the compiler contract and the emitted shape. +- [Release Flow](Release-Flow.md) — where the authored skill set is released from. diff --git a/docs/wiki/Diagnostics.md b/docs/wiki/Diagnostics.md index 9ccbb49..ce3e002 100644 --- a/docs/wiki/Diagnostics.md +++ b/docs/wiki/Diagnostics.md @@ -9,7 +9,7 @@ whether it can emit and reports only `UNI9000`. A rule is reported once. | `UNI0002` | Error | Unsupported declaration kind (interface, enum, static/abstract class, `ref struct`) | | `UNI0003` | Error | No case list was found | | `UNI0004` | Error | Duplicate case type | -| `UNI0005` | Error | A case type is not public | +| `UNI0005` | Error | A case type is less accessible than the union | | `UNI0006` | Error | More cases than a `ushort` tag can address | | `UNI0007` | Error | `[Union]` is required but missing (fallback path only) | | `UNI0008` | Error | Two case types are implicitly convertible | @@ -26,6 +26,11 @@ whether it can emit and reports only `UNI9000`. A rule is reported once. Every error blocks its union: the generated constructors, storage and access members are one coherent shape, so there is no partial output. `UNI0015` is the only warning and does not block. +`UNI0000` and `UNI9001` are **suppression descriptors**, not reported rules. `UNI0000` suppresses `CA1034` +(nested types should not be visible) and `UNI9001` suppresses `CA1515` (public types should be internal) on a +union's public nested `IUnionMembers` provider, which the C# union contract requires. Add either id to +`NoWarn` to turn that suppression off. + ## Details ### `UNI0001` — not `partial` @@ -47,10 +52,12 @@ public single-parameter constructors. Each case type appears once. A nullable value type and its underlying type are the same case (`int?` and `int`). -### `UNI0005` — case type not public +### `UNI0005` — case type less accessible than the union -Every union creation member must be public, and a public constructor cannot take a less accessible parameter -type. A nested case type must be public all the way up its containers too. +A case type must be at least as accessible as the union. A public union's creation members are public API, so +its case types must be public all the way up their containers. An internal union's creation members are +effectively internal, so an internal case type is enough. The code fix adds `public` to the case type and its +containing types. ### `UNI0006` — too many cases diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index f64b6e5..c26a6dc 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -66,8 +66,9 @@ public readonly partial struct Option Option some = new Some(42); ``` -A nested `IUnionMembers` provider is the alternative when another generator must see the case list; see -[Union Design](Union-Design.md). +A nested `IUnionMembers` provider is the alternative when another generator must see the case list; the +provider is public, so the package's `UNI0000` and `UNI9001` suppressors handle the resulting `CA1034` and +`CA1515`. See [Union Design](Union-Design.md). ## No generator @@ -80,9 +81,11 @@ if (value.TryGetValue(out int number)) { } ## Requirements -- The .NET 11 SDK (C# 15) with `LangVersion=preview` in the project that declares a union. +- The .NET 11 SDK (C# 15, GA). - Target `net10.0` or `net11.0`. -- Do not set `EnablePreviewFeatures`; `LangVersion=preview` is the only switch needed. +- A `net11.0` project gets C# 15 by default, so no `LangVersion` is needed. A `net10.0` project defaults to + C# 14, so set `latest` in the project that declares a union. +- Do not set `EnablePreviewFeatures`; `LangVersion=latest` (and only on `net10.0`) is the only switch needed. On `net10.0` the generator emits internal `[Embedded]` polyfills for `UnionAttribute`/`IUnion`. On `net11.0` the BCL types are used and no polyfill is emitted. diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index eabe8d8..cdd2284 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -12,6 +12,7 @@ not use a generator. | [Interop](Interop.md) | Results, Telemetry, ZodSharp, JSON, trimming and AOT | | [Trimming and AOT](Trimming-AOT.md) | why the generated shape and `Union` are trim- and AOT-safe | | [Performance](Performance.md) | the BenchmarkDotNet suite, measured results and what an allocation means | +| [Agent Skills](Agent-Skills.md) | the published agent skills and when to load each one | | [Release Flow](Release-Flow.md) | the PR and release gates, pack validation and how a version ships | The package targets `net10.0` and `net11.0`. On `net10.0` the generator emits internal `[Embedded]` polyfills for diff --git a/docs/wiki/Interop.md b/docs/wiki/Interop.md index 4736516..6358fa6 100644 --- a/docs/wiki/Interop.md +++ b/docs/wiki/Interop.md @@ -13,41 +13,47 @@ each other's output. ## Purview.Results -`[GenerateResult]` requires `ITypeSymbol.IsUnion` and discovers cases from public single-parameter -constructors. For a union generated here: +`[GenerateResult]` recognises a `[GenerateUnion]` target by metadata identity and reads the union's +**source-visible** case list instead of relying on generated output. A generated union therefore works with +Results without the author writing `[Union]` or constructors: -- the `[Union]` attribute is emitted into generated output, which Results' generator cannot see; -- the constructors are generated, which Results' generator also cannot see. +- surface A (`typeof` list) and surface B (partial factory signatures) are read from the source; +- surface C (`IUnionMembers` provider) is the portable source-visible route when another generator also needs + the case list; +- a `[GenerateUnion]` target with no source-visible case list is reported as `RSG1001` by Results. -A Results error union therefore needs its attribute and constructors written in source. That is a **known -gap**, and the integration test is skipped with the follow-up link rather than worked around. +The Results tests use a stand-in `GenerateUnionAttribute` and source-visible conversions because +`Purview.Unions` is not on a CI-safe package feed yet, so the real two-generator integration test remains a +follow-up in `purview-dev/results`. -### Follow-up +Do not hand-write the constructors to satisfy Results: the union generator would then treat the union as +hand-written and the declared and existing lists would disagree (`UNI0012`/`UNI0014`). -> `purview-dev/results`: `[GenerateResult]` case discovery should read the source case list — the -> `[GenerateUnion]` `typeof` list, the partial factory signatures, or the nested `IUnionMembers` provider — -> instead of relying on the generated constructors. +### Follow-up -Do not hand-write the constructors to satisfy Results: the generator would then treat the union as -hand-written and the declared and existing lists would disagree (`UNI0012`/`UNI0014`). +> `purview-dev/results`: add the two-generator integration test once `Purview.Unions` is on a CI-safe package +> feed. ## Purview.Telemetry -Telemetry's `UnionHelpers` prefers a nested `IUnionMembers` provider's static `Create` methods, then falls -back to public constructors, and treats the non-boxing pattern as present when `HasValue` and a `TryGetValue` -per case exist. - -- **Surface C works today**, because the provider is in source and the generated union implements the - required `HasValue`/`TryGetValue` members. -- **Surface A** is invisible: the cases live only in generated constructors. +`Purview.Telemetry.SourceGenerator` 6.0.0 recognises a `[Purview.Unions.GenerateUnion]` target and reads the +source-visible case list in the same order as this package: the `typeof` list, partial factory signatures, a +nested `IUnionMembers` provider, then source-visible constructors. It emits the accessor against the +`HasValue`/`TryGetValue` members the unions generator emits, so a surface A/B/C union is unwrapped to its case +value without boxing the union. + +`just test-telemetry-integration` proves the real two-generator path from the packages: it packs +`Purview.Unions`, references `Purview.Telemetry.SourceGenerator` 6.0.0 from `LOCAL_NUGET_FEED_PATH` (or +`TELEMETRY_PRERELEASE_FEED`), and asserts the generated accessor calls `TryGetValue` for each case on +`net10.0` and `net11.0`. + +- Surface A/B/C work; surface C remains the portable source-visible route for another generator that does not + read the `[GenerateUnion]` list. +- A `[GenerateUnion]` target with no source-visible case list is not treated as a union by Telemetry; the + unions generator reports `UNI0003`. - A telemetry parameter typed as `Purview.Unions.Union` is visible as a union on `net11.0`, where the package assembly carries `[Union]`. -### Follow-up - -> `purview-dev/telemetry-sourcegenerator`: when a parameter's type is a `[GenerateUnion]` union, read the -> source case list in the same way. - ## ZodSharp `ZodSharp.Unions.Union` and `NativeUnion` predate this package. `Purview.Unions.Union` diff --git a/docs/wiki/Performance.md b/docs/wiki/Performance.md index 21084f2..34168dd 100644 --- a/docs/wiki/Performance.md +++ b/docs/wiki/Performance.md @@ -4,7 +4,9 @@ The allocation claim is the reason this package exists: a value-type union case `TryGetValue`, `is` or `switch`. The committed BenchmarkDotNet suite measures that claim, the generator that produces the shape, and the object-backed baseline the shape replaces. -Results are produced by [`src/src/Benchmarks`](../../src/src/Benchmarks) and folded here for reference. +Results are produced by [`src/src/Benchmarks`](../../src/src/Benchmarks) and folded here for reference. Every +benchmark batches 1,000 operations through `OperationsPerInvoke`, so a sub-nanosecond operation is measurable +and the JIT cannot fold the loop body away; the reported mean and allocation are per operation. ## How to run @@ -13,14 +15,16 @@ Results are produced by [`src/src/Benchmarks`](../../src/src/Benchmarks) and fol just perf-tests # Full suite on .NET 11 -just perf-tests -f net11.0 +just perf-tests-net11 # One suite (the `--` passes the filter to BenchmarkDotNet) just perf-tests -- --filter "*GeneratedUnionBenchmarks*" ``` -Always use a Release build. BenchmarkDotNet writes `BenchmarkDotNet.Artifacts/` in the project folder; the -Markdown reports are the source for the tables below. +Always use a Release build. BenchmarkDotNet writes `BenchmarkDotNet.Artifacts/` at the repository root (the +working directory the recipe runs in); the Markdown reports are the source for the tables below. + +The suite pins BenchmarkDotNet `0.16.0-preview.2` because `0.15.8` does not recognise the .NET 11 runtime. ## What is measured @@ -37,33 +41,99 @@ and is measured deliberately; do not “fix” it. Everything else in the genera ## Environment - +- BenchmarkDotNet v0.16.0-preview.2, Windows 11 (10.0.26300.9818) +- 13th Gen Intel Core i9-13900KF 3.00 GHz, 1 CPU, 32 logical / 24 physical cores; 63.77 GB total, 38.32 GB available +- .NET SDK 11.0.100; .NET 10.0.15 (10.0.15, 10.0.1526.53008), X64 RyuJIT x86-64-v3 +- `IterationCount=5 LaunchCount=1 WarmupCount=3` + +These are the recorded `net10.0` numbers. The suite also targets `net11.0`; run `just perf-tests-net11` to +measure the current runtime. Numbers from one machine are indicative — use them for relative comparison, and +re-run before quoting absolute values. ## Generated union (surface A) - +| Method | Mean | Error | StdDev | Gen0 | Allocated | +|------------------- |----------:|----------:|----------:|-------:|----------:| +| Create_Int | 0.2200 ns | 0.0182 ns | 0.0028 ns | - | - | +| Create_String | 0.1914 ns | 0.0122 ns | 0.0032 ns | - | - | +| TryGetValue_Int | 0.2670 ns | 0.1613 ns | 0.0419 ns | - | - | +| TryGetValue_String | 0.3538 ns | 0.2877 ns | 0.0747 ns | - | - | +| TryGetValue_Miss | 0.2575 ns | 0.0840 ns | 0.0218 ns | - | - | +| HasValue_Int | 0.2377 ns | 0.0204 ns | 0.0053 ns | - | - | +| Is_Int | 0.2158 ns | 0.0463 ns | 0.0120 ns | - | - | +| Switch_Int | 0.1916 ns | 0.0069 ns | 0.0018 ns | - | - | +| Switch_String | 0.3762 ns | 0.0209 ns | 0.0054 ns | - | - | +| Value_Int | 1.7450 ns | 0.5377 ns | 0.1396 ns | 0.0013 | 24 B | + +Construction, both `TryGetValue` directions, the miss path, `HasValue`, `is` and both `switch` shapes allocate +**0 B**. `Value` allocates 24 bytes: the box for the `int` case. ## Hand-written union (surface D) - +| Method | Mean | Error | StdDev | Gen0 | Allocated | +|------------------- |----------:|----------:|----------:|-------:|----------:| +| Create_Int | 0.2120 ns | 0.0043 ns | 0.0011 ns | - | - | +| Create_String | 0.1817 ns | 0.0055 ns | 0.0008 ns | - | - | +| TryGetValue_Int | 0.1843 ns | 0.0016 ns | 0.0004 ns | - | - | +| TryGetValue_String | 0.1855 ns | 0.0040 ns | 0.0010 ns | - | - | +| TryGetValue_Miss | 0.1830 ns | 0.0009 ns | 0.0001 ns | - | - | +| HasValue_Int | 0.1837 ns | 0.0019 ns | 0.0003 ns | - | - | +| Tag_Int | 0.1805 ns | 0.0008 ns | 0.0002 ns | - | - | +| Match_Int | 0.4778 ns | 0.0054 ns | 0.0008 ns | - | - | +| Value_Int | 1.4837 ns | 0.0154 ns | 0.0024 ns | 0.0013 | 24 B | + +`Union` has the same allocation profile as the generated shape: everything except `Value` is 0 B. ## Generic unions (surfaces B and C) - +| Method | Mean | Error | StdDev | Gen0 | Allocated | +|-------------------------- |----------:|----------:|----------:|-------:|----------:| +| Maybe_CreateSome | 0.1832 ns | 0.0072 ns | 0.0011 ns | - | - | +| Maybe_CreateNone | 0.1899 ns | 0.0112 ns | 0.0029 ns | - | - | +| Maybe_TryGetValue_Some | 0.2185 ns | 0.0035 ns | 0.0009 ns | - | - | +| Maybe_TryGetValue_None | 0.1997 ns | 0.0034 ns | 0.0009 ns | - | - | +| Outcome_CreateValue | 0.2244 ns | 0.0354 ns | 0.0092 ns | - | - | +| Outcome_CreateError | 3.8637 ns | 0.0569 ns | 0.0148 ns | 0.0064 | 120 B | +| Outcome_TryGetValue_Value | 0.1837 ns | 0.0043 ns | 0.0007 ns | - | - | +| Outcome_TryGetValue_Error | 0.2717 ns | 0.0040 ns | 0.0006 ns | - | - | + +The generic surfaces behave like the non-generic ones. `Outcome_CreateError` allocates 120 bytes because the +benchmark constructs a new `InvalidOperationException` for every operation; that is the exception, not the +union. Reading the same error case through `Outcome_TryGetValue_Error` is 0 B. ## Object-backed baseline - +| Method | Mean | Error | StdDev | Ratio | Gen0 | Allocated | +|------------ |----------:|----------:|----------:|------:|-------:|----------:| +| Create_Int | 2.1729 ns | 0.9646 ns | 0.2505 ns | 1.00 | 0.0013 | 24 B | +| Pattern_Int | 0.3319 ns | 0.0142 ns | 0.0022 ns | 0.15 | - | - | +| Switch_Int | 0.3174 ns | 0.0323 ns | 0.0084 ns | 0.15 | - | - | + +This is the shape a `union` declaration lowers to: one `object` field, so storing a value-type case boxes it +(24 B and a slower path), and matching an already-boxed value does not. The generated union and +`Union` remove the boxing at entry. ## Generator - +| Method | UnionCount | Mean | Error | StdDev | Ratio | Gen0 | Gen1 | Allocated | +|--------------- |----------- |-----------:|-----------:|----------:|------:|---------:|---------:|-----------:| +| **ColdRun** | **1** | **668.4 μs** | **25.93 μs** | **6.73 μs** | **1.00** | **17.5781** | **4.8828** | **328.28 KB** | +| IncrementalRun | 1 | 128.8 μs | 4.92 μs | 0.76 μs | 0.19 | 2.6855 | 0.4883 | 51.91 KB | +| **ColdRun** | **10** | **897.1 μs** | **559.07 μs** | **145.19 μs** | **1.00** | **39.0625** | **8.7891** | **730.17 KB** | +| IncrementalRun | 10 | 223.7 μs | 14.83 μs | 3.85 μs | 0.25 | 7.5684 | 0.4883 | 139.65 KB | +| **ColdRun** | **100** | **2,347.0 μs** | **69.76 μs** | **10.80 μs** | **1.00** | **261.7188** | **148.4375** | **4835.03 KB** | +| IncrementalRun | 100 | 1,244.5 μs | 71.05 μs | 18.45 μs | 0.53 | 58.5938 | 9.7656 | 1087.95 KB | + +`ColdRun` constructs a fresh driver for every operation, which is the first generation in an IDE or build. +`IncrementalRun` reuses the driver and compilation after a warm run, so it measures the cached path. The +incremental path is 19–53% of the cold path and 16–23% of its allocation; the remainder is Roslyn updating the +compilation with the generated trees, not the generator re-discovering cases. ## Interpreting the results 1. **Mean** — average execution time. 2. **Error / StdDev** — noise and spread. -3. **Gen 0/1/2** — collections per 1000 operations. +3. **Gen 0/1** — collections per 1000 operations. 4. **Allocated** — managed bytes per operation. Signs to investigate: @@ -71,8 +141,8 @@ Signs to investigate: - **An allocation on construction, `TryGetValue`, `is`, `switch` or `HasValue`** means the non-boxing access pattern regressed. - **An allocation through `Value`** is expected for a value-type case and is the documented cost of the contract. -- **A regression in the generator's cold path** with no change to the incremental path usually means discovery - started walking symbols it used to cache. +- **A growing gap between `ColdRun` and `IncrementalRun`** means an incremental pipeline stage stopped being + value-equatable and is re-running on every keystroke. ## Regenerating the tables diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index b1b7579..eed0405 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -14,9 +14,9 @@ GitHub Actions workflows in `.github/workflows/`. This repository owns configura A push to `main` publishes, so everything below has to be true **before** the merge. -1. **Rename the changelog heading to the version being released.** `CHANGELOG.md`'s newest `## ` - heading is the one that ships; there is no `Unreleased` heading, precisely so a merge cannot publish an - unnamed release. +1. **Give the release a dated changelog heading.** Move the entries under `## [Unreleased]` into a new + `## [] - YYYY-MM-DD` heading before the merge, so the record names the version that shipped. + `package.json` stays the authoritative version; the heading is the human-facing history. 2. **Move new diagnostics into a shipped block.** New or changed rules stay in `src/src/SourceGenerator/AnalyzerReleases.Unshipped.md` until that version ships, then move into a `## Release ` block in `AnalyzerReleases.Shipped.md`. A rule stays recorded under the release that @@ -32,12 +32,17 @@ A push to `main` publishes, so everything below has to be true **before** the me | Workflow | Trigger | Gate | | --- | --- | --- | -| `.github/workflows/pr.yml` | pull requests against `main` (opened, synchronize, reopened, ready_for_review) | `build`: shared `purview-build.yml` with the `[Category=Unit]` test filter, `run-pack: true` and `validate-pack: true`. `packed-consumers`: packs to an isolated feed, runs a `net10.0` and a `net11.0` consumer against the package only, leak-scans their output and proves every `ForbiddenContent` rule rejects a package. | +| `.github/workflows/pr.yml` | pull requests against `main` (opened, synchronize, reopened, ready_for_review) | `build`: shared `purview-build.yml` with `run-pack: true` and `validate-pack: true`; the test filter lives only in `.config/purview-build.json`. `skills`: verifies `.agents/skills` against `skills-lock.json` without installing anything. `packed-consumers`: packs to an isolated feed, runs a `net10.0` and a `net11.0` consumer against the package only, leak-scans their output and proves every `ForbiddenContent` rule rejects a package. | | `.github/workflows/release.yml` | push to `main` | the `purview-release` composite action with `release-mode: NuGet`, Trusted Publishing, and the GitHub Packages mirror controlled by repository variables. | -Both workflows pin `dotnet-version` to the SDK in `global.json` (`11.0.100`). The shared workflow defaults to -10.0.x, which cannot resolve a repository that targets `net11.0`; keep the two workflow files and `global.json` -in sync, and read the pinned version from `global.json` rather than from this page. +Both workflows get their SDK from `global.json` through `purview-dev/build`, which reads `.sdk.version` and +installs exactly that (`11.0.100`). The shared `purview-build.yml`/`purview-release` code does it, and the two +hand-rolled PR jobs call `purview-dev/build/actions/dotnet-sdk@main` directly. Keep the pin in `global.json` +alone: a workflow-level `dotnet-version` is ignored once `global.json` states one, and +`actions/setup-dotnet`'s `global-json-file` input falls back to the LTS channel when it cannot install the pin, +reports success, and leaves the first `dotnet` command to fail with "A compatible .NET SDK was not found". The +`packed-consumers` job additionally installs the .NET 10 runtime the `net10.0` consumer executes on; +`global.json` still selects the pinned SDK. `release.yml` runs the composite action rather than the reusable workflow because nuget.org validates the OIDC `job_workflow_ref` claim against the registered Trusted Publishing policy, and inside a called workflow that @@ -54,7 +59,7 @@ does not already exist. Do not create release tags or publish packages manually. | `Build:TestRoot` | `src/tests` | | `Build:TestPatterns` | `*Tests.csproj` | | `Build:TestProjects` | `*Tests*` | -| `Build:TestFilter` | `/*/*/*/*[Category=Unit]` | +| `Build:TestFilter` | `/*/*/*/*` | | `Build:RunPack` / `Build:ValidatePack` | schema default `true`; the PR workflow passes both explicitly | | `PackValidation:RequiredCompilerFlags` | `optimization=release`, asserted for every assembly that ships a PDB | | `PackValidation:RequireSymbolPackage` | `true` | @@ -91,6 +96,7 @@ just pipeline-pack-validate # restore, build, lint, tests, pack, validate cont just pipeline-release # pack, publish, GitHub release (NuGet mode) just pipeline-local-release # pack, publish to a local NuGet feed just smoke-packed-consumers # pack, then run net10.0 and net11.0 consumers of the package +just test-telemetry-integration # pack, then prove the Purview.Telemetry 6.0.0 integration (local feed) just verify-pack-rules # prove every ForbiddenContent rule rejects a package just version # the version package.json declares ``` diff --git a/docs/wiki/Union-Design.md b/docs/wiki/Union-Design.md index 4d7b922..facdbdc 100644 --- a/docs/wiki/Union-Design.md +++ b/docs/wiki/Union-Design.md @@ -14,7 +14,8 @@ that defines it, so an internal polyfill in the consumer's own compilation is en ## The compiler contract (verified, not assumed) The following were read from the C# 15 union proposal and the compiler's own metadata, then proven by the -experiments kept in `src/tests/SourceGenerator.UnitTests/CompilerBehaviourTests.cs`. +experiments kept in `src/tests/SourceGenerator.UnitTests/CompilerBehaviourTests.cs` (1–6) and +`GeneratedUnionRuntimeTests.cs` (7). | Fact | Evidence | | --- | --- | @@ -38,7 +39,7 @@ the design. | 2 | Source `[Union]` + generated constructors | **Passes.** This is the safe fallback if attribute emission ever regresses; the code fix for `UNI0007` supports it. | | 3 | `static partial` factory signatures inside the union | **Passes.** The generated implementation part binds before the caller needs it. | | 4 | A nested `IUnionMembers` provider whose `Create` bodies come from the source | **Passes, with the documented fallback.** A generator cannot add bodies to a source-declared interface, so the provider owns one-line `Create` bodies that delegate to the generated `CreateCaseN` methods. | -| 5 | `ITypeSymbol.IsUnion` for a generated union | **True.** This is why the analyzer and the `PDS0006` suppressor work; `Results` still cannot see generated constructors (see [Interop](Interop.md)). | +| 5 | `ITypeSymbol.IsUnion` for a generated union | **True.** This is why the analyzer and the `PDS0006` suppressor work; Results reads the source case list (see [Interop](Interop.md)). | | 6 | Internal polyfills on `net10.0`, no duplicate on `net11.0` | **Passes.** On `net10.0` the compiler recognises an internal `UnionAttribute` defined in the compilation. On `net11.0` no polyfill is emitted and the BCL type is used. | | 7 | No boxing in `is`/`switch`/`TryGetValue`, boxing in `Value` | **Passes.** Measured with `GC.GetAllocatedBytesForCurrentThread()`: 0 bytes for `is`, `switch` and `TryGetValue`, 24 bytes for `Value`. | @@ -99,8 +100,14 @@ Design points: - **No equality, `ToString`, `Deconstruct`, `Match` or `Switch`** for generated unions in v1. A union is matched by its active case; `CA1815` is expected and `PDS0006` answers it. A `record` union gets the compiler's record equality over the tag and fields. +- **Surface C is `CA1034`- and `CA1515`-clean.** The provider must be a public nested interface, so the + package's `UNI0000` and `UNI9001` suppression descriptors suppress the nesting and visibility warnings for + an `IUnionMembers` provider inside a union; a differently named nested type, or one outside a union, keeps + them. - **Accessibility is never widened.** The partial declaration repeats the union's accessibility, and every - case type must be public because a public constructor cannot expose a less accessible type. + case type must be at least as accessible as the union because a public creation member cannot expose a less + accessible parameter type. An internal union can therefore use internal case types; a public union needs + public case types. - **Fully-qualified literal type names** are emitted for case types, so type parameters, arrays and nullable types render exactly without relying on a `TypeReference` identity that a type parameter does not have. diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index e7c264e..786d1ef 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -5,4 +5,5 @@ - [Interop](Interop.md) - [Trimming and AOT](Trimming-AOT.md) - [Performance](Performance.md) +- [Agent Skills](Agent-Skills.md) - [Release Flow](Release-Flow.md) diff --git a/package.json b/package.json index 134ef4e..70f8495 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,7 @@ { "name": "unions", - "version": "0.1.0-prerelease.1", + "version": "1.0.0-prerelease.1", "license": "MIT", - "private": true, "homepage": "https://purview.dev/projects/unions/", "bugs": { "url": "https://github.com/purview-dev/unions/issues" @@ -15,4 +14,4 @@ "type": "git", "url": "git+https://github.com/purview-dev/unions.git" } -} +} \ No newline at end of file diff --git a/scripts/assert-no-compiler-host-assemblies.ps1 b/scripts/assert-no-compiler-host-assemblies.ps1 index 3c1495a..9645304 100644 --- a/scripts/assert-no-compiler-host-assemblies.ps1 +++ b/scripts/assert-no-compiler-host-assemblies.ps1 @@ -19,6 +19,7 @@ Purview.SourceGeneratorFramework* Microsoft.CodeAnalysis* Purview.Unions.SourceGenerator* (the generator and Purview.Unions.SourceGenerator.CodeFixes) + Purview.Telemetry.SourceGenerator* (the telemetry generator and refactorings) A path that does not exist, or that holds no files, fails too: a scan of nothing proves nothing. @@ -59,6 +60,7 @@ $forbiddenNames = @( 'Purview.SourceGeneratorFramework' 'Microsoft.CodeAnalysis' 'Purview.Unions.SourceGenerator' + 'Purview.Telemetry.SourceGenerator' ) $forbiddenContent = [regex]::new( diff --git a/scripts/test-packed-consumers.ps1 b/scripts/test-packed-consumers.ps1 index 451dccc..df7cfcd 100644 --- a/scripts/test-packed-consumers.ps1 +++ b/scripts/test-packed-consumers.ps1 @@ -45,10 +45,27 @@ $project = Join-Path $repositoryRoot 'src/src/Unions/Unions.csproj' $scratch = Join-Path ([System.IO.Path]::GetTempPath()) "unions-consumers-$([guid]::NewGuid().ToString('N').Substring(0, 8))" $feed = Join-Path $scratch 'feed' $failures = [System.Collections.Generic.List[string]]::new() +$originalNuGetPackages = $env:NUGET_PACKAGES try { New-Item -ItemType Directory -Force -Path $feed, $PackOutput | Out-Null + # Purview.Unions deliberately has no ProjectReference to the generator, so packing it restores only + # itself. The pack target invokes `GetPurviewMergedAnalyzerFile` on the generator project and + # `Build` on the code-fix project through MSBuild, and a project that was never restored has no + # package build assets: the framework's targets are not imported and the target does not exist + # (MSB4057). Restore both before packing so the gate works on a clean checkout, not only on a + # developer machine where an earlier build already restored them. + $generator = Join-Path $repositoryRoot 'src/src/SourceGenerator/SourceGenerator.csproj' + $codeFixes = Join-Path $repositoryRoot 'src/src/SourceGenerator.CodeFixes/SourceGenerator.CodeFixes.csproj' + + Write-Host 'Restoring the generator and code-fix projects...' -ForegroundColor Cyan + & dotnet restore $generator --nologo + if ($LASTEXITCODE -ne 0) { throw "dotnet restore failed for $generator with exit code $LASTEXITCODE." } + + & dotnet restore $codeFixes --nologo + if ($LASTEXITCODE -ne 0) { throw "dotnet restore failed for $codeFixes with exit code $LASTEXITCODE." } + Write-Host "Packing Purview.Unions $version to $feed..." -ForegroundColor Cyan & dotnet pack $project -c $Configuration -o $feed --nologo if ($LASTEXITCODE -ne 0) { throw "dotnet pack failed with exit code $LASTEXITCODE." } @@ -56,7 +73,23 @@ try { $package = Join-Path $feed "Purview.Unions.$version.nupkg" if (-not (Test-Path $package)) { throw "The packed package '$package' was not produced." } + # The scratch feed is temporary and removed below, so mirror what was packed into PackOutput as well. + # The workflow validates these same files with test-pack-validation-rules.ps1 after this script, and a + # PackOutput that received nothing made that gate fail on a clean runner even though packing succeeded. + Copy-Item $package $PackOutput -Force + $symbolPackage = [System.IO.Path]::ChangeExtension($package, '.snupkg') + if (Test-Path $symbolPackage) { Copy-Item $symbolPackage $PackOutput -Force } + + # NuGet reuses an extracted package for the same id and version, so an isolated global-packages + # folder is what guarantees each consumer tests the package just packed rather than a stale extract + # left by an earlier run with the same version. + $env:NUGET_PACKAGES = Join-Path $scratch 'packages' + foreach ($tfm in @('net10.0', 'net11.0')) { + # net11.0 gets C# 15 by default in the .NET 11 SDK; net10.0 defaults to C# 14, so its + # consumer sets latest to prove the union syntax with the generator's polyfills. Neither + # consumer sets preview. + $langVersionProperty = if ($tfm -eq 'net10.0') { 'latest' } else { '' } $consumer = Join-Path $scratch "Consumer.$tfm" New-Item -ItemType Directory -Force -Path $consumer | Out-Null @@ -75,9 +108,14 @@ try { Exe $tfm - preview + $langVersionProperty enable enable + + All + CA1034;CA1515 + + CA1000;CA1050;CA1815;CA1852 @@ -115,11 +153,70 @@ if (empty.HasValue || empty.TryGetValue(out string _)) return 1; } +Outcome outcome = 9; +if (!outcome.TryGetValue(out int result) || result != 9) +{ + Console.Error.WriteLine($"{Environment.Version}: surface C did not hold the value."); + return 1; +} + +Outcome failure = new InvalidOperationException("boom"); +if (!failure.TryGetValue(out Exception? error) || error?.Message != "boom") +{ + Console.Error.WriteLine($"{Environment.Version}: surface C did not hold the error."); + return 1; +} + +PackedMaybe packedSome = new PackedSome(7); +if (!packedSome.TryGetValue(out PackedSome packedValue) || packedValue.Value != 7) +{ + Console.Error.WriteLine($"{Environment.Version}: internal surface B did not hold the value."); + return 1; +} + +PackedMaybe packedNone = new PackedNone(); +if (!packedNone.TryGetValue(out PackedNone _)) +{ + Console.Error.WriteLine($"{Environment.Version}: internal surface B did not hold None."); + return 1; +} + Console.WriteLine($"{Environment.Version}: {number} (ok)"); return 0; [GenerateUnion(typeof(int), typeof(string))] -public readonly partial struct IntOrString; +readonly partial struct IntOrString; + +readonly record struct PackedSome(T Value); + +readonly record struct PackedNone; + +[GenerateUnion] +readonly partial struct PackedMaybe +{ + internal static partial PackedMaybe Some(PackedSome value); + + internal static partial PackedMaybe None(PackedNone value); +} + +[GenerateUnion] +readonly partial struct Outcome : Outcome.IUnionMembers +{ + public interface IUnionMembers + { + static Outcome Create(T? value) => Outcome.CreateCase0(value); + + static Outcome Create(Exception? value) => Outcome.CreateCase1(value); + + object? Value { get; } + + bool HasValue { get; } + + bool TryGetValue(out T value); + + bool TryGetValue(out Exception value); + } +} '@ | Set-Content (Join-Path $consumer 'Program.cs') -Encoding utf8 Push-Location $consumer @@ -150,6 +247,13 @@ catch { $failures.Add($_.Exception.Message) } finally { + if ($null -ne $originalNuGetPackages) { + $env:NUGET_PACKAGES = $originalNuGetPackages + } + else { + Remove-Item Env:NUGET_PACKAGES -ErrorAction SilentlyContinue + } + if ($KeepArtifacts) { Write-Host "Scratch consumers kept at $scratch" -ForegroundColor Yellow } diff --git a/scripts/test-telemetry-integration.ps1 b/scripts/test-telemetry-integration.ps1 new file mode 100644 index 0000000..0b3f500 --- /dev/null +++ b/scripts/test-telemetry-integration.ps1 @@ -0,0 +1,223 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Proves Purview.Telemetry reads a Purview.Unions generated union as a telemetry parameter. + +.DESCRIPTION + Packs Purview.Unions to an isolated feed, then creates a scratch console project for each supported + target framework that references the packed Purview.Unions package and the locally available + Purview.Telemetry.SourceGenerator package. The consumer declares: + + * a union generated by Purview.Unions from a source-visible case list; + * a telemetry interface whose method takes that union as a [Tag] parameter. + + The telemetry generator must recognise the union from its source-visible list, emit the union accessor + against the HasValue/TryGetValue members the unions generator emits, and compile. The test asserts the + generated accessor calls TryGetValue for the cases, then leak-scans the consumer output for compiler-host + assemblies. + + This is the real two-generator integration: both generators run in one compilation and neither sees the + other's output, which is exactly the contract the packages must satisfy. + + The Purview.Telemetry.SourceGenerator package is a prerelease dependency of this gate. It is read from + LOCAL_NUGET_FEED_PATH (or TELEMETRY_PRERELEASE_FEED); pass -TelemetryFeed when neither is set. + +.PARAMETER PackOutput + Folder that receives the packed Purview.Unions .nupkg. Defaults to artifacts/telemetry-integration. + +.PARAMETER TelemetryFeed + The feed that carries Purview.Telemetry.SourceGenerator. Defaults to LOCAL_NUGET_FEED_PATH, then + TELEMETRY_PRERELEASE_FEED. + +.PARAMETER TelemetryVersion + The Purview.Telemetry.SourceGenerator version to test. Defaults to 6.0.0. + +.PARAMETER Configuration + The pack/build configuration. Defaults to Release. + +.PARAMETER KeepArtifacts + Leave the scratch consumers and feed in place for inspection. + +.EXAMPLE + ./scripts/test-telemetry-integration.ps1 + +.EXAMPLE + ./scripts/test-telemetry-integration.ps1 -TelemetryFeed p:/_sync-projects/.local-nuget -KeepArtifacts +#> +[CmdletBinding()] +param ( + [string] $PackOutput = (Join-Path (Split-Path -Parent $PSScriptRoot) 'artifacts/telemetry-integration'), + [string] $TelemetryFeed = $env:LOCAL_NUGET_FEED_PATH, + [string] $TelemetryVersion = '6.0.0', + [ValidateSet('Debug', 'Release')] + [string] $Configuration = 'Release', + [switch] $KeepArtifacts +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +if ([string]::IsNullOrWhiteSpace($TelemetryFeed)) { + $TelemetryFeed = $env:TELEMETRY_PRERELEASE_FEED +} + +if ([string]::IsNullOrWhiteSpace($TelemetryFeed)) { + throw "Purview.Telemetry.SourceGenerator is not on a public feed yet. Set LOCAL_NUGET_FEED_PATH or TELEMETRY_PRERELEASE_FEED, or pass -TelemetryFeed." +} + +if (-not (Test-Path -LiteralPath $TelemetryFeed)) { + throw "The telemetry feed '$TelemetryFeed' does not exist." +} + +$telemetryPackage = Get-ChildItem -LiteralPath $TelemetryFeed -Filter "Purview.Telemetry.SourceGenerator.$TelemetryVersion.nupkg" | + Select-Object -First 1 +if ($null -eq $telemetryPackage) { + throw "The telemetry feed '$TelemetryFeed' does not carry Purview.Telemetry.SourceGenerator $TelemetryVersion." +} + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$version = (Get-Content (Join-Path $repositoryRoot 'package.json') -Raw | ConvertFrom-Json).version +$project = Join-Path $repositoryRoot 'src/src/Unions/Unions.csproj' + +$scratch = Join-Path ([System.IO.Path]::GetTempPath()) "unions-telemetry-$([guid]::NewGuid().ToString('N').Substring(0, 8))" +$feed = Join-Path $scratch 'feed' +$failures = [System.Collections.Generic.List[string]]::new() +$originalNuGetPackages = $env:NUGET_PACKAGES + +try { + New-Item -ItemType Directory -Force -Path $feed, $PackOutput | Out-Null + + Write-Host "Packing Purview.Unions $version to $feed..." -ForegroundColor Cyan + & dotnet pack $project -c $Configuration -o $feed --nologo + if ($LASTEXITCODE -ne 0) { throw "dotnet pack failed with exit code $LASTEXITCODE." } + + $unionsPackage = Join-Path $feed "Purview.Unions.$version.nupkg" + if (-not (Test-Path $unionsPackage)) { throw "The packed package '$unionsPackage' was not produced." } + + # NuGet reuses an extracted package for the same id and version. An isolated cache makes each consumer + # test the package just packed and the telemetry package at the requested version. + $env:NUGET_PACKAGES = Join-Path $scratch 'packages' + + foreach ($tfm in @('net10.0', 'net11.0')) { + $consumer = Join-Path $scratch "Consumer.$tfm" + New-Item -ItemType Directory -Force -Path $consumer | Out-Null + + @" + + + + + + + + +"@ | Set-Content (Join-Path $consumer 'nuget.config') -Encoding utf8 + + @" + + + Exe + $tfm + latest + enable + enable + + true + obj/generated + + + + + + + + + + +"@ | Set-Content (Join-Path $consumer 'Consumer.csproj') -Encoding utf8 + + @' +using Purview.Telemetry; +using Purview.Unions; + +Console.WriteLine("telemetry integration consumer"); + +[GenerateUnion(typeof(int), typeof(string))] +internal readonly partial struct TelemetryValue; + +[ActivitySource("unions-telemetry-integration")] +internal interface ISmokeActivities +{ + [Activity] + System.Diagnostics.Activity? Run([Tag] TelemetryValue value); +} +'@ | Set-Content (Join-Path $consumer 'Program.cs') -Encoding utf8 + + Push-Location $consumer + try { + Write-Host "Running the $tfm consumer..." -ForegroundColor Cyan + & dotnet run -c $Configuration --nologo + if ($LASTEXITCODE -ne 0) { + $failures.Add("The $tfm consumer exited with $LASTEXITCODE.") + continue + } + + $generatedRoot = Join-Path $consumer 'obj/generated' + $activitySource = Get-ChildItem $generatedRoot -Recurse -File -Filter '*.Activity.g.cs' | + Select-Object -First 1 + if ($null -eq $activitySource) { + $failures.Add("The $tfm consumer produced no telemetry Activity source; the generator did not run.") + continue + } + + $generated = Get-Content $activitySource.FullName -Raw + if ($generated -notmatch 'UnwrapUnion_TelemetryValue' -or $generated -notmatch 'TryGetValue') { + $failures.Add("The $tfm telemetry accessor did not unwrap TelemetryValue through TryGetValue.") + continue + } + + Write-Host " $tfm passed" -ForegroundColor Green + } + finally { + Pop-Location + } + + $outputPath = Join-Path $consumer "bin/$Configuration/$tfm" + if (Test-Path $outputPath) { + & (Join-Path $PSScriptRoot 'assert-no-compiler-host-assemblies.ps1') -Path $outputPath + if ($LASTEXITCODE -ne 0) { + $failures.Add("The $tfm consumer output carries a compiler-host assembly.") + } + } + } +} +catch { + $failures.Add($_.Exception.Message) +} +finally { + if ($null -ne $originalNuGetPackages) { + $env:NUGET_PACKAGES = $originalNuGetPackages + } + else { + Remove-Item Env:NUGET_PACKAGES -ErrorAction SilentlyContinue + } + + if ($KeepArtifacts) { + Write-Host "Scratch consumers kept at $scratch" -ForegroundColor Yellow + } + else { + Remove-Item $scratch -Recurse -Force -ErrorAction SilentlyContinue + } +} + +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 telemetry integration consumer passed.' -ForegroundColor Green +exit 0 diff --git a/skills-lock.json b/skills-lock.json index 3bd6912..01f2ef9 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -1,125 +1,149 @@ { - "version": 1, + "version": 2, "skills": { "purview-architecture": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-architecture/SKILL.md", - "computedHash": "d194bd0f30091ffab6ef35c29582e56511d2daca32fdbb1942c50134198f9270" + "computedHash": "2500e935ff2ccb350f26ebb9be321dc9f99800121aa9412fdc597f95c2c465c8" }, "purview-architecture-testing": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-architecture-testing/SKILL.md", - "computedHash": "5907c6bf28788b755b752041e2f0cd88222604a949988d487c25940b6284fce4" + "computedHash": "23af6d9baeb408e1e3f4034cef6539d7efa3fecb1b4da918cb539bd8dfbd5a00" }, "purview-buildsdk-adoption": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-adoption/SKILL.md", - "computedHash": "ca2e13fc8074281f8ca2b510b742ae99db28f97d78f5fb36a6f27936a91941fc" + "computedHash": "5ddad1fe7d1dc65c1b352743e5cd616d4720a33228b06faa6259572cd4a8c265" }, "purview-buildsdk-configuration": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-configuration/SKILL.md", - "computedHash": "88b4a655efdc058b9a10adb1b5cbcd44e89bff6ed8c858e066092e5be0ca3e0b" + "computedHash": "3f3fcf4c50303198fe04bb4f032e66f31b9ec6558e497d9cc4bfc8129d8df75f" }, "purview-buildsdk-project-detection": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-project-detection/SKILL.md", - "computedHash": "7c30062fe592105b7a2539384843759521c5a3f90ace8f7c60f0e6ae2405b0b1" + "computedHash": "2ef5e876ff0bf3b5fb9789fd7bb2c56cdd6283cad6287bb3c68393e993f0b36b" }, "purview-buildsdk-project-layout": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-project-layout/SKILL.md", - "computedHash": "101f09c92cf97e93387b926c48adab9598a0cf0a64a4932d41085ab0a0078cfa" + "computedHash": "d8b1fb6b95ae5e91f81734616f70a9f3d2d7547dc7406e4eda0f8d674ebf808b" }, "purview-buildsdk-repository-review": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-repository-review/SKILL.md", - "computedHash": "d366821995ff410caa436ccb996eaa6429f1ba51ba985b210d74ec3963f6c06f" + "computedHash": "babfe657a1eef5be67dc1e4ec387e9e8215fb0263956c4c926465fa27b648604" }, "purview-buildsdk-skill-installation": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-skill-installation/SKILL.md", - "computedHash": "b61cce9f0f213469c3acab4dd5ebaec450ee2be730680b166c9718c901aedffe" + "computedHash": "8e1dc7c0f499eb742264d5cc293784758c5f0499f849671a0be3353f91f89491" }, "purview-buildsdk-skill-tests": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-skill-tests/SKILL.md", - "computedHash": "807b64466de0a871e5f8e678e7686c48a06d8bb9d42a03448f604a35d40d3935" + "computedHash": "27b3520ced5c5a8009cfb47149f0fc1826246275d3ec2991667e1543b011d657" + }, + "purview-buildsdk-skills-justfile": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-skills-justfile/SKILL.md", + "computedHash": "a41f7bbd28333e7f2e40cadbbdd35a75b2f626d72e53901cbc79c7325ac2e50c" }, "purview-buildsdk-testing": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-testing/SKILL.md", - "computedHash": "d6a2ac1167faf68e8c27493de81c2e5b0d8183e523de4411aca5f5b9249c8a9c" + "computedHash": "e7e00ede43f5ee8aa4517329e4fdbde00a5a01bd8e98ea42990c851060bcaf9e" + }, + "purview-component-compatibility": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-component-compatibility/SKILL.md", + "computedHash": "070634caa90d032e2319d6799edbee973be0f9380008917474909328e74f143b" }, "purview-engineering-principles": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-engineering-principles/SKILL.md", - "computedHash": "fd38d374b816ecf77964c41486e7c734ea34998c3f2a5538c90a2ba793905994" + "computedHash": "d1b0d2b50e635160f96d03d2790cb725242434bb28b1b47913371bb39d83215b" + }, + "purview-org-harvest": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-org-harvest/SKILL.md", + "computedHash": "040b34d9cd41f23e71dbe1500b0748793377703d1032e1135ed0a3eeeee4a7f8" }, "purview-persistence": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-persistence/SKILL.md", - "computedHash": "0523ecc210bf3571e2fccb859128a499fd80694e9d4089b377a8d39d73c028f4" + "computedHash": "28e92fa8bccc241ca864b6731148bd721f47699baf3a1a2ec07cce42ed650bae" + }, + "purview-sourcegenerator-attribute-data-models": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-attribute-data-models/SKILL.md", + "computedHash": "475bf4ef2c6e5c29e02725383f97fb0aea60243c05a2d779b03533c392c6d395" }, "purview-sourcegenerator-codewriter": { "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-codewriter/SKILL.md", - "computedHash": "f24ae680340784e62236d044cbc3658d5ca7bdc3cea3fc44f2bb5cf75a84f5e4" + "computedHash": "79c739daf06831f574982dc9034da1305d7c80d82e0e9ba5504c5fd52c1814e4" }, "purview-sourcegenerator-diagnostics": { "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-diagnostics/SKILL.md", - "computedHash": "c3c7920701e0e688f34ddece9e448472e010e69767e8c08914638128d34a8f17" + "computedHash": "8e3571221c2a2225f3826b0c501cd28ea2a27e91defd121f45117c0e36a308fd" }, "purview-sourcegenerator-packaging": { "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-packaging/SKILL.md", - "computedHash": "e0e6372e92aefc8df026a73010d9e7436b0a8c2378a96fdebd8b4de9e025d4a0" + "computedHash": "af9958e351e8b37c5cf13072fe4318b637a8383c0c6efef2094e66917333245f" }, "purview-sourcegenerator-pipeline": { "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-pipeline/SKILL.md", - "computedHash": "7d48394d7e3890f288949c79f040646207f34af9ae85827a63d056bae1b1b985" + "computedHash": "62835d55eab1ccf233241796d34d17bfd7b2fbe4be03424bf9a3a40d8ad2e6a7" }, "purview-sourcegenerator-testing": { "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-testing/SKILL.md", - "computedHash": "d88bf2353073013ff9ddf9d3fe1a2e9415eb60d319bbd053baa105f50fa3f889" + "computedHash": "8e0e02d156f420215f9f2b12b39a5fe07c7104efd3060c29e1c60cc7dbacffdd" }, "purview-sourcegenerator-tunit-tests": { "source": "purview-dev/sourcegenerator-framework", "sourceType": "github", "skillPath": "skills/purview-sourcegenerator-tunit-tests/SKILL.md", - "computedHash": "14809d3727643e9a779d590b6627b3283af7b36c835e902cb80162552cb45b4f" + "computedHash": "29f966d9331a556bc3a7d5aa8ad8fe7b1a9091557d629e9c23f65ebd14e3aee7" }, "purview-telemetry": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-telemetry/SKILL.md", - "computedHash": "08ba26e1379dfa2bb826ddfd011e95ac84cd583b865f0b89673d7302b2c2c27d" + "computedHash": "f92aab10e3d2be7b24be559316537ba7d0485d0e479c67f4d5c6126f5f2fc652" }, "purview-testing-conventions": { "source": "purview-dev/engineering-playbook", "sourceType": "github", "skillPath": "skills/purview-testing-conventions/SKILL.md", - "computedHash": "388b0a79b565ed45a8513d77e8ed6e3d4491f96b43328cb09933cec7811c10f6" + "computedHash": "e053513db856852a23aad0e22625e1408fdd63377acb793e6d720fe37b9f7214" } } } diff --git a/skills/purview-unions-diagnostics/SKILL.md b/skills/purview-unions-diagnostics/SKILL.md index 2dac62e..936b867 100644 --- a/skills/purview-unions-diagnostics/SKILL.md +++ b/skills/purview-unions-diagnostics/SKILL.md @@ -15,7 +15,7 @@ never reported twice. | `UNI0002` | Error | Unsupported declaration kind (interface, enum, static/abstract class, `ref struct`) | Use a struct, readonly struct, class or record | | `UNI0003` | Error | No case list was found | Add a `typeof` list, partial factories, a provider, or public constructors | | `UNI0004` | Error | The same case type appears twice | Remove one; `int?` and `int` are the same case | -| `UNI0005` | Error | A case type is not public | Make the case type and its containers public | +| `UNI0005` | Error | A case type is less accessible than the union | Make the case type at least as accessible as the union (code fix adds `public`) | | `UNI0006` | Error | More cases than a `ushort` tag can address | Split the union | | `UNI0007` | Error | `[Union]` is required but missing | Add it (code fix); only on the documented fallback path | | `UNI0008` | Error | Two case types are implicitly convertible | Replace one, or wrap it in a distinct record | @@ -34,6 +34,11 @@ never reported twice. Every error blocks its union: the generated constructors, storage and access members are one coherent shape, so there is no partial output to emit. `UNI0015` is the only warning and does not block. +`UNI0000` and `UNI9001` are **suppression descriptors**, not reported rules. `UNI0000` suppresses `CA1034` +(nested types should not be visible) and `UNI9001` suppresses `CA1515` (public types should be internal) on a +union's public nested `IUnionMembers` provider, which the C# union contract requires. Add either id to +`NoWarn` to turn that suppression off. + ## `UNI0001` — not partial The generator adds a second partial declaration. Without `partial` it cannot: @@ -55,10 +60,12 @@ public readonly partial struct IntOrString; For a generic union, a `typeof` list cannot name `T`. Use partial factories (surface B) or a nested `IUnionMembers` provider (surface C), as described in the `purview-unions-modelling` skill. -## `UNI0005` — a case is not public +## `UNI0005` — a case is less accessible than the union -Every union creation member must be `public`, and a public constructor cannot take a less accessible -parameter type. A nested type must be public all the way up its containers. +A case type must be at least as accessible as the union. A public union's creation members are public API, +so its case types must be public all the way up their containers. An internal union's creation members are +effectively internal, so an internal case type is enough. The code fix adds `public` to the case type and its +containing types. ## `UNI0008` — creation is ambiguous @@ -95,9 +102,9 @@ The generator catches its own failures so one malformed union cannot discard eve ## Fixing the code, not the warning -There is deliberately no `NoWarn`/`#pragma` in this stack. If a rule fires, change the declaration; the -only suppression the package relies on is `Purview.BuildSdk`'s `PDS0006` for `CA1815`, which is narrow to -the union declaration itself. +There is deliberately no `NoWarn`/`#pragma` in this stack. The suppressions are `DiagnosticSuppressors`, not +reported rules: `Purview.BuildSdk`'s `PDS0006` for `CA1815` on a union declaration, and this package's +`UNI0000` for `CA1034` and `UNI9001` for `CA1515` on a union's nested `IUnionMembers` provider. ## Checklist diff --git a/skills/purview-unions-interop/SKILL.md b/skills/purview-unions-interop/SKILL.md index 9c5105c..3dd0bed 100644 --- a/skills/purview-unions-interop/SKILL.md +++ b/skills/purview-unions-interop/SKILL.md @@ -21,30 +21,26 @@ is why surfaces B and C exist, and why surface A (`typeof` only) is invisible to ## Purview.Results -`[GenerateResult]` requires `ITypeSymbol.IsUnion` and reads the union's public single-parameter -constructors. For a union generated here: +`[GenerateResult]` recognises a `[GenerateUnion]` target by metadata identity and reads its source-visible +case list instead of generated output. Surface A (`typeof` list), B (partial factory signatures) and C +(`IUnionMembers` provider) all work; no `[Union]` attribute or constructors need to be written by hand. A +`[GenerateUnion]` target with no source-visible case list is `RSG1001`. Surface C remains the portable route +when another generator also needs the case list. -- the `[Union]` attribute is emitted by this generator, so Results' generator cannot see it; -- the constructors are generated, so Results cannot see them either. - -A Results error union therefore needs `[Union]` and the constructor set written in source. **This is a -known gap** and a follow-up in `purview-dev/results`: `[GenerateResult]` should read the source case list -(the `typeof` list, partial factories or a provider) instead of the generated constructors. - -The integration test is marked `Skip` with the follow-up link; do not work around it by hand-writing the -constructors, because that is `UNI0012`/`UNI0014`. +The two-generator integration test is a follow-up in `purview-dev/results` because `Purview.Unions` is not on +a CI-safe package feed yet. Do not work around it by hand-writing the constructors, because that is +`UNI0012`/`UNI0014`. ## Purview.Telemetry -The telemetry generator's `UnionHelpers` prefers a nested `IUnionMembers` provider's `Create` methods, -then falls back to public constructors, and treats the non-boxing pattern as present when `HasValue` and -one `TryGetValue` per case exist. - -- **Surface C works today**: the provider is in source, and the union implements the required - `HasValue`/`TryGetValue` members. -- **Surface A does not**: the cases live only in the emitted constructors. -- A telemetry parameter typed as `Purview.Unions.Union` is not detected as a union unless the - `net11.0` asset's `[Union]` is visible — it is, because it is compiled into the package assembly. +`Purview.Telemetry.SourceGenerator` 6.0.0 recognises a `[GenerateUnion]` target and reads its source-visible +case list (the `typeof` list, partial factories, a nested `IUnionMembers` provider, then source-visible +constructors). It emits the accessor against the generated `HasValue`/`TryGetValue` members, so surface A/B/C +unions are unwrapped to their case value without boxing the union. `just test-telemetry-integration` proves +both generators in one compilation from the packages. A `[GenerateUnion]` target with no source-visible case +list is not treated as a union by Telemetry; the unions generator reports `UNI0003`. A telemetry parameter +typed as `Purview.Unions.Union` is visible as a union on `net11.0`, where the package assembly carries +`[Union]`. ## ZodSharp @@ -75,12 +71,13 @@ A trimmed or Native AOT application can use any surface. On .NET 10 the generato | | `net10.0` | `net11.0` | | --- | --- | --- | -| Compiler | C# 15 (`LangVersion=preview`) | C# 15 | +| Compiler | C# 15 (`LangVersion=latest`) | C# 15 (the default) | | `UnionAttribute`/`IUnion` | internal polyfills emitted by the generator | BCL types | | `Union` | plain struct with the same API | also `[Union]`/`IUnion`; exhaustive and non-boxing patterns | | Generated unions | compiler unions via the polyfill | compiler unions via the BCL | -`LangVersion=preview` is the only switch needed. Do not set `EnablePreviewFeatures`: it emits +A `net11.0` project needs no `LangVersion`; a `net10.0` project sets `latest` +because its default is C# 14. `preview` is not required. Do not set `EnablePreviewFeatures`: it emits `RequiresPreviewFeatures` assembly-wide and makes `CA2252` fire on every member a consumer touches. ## Checklist diff --git a/skills/purview-unions-modelling/SKILL.md b/skills/purview-unions-modelling/SKILL.md index 000a70e..a0df7ee 100644 --- a/skills/purview-unions-modelling/SKILL.md +++ b/skills/purview-unions-modelling/SKILL.md @@ -75,6 +75,11 @@ one exists. A source generator cannot add bodies to an interface you declared, s one-line `Create` bodies and delegates to the generated `CreateCaseN` methods. Use this surface when you want the case list visible to other generators. +Because `IUnionMembers` must be **public**, a strict analyzer set reports `CA1034` and `CA1515` (nested types +should not be visible; public types should be internal). The package's `UNI0000`/`UNI9001` suppression +descriptors handle both for an `IUnionMembers` provider nested in a union; no attribute is needed. The nested +shape is mandated by the language, not a design choice. + ### D — hand-written `Purview.Unions.Union` ```csharp @@ -123,7 +128,8 @@ if (value is null) { } // HasValue, no boxing ## Checklist -1. Every case type is `public` (a public constructor cannot expose a less accessible type). +1. Every case type is at least as accessible as the union: public for a public union, internal is enough for + an internal union. 2. The declaration is `partial` and is a `struct`, `readonly struct`, `class` or record. 3. A generic union uses surface B or C, never a `typeof` list. 4. No two cases are implicitly convertible to each other (`UNI0008`). diff --git a/skills/purview-unions-quickstart/SKILL.md b/skills/purview-unions-quickstart/SKILL.md new file mode 100644 index 0000000..0e19167 --- /dev/null +++ b/skills/purview-unions-quickstart/SKILL.md @@ -0,0 +1,130 @@ +--- +name: purview-unions-quickstart +description: "Use when adding Purview.Unions to a .NET project for the first time — installing the package, compiling with C# 15, declaring a first [GenerateUnion] union, matching it with an exhaustive switch, or falling back to the hand-written Union without a generator." +--- + +# Purview.Unions quickstart + +Use this skill when a project needs a closed set of cases and you want value-type cases to stay +allocation-free. A `union` declaration always stores one `object?`, so it boxes every value-type case; +`Purview.Unions` generates the language's **custom union type** instead. + +## Install + +```bash +dotnet add package Purview.Unions +``` + +One `PackageReference` is enough: the runtime, the merged source generator and its code fixes all ship in +the same package under `analyzers/dotnet/cs`. + +## Requirements + +- A **C# 15 compiler**: the .NET 11 SDK (or later). C# 15 is GA, so `LangVersion=preview` is not required. +- Target `net10.0` or `net11.0`. A `net11.0` project gets C# 15 by default and needs no `LangVersion`; a + `net10.0` project sets `latest` because its default is C# 14. +- Do **not** set `EnablePreviewFeatures`. It emits `RequiresPreviewFeatures` assembly-wide and makes + `CA2252` fire on every member a consumer touches; `LangVersion=latest` (only on `net10.0`) is the only + switch needed. + +On `net10.0` the generator emits internal `[Embedded]` polyfills for `UnionAttribute`/`IUnion`. On +`net11.0` the BCL types are used and no polyfill is emitted. + +## First union + +```csharp +using Purview.Unions; + +[GenerateUnion(typeof(int), typeof(string))] +public readonly partial struct IntOrString; +``` + +The declaration must be `partial`. The generator adds: + +- the `[System.Runtime.CompilerServices.Union]` attribute and the union interface; +- one typed field per case and a tag; +- one public constructor per case; +- `public object? Value { get; }` and `public bool HasValue { get; }`; +- `public bool TryGetValue(out TCase value)` per case. + +## Use it + +```csharp +IntOrString value = 42; // implicit union conversion + +var text = value switch +{ + int number => $"int {number}", + string message => $"string {message}", + null => "empty", // a struct union can be empty +}; + +if (value is int number) { } // TryGetValue, no boxing +if (value is null) { } // HasValue, no boxing +``` + +`switch` is exhaustive over the case types. `Value` still boxes a value-type case when you read it — that +is inherent to the language contract — but construction, `TryGetValue`, `is` and `switch` do not. + +## Generic unions + +`typeof(T)` is not a legal attribute argument (`CS0416`), so a generic union names its cases in source. +Either declare partial factory signatures: + +```csharp +[GenerateUnion] +public readonly partial struct Option +{ + internal static partial Option Some(Some value); + internal static partial Option None(None value); +} +``` + +or implement a nested `IUnionMembers` provider when another generator must see the case list. The +`purview-unions-modelling` skill explains when each surface is right. + +## Without the generator + +For exactly two cases, or a project that cannot run the generator: + +```csharp +Purview.Unions.Union value = 42; + +if (value.TryGetValue(out int number)) + Console.WriteLine(number); // 42 + +var text = value.Match(n => $"int {n}", s => $"string {s}"); +``` + +`Union` has `Value`, `HasValue`, `Tag`, `TryGetValue`, `Match`, `Switch`, equality and `ToString`. +On `net10.0` it is an ordinary struct; on `net11.0` it also carries `[Union]`, so it is a compiler union +there too. + +## Turn the generator off + +`DisableUnionsSourceGenerator=true` (MSBuild property) disables generation and the polyfills. It is useful +for inspecting the declared source in isolation; it is not a way to make a failing build pass. + +## Choose a surface + +| Surface | Shape | Use when | +| --- | --- | --- | +| A | `[GenerateUnion(typeof(int), typeof(string))]` | Every case can be named in an attribute argument | +| B | partial static factory signatures | A case names a type parameter | +| C | a nested `IUnionMembers` provider | A generic union, or another generator must see the cases (the provider is public, but `UNI0000`/`UNI9001` suppress the resulting `CA1034`/`CA1515`) | +| D | `Union` | Exactly two cases, or no generator | + +## Checklist + +1. A `net11.0` project needs no `LangVersion`; a `net10.0` project sets `LangVersion=latest`. Neither sets + `EnablePreviewFeatures`. +2. The union is `partial` and is a `struct`, `readonly struct`, `class` or record. +3. Every case type is `public`. +4. A generic union uses surface B or C, never a `typeof` list. +5. The build is clean of `UNI0001`–`UNI0016` and `UNI9000`. + +## Where next + +- `purview-unions-modelling` — choosing and shaping the case list. +- `purview-unions-diagnostics` — what a `UNI` finding means and how to fix it. +- `purview-unions-interop` — Results, Telemetry, ZodSharp, JSON, trimming and AOT. diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 5ad61cf..320cd43 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -18,16 +18,25 @@ SDK's default (*) would make InstallPurviewSkills write a copy into every agent's folder. --> universal - preview + + + latest + latest enable enable false diff --git a/src/src/Benchmarks/BoxedBaselineBenchmarks.cs b/src/src/Benchmarks/BoxedBaselineBenchmarks.cs index b7e2c5f..52febff 100644 --- a/src/src/Benchmarks/BoxedBaselineBenchmarks.cs +++ b/src/src/Benchmarks/BoxedBaselineBenchmarks.cs @@ -6,25 +6,65 @@ namespace Purview.Unions.Benchmarks; /// The object-backed baseline the union shape exists to avoid. /// /// +/// /// A single field is what the C# union keyword lowers to. Storing a value-type -/// case boxes it, so and allocate; pattern matching an -/// already-boxed value does not. Compare these rows with GeneratedUnionBenchmarks. +/// case boxes it, so allocates; pattern matching an already-boxed value does not. +/// Compare these rows with . +/// +/// +/// Every benchmark batches operations through +/// ; the reported figures are per operation. +/// /// [MemoryDiagnoser] [SimpleJob(launchCount: 1, warmupCount: 3, iterationCount: 5)] public class BoxedBaselineBenchmarks { + const int Operations = 1_000; + readonly object _boxed = 42; - [Benchmark(Baseline = true)] - public object Create_Int() => 42; + [Benchmark(Baseline = true, OperationsPerInvoke = Operations)] + public int Create_Int() + { + var total = 0; + + for (var index = 0; index < Operations; index++) + { + object value = index; + + if (value is int number) + total += number; + } + + return total; + } + + [Benchmark(OperationsPerInvoke = Operations)] + public int Pattern_Int() + { + var total = 0; + + for (var index = 0; index < Operations; index++) + if (_boxed is int value) + total += value; + + return total; + } + + [Benchmark(OperationsPerInvoke = Operations)] + public int Switch_Int() + { + var total = 0; + + for (var index = 0; index < Operations; index++) + total += Match(_boxed); - [Benchmark] - public int Pattern_Int() => _boxed is int value ? value : -1; + return total; + } - [Benchmark] - public int Switch_Int() => - _boxed switch + static int Match(object value) => + value switch { int number => number, string text => text.Length, diff --git a/src/src/Benchmarks/GeneratedUnionBenchmarks.cs b/src/src/Benchmarks/GeneratedUnionBenchmarks.cs index 3c4e039..6f44314 100644 --- a/src/src/Benchmarks/GeneratedUnionBenchmarks.cs +++ b/src/src/Benchmarks/GeneratedUnionBenchmarks.cs @@ -10,43 +10,151 @@ namespace Purview.Unions.Benchmarks; /// Measures the generated custom-union shape for a two-case value/reference union. /// /// +/// +/// Every benchmark performs operations and declares them through +/// , so a sub-nanosecond operation is still measurable and +/// the JIT cannot fold the loop body away. The reported mean and allocation are per operation. +/// +/// /// Every operation except must report 0 B allocated: construction, /// TryGetValue, is, switch and HasValue all use the non-boxing access pattern. /// Value returns and boxes a value-type case by design. +/// /// [MemoryDiagnoser] [SimpleJob(launchCount: 1, warmupCount: 3, iterationCount: 5)] public class GeneratedUnionBenchmarks { + const int Operations = 1_000; + readonly IntOrString _intValue = 42; readonly IntOrString _stringValue = "hello"; - [Benchmark(Baseline = true)] - public IntOrString Create_Int() => 42; + [Benchmark(Baseline = true, OperationsPerInvoke = Operations)] + public int Create_Int() + { + var total = 0; + + for (var index = 1; index <= Operations; index++) + { + IntOrString value = index; + + if (value.TryGetValue(out int number)) + total += number; + } + + return total; + } + + [Benchmark(OperationsPerInvoke = Operations)] + public int Create_String() + { + var total = 0; + + for (var index = 0; index < Operations; index++) + { + IntOrString value = "hello"; + + if (value.TryGetValue(out string? text)) + total += text.Length; + } + + return total; + } + + [Benchmark(OperationsPerInvoke = Operations)] + public int TryGetValue_Int() + { + var total = 0; + + for (var index = 0; index < Operations; index++) + if (_intValue.TryGetValue(out int value)) + total += value; + + return total; + } + + [Benchmark(OperationsPerInvoke = Operations)] + public int TryGetValue_String() + { + var total = 0; + + for (var index = 0; index < Operations; index++) + if (_stringValue.TryGetValue(out string? text)) + total += text.Length; + + return total; + } + + [Benchmark(OperationsPerInvoke = Operations)] + public int TryGetValue_Miss() + { + var total = 0; + + for (var index = 0; index < Operations; index++) + if (!_intValue.TryGetValue(out string? _)) + total++; + + return total; + } + + [Benchmark(OperationsPerInvoke = Operations)] + public int HasValue_Int() + { + var total = 0; + + for (var index = 0; index < Operations; index++) + if (_intValue.HasValue) + total++; + + return total; + } + + [Benchmark(OperationsPerInvoke = Operations)] + public int Is_Int() + { + var total = 0; + + for (var index = 0; index < Operations; index++) + if (_intValue is int) + total++; + + return total; + } + + [Benchmark(OperationsPerInvoke = Operations)] + public int Switch_Int() + { + var total = 0; - [Benchmark] - public IntOrString Create_String() => "hello"; + for (var index = 0; index < Operations; index++) + total += Match(_intValue); - [Benchmark] - public int TryGetValue_Int() => _intValue.TryGetValue(out int value) ? value : -1; + return total; + } - [Benchmark] - public bool TryGetValue_Miss() => _intValue.TryGetValue(out string? _); + [Benchmark(OperationsPerInvoke = Operations)] + public int Switch_String() + { + var total = 0; - [Benchmark] - public bool HasValue_Int() => _intValue.HasValue; + for (var index = 0; index < Operations; index++) + total += Match(_stringValue); - [Benchmark] - public bool Is_Int() => _intValue is int; + return total; + } - [Benchmark] - public int Switch_Int() => Match(_intValue); + [Benchmark(OperationsPerInvoke = Operations)] + public int Value_Int() + { + var total = 0; - [Benchmark] - public int Switch_String() => Match(_stringValue); + for (var index = 0; index < Operations; index++) + if (_intValue.Value is int value) + total += value; - [Benchmark] - public object? Value_Int() => _intValue.Value; + return total; + } static int Match(IntOrString value) => value switch diff --git a/src/src/Benchmarks/GeneratorBenchmarks.cs b/src/src/Benchmarks/GeneratorBenchmarks.cs index d85cf02..99ac664 100644 --- a/src/src/Benchmarks/GeneratorBenchmarks.cs +++ b/src/src/Benchmarks/GeneratorBenchmarks.cs @@ -26,6 +26,8 @@ namespace Purview.Unions.Benchmarks; [SimpleJob(launchCount: 1, warmupCount: 3, iterationCount: 5)] public class GeneratorBenchmarks { + // The pinned Roslyn 5.9.0 package's Latest is C# 14; parse under Preview so the C# 15 union syntax is + // available. The SDK compiler that builds consumers accepts it with LangVersion=latest. readonly CSharpParseOptions _parseOptions = new(LanguageVersion.Preview); MetadataReference[] _references = []; SyntaxTree _syntaxTree = null!; @@ -41,7 +43,10 @@ public void Setup() _references = BuildReferences(); _syntaxTree = CSharpSyntaxTree.ParseText(BuildSource(UnionCount), _parseOptions); _compilation = CreateCompilation(); - _driver = CSharpGeneratorDriver.Create(new UnionsSourceGenerator()); + _driver = CSharpGeneratorDriver.Create( + [new UnionsSourceGenerator().AsSourceGenerator()], + parseOptions: _parseOptions + ); // Warm the incremental pipeline so IncrementalRun measures the cached path, and fail the setup // (rather than silently benchmarking a broken generator) if the source does not compile. @@ -53,7 +58,10 @@ public void Setup() public void ColdRun() { var compilation = CreateCompilation(); - var driver = CSharpGeneratorDriver.Create(new UnionsSourceGenerator()); + var driver = CSharpGeneratorDriver.Create( + [new UnionsSourceGenerator().AsSourceGenerator()], + parseOptions: _parseOptions + ); driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out _); } diff --git a/src/src/Benchmarks/GenericUnionBenchmarks.cs b/src/src/Benchmarks/GenericUnionBenchmarks.cs index f744339..b6da843 100644 --- a/src/src/Benchmarks/GenericUnionBenchmarks.cs +++ b/src/src/Benchmarks/GenericUnionBenchmarks.cs @@ -44,38 +44,132 @@ public interface IUnionMembers /// /// Measures the generic declaration surfaces, where the case type is a type parameter. /// +/// +/// Every benchmark batches operations through +/// ; the reported figures are per operation. +/// [MemoryDiagnoser] [SimpleJob(launchCount: 1, warmupCount: 3, iterationCount: 5)] public class GenericUnionBenchmarks { + const int Operations = 1_000; + readonly Maybe _some = new Some(7); readonly Maybe _none = CreateNone(); readonly Outcome _outcome = 9; readonly Outcome _failure = new InvalidOperationException("boom"); - [Benchmark(Baseline = true)] - public Maybe Maybe_CreateSome() => new Some(7); + [Benchmark(Baseline = true, OperationsPerInvoke = Operations)] + public int Maybe_CreateSome() + { + var total = 0; + + for (var index = 0; index < Operations; index++) + { + Maybe value = new Some(7); + + if (value.TryGetValue(out Some some)) + total += some.Value; + } + + return total; + } - [Benchmark] - public Maybe Maybe_CreateNone() => new None(); + [Benchmark(OperationsPerInvoke = Operations)] + public int Maybe_CreateNone() + { + var total = 0; - [Benchmark] - public int Maybe_TryGetValue_Some() => _some.TryGetValue(out Some value) ? value.Value : -1; + for (var index = 0; index < Operations; index++) + { + Maybe value = new None(); - [Benchmark] - public bool Maybe_TryGetValue_None() => _none.TryGetValue(out None _); + if (value.TryGetValue(out None _)) + total++; + } - [Benchmark] - public Outcome Outcome_CreateValue() => 9; + return total; + } + + [Benchmark(OperationsPerInvoke = Operations)] + public int Maybe_TryGetValue_Some() + { + var total = 0; - [Benchmark] - public Outcome Outcome_CreateError() => new InvalidOperationException("boom"); + for (var index = 0; index < Operations; index++) + if (_some.TryGetValue(out Some some)) + total += some.Value; + + return total; + } + + [Benchmark(OperationsPerInvoke = Operations)] + public int Maybe_TryGetValue_None() + { + var total = 0; + + for (var index = 0; index < Operations; index++) + if (_none.TryGetValue(out None _)) + total++; + + return total; + } + + [Benchmark(OperationsPerInvoke = Operations)] + public int Outcome_CreateValue() + { + var total = 0; - [Benchmark] - public int Outcome_TryGetValue_Value() => _outcome.TryGetValue(out int value) ? value : -1; + for (var index = 0; index < Operations; index++) + { + Outcome value = index; - [Benchmark] - public bool Outcome_TryGetValue_Error() => _failure.TryGetValue(out Exception? _); + if (value.TryGetValue(out int number)) + total += number; + } + + return total; + } + + [Benchmark(OperationsPerInvoke = Operations)] + public int Outcome_CreateError() + { + var total = 0; + + for (var index = 0; index < Operations; index++) + { + Outcome value = new InvalidOperationException("boom"); + + if (value.TryGetValue(out Exception? _)) + total++; + } + + return total; + } + + [Benchmark(OperationsPerInvoke = Operations)] + public int Outcome_TryGetValue_Value() + { + var total = 0; + + for (var index = 0; index < Operations; index++) + if (_outcome.TryGetValue(out int value)) + total += value; + + return total; + } + + [Benchmark(OperationsPerInvoke = Operations)] + public int Outcome_TryGetValue_Error() + { + var total = 0; + + for (var index = 0; index < Operations; index++) + if (_failure.TryGetValue(out Exception? _)) + total++; + + return total; + } static Maybe CreateNone() => new None(); } diff --git a/src/src/Benchmarks/HandWrittenUnionBenchmarks.cs b/src/src/Benchmarks/HandWrittenUnionBenchmarks.cs index d87e15f..380846d 100644 --- a/src/src/Benchmarks/HandWrittenUnionBenchmarks.cs +++ b/src/src/Benchmarks/HandWrittenUnionBenchmarks.cs @@ -8,38 +8,129 @@ namespace Purview.Unions.Benchmarks; /// /// It stores each case in its own typed field, so creation, TryGetValue, Match and Tag /// are allocation-free. Value boxes a value-type case for the same reason as the generated shape. +/// Every benchmark batches operations through +/// ; the reported figures are per operation. /// [MemoryDiagnoser] [SimpleJob(launchCount: 1, warmupCount: 3, iterationCount: 5)] public class HandWrittenUnionBenchmarks { + const int Operations = 1_000; + readonly Union _intValue = 42; readonly Union _stringValue = "hello"; - [Benchmark(Baseline = true)] - public Union Create_Int() => 42; + [Benchmark(Baseline = true, OperationsPerInvoke = Operations)] + public int Create_Int() + { + var total = 0; + + for (var index = 1; index <= Operations; index++) + { + Union value = index; + + if (value.TryGetValue(out int number)) + total += number; + } + + return total; + } + + [Benchmark(OperationsPerInvoke = Operations)] + public int Create_String() + { + var total = 0; + + for (var index = 0; index < Operations; index++) + { + Union value = "hello"; + + if (value.TryGetValue(out string? text)) + total += text.Length; + } + + return total; + } + + [Benchmark(OperationsPerInvoke = Operations)] + public int TryGetValue_Int() + { + var total = 0; + + for (var index = 0; index < Operations; index++) + if (_intValue.TryGetValue(out int value)) + total += value; + + return total; + } + + [Benchmark(OperationsPerInvoke = Operations)] + public int TryGetValue_String() + { + var total = 0; + + for (var index = 0; index < Operations; index++) + if (_stringValue.TryGetValue(out string? text)) + total += text.Length; + + return total; + } + + [Benchmark(OperationsPerInvoke = Operations)] + public int TryGetValue_Miss() + { + var total = 0; + + for (var index = 0; index < Operations; index++) + if (!_intValue.TryGetValue(out string? _)) + total++; + + return total; + } + + [Benchmark(OperationsPerInvoke = Operations)] + public int HasValue_Int() + { + var total = 0; + + for (var index = 0; index < Operations; index++) + if (_intValue.HasValue) + total++; + + return total; + } + + [Benchmark(OperationsPerInvoke = Operations)] + public int Tag_Int() + { + var total = 0; - [Benchmark] - public Union Create_String() => "hello"; + for (var index = 0; index < Operations; index++) + total += _intValue.Tag; - [Benchmark] - public int TryGetValue_Int() => _intValue.TryGetValue(out int value) ? value : -1; + return total; + } - [Benchmark] - public int TryGetValue_String() => _stringValue.TryGetValue(out string? value) ? value.Length : -1; + [Benchmark(OperationsPerInvoke = Operations)] + public int Match_Int() + { + var total = 0; - [Benchmark] - public bool TryGetValue_Miss() => _intValue.TryGetValue(out string? _); + for (var index = 0; index < Operations; index++) + total += _intValue.Match(static number => number, static text => text.Length); - [Benchmark] - public bool HasValue_Int() => _intValue.HasValue; + return total; + } - [Benchmark] - public int Tag_Int() => _intValue.Tag; + [Benchmark(OperationsPerInvoke = Operations)] + public int Value_Int() + { + var total = 0; - [Benchmark] - public int Match_Int() => _intValue.Match(static number => number, static text => text.Length); + for (var index = 0; index < Operations; index++) + if (_intValue.Value is int value) + total += value; - [Benchmark] - public object? Value_Int() => _intValue.Value; + return total; + } } diff --git a/src/src/Benchmarks/README.md b/src/src/Benchmarks/README.md index 1cf7316..6011955 100644 --- a/src/src/Benchmarks/README.md +++ b/src/src/Benchmarks/README.md @@ -26,7 +26,7 @@ From the repository root: just perf-tests # Full suite on .NET 11 -just perf-tests -f net11.0 +just perf-tests-net11 # One suite (the `--` passes the filter to BenchmarkDotNet) just perf-tests -- --filter "*GeneratedUnionBenchmarks*" @@ -36,9 +36,9 @@ Always use a Release build; running benchmarks in Debug produces meaningless num ## Results -BenchmarkDotNet writes `BenchmarkDotNet.Artifacts/` in this folder: HTML and Markdown reports plus logs. -The committed results and their interpretation live in -[`docs/wiki/Performance.md`](../../../docs/wiki/Performance.md). +BenchmarkDotNet writes `BenchmarkDotNet.Artifacts/` at the repository root (the working directory `just +perf-tests` runs in): HTML and Markdown reports plus logs. The committed results and their interpretation +live in [`docs/wiki/Performance.md`](../../../docs/wiki/Performance.md). ## Adding a suite diff --git a/src/src/Examples/Examples.csproj b/src/src/Examples/Examples.csproj index 578085a..6a382e5 100644 --- a/src/src/Examples/Examples.csproj +++ b/src/src/Examples/Examples.csproj @@ -1,8 +1,6 @@ Exe - false - Purview Unions Examples diff --git a/src/src/Examples/Models.cs b/src/src/Examples/Models.cs new file mode 100644 index 0000000..a516ba9 --- /dev/null +++ b/src/src/Examples/Models.cs @@ -0,0 +1,42 @@ +namespace Purview.Unions.Examples; + +// Surface A: the case list is a typeof list on the attribute. The generator emits the union members, +// including the [Union] attribute the compiler recognises. +[GenerateUnion(typeof(int), typeof(string))] +readonly partial struct IntOrString; + +// Surface B: the case list is the set of partial static factory signatures, which is how a generic +// union names a type parameter (typeof(T) is CS0416). +[GenerateUnion] +readonly partial struct Maybe +{ + internal static partial Maybe Some(Some value); + + internal static partial Maybe None(None value); +} + +readonly record struct Some(T Value); + +readonly record struct None; + +// Surface C: the case list is a nested IUnionMembers provider. The provider's Create bodies delegate to +// the generator's CreateCaseN methods; a generator cannot add bodies to a source-declared interface. +[GenerateUnion] +readonly partial struct Outcome : Outcome.IUnionMembers +{ + // The language requires the union member provider to be a public nested interface named IUnionMembers. + public interface IUnionMembers + { + static Outcome Create(T? value) => Outcome.CreateCase0(value); + + static Outcome Create(Exception? value) => Outcome.CreateCase1(value); + + object? Value { get; } + + bool HasValue { get; } + + bool TryGetValue(out T value); + + bool TryGetValue(out Exception value); + } +} diff --git a/src/src/Examples/Program.cs b/src/src/Examples/Program.cs index e0db470..f395e6f 100644 --- a/src/src/Examples/Program.cs +++ b/src/src/Examples/Program.cs @@ -1,137 +1,79 @@ -using System.Diagnostics.CodeAnalysis; +PrintSurfaceA(); +PrintSurfaceB(); +PrintSurfaceC(); +PrintSurfaceD(); +PrintAllocations(); -namespace Purview.Unions.Examples; - -// Surface A: the case list is a typeof list on the attribute. The generator emits the union members, -// including the [Union] attribute the compiler recognises. -[GenerateUnion(typeof(int), typeof(string))] -public readonly partial struct IntOrString; - -// Surface B: the case list is the set of partial static factory signatures, which is how a generic -// union names a type parameter (typeof(T) is CS0416). -[GenerateUnion] -public readonly partial struct Maybe +void PrintSurfaceA() { - internal static partial Maybe Some(Some value); + IntOrString value = 42; - internal static partial Maybe None(None value); -} + if (value.TryGetValue(out int number)) + Console.WriteLine($"A: int {number}; has-value={value.HasValue}"); -public readonly record struct Some(T Value); + IntOrString text = "hello"; + if (text.TryGetValue(out string message)) + Console.WriteLine($"A: string {message}; has-value={text.HasValue}"); -public readonly record struct None; + IntOrString empty = default; + Console.WriteLine($"A: default has-value={empty.HasValue}; value={empty.Value ?? ""}"); +} -// Surface C: the case list is a nested IUnionMembers provider. The provider's Create bodies delegate to -// the generator's CreateCaseN methods; a generator cannot add bodies to a source-declared interface. -[GenerateUnion] -public readonly partial struct Outcome : Outcome.IUnionMembers +void PrintSurfaceB() { - // The language requires the union member provider to be a public nested interface named IUnionMembers. - [SuppressMessage( - "Design", - "CA1034:Nested types should not be visible", - Justification = "The C# union member provider is required by the language to be a public nested interface named IUnionMembers." - )] - public interface IUnionMembers - { - static Outcome Create(T? value) => Outcome.CreateCase0(value); - - static Outcome Create(Exception? value) => Outcome.CreateCase1(value); - - object? Value { get; } - - bool HasValue { get; } - - bool TryGetValue(out T value); + Maybe some = new Some(7); + Maybe none = new None(); - bool TryGetValue(out Exception value); - } + Console.WriteLine( + $"B: some={some.TryGetValue(out Some value) && value.Value == 7}; none={none.TryGetValue(out None _)}" + ); } -static class Program +void PrintSurfaceC() { - static void Main() - { - PrintSurfaceA(); - PrintSurfaceB(); - PrintSurfaceC(); - PrintSurfaceD(); - PrintAllocations(); - } - - static void PrintSurfaceA() - { - IntOrString value = 42; + Outcome outcome = 9; - if (value.TryGetValue(out int number)) - Console.WriteLine($"A: int {number}; has-value={value.HasValue}"); + if (outcome.TryGetValue(out int number)) + Console.WriteLine($"C: int {number}"); - IntOrString text = "hello"; - if (text.TryGetValue(out string message)) - Console.WriteLine($"A: string {message}; has-value={text.HasValue}"); - - IntOrString empty = default; - Console.WriteLine($"A: default has-value={empty.HasValue}; value={empty.Value ?? ""}"); - } - - static void PrintSurfaceB() - { - Maybe some = new Some(7); - Maybe none = new None(); + Outcome failure = new InvalidOperationException("boom"); + if (failure.TryGetValue(out Exception error)) + Console.WriteLine($"C: error {error.Message}"); +} - Console.WriteLine( - $"B: some={some.TryGetValue(out Some value) && value.Value == 7}; none={none.TryGetValue(out None _)}" - ); - } +void PrintSurfaceD() +{ + Union value = 42; - static void PrintSurfaceC() - { - Outcome outcome = 9; + if (value.TryGetValue(out int number)) + Console.WriteLine($"D: int {number}; tag={value.Tag}"); - if (outcome.TryGetValue(out int number)) - Console.WriteLine($"C: int {number}"); + Union text = "hello"; + Console.WriteLine($"D: {text.Match(n => $"int {n}", s => $"string {s}")}; text={text.Value}"); +} - Outcome failure = new InvalidOperationException("boom"); - if (failure.TryGetValue(out Exception error)) - Console.WriteLine($"C: error {error.Message}"); - } +void PrintAllocations() +{ + IntOrString value = 42; - static void PrintSurfaceD() + // Warm the paths so JIT/tiering allocations are not attributed to the measurement. + for (var index = 0; index < 100; index++) { - Union value = 42; - - if (value.TryGetValue(out int number)) - Console.WriteLine($"D: int {number}; tag={value.Tag}"); - - Union text = "hello"; - Console.WriteLine($"D: {text.Match(n => $"int {n}", s => $"string {s}")}; text={text.Value}"); + _ = value.TryGetValue(out int _); + _ = value is int; } - static void PrintAllocations() - { - IntOrString value = 42; - - // Warm the paths so JIT/tiering allocations are not attributed to the measurement. - for (var index = 0; index < 100; index++) - { - _ = value.TryGetValue(out int _); - _ = value is int; - } + var beforeIs = GC.GetAllocatedBytesForCurrentThread(); + _ = value is int; + var afterIs = GC.GetAllocatedBytesForCurrentThread(); - var beforeIs = GC.GetAllocatedBytesForCurrentThread(); - _ = value is int; - var afterIs = GC.GetAllocatedBytesForCurrentThread(); - - var beforeTry = GC.GetAllocatedBytesForCurrentThread(); - _ = value.TryGetValue(out int _); - var afterTry = GC.GetAllocatedBytesForCurrentThread(); + var beforeTry = GC.GetAllocatedBytesForCurrentThread(); + _ = value.TryGetValue(out int _); + var afterTry = GC.GetAllocatedBytesForCurrentThread(); - var beforeValue = GC.GetAllocatedBytesForCurrentThread(); - _ = value.Value; - var afterValue = GC.GetAllocatedBytesForCurrentThread(); + var beforeValue = GC.GetAllocatedBytesForCurrentThread(); + _ = value.Value; + var afterValue = GC.GetAllocatedBytesForCurrentThread(); - Console.WriteLine( - $"alloc: is={afterIs - beforeIs} tryget={afterTry - beforeTry} value={afterValue - beforeValue}" - ); - } + Console.WriteLine($"alloc: is={afterIs - beforeIs} tryget={afterTry - beforeTry} value={afterValue - beforeValue}"); } diff --git a/src/src/SourceGenerator.CodeFixes/AddUnionAttributeCodeFixProvider.cs b/src/src/SourceGenerator.CodeFixes/AddUnionAttributeCodeFixProvider.cs index ca7683e..ca0a071 100644 --- a/src/src/SourceGenerator.CodeFixes/AddUnionAttributeCodeFixProvider.cs +++ b/src/src/SourceGenerator.CodeFixes/AddUnionAttributeCodeFixProvider.cs @@ -88,8 +88,8 @@ static bool HasUnionAttribute(TypeDeclarationSyntax declaration) var last = separator >= 0 ? name.Substring(separator + 1) : name; if ( - string.Equals(last, "Union", System.StringComparison.Ordinal) - || string.Equals(last, "UnionAttribute", System.StringComparison.Ordinal) + string.Equals(last, "Union", StringComparison.Ordinal) + || string.Equals(last, "UnionAttribute", StringComparison.Ordinal) ) return true; } diff --git a/src/src/SourceGenerator.CodeFixes/MakeCaseTypePublicCodeFixProvider.cs b/src/src/SourceGenerator.CodeFixes/MakeCaseTypePublicCodeFixProvider.cs new file mode 100644 index 0000000..6ad2600 --- /dev/null +++ b/src/src/SourceGenerator.CodeFixes/MakeCaseTypePublicCodeFixProvider.cs @@ -0,0 +1,116 @@ +using System.Collections.Immutable; +using System.Composition; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Purview.Unions.SourceGenerator.CodeFixes; + +/// +/// Makes a union case type public for UNI0005, including any containing type that is not public. +/// +/// +/// A public union's creation members are public API, so its case types must be public. The fix adds +/// public to the case type and every containing type in the declaration chain; an internal union +/// does not report UNI0005 at all, so this fix is only offered where the union's effective +/// accessibility actually requires it. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(MakeCaseTypePublicCodeFixProvider))] +[Shared] +public sealed class MakeCaseTypePublicCodeFixProvider : CodeFixProvider +{ + /// + public override ImmutableArray FixableDiagnosticIds => [UnionsDiagnosticIds.UnionCaseTypeNotAccessible]; + + /// + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + return; + + foreach (var diagnostic in context.Diagnostics) + { + if (!diagnostic.Location.IsInSource) + continue; + + var node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true); + var declaration = node.FirstAncestorOrSelf(); + if (declaration is null) + continue; + + var chain = declaration.AncestorsAndSelf().OfType().ToArray(); + if (chain.Any(static type => type.Modifiers.Any(SyntaxKind.FileKeyword))) + continue; + + if (chain.All(static type => type.Modifiers.Any(SyntaxKind.PublicKeyword))) + continue; + + context.RegisterCodeFix( + CodeAction.Create( + title: $"Make '{declaration.Identifier.ValueText}' public", + createChangedDocument: cancellationToken => + MakePublicAsync(context.Document, declaration, cancellationToken), + equivalenceKey: nameof(MakeCaseTypePublicCodeFixProvider) + ), + diagnostic + ); + } + } + + static async Task MakePublicAsync( + Document document, + TypeDeclarationSyntax declaration, + CancellationToken cancellationToken + ) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) + return document; + + var declarations = declaration.AncestorsAndSelf().OfType().ToArray(); + var updatedRoot = root.ReplaceNodes(declarations, static (_, rewritten) => AddPublicModifier(rewritten)); + + return document.WithSyntaxRoot(updatedRoot); + } + + static TypeDeclarationSyntax AddPublicModifier(TypeDeclarationSyntax declaration) + { + if (declaration.Modifiers.Any(SyntaxKind.PublicKeyword) || declaration.Modifiers.Any(SyntaxKind.FileKeyword)) + { + return declaration; + } + + var existing = declaration.Modifiers; + var leading = existing.Count > 0 ? existing[0].LeadingTrivia : declaration.Keyword.LeadingTrivia; + var publicToken = SyntaxFactory + .Token(SyntaxKind.PublicKeyword) + .WithLeadingTrivia(leading) + .WithTrailingTrivia(SyntaxFactory.Space); + + var kept = existing + .Where(static modifier => !IsAccessibilityModifier(modifier.Kind())) + .Select(static modifier => modifier.WithLeadingTrivia(default(SyntaxTriviaList))); + + var modified = declaration.WithModifiers(SyntaxFactory.TokenList(publicToken).AddRange(kept)); + + // With no existing modifiers the leading trivia lived on the declaration keyword, so move it to + // the new first token instead. + return existing.Count == 0 + ? modified.WithKeyword(declaration.Keyword.WithLeadingTrivia(default(SyntaxTriviaList))) + : modified; + } + + static bool IsAccessibilityModifier(SyntaxKind kind) => + kind + is SyntaxKind.PublicKeyword + or SyntaxKind.ProtectedKeyword + or SyntaxKind.InternalKeyword + or SyntaxKind.PrivateKeyword + or SyntaxKind.FileKeyword; +} diff --git a/src/src/SourceGenerator.CodeFixes/SourceGenerator.CodeFixes.csproj b/src/src/SourceGenerator.CodeFixes/SourceGenerator.CodeFixes.csproj index 467fdeb..d608cf4 100644 --- a/src/src/SourceGenerator.CodeFixes/SourceGenerator.CodeFixes.csproj +++ b/src/src/SourceGenerator.CodeFixes/SourceGenerator.CodeFixes.csproj @@ -10,7 +10,6 @@ components share only public diagnostic ids. --> true - netstandard2.0 Purview Unions Source Generator Code Fixes IDE code fixes for the Purview.Unions source generator: add the missing partial modifier (UNI0001) and, on the documented fallback path, add the compiler's [Union] attribute (UNI0007). diff --git a/src/src/SourceGenerator.CodeFixes/UnionsDiagnosticIds.cs b/src/src/SourceGenerator.CodeFixes/UnionsDiagnosticIds.cs index f0783ec..f43469f 100644 --- a/src/src/SourceGenerator.CodeFixes/UnionsDiagnosticIds.cs +++ b/src/src/SourceGenerator.CodeFixes/UnionsDiagnosticIds.cs @@ -19,4 +19,9 @@ public static class UnionsDiagnosticIds /// The compiler's union attribute is required but missing. /// public const string UnionAttributeRequired = "UNI0007"; + + /// + /// A union case type is less accessible than the union. + /// + public const string UnionCaseTypeNotAccessible = "UNI0005"; } diff --git a/src/src/SourceGenerator/Analyzers/UnionMembersNestingSuppressor.cs b/src/src/SourceGenerator/Analyzers/UnionMembersNestingSuppressor.cs new file mode 100644 index 0000000..9f0c429 --- /dev/null +++ b/src/src/SourceGenerator/Analyzers/UnionMembersNestingSuppressor.cs @@ -0,0 +1,130 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Purview.Unions.SourceGenerator.Common; +using Purview.Unions.SourceGenerator.Discovery; + +namespace Purview.Unions.SourceGenerator.Analyzers; + +/// +/// Suppresses CA1034 (nested types should not be visible) and CA1515 (public types should be +/// internal) on the IUnionMembers provider the C# union contract requires a union to nest. +/// +/// +/// +/// The language finds a union's members on a public nested interface named IUnionMembers. A +/// public nested type is exactly what CA1034 asks you not to write, and an internal union's public +/// nested provider is exactly what CA1515 asks you to make internal. Under a strict analyzer set every +/// surface-C union fails the build for following the language contract. The advice cannot be followed: +/// making the interface internal removes the union semantics. +/// +/// +/// The suppression is deliberately narrow. It applies only when the reported type is an interface named +/// IUnionMembers whose immediate containing type is a union (a type carrying +/// [GenerateUnion] or [Union]). A type of that name nested in an ordinary type, and any other +/// nested type in a union, keep the warnings. +/// +/// +/// These are suppression descriptors, not reported rules, so they are not release-tracked. UNI0000 +/// turns the CA1034 suppression off and UNI9001 turns the CA1515 suppression off. +/// +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class UnionMembersNestingSuppressor : DiagnosticSuppressor +{ + /// + /// The suppression of CA1034 on a union's IUnionMembers provider. + /// + public static readonly SuppressionDescriptor SuppressNestedUnionMembers = new( + "UNI0000", + "CA1034", + "IUnionMembers is required by the C# union contract to be a public nested interface, so CA1034 cannot be satisfied without removing the union semantics." + ); + + /// + /// The suppression of CA1515 on a union's IUnionMembers provider. + /// + public static readonly SuppressionDescriptor SuppressNestedUnionMembersVisibility = new( + "UNI9001", + "CA1515", + "IUnionMembers is required by the C# union contract to be a public nested interface, so CA1515 cannot be satisfied without removing the union semantics." + ); + + /// + public override ImmutableArray SupportedSuppressions => + [SuppressNestedUnionMembers, SuppressNestedUnionMembersVisibility]; + + /// + public override void ReportSuppressions(SuppressionAnalysisContext context) + { + foreach (var diagnostic in context.ReportedDiagnostics) + { + var descriptor = diagnostic.Id switch + { + "CA1034" => SuppressNestedUnionMembers, + "CA1515" => SuppressNestedUnionMembersVisibility, + _ => null, + }; + + if (descriptor is null || !IsUnionMembersProvider(context, diagnostic)) + continue; + + context.ReportSuppression(Suppression.Create(descriptor, diagnostic)); + } + } + + /// + /// Determines whether the reported diagnostic sits on a union's IUnionMembers provider. + /// + /// + /// CA1034 is reported at the nested type's identifier, so the location is resolved to the nearest + /// enclosing declaration that declares a type. The innermost one wins: a different nested type in a union + /// does not become suppressible just because its containing type is a union. + /// + static bool IsUnionMembersProvider(SuppressionAnalysisContext context, Diagnostic diagnostic) + { + var location = diagnostic.Location; + if (!location.IsInSource || location.SourceTree is not { } tree) + return false; + + var semanticModel = context.GetSemanticModel(tree); + var root = tree.GetRoot(context.CancellationToken); + var node = root.FindNode(location.SourceSpan, getInnermostNodeForTie: true); + + for (var current = node; current is not null; current = current.Parent) + { + if (semanticModel.GetDeclaredSymbol(current, context.CancellationToken) is not INamedTypeSymbol type) + continue; + + return + type.TypeKind != TypeKind.Interface + || !string.Equals(type.Name, PropertyLibrary.UnionMembersInterfaceName, StringComparison.Ordinal) + ? false + : type.ContainingType is { } containingType && IsUnion(containingType); + } + + return false; + } + + /// + /// Determines whether a type is a union. + /// + /// + /// Both attributes are checked: [GenerateUnion] is what the consumer writes, and [Union] is + /// what the compiler recognises (the generator emits it, or the BCL supplies it on net11.0). The + /// source check keeps the suppressor correct when generation is disabled. + /// + static bool IsUnion(INamedTypeSymbol type) + { + if (UnionAnalysis.HasGenerateUnionAttribute(type)) + return true; + + foreach (var attribute in type.GetAttributes()) + { + if (UnionsTypeLibrary.UnionAttribute.Matches(attribute.AttributeClass)) + return true; + } + + return false; + } +} diff --git a/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs b/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs index e2efb2c..033f520 100644 --- a/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs +++ b/src/src/SourceGenerator/Diagnostics/DiagnosticLibrary.cs @@ -76,11 +76,11 @@ static class DiagnosticLibrary public static readonly DiagnosticDescriptor InaccessibleCaseType = new( id: "UNI0005", title: "Union case type is not accessible", - messageFormat: "The case type '{0}' is not public, so the public union constructor generated for '{1}' could not expose it; make the case type public", + messageFormat: "The case type '{0}' is less accessible than the union '{1}', so the public union constructor generated for it could not expose it; make the case type at least as accessible as the union", category: Category, defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true, - description: "Every union creation member must be public. A public constructor cannot take a less accessible parameter type, so every case type must be public." + description: "A public union's creation members are public API, so every case type must be public. An internal union's creation members are effectively internal, so an internal case type is enough; the case type must be accessible from the union's declaration." ); /// diff --git a/src/src/SourceGenerator/Discovery/UnionAnalysis.cs b/src/src/SourceGenerator/Discovery/UnionAnalysis.cs index 45592d2..d35c41c 100644 --- a/src/src/SourceGenerator/Discovery/UnionAnalysis.cs +++ b/src/src/SourceGenerator/Discovery/UnionAnalysis.cs @@ -127,6 +127,7 @@ is not { } resolution if ( TryCreateCases( + union, resolution, compilation, displayName, @@ -304,6 +305,7 @@ CancellationToken cancellationToken } static ImmutableArray? TryCreateCases( + INamedTypeSymbol union, CaseResolution resolution, Compilation compilation, string displayName, @@ -314,8 +316,8 @@ CancellationToken cancellationToken ) { var builder = ImmutableArray.CreateBuilder(resolution.Cases.Length); - List exposedTypes = new(resolution.Cases.Length); - HashSet seen = new(StringComparer.Ordinal); + List exposedTypes = [with(resolution.Cases.Length)]; + HashSet seen = [with(StringComparer.Ordinal)]; var blocked = false; foreach (var caseSymbol in resolution.Cases) @@ -347,7 +349,7 @@ out var storageType continue; } - if (!IsPubliclyAccessible(exposed)) + if (!IsCaseAccessible(exposed, union, compilation)) { Add(findings, DiagnosticLibrary.InaccessibleCaseType, caseLocation, caseName, displayName); blocked = true; @@ -390,6 +392,7 @@ out var storageType if (!TryValidateCaseConversions(compilation, builder, exposedTypes, displayName, declarationLocation, findings)) return null; + // The builder is already in declaration order, so the tag values are correct. return builder.ToImmutable(); } @@ -763,7 +766,7 @@ static bool CaseSetsEqual(ImmutableArray left, ImmutableArray leftNames = new(StringComparer.Ordinal); + HashSet leftNames = [with(StringComparer.Ordinal)]; foreach (var type in left) { if (!TryNormalizeCase(type, out var exposed, out _, out _, out _)) @@ -882,44 +885,81 @@ symbol is INamedTypeSymbol named } /// - /// Determines whether a type and everything it references is public, so a public constructor can - /// expose it. + /// Determines whether a case type can be exposed by the public creation member generated for the union. /// - static bool IsPubliclyAccessible(ITypeSymbol type) + /// + /// The generated constructor is public, but its effective accessibility is capped by the union and its + /// containing types. A public union therefore needs public case types, while an internal or private union + /// can use a case type that is at least as accessible as the union and reachable from its declaration. + /// This is what lets an internal union use internal case types without a warning. + /// + static bool IsCaseAccessible(ITypeSymbol caseType, INamedTypeSymbol union, Compilation compilation) + { + if (caseType is ITypeParameterSymbol) + return true; + + if (!compilation.IsSymbolAccessibleWithin(caseType, union)) + return false; + + // The effective accessibility rank is conservative for the protected forms: + // a protected or protected-internal union requires a public case type, which is always safe. + return EffectiveAccessibilityRank(caseType) >= EffectiveAccessibilityRank(union); + } + + /// + /// Computes the effective accessibility of a type as a rank. + /// + /// + /// Accessibility domains are not a total order (protected and internal are incomparable), but the rank + /// is only used to decide whether a case type is at least as accessible as the union. It is conservative + /// for the protected forms: a protected or protected-internal union requires a public case type, which + /// is always safe. + /// + static int EffectiveAccessibilityRank(ITypeSymbol type) { switch (type) { case ITypeParameterSymbol: - return true; + return AccessibilityRank(Accessibility.Public); case IArrayTypeSymbol array: - return IsPubliclyAccessible(array.ElementType); + return EffectiveAccessibilityRank(array.ElementType); case INamedTypeSymbol named: { if (named.IsFileLocal || named.IsUnboundGenericType) - return false; + return 0; - foreach (var argument in named.TypeArguments) - { - if (!IsPubliclyAccessible(argument)) - return false; - } + var rank = AccessibilityRank(named.DeclaredAccessibility); - for (var current = named; current is not null; current = current.ContainingType) - { - if (current.DeclaredAccessibility != Accessibility.Public) - return false; - } + for (var current = named.ContainingType; current is not null; current = current.ContainingType) + rank = Math.Min(rank, AccessibilityRank(current.DeclaredAccessibility)); - return true; + foreach (var argument in named.TypeArguments) + rank = Math.Min(rank, EffectiveAccessibilityRank(argument)); + + return rank; } default: - return false; + return 0; } } + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0072:Add missing cases")] + static int AccessibilityRank(Accessibility accessibility) => + accessibility switch + { + Accessibility.Public => 6, + Accessibility.ProtectedOrInternal => 5, + Accessibility.Protected => 4, + Accessibility.Internal => 3, + Accessibility.ProtectedAndInternal => 2, + Accessibility.Private => 1, + _ => 0, + }; + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0072:Add missing cases")] static TypeDeclarationKind? GetDeclarationKind(INamedTypeSymbol symbol) => symbol.TypeKind switch { diff --git a/src/src/SourceGenerator/Emit/PolyfillEmitter.cs b/src/src/SourceGenerator/Emit/PolyfillEmitter.cs index 44a32d3..911e0b6 100644 --- a/src/src/SourceGenerator/Emit/PolyfillEmitter.cs +++ b/src/src/SourceGenerator/Emit/PolyfillEmitter.cs @@ -68,7 +68,7 @@ public static void Emit(SourceProductionContext context, GeneratorContext genera body => body.Property( PropertyLibrary.ValueMemberName, - TypeIdentity.Create().MakeNullable(), + TypeIdentity.Create().MakeNullable(body), TypeDeclarationAccessibility.Public, options => options with { HasGetter = true } ) diff --git a/src/src/SourceGenerator/Emit/UnionEmitter.cs b/src/src/SourceGenerator/Emit/UnionEmitter.cs index 6964d69..437667e 100644 --- a/src/src/SourceGenerator/Emit/UnionEmitter.cs +++ b/src/src/SourceGenerator/Emit/UnionEmitter.cs @@ -72,39 +72,36 @@ static void EmitUnion(CodeWriter writer, UnionModel model) : [], }; - writer.Type( - options, - body => + writer.Type(options, body => + { + if (!model.EmitMembers) { - if (!model.EmitMembers) - { - body.Comment( - "The declaration is hand-written: its constructors and access members already exist, so only the compiler attribute is generated." - ); - - return; - } + body.Comment( + "The declaration is hand-written: its constructors and access members already exist, so only the compiler attribute is generated." + ); - EmitFields(body, model); - EmitConstructors(body, model); - EmitValue(body, model); - EmitHasValue(body, model); - EmitTryGetValue(body, model); - EmitFactories(body, model, reference); - EmitProviderFactories(body, model, reference); + return; } - ); + + EmitFields(body, model); + EmitConstructors(body, model); + EmitValue(body, model); + EmitHasValue(body, model); + EmitTryGetValue(body, model); + EmitFactories(body, model, reference); + EmitProviderFactories(body, model, reference); + }); } static void EmitFields(CodeWriter writer, UnionModel model) { foreach (var unionCase in model.Cases) { - writer.Field( - unionCase.FieldName, - unionCase.StorageType, - TypeDeclarationAccessibility.Private, - options => options with { IsReadOnly = true } + writer.Field(unionCase.FieldName, unionCase.StorageType, TypeDeclarationAccessibility.Private, options => + options with + { + IsReadOnly = true, + } ); } @@ -203,7 +200,7 @@ static void EmitValue(CodeWriter writer, UnionModel model) writer.Property( PropertyLibrary.ValueMemberName, - TypeIdentity.Create().MakeNullable(), + TypeIdentity.Create().MakeNullable(writer), TypeDeclarationAccessibility.Public, body => body.Return($"{PropertyLibrary.TagFieldName} switch {{ {arms}_ => null }}"), null @@ -336,7 +333,7 @@ options with if (unionCase.RequiresNullCheck) body.IfBlock($"{PropertyLibrary.ValueParameterName} is null", b => b.Return("default")); - List arguments = new(model.Cases.Count + 1); + List arguments = [with(model.Cases.Count + 1)]; foreach (var other in model.Cases) { arguments.Add(other.Tag == unionCase.Tag ? ActiveValueExpression(unionCase) : "default"); diff --git a/src/src/SourceGenerator/README.md b/src/src/SourceGenerator/README.md index 20a928d..daadd81 100644 --- a/src/src/SourceGenerator/README.md +++ b/src/src/SourceGenerator/README.md @@ -47,6 +47,10 @@ keeps the two in sync. `UNI0001`–`UNI0016` and `UNI9000`; see `docs/wiki/Diagnostics.md`. `AnalyzerReleases.Unshipped.md` is the release-tracked catalogue (RS2008). +`UNI0000` and `UNI9001` are suppression descriptors, not rules: the assembly also ships +`UnionMembersNestingSuppressor`, which suppresses `CA1034` and `CA1515` on a union's public nested +`IUnionMembers` provider. + ## Tests `src/tests/SourceGenerator.UnitTests` contains the Phase 0 compiler experiments, generation and determinism diff --git a/src/src/SourceGenerator/SourceGenerator.csproj b/src/src/SourceGenerator/SourceGenerator.csproj index 8a3c967..b547166 100644 --- a/src/src/SourceGenerator/SourceGenerator.csproj +++ b/src/src/SourceGenerator/SourceGenerator.csproj @@ -10,8 +10,8 @@ - netstandard2.0 Purview Unions Source Generator .NET source generator that emits the C# 15 non-boxing custom-union shape for [GenerateUnion] declarations, so value-type union cases are never boxed by construction or pattern matching. source-generator;unions;union;csharp-15;net10;net11;boxing;purview; @@ -25,7 +25,7 @@ - + @@ -48,19 +48,10 @@ - - - - + diff --git a/src/src/SourceGenerator/UnionsSourceGenerator.cs b/src/src/SourceGenerator/UnionsSourceGenerator.cs index 2ee5455..7c3d62a 100644 --- a/src/src/SourceGenerator/UnionsSourceGenerator.cs +++ b/src/src/SourceGenerator/UnionsSourceGenerator.cs @@ -51,9 +51,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // One source output per union. The analyzer reports the per-target diagnostics; this callback // reports only an unexpected generator failure. var withContext = targets.Combine(generationContext); - context.RegisterSourceOutput( - withContext, - static (sourceProductionContext, item) => ExecuteSafely(sourceProductionContext, item.Left, item.Right) + context.RegisterSourceOutput(withContext, static (sourceProductionContext, item) => + ExecuteSafely(sourceProductionContext, item.Left, item.Right) ); // The .NET 10 polyfills are emitted only when the compiler's union types are absent, at least one @@ -79,14 +78,11 @@ public void Initialize(IncrementalGeneratorInitializationContext context) ) .WithTrackingName("GetUnionPolyfillRequest"); - context.RegisterSourceOutput( - polyfill, - static (sourceProductionContext, request) => - { - if (request.Emit) - EmitPolyfillsSafely(sourceProductionContext, request.Context); - } - ); + context.RegisterSourceOutput(polyfill, static (sourceProductionContext, request) => + { + if (request.Emit) + EmitPolyfillsSafely(sourceProductionContext, request.Context); + }); } /// diff --git a/src/src/Unions/GenerateUnionAttribute.cs b/src/src/Unions/GenerateUnionAttribute.cs index 369b007..3114119 100644 --- a/src/src/Unions/GenerateUnionAttribute.cs +++ b/src/src/Unions/GenerateUnionAttribute.cs @@ -20,18 +20,16 @@ namespace Purview.Unions; /// because typeof(T) is not a legal attribute argument (CS0416). /// /// +/// +/// Initializes a new instance of the class with the case types +/// in declaration order. +/// +/// The case types of the generated union. [AttributeUsage(AttributeTargets.Struct | AttributeTargets.Class, AllowMultiple = false, Inherited = false)] -public sealed class GenerateUnionAttribute : Attribute +public sealed class GenerateUnionAttribute(params Type[] cases) : Attribute { - /// - /// Initializes a new instance of the class with the case types - /// in declaration order. - /// - /// The case types of the generated union. - public GenerateUnionAttribute(params Type[] cases) => Cases = cases; - /// /// Gets the case types of the generated union, in declaration order. /// - public IReadOnlyList Cases { get; } + public IReadOnlyList Cases { get; } = cases; } diff --git a/src/src/Unions/Sdk/README.md b/src/src/Unions/Sdk/README.md index 866ff80..4a70484 100644 --- a/src/src/Unions/Sdk/README.md +++ b/src/src/Unions/Sdk/README.md @@ -1,6 +1,7 @@ # Purview.Unions [![NuGet version](https://img.shields.io/nuget/v/Purview.Unions.svg)](https://www.nuget.org/packages/Purview.Unions) +[![Release](https://github.com/purview-dev/unions/actions/workflows/release.yml/badge.svg)](https://github.com/purview-dev/unions/actions/workflows/release.yml) Dependency-free **C# 15 unions that do not box value-type cases**. The `union` keyword stores one `object?`, so every value-type case is boxed. This package generates the language's **custom union type** @@ -57,10 +58,11 @@ matching does not use it. ## Requirements -- .NET 11 SDK (C# 15) with `LangVersion=preview` in the project that declares a union. -- `net10.0` or `net11.0`. On `net10.0` the generator emits internal `[Embedded]` polyfills; `net11.0` uses - the BCL types and never receives a duplicate. -- Do not set `EnablePreviewFeatures`; `LangVersion=preview` is enough. +- .NET 11 SDK (C# 15, GA). +- `net10.0` or `net11.0`. A `net11.0` project gets C# 15 by default and needs no `LangVersion`; a `net10.0` + project sets `latest` because its default is C# 14. On `net10.0` the generator + emits internal `[Embedded]` polyfills; `net11.0` uses the BCL types and never receives a duplicate. +- Do not set `EnablePreviewFeatures`; `LangVersion=latest` (only on `net10.0`) is enough. ## Supported declarations @@ -72,8 +74,11 @@ takes `int?`, and `TryGetValue(out int)` returns false for null. ## Diagnostics -`UNI0001`–`UNI0016` and `UNI9000`. See `docs/wiki/Diagnostics.md` in the repository. Code fixes add a missing -`partial` (`UNI0001`) and, on the fallback path, the `[Union]` attribute (`UNI0007`). +`UNI0001`–`UNI0016` and `UNI9000`. See the +[Diagnostics](https://github.com/purview-dev/unions/blob/main/docs/wiki/Diagnostics.md) page. Code fixes add a +missing `partial` (`UNI0001`), make an inaccessible case type public (`UNI0005`) and, on the fallback path, the +`[Union]` attribute (`UNI0007`). `UNI0000` and `UNI9001` are suppression descriptors, not rules: they suppress +`CA1034` and `CA1515` on a union's nested `IUnionMembers` provider. ## Switches @@ -81,6 +86,16 @@ takes `int?`, and `TryGetValue(out int)` returns false for null. | --- | --- | --- | | `DisableUnionsSourceGenerator` | `false` | Disables generation and the polyfills | +## Agent skills + +The `purview-unions-quickstart`, `purview-unions-modelling`, `purview-unions-diagnostics` and +`purview-unions-interop` agent skills cover this package. No package ships skills: install them from the git +repository with the skills CLI: + +```bash +npx skills add purview-dev/unions +``` + ## Licence MIT. diff --git a/src/src/Unions/Union{T1,T2}.cs b/src/src/Unions/Union.cs similarity index 100% rename from src/src/Unions/Union{T1,T2}.cs rename to src/src/Unions/Union.cs diff --git a/src/tests/Repository.UnitTests/Repository.UnitTests.csproj b/src/tests/Repository.UnitTests/Repository.UnitTests.csproj index ac656ff..3e0e400 100644 --- a/src/tests/Repository.UnitTests/Repository.UnitTests.csproj +++ b/src/tests/Repository.UnitTests/Repository.UnitTests.csproj @@ -6,8 +6,6 @@ reach, so running them once is the whole job, and Purview.Skills.Testing ships net10.0 only. --> net10.0 - false - Purview Unions Repository Unit Tests diff --git a/src/tests/Repository.UnitTests/RepositoryConfigTests.cs b/src/tests/Repository.UnitTests/RepositoryConfigTests.cs index c2e41c1..67743c3 100644 --- a/src/tests/Repository.UnitTests/RepositoryConfigTests.cs +++ b/src/tests/Repository.UnitTests/RepositoryConfigTests.cs @@ -38,16 +38,6 @@ public async Task Solution_ReferencesTheRealBuildConfiguration(CancellationToken await Assert.That(File.Exists(Path.Combine(RepositoryRoot.Value, ".config", "purview-build.json"))).IsTrue(); } - [Test] - public async Task PrWorkflow_TestFilterMatchesTheBuildConfiguration(CancellationToken cancellationToken) - { - var config = await ReadAsync(".config/purview-build.json", cancellationToken); - var workflow = await ReadAsync(".github/workflows/pr.yml", cancellationToken); - - await Assert.That(config).Contains("/*/*/*/*[Category=Unit]"); - await Assert.That(workflow).Contains("/*/*/*/*[Category=Unit]"); - } - [Test] public async Task PerformanceStructure_IsWiredIntoTheRepository(CancellationToken cancellationToken) { @@ -63,6 +53,29 @@ public async Task PerformanceStructure_IsWiredIntoTheRepository(CancellationToke await Assert.That(readme).Contains("docs/wiki/Performance.md"); } + [Test] + public async Task SourceProjects_KeepTheDependencyDirectionOneWay(CancellationToken cancellationToken) + { + foreach ( + var project in new[] + { + "src/src/Unions/Unions.csproj", + "src/src/SourceGenerator/SourceGenerator.csproj", + "src/src/SourceGenerator.CodeFixes/SourceGenerator.CodeFixes.csproj", + } + ) + { + var text = await ReadAsync(project, cancellationToken); + + await Assert + .That(text) + .DoesNotContain("The descriptor whose id the synthetic diagnostic carries. /// A token to monitor for cancellation requests. /// The rewritten source, or the original source when no fix was offered. + public static Task ApplyAsync( + string source, + DiagnosticDescriptor descriptor, + CancellationToken cancellationToken + ) + where TProvider : CodeFixProvider, new() => + ApplyAsync(source, descriptor, targetTypeName: null, cancellationToken); + + /// + /// Applies the first fix offered by to the named type declaration. + /// + /// The code-fix provider to run. + /// The source containing the union declaration. + /// The descriptor whose id the synthetic diagnostic carries. + /// The simple name of the declaration to target, or null for the first. + /// A token to monitor for cancellation requests. + /// The rewritten source, or the original source when no fix was offered. [System.Diagnostics.CodeAnalysis.SuppressMessage( "Maintainability", "CA1506:Avoid excessive class coupling", @@ -37,10 +54,13 @@ static class CodeFixHarness public static async Task ApplyAsync( string source, DiagnosticDescriptor descriptor, + string? targetTypeName, CancellationToken cancellationToken ) where TProvider : CodeFixProvider, new() { + // The pinned Roslyn 5.9.0 package's Latest is C# 14; parse under Preview so the C# 15 union syntax + // is available. The SDK compiler that builds consumers accepts it with LangVersion=latest. CSharpParseOptions parseOptions = new(LanguageVersion.Preview); CSharpCompilationOptions compilationOptions = new( OutputKind.DynamicallyLinkedLibrary, @@ -51,10 +71,18 @@ CancellationToken cancellationToken var tree = CSharpSyntaxTree.ParseText(source, parseOptions, cancellationToken: cancellationToken); var compilation = CSharpCompilation.Create("CodeFixTest", [tree], references, compilationOptions); - var declaration = (await tree.GetRootAsync(cancellationToken)) + var declarations = (await tree.GetRootAsync(cancellationToken)) .DescendantNodes() - .OfType() - .First(); + .OfType(); + var declaration = targetTypeName is null + ? declarations.FirstOrDefault() + : declarations.FirstOrDefault(type => + string.Equals(type.Identifier.ValueText, targetTypeName, StringComparison.Ordinal) + ); + + if (declaration is null) + return source; + var diagnostic = Diagnostic.Create(descriptor, declaration.Identifier.GetLocation()); var projectId = ProjectId.CreateNewId(); @@ -105,9 +133,7 @@ static ImmutableArray MetadataReferences() foreach (var path in trusted) builder.Add(MetadataReference.CreateFromFile(path)); - builder.Add( - MetadataReference.CreateFromFile(typeof(global::Purview.Unions.GenerateUnionAttribute).Assembly.Location) - ); + builder.Add(MetadataReference.CreateFromFile(typeof(GenerateUnionAttribute).Assembly.Location)); return builder.ToImmutable(); } diff --git a/src/tests/SourceGenerator.UnitTests/CodeFixTests.cs b/src/tests/SourceGenerator.UnitTests/CodeFixTests.cs index 12e395f..6c1eaa9 100644 --- a/src/tests/SourceGenerator.UnitTests/CodeFixTests.cs +++ b/src/tests/SourceGenerator.UnitTests/CodeFixTests.cs @@ -16,6 +16,9 @@ public async Task DiagnosticIds_MatchTheGeneratorCatalogue() await Assert .That(UnionsDiagnosticIds.UnionAttributeRequired) .IsEqualTo(DiagnosticLibrary.UnionAttributeRequired.Id); + await Assert + .That(UnionsDiagnosticIds.UnionCaseTypeNotAccessible) + .IsEqualTo(DiagnosticLibrary.InaccessibleCaseType.Id); } [Test] @@ -39,6 +42,58 @@ namespace Test; await Assert.That(fixedSource).Contains("partial struct IntOrString"); } + [Test] + public async Task UNI0005_MakesTheCaseTypePublic(CancellationToken cancellationToken) + { + const string source = """ + using Purview.Unions; + + namespace Test; + + internal readonly record struct Hidden(int Value); + + [GenerateUnion(typeof(Hidden), typeof(string))] + public readonly partial struct HiddenOrString; + """; + + var fixedSource = await CodeFixHarness.ApplyAsync( + source, + DiagnosticLibrary.InaccessibleCaseType, + "Hidden", + cancellationToken + ); + + await Assert.That(fixedSource).Contains("public readonly record struct Hidden"); + } + + [Test] + public async Task UNI0005_MakesContainingTypesPublic(CancellationToken cancellationToken) + { + const string source = """ + using Purview.Unions; + + namespace Test; + + internal class Container + { + internal readonly record struct Hidden(int Value); + } + + [GenerateUnion(typeof(Container.Hidden), typeof(string))] + public readonly partial struct HiddenOrString; + """; + + var fixedSource = await CodeFixHarness.ApplyAsync( + source, + DiagnosticLibrary.InaccessibleCaseType, + "Hidden", + cancellationToken + ); + + await Assert.That(fixedSource).Contains("public class Container"); + await Assert.That(fixedSource).Contains("public readonly record struct Hidden"); + } + [Test] public async Task UNI0007_AddsTheUnionAttribute(CancellationToken cancellationToken) { diff --git a/src/tests/SourceGenerator.UnitTests/CrossGeneratorIntegrationTests.cs b/src/tests/SourceGenerator.UnitTests/CrossGeneratorIntegrationTests.cs deleted file mode 100644 index d54d396..0000000 --- a/src/tests/SourceGenerator.UnitTests/CrossGeneratorIntegrationTests.cs +++ /dev/null @@ -1,36 +0,0 @@ -namespace Purview.Unions.SourceGenerator; - -/// -/// Cross-generator integration, recorded as skipped tests until the downstream repositories read a union's -/// source case list. -/// -/// -/// Roslyn generators never see each other's output. A union generated here has its constructors and access -/// members in generated output, so Purview.Results and Purview.Telemetry cannot discover its -/// cases from the constructors. Surface C (a nested IUnionMembers provider) is source-visible and is -/// what those generators need; the follow-ups are recorded in docs/wiki/Interop.md. -/// -public sealed class CrossGeneratorIntegrationTests -{ - [Test] - [Skip( - "Purview.Results' [GenerateResult] discovery reads generated constructors, which a generator cannot see. Follow-up: purview-dev/results should read the source case list. See docs/wiki/Interop.md." - )] - public async Task GeneratedUnion_AsAResultsErrorUnion_IsDiscovered(CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - await Task.CompletedTask; - } - - [Test] - [Skip( - "A telemetry parameter typed as a surface-A union is not discovered because its cases live only in generated constructors. Surface C is source-visible and is the documented route; see docs/wiki/Interop.md." - )] - public async Task GeneratedUnion_AsATelemetryParameter_IsDiscovered(CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - await Task.CompletedTask; - } -} diff --git a/src/tests/SourceGenerator.UnitTests/DiagnosticsTests.cs b/src/tests/SourceGenerator.UnitTests/DiagnosticsTests.cs index b1538a9..31a3392 100644 --- a/src/tests/SourceGenerator.UnitTests/DiagnosticsTests.cs +++ b/src/tests/SourceGenerator.UnitTests/DiagnosticsTests.cs @@ -196,6 +196,75 @@ namespace Test; await Assert.That(result).HasDiagnostic("UNI0005"); } + [Test] + public async Task UNI0005_GivenInternalCaseTypeInInternalUnion_IsNotReported(CancellationToken cancellationToken) + { + const string source = """ + using Purview.Unions; + + namespace Test; + + internal readonly record struct Hidden(int Value); + + [GenerateUnion(typeof(Hidden), typeof(string))] + internal readonly partial struct HiddenOrString; + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result.Diagnostics.Select(static diagnostic => diagnostic.Id)).DoesNotContain("UNI0005"); + } + + [Test] + public async Task UNI0005_GivenInternalCaseTypesInInternalGenericUnion_IsNotReported( + CancellationToken cancellationToken + ) + { + const string source = """ + using Purview.Unions; + + namespace Test; + + internal readonly record struct Some(T Value); + + internal readonly record struct None; + + [GenerateUnion] + internal readonly partial struct Option + { + internal static partial Option Some(Some value); + + internal static partial Option None(None value); + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result.Diagnostics.Select(static diagnostic => diagnostic.Id)).DoesNotContain("UNI0005"); + } + + [Test] + public async Task UNI0005_GivenInternalCaseTypeInPublicGenericUnion_IsReported(CancellationToken cancellationToken) + { + const string source = """ + using Purview.Unions; + + namespace Test; + + internal readonly record struct Some(T Value); + + [GenerateUnion] + public readonly partial struct Option + { + internal static partial Option Some(Some value); + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostic("UNI0005"); + } + [Test] public async Task UNI0005_GivenPublicCaseType_IsNotReported(CancellationToken cancellationToken) { diff --git a/src/tests/SourceGenerator.UnitTests/GeneratedUnionRuntimeTests.cs b/src/tests/SourceGenerator.UnitTests/GeneratedUnionRuntimeTests.cs index 8b0233c..0c4b3c0 100644 --- a/src/tests/SourceGenerator.UnitTests/GeneratedUnionRuntimeTests.cs +++ b/src/tests/SourceGenerator.UnitTests/GeneratedUnionRuntimeTests.cs @@ -72,6 +72,19 @@ public async Task SurfaceB_GenericFactories_CreateTheCases(CancellationToken can await Assert.That(none.TryGetValue(out None _)).IsTrue(); } + [Test] + public async Task SurfaceB_InternalUnionAndInternalCases_CreateTheCases(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + InternalMaybe some = new InternalSome(7); + InternalMaybe none = new InternalNone(); + + await Assert.That(some.TryGetValue(out InternalSome value)).IsTrue(); + await Assert.That(value.Value).IsEqualTo(7); + await Assert.That(none.TryGetValue(out InternalNone _)).IsTrue(); + } + [Test] public async Task SurfaceC_ProviderFactories_CreateTheCases(CancellationToken cancellationToken) { diff --git a/src/tests/SourceGenerator.UnitTests/GeneratedUnions.cs b/src/tests/SourceGenerator.UnitTests/GeneratedUnions.cs index 41a9ba2..6acb7c9 100644 --- a/src/tests/SourceGenerator.UnitTests/GeneratedUnions.cs +++ b/src/tests/SourceGenerator.UnitTests/GeneratedUnions.cs @@ -19,6 +19,20 @@ public readonly partial struct Maybe public readonly record struct None; +// Surface B: an internal union with internal case types. The generated creation members are public +// members of an internal type, so an internal case type is sufficient; UNI0005 must not block it. +readonly record struct InternalSome(T Value); + +readonly record struct InternalNone; + +[GenerateUnion] +readonly partial struct InternalMaybe +{ + internal static partial InternalMaybe Some(InternalSome value); + + internal static partial InternalMaybe None(InternalNone value); +} + // Surface C: a generic union declared with a nested IUnionMembers provider. [GenerateUnion] public readonly partial struct Outcome : Outcome.IUnionMembers diff --git a/src/tests/SourceGenerator.UnitTests/GenerationTests.cs b/src/tests/SourceGenerator.UnitTests/GenerationTests.cs index f24e295..0d20ede 100644 --- a/src/tests/SourceGenerator.UnitTests/GenerationTests.cs +++ b/src/tests/SourceGenerator.UnitTests/GenerationTests.cs @@ -68,6 +68,39 @@ await Assert await Assert.That(generated!).ContainsGeneratedCode("public Option(global::Test.Some value)"); } + [Test] + public async Task SurfaceB_GivenInternalUnionAndInternalCases_GeneratesAndCompiles( + CancellationToken cancellationToken + ) + { + const string source = """ + using Purview.Unions; + + namespace Test; + + internal readonly record struct Some(T Value); + internal readonly record struct None; + + [GenerateUnion] + internal readonly partial struct Option + { + internal static partial Option Some(Some value); + internal static partial Option None(None value); + } + """; + + using var result = await GenerateAsync(source, cancellationToken); + result.AssertNoCompilationErrors(); + + var generated = result.GetSource("Test.Option_1.g.cs"); + await Assert.That(generated).IsNotNull(); + + await Assert + .That(generated!) + .ContainsGeneratedCode("internal static partial global::Test.Option Some(global::Test.Some value)"); + await Assert.That(generated!).ContainsGeneratedCode("public Option(global::Test.Some value)"); + } + [Test] public async Task SurfaceC_EmitsProviderFactoryImplementations(CancellationToken cancellationToken) { diff --git a/src/tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj b/src/tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj index c0b05fc..bc33a05 100644 --- a/src/tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj +++ b/src/tests/SourceGenerator.UnitTests/SourceGenerator.UnitTests.csproj @@ -5,8 +5,6 @@ syntax directly, these tests declare unions through [GenerateUnion], so the net10.0 leg is exercised against the generator's internal polyfills and the net11.0 leg against the BCL types. --> - false - Purview Unions Source Generator Unit Tests diff --git a/src/tests/SourceGenerator.UnitTests/UnionMembersNestingSuppressorTests.cs b/src/tests/SourceGenerator.UnitTests/UnionMembersNestingSuppressorTests.cs new file mode 100644 index 0000000..ecc7aa2 --- /dev/null +++ b/src/tests/SourceGenerator.UnitTests/UnionMembersNestingSuppressorTests.cs @@ -0,0 +1,270 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Purview.Unions.SourceGenerator.Analyzers; + +// The stand-in analyzer below reports the real CA1034 id and lives in a test assembly. These are +// analyzer-authoring rules for a shipped component; the stand-in is never packed, and the test assembly +// already references Workspaces for the code-fix tests. +#pragma warning disable RS1029 // CA1034 and CA1515 are the real rules under test. +#pragma warning disable RS1036 // This is a test assembly, not an analyzer project. +#pragma warning disable RS1038 // The test assembly references Workspaces for the code-fix tests. +#pragma warning disable RS1041 // The test assembly targets net10.0 and net11.0. +#pragma warning disable RS2008 // The stand-in is not shipped and has no release tracking. + +namespace Purview.Unions.SourceGenerator; + +/// +/// Tests for . +/// +/// +/// CA1034 is reported by a stand-in analyzer rather than by the real CodeQuality rule: a suppressor only +/// sees a diagnostic id and a location, so what is under test is which declarations the suppression lands +/// on, not the real rule's implementation. The stand-in reports on every nested type, which is what the +/// real rule does. +/// +public sealed class UnionMembersNestingSuppressorTests +{ + [Test] + public async Task CA1034_GivenUnionMembersProvider_IsSuppressed(CancellationToken cancellationToken) + { + const string source = """ + using Purview.Unions; + + namespace Test; + + [GenerateUnion] + public readonly partial struct Outcome + { + public interface IUnionMembers + { + object? Value { get; } + + bool HasValue { get; } + + bool TryGetValue(out T value); + } + } + """; + + var diagnostics = await AnalyzeAsync(source, cancellationToken); + + var diagnostic = diagnostics.Single(static entry => entry.Id == "CA1034"); + await Assert.That(diagnostic.IsSuppressed).IsTrue(); + } + + [Test] + public async Task CA1034_GivenIUnionMembersOutsideAUnion_IsNotSuppressed(CancellationToken cancellationToken) + { + const string source = """ + namespace Test; + + public readonly partial struct NotAUnion + { + public interface IUnionMembers { } + } + """; + + var diagnostics = await AnalyzeAsync(source, cancellationToken); + + var diagnostic = diagnostics.Single(static entry => entry.Id == "CA1034"); + await Assert.That(diagnostic.IsSuppressed).IsFalse(); + } + + [Test] + public async Task CA1034_GivenAnotherNestedTypeInAUnion_IsNotSuppressed(CancellationToken cancellationToken) + { + const string source = """ + using Purview.Unions; + + namespace Test; + + [GenerateUnion(typeof(int))] + public readonly partial struct IntOrString + { + public interface OtherNested { } + } + """; + + var diagnostics = await AnalyzeAsync(source, cancellationToken); + + var diagnostic = diagnostics.Single(static entry => entry.Id == "CA1034"); + await Assert.That(diagnostic.IsSuppressed).IsFalse(); + } + + [Test] + public async Task CA1515_GivenUnionMembersProvider_IsSuppressed(CancellationToken cancellationToken) + { + const string source = """ + using Purview.Unions; + + namespace Test; + + [GenerateUnion] + public readonly partial struct Outcome + { + public interface IUnionMembers + { + object? Value { get; } + + bool HasValue { get; } + + bool TryGetValue(out T value); + } + } + """; + + var diagnostics = await AnalyzeAsync(source, cancellationToken); + + var diagnostic = diagnostics.Single(static entry => entry.Id == "CA1515"); + await Assert.That(diagnostic.IsSuppressed).IsTrue(); + } + + [Test] + public async Task CA1515_GivenIUnionMembersOutsideAUnion_IsNotSuppressed(CancellationToken cancellationToken) + { + const string source = """ + namespace Test; + + public readonly partial struct NotAUnion + { + public interface IUnionMembers { } + } + """; + + var diagnostics = await AnalyzeAsync(source, cancellationToken); + + var diagnostic = diagnostics.Single(static entry => entry.Id == "CA1515"); + await Assert.That(diagnostic.IsSuppressed).IsFalse(); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Maintainability", + "CA1506:Avoid excessive class coupling", + Justification = "Test harness: composing a Roslyn compilation, analyzers and a suppressor necessarily touches many Roslyn types." + )] + static async Task AnalyzeAsync(string source, CancellationToken cancellationToken) + { + var tree = CSharpSyntaxTree.ParseText( + source, + new CSharpParseOptions(LanguageVersion.Preview), + cancellationToken: cancellationToken + ); + var compilation = CSharpCompilation.Create( + "UnionMembersNestingSuppressorTests", + [tree], + References(), + new CSharpCompilationOptions( + OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: NullableContextOptions.Enable + ) + ); + + var analyzers = ImmutableArray.Create( + new NestedTypeCa1034Analyzer(), + new NestedTypeCa1515Analyzer(), + new UnionMembersNestingSuppressor() + ); + + // reportSuppressedDiagnostics keeps a suppressed diagnostic in the results, carrying IsSuppressed. + // Without it a suppressed diagnostic is filtered out, and "suppressed" would be indistinguishable + // from "the stand-in never reported it". + CompilationWithAnalyzersOptions analysisOptions = new( + new AnalyzerOptions([]), + onAnalyzerException: null, + concurrentAnalysis: false, + logAnalyzerExecutionTime: false, + reportSuppressedDiagnostics: true + ); + + var diagnostics = await compilation + .WithAnalyzers(analyzers, analysisOptions) + .GetAllDiagnosticsAsync(cancellationToken); + + return [.. diagnostics.Where(static diagnostic => diagnostic.Id is "CA1034" or "CA1515")]; + } + + static ImmutableArray References() + { + var builder = ImmutableArray.CreateBuilder(); + + if (AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") is string trusted) + { + foreach (var path in trusted.Split(Path.PathSeparator)) + builder.Add(MetadataReference.CreateFromFile(path)); + } + + builder.Add(MetadataReference.CreateFromFile(typeof(GenerateUnionAttribute).Assembly.Location)); + + return builder.ToImmutable(); + } + + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class NestedTypeCa1034Analyzer : DiagnosticAnalyzer + { + static readonly DiagnosticDescriptor Rule = new( + "CA1034", + "Nested types should not be visible", + "Do not nest type {0}", + "Design", + DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + public override ImmutableArray SupportedDiagnostics => [Rule]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSymbolAction( + static symbolContext => + { + if (symbolContext.Symbol is INamedTypeSymbol { ContainingType: not null } type) + { + symbolContext.ReportDiagnostic( + Diagnostic.Create(Rule, type.Locations.FirstOrDefault(), type.Name) + ); + } + }, + SymbolKind.NamedType + ); + } + } + + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class NestedTypeCa1515Analyzer : DiagnosticAnalyzer + { + static readonly DiagnosticDescriptor Rule = new( + "CA1515", + "Consider making public types internal", + "Because an application's API isn't typically referenced from outside the assembly, types can be made internal", + "Design", + DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + public override ImmutableArray SupportedDiagnostics => [Rule]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSymbolAction( + static symbolContext => + { + if (symbolContext.Symbol is INamedTypeSymbol { ContainingType: not null } type) + { + symbolContext.ReportDiagnostic( + Diagnostic.Create(Rule, type.Locations.FirstOrDefault(), type.Name) + ); + } + }, + SymbolKind.NamedType + ); + } + } +} diff --git a/src/tests/SourceGenerator.UnitTests/UnionsSourceGeneratorTestOptions.cs b/src/tests/SourceGenerator.UnitTests/UnionsSourceGeneratorTestOptions.cs index 2799d8c..0315366 100644 --- a/src/tests/SourceGenerator.UnitTests/UnionsSourceGeneratorTestOptions.cs +++ b/src/tests/SourceGenerator.UnitTests/UnionsSourceGeneratorTestOptions.cs @@ -25,12 +25,14 @@ public record UnionsSourceGeneratorTestOptions : AnalyzerTestOptions /// public UnionsSourceGeneratorTestOptions() { - // A custom union and [Union] are C# 15 features. + // The pinned Roslyn 5.9.0 package's Latest is C# 14; the union syntax it must parse is exposed under + // Preview there. This is a test-harness detail, not a consumer requirement: the .NET 11 SDK compiler + // accepts C# 15 unions with LangVersion=latest, and a net11.0 project needs no switch at all. LanguageVersion = Microsoft.CodeAnalysis.CSharp.LanguageVersion.Preview; AdditionalAssemblyTypes = AdditionalAssemblyTypes.AddRange( - typeof(global::Purview.Unions.GenerateUnionAttribute), - typeof(global::Purview.Unions.Union) + typeof(GenerateUnionAttribute), + typeof(Union) ); AdditionalNamespaces = AdditionalNamespaces.Add("Purview.Unions"); diff --git a/src/tests/Unions.UnitTests/Unions.UnitTests.csproj b/src/tests/Unions.UnitTests/Unions.UnitTests.csproj index aafb1e7..e6c35ec 100644 --- a/src/tests/Unions.UnitTests/Unions.UnitTests.csproj +++ b/src/tests/Unions.UnitTests/Unions.UnitTests.csproj @@ -1,10 +1 @@ - - - false - Purview Unions Unit Tests - - - - - - +