diff --git a/.agents/skills/purview-architecture-testing/SKILL.md b/.agents/skills/purview-architecture-testing/SKILL.md new file mode 100644 index 00000000..0619225f --- /dev/null +++ b/.agents/skills/purview-architecture-testing/SKILL.md @@ -0,0 +1,183 @@ +--- +name: purview-architecture-testing +description: >- + Use when adding, changing or debugging architecture tests in a .NET solution - dependency-direction + rules, forbidden framework references, project naming checks, cycle detection - or when a boundary + keeps being broken in review and should fail the build instead. Adopts Purview.Architecture.Testing + and a single policy file rather than hand-maintained lists. +license: MIT +metadata: + author: Purview-Dev + version: "2.0" +--- + +# Purview architecture testing + +A boundary that only review enforces is a boundary that erodes. The job of this skill is to turn a +rule a reviewer keeps repeating into a test that fails the build, **without** producing a wall of +brittle assertions that everyone disables six months later. + +Prerequisite: know what the projects are *for*. See `purview-architecture`. + +## 1. Only test a rule that is real + +Before writing anything, answer: **has this rule been broken, or is it certain to be?** + +A test for a dependency nobody would plausibly add is cost with no benefit. Good candidates: + +- A direction that has already been violated once. +- A boundary that is load-bearing - Core staying free of EF Core, Composition being referenced by + nothing. +- A rule a newcomer cannot infer from the code. + +Bad candidates: anything the compiler already enforces, and style preferences dressed up as +architecture. + +## 2. Adopt the package; define the policy once + +The failure mode of architecture tests is a dependency matrix written twice - once in a wiki table and +once in assertions - which then disagree. The prose is the copy that drifts, because nothing fails +when it is wrong. + +So do not write the rule engine. `Purview.Architecture.Testing` ships it, so the matrix, naming, +sibling-independence, composition, forbidden-package, cycle, test-reference and waiver rules are +defined once and shared. Adoption is two files. + +**A policy** at `.config/architecture-policy.json`, pointing at the published schema so an editor +validates it as you type: + +```json +{ + "$schema": "https://raw.githubusercontent.com/purview-dev/architests/main/architecture-policy.schema.json", + "version": 2, + "sourceRoots": ["src"], + "responsibilities": [ + { + "name": "Core", + "aliases": ["Domain"], + "category": "domain", + "purpose": "The domain model, its invariants and its rules.", + "mayDependOn": ["Shared"], + "mustNotDependOn": ["Application", "Persistence", "Composition"], + "forbiddenPackagePrefixes": ["Microsoft.EntityFrameworkCore", "Microsoft.AspNetCore"] + } + ], + "waivers": [] +} +``` + +**One derived test class:** + +```csharp +[InheritsTests] +public sealed class ArchitectureTests : PurviewArchitectureTests; +``` + +That is the whole of the adoption. Each rule is a separate test, so a failure names the rule rather +than collapsing the set into one assertion, and the package validates the policy against its schema +first - a policy that does not match the schema may be silently missing a rule. + +The build-sdk ships a skill for the package itself, `purview-buildsdk-architecture-tests`, which is +authoritative on its API and rule ids. Read it rather than this page when you are extending or +debugging the package. + +Then add a test that the documentation matches the policy, so the table in the wiki is falsifiable +too. Full worked example: +[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Project-Responsibilities.md). + +## 3. Read project files, and assemblies + +Two sources, because they catch different things: + +| Source | Catches | Misses | +| -- | -- | -- | +| `.csproj` `ProjectReference` | The reference a reviewer actually added | A dependency arriving transitively | +| Compiled assembly references | A dependency that arrived indirectly | Intent — it cannot tell direct from transitive | + +The package's rules read the **project files**, because a declared `ProjectReference` is what a +reviewer adds and what the policy governs. Add a small hand-written test that reads the **compiled +assemblies** for the leak a project file cannot show - a namespace from the wrong layer reaching a +public type. + +Classify a project by the **first segment** of its name, so `Persistence.SqlServer` and `Persistence` +are both Persistence - qualification is a naming choice, never a different rule. The package does +this for you; a hand-written assembly test must do it too. + +## 4. The rules worth having + +| Rule | What it catches | +| -- | -- | +| Dependency matrix | Core referencing Persistence; Application referencing a concrete provider | +| Nothing references Composition | A circular implementation choice | +| Sibling independence | `Providers.GitHub` reaching into `Providers.AzureDevOps` | +| Forbidden package prefixes | EF Core in Core; ASP.NET hosting in a domain project | +| Cycle detection | A graph that MSBuild rejects with an unreadable message | +| Catch-all names | `Infrastructure`, `Services`, `Helpers` appearing as project names | +| No production → test reference | A test helper leaking into shipped code | + +The package provides all of these. Do not re-implement one; change the policy instead. + +## 5. Write failures someone can act on + +This is what decides whether the suite survives. A failure must name: + +1. The offending project. +2. The rule violated. +3. The specific reference that broke it. +4. The direction the dependency *should* run. +5. The likely fix. + +```text +'Application' (Application) references 'Persistence.SqlServer' (Persistence), but Application +must not depend on Persistence. Invert it: Persistence may depend on Application, or introduce +an abstraction that Application owns and Persistence implements. +``` + +Not `Expected collection to be empty`. Collect every violation and report them as a numbered list - +one test run should show all of them, not the first. The package already reports this way; keep the +same standard in any rule you add. + +## 6. Waivers, not suppressions + +A repository that predates the policy will violate it. Deleting the test or adding a blanket +`#pragma` makes the policy a lie. Record the departure in the policy instead: + +```json +{ + "project": "Infrastructure", + "mapsTo": "Adapters", + "rules": ["naming"], + "reason": "Holds one adapter, predates the catalogue; renaming churns the wiki and docs for no gain.", + "reviewedOn": "2026-10-15" +} +``` + +The package tests the waivers themselves: each must name a project that still exists, a rule, and a +substantive reason. A waiver for a deleted project is dead weight that hides the next one. + +Critically, a waiver excuses **the narrowest thing possible** - usually the name. `mapsTo` keeps the +project classified so every dependency rule still applies to it. Prefer a waiver to +`SkipRule`, which removes a rule for the whole repository with no record. + +## 7. Conventions + +- TUnit, matching the repository's other tests, adopted through `PurviewArchitectureTests` with + `[InheritsTests]`. +- Arrange/Act/Assert, one behaviour per test. +- Let the package find the repository root - it probes upward for the policy and stops at the `.git` + boundary. Override `RepositoryRoot` only when the policy does not sit above the test binary. +- Keep the policy file free of anything a rule cannot read; a field no rule consults is a rule that + looks enforced and is not. + +## Checks before you answer + +- Is every rule you added one that has been, or will be, broken? +- Is the matrix defined once, in the policy, with the documentation checked against it? +- Does each failure name the fix, not just the fact? +- Did you record a waiver rather than weaken a rule for existing code? + +## See also + +- `purview-architecture` — what the projects are for, and how they are named +- `purview-buildsdk-architecture-tests` — the package's own skill, authoritative on its API +- [Architecture as Tests](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Architecture-As-Tests.md) diff --git a/.agents/skills/purview-architecture/SKILL.md b/.agents/skills/purview-architecture/SKILL.md new file mode 100644 index 00000000..c04f3d61 --- /dev/null +++ b/.agents/skills/purview-architecture/SKILL.md @@ -0,0 +1,176 @@ +--- +name: purview-architecture +description: >- + Use when deciding the shape of a .NET solution that uses Purview libraries — how many projects, + whether to add a repository or CQRS layer, where validation lives, how an API and its clients share + a contract (Contracts, OpenAPI or Hybrid) and when a BFF is earned — or before generating + substantial implementation code. Establishes project context first, then applies DRY, KISS and + architecture proportional to the actual requirements rather than to an enterprise reference diagram. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" +--- + +# Purview architecture + +The job of this skill is to stop two failures that cost far more than they look: + +- **Architecture bought in advance.** Four projects, a repository interface and a mapping layer for an + application that has one table and one caller. +- **A recommendation made without context.** Advice about database-side GUID generation given to a + stateless CLI, or a Clean Architecture split proposed for a 300-line utility. + +So: establish context, then choose proportionally. Both steps are mandatory, and the first one is the +one that gets skipped. + +## 1. Establish context before deciding + +Before recommending a structure, adding a project, or generating substantial code, you must know four +things. **Read them out of the repository first** — asking a user a question the code already answers +is the most common way this skill is applied badly. + +| Dimension | Read it from | Ask only if | +| -- | -- | -- | +| Complexity (small / medium / large) | Number of functional areas, integrations and external consumers — not file count | The requirements are genuinely not written down anywhere | +| Project type | SDK attribute, `OutputType`, `Microsoft.NET.Sdk.Web`, an AppHost project, a `Main` | The repository is empty | +| Persistence | `PackageReference` to an EF Core provider, `DbContext`, a connection string, a `Purview.Containers` resource | Persistence is being introduced now | +| Telemetry | `Purview.Telemetry.SourceGenerator`, `ILogger` injection, `ActivitySource`, an OTLP exporter | Nothing is configured and you are about to add it | + +Identifier generation is a **conditional** dimension: resolve it only when persistence exists *and* +GUID keys are in play. See `purview-persistence`. + +Judge complexity on requirements, not size. A 2,000-line CLI with one output format is small. A +900-line service that reconciles three upstream systems and owns money movement is not. + +**Do not re-ask.** A dimension answered by repository configuration, existing source, a project file, +an earlier turn in the conversation, or a recorded architectural decision is answered. Ask concise, +targeted questions only for the unknowns that change your recommendation — and if nothing changes, +ask nothing. + +Full decision procedure: `references/project-context-discovery.md`. + +## 2. Choose proportionally + +| Scale | Shape | What you still do not add | +| -- | -- | -- | +| Small | One project, or two when a test project needs a non-trivial subject. Direct implementations. | Repository over `DbContext`, CQRS, a mapping layer, speculative interfaces | +| Medium | Explicit boundaries where complexity already hurts — usually a domain project and a host | Event sourcing, a generic infrastructure framework, a second infrastructure implementation nobody asked for | +| Large | Modularity, independent infrastructure implementations, enforceable boundaries (see the playbook's Architecture-As-Tests page) | Patterns adopted for symmetry rather than for a named problem | + +Architecture is allowed to arrive late. A boundary added when the second caller appears is cheap; a +boundary added in anticipation of a second caller that never appears is permanent. + +**Never add a project for symmetry.** "Domain, Application, Infrastructure, API" is a response to a +problem, not a starting position. If you cannot name the failure a layer prevents in this codebase, +do not add the layer. + +Full guidance and the per-pattern justification tests: `references/proportional-architecture.md`. + +## 3. Name what you do create + +A project name states a **responsibility**, optionally qualified by an implementation: + +```text +[.] +``` + +The responsibility decides the rules; the qualifier never changes them. Three rules people usually +get wrong: + +- **Do not qualify a single implementation.** `Persistence` is correct even when it uses SQL Server. + Rename when a second provider arrives, not in anticipation. +- **Do not create an empty parent.** `Persistence.SqlServer` + `Persistence.PostgreSql` with nothing + shared need no `Persistence` project. +- **Multiple implementations do not require multiple assemblies.** Two small adapters with the same + dependencies belong in one project. + +Never use a *category* as a project name — `Infrastructure`, `Services`, `Managers`, `Models`, +`Helpers`, `Common`. Nothing can be excluded from a category, so it grows without limit. And never +rename an established project just to match canonical terminology; `Domain`, `UseCases` and +`Integrations` are recognised aliases. + +Responsibility catalogue, dependency matrix and the naming decision tree: +`references/project-responsibilities.md`. + +## 4. Check the direction + +`A → B` means **A depends on B at compile time** — not a runtime call, a DI registration, an HTTP +request or an Aspire orchestration edge. Confusing those is the most common source of a wrong answer +about whether a dependency is allowed. + +The rules that matter most: + +- Core depends on nothing but `Shared`. +- Application never references a concrete `Persistence`, `Providers` or `Adapters` project. +- Composition references implementations; **nothing references Composition** except a host. +- Sibling implementations (`Providers.GitHub`, `Providers.AzureDevOps`) stay independent. +- Persistence owns migration *definitions*; `Migrator` owns their *execution*. Production API startup + does not apply schema migrations. + +When a boundary is worth having, make it fail the build rather than the review — see +`purview-architecture-testing`. + +## 5. Choose how an API and its clients share a contract + +When there is an HTTP API and at least one client, choose **Contracts**, **OpenAPI** or **Hybrid** +from the clients that **exist today**. A planned client is noted; it does not count. + +| Clients today | Strategy | Failure it prevents | +| -- | -- | -- | +| All .NET, same solution, shipped in lockstep | **Contracts** — a library of request/response types on the domain's value objects, referenced by the server and every .NET client | Rules drifting, and a weaker second copy of them in the UI | +| At least one non-.NET client, or one that ships independently | **Hybrid** — Contracts is the source; the OpenAPI document and its schemas are generated from it | Non-.NET clients hand-writing shapes, and .NET clients losing their value objects to a generated client | +| No .NET client — partners or the public only | **OpenAPI**, generated from the server's types; no Contracts project | A project with no consumer but the server | + +The rules that do not bend: + +- **OpenAPI is an output, never an input**, while Contracts exists. Never regenerate a C# client from + your own API's document when that client is in the same solution. +- **Never hand-maintain a parallel set of models**, and **the server always validates** — a shared + validator is for user experience, not trust. +- **Contracts is browser-safe** when a WebAssembly client references it: architecture-policy bans, + the trim and AOT analyzers and the browser platform analyzer check it, not a reviewer. +- **A BFF is earned by keeping tokens out of the browser.** It holds the tokens, gives the browser an + `HttpOnly` cookie, forwards with the user's token, and requires an anti-forgery header on every + forwarded call. In Blazor it is the host that serves the WebAssembly client, and a BFF that only + forwards does not reference Contracts. The policy does not define a BFF yet, so these are review + obligations until a future Build SDK release adds Blazor and BFF support. + +Full procedure, version-skew rules, BFF guidance and worked examples: `references/contract-strategy.md`. + +## 6. DRY, applied where it pays + +Reuse the Purview libraries when they remove a real source of truth, not for coverage: + +| Concern | Library | Use it when | +| -- | -- | -- | +| Operation outcomes without exceptions for control flow | `Purview.Results` | Failures are expected and the caller must handle them | +| Primitive obsession, validated identity types | `Purview.ValueObjects` | A value has rules that would otherwise be re-checked at each boundary | +| Schema shared with a TypeScript client | `Purview.ZodSharp` | A contract is duplicated between C# and TypeScript today | +| Local dependencies for dev and integration tests | `Purview.Containers` | Tests need a real database, queue or emulator | +| Logging, tracing and metrics from one declaration | `Purview.Telemetry.SourceGenerator` | Always — see `purview-telemetry` | + +Duplication that matters: validation repeated per entry point, a domain rule restated in the UI, +configuration bound in two places, two types that both claim to be the truth. Duplication that does +not: two similar-looking methods that answer to different owners. + +## 7. Telemetry is not an advanced topic + +Recommend instrumentation from the first commit, at every project size. A small application gets +small telemetry — it does not get none. See `purview-telemetry`. + +## Checks before you answer + +- Did you read the four context dimensions rather than ask for them? +- Can you name, for each project and each abstraction you are proposing, the failure it prevents here? +- Is anything in your proposal there only because a reference architecture has it? +- If there is an API with clients, did you choose its contract strategy from the clients that exist + today? +- Did you include telemetry? + +## See also + +- `purview-telemetry` — instrumentation at every scale +- `purview-persistence` — storage and GUID-key guidance, only when persistence is real +- [Engineering Principles](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Engineering-Principles.md) +- [Adopting This At Your Scale](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Adopting-This-At-Your-Scale.md) diff --git a/.agents/skills/purview-architecture/references/contract-strategy.md b/.agents/skills/purview-architecture/references/contract-strategy.md new file mode 100644 index 00000000..e281a410 --- /dev/null +++ b/.agents/skills/purview-architecture/references/contract-strategy.md @@ -0,0 +1,236 @@ +# Contract strategy: Contracts, OpenAPI or Hybrid + +When a solution has an HTTP API and one or more clients, something has to define the shapes that cross +between them. There are three ways to do it, and the wrong one fails in one of two directions: + +- **Rules drift.** The client holds a second, weaker copy of the domain rules, and a request the UI + accepted is rejected by the server — or a break only surfaces at runtime. +- **Decoupling bought in advance.** A .NET client in the same solution regenerates its models from + the API's own OpenAPI document, and throws away every value object and rule to buy an independence + the solution does not have. + +The strategy is chosen from the clients that **exist today**. A planned client is noted; it does not +count. "Later" and "in case" do not justify a strategy any more than they justify a project — see +`proportional-architecture.md`. + +## The three strategies + +### Contracts + +A .NET class library holding the request and response types, built on the domain's value objects and +carrying `[ZodSchema]`. The server references it, and so does every .NET client — including a Blazor +WebAssembly client. + +There is one copy of every rule, error code and normalisation, and both sides run the same generated +validators. + +### OpenAPI + +The server publishes an OpenAPI document, and clients generate their models and calls from it. It is +language-neutral and versions independently. + +Generated models are primitives. Value objects, custom ZodSharp rules, error codes, and trimming and +casing are lost; only what JSON Schema can express survives. + +### Hybrid + +Contracts is the **single source of truth**. The OpenAPI document is **generated from it**, with its +component schemas taken from ZodSharp's export — the generated `{T}Schema.BuildJsonSchema()` is built +from the same metadata as the validator, so the document and the validators cannot drift. + +.NET clients reference Contracts. Non-.NET clients generate from the document and take their +validation rules from the exported JSON Schema — for TypeScript, `z.fromJSONSchema` (Zod 4) reads it +back as Zod schemas. The `purview-zodsharp-json` and `purview-zodsharp-source-generator` skills are +authoritative on the export. + +Three facts about the export that a Hybrid design has to plan for: + +- **ZodSharp ships no OpenAPI integration.** It produces JSON Schema; putting that into the OpenAPI + document — for example with a schema transformer in `Microsoft.AspNetCore.OpenApi` — is the API + host's job. Without it, ASP.NET Core describes a value object from its CLR shape, not from its rules, + and the document and the validators drift after all. +- **A non-.NET client still gets only what JSON Schema can express.** A value object exports as its + underlying value, and a rule the exporter cannot represent is listed under `x-zodsharp-unsupported` + rather than dropped. The contract test should fail when that list grows. +- **Exported objects set `additionalProperties: false`.** A client that validates a *response* against + that schema rejects a field the server has added — so "additive changes only" is not safe until + clients tolerate unknown response fields. Validate requests strictly and responses leniently, or the + versioning rule below protects nobody. (The reverse also holds: System.Text.Json ignores unknown + members, so the server accepts a request with an extra key that the schema rejects.) + +## Decision procedure + +1. **List the clients that exist today.** For each, record: + - its language and runtime; + - who owns it; + - whether it lives in the same repository and solution; + - whether it ships **in lockstep** with the server or on its own cadence. + + A planned client is noted, but it does not count yet. +2. **Every client is .NET, in the same solution, and ships in lockstep → Contracts.** This prevents: + - rules drifting between client and server; + - a second, weaker copy of the domain rules in the UI; + - contract breaks that only surface at runtime. + + Publish OpenAPI anyway if documentation or tooling wants it, but generate it from Contracts. It is + an output, never an input. +3. **At least one client is not .NET, or ships independently → Hybrid.** This prevents: + - non-.NET clients hand-writing shapes and rules; + - the .NET clients losing their value objects and rules to a generated client. + + It requires the independent-versioning rules under [Version skew](#version-skew). +4. **No .NET client exists, or the API is a surface whose clients the team does not own (public or + partner) → OpenAPI**, still generated from the server's types. A separate Contracts project is not + earned when the server is its only consumer. +5. **A .NET client in another repository with its own release cadence** prefers Contracts shipped as + a package, if that team accepts the dependency. Otherwise treat it as step 3. + +```text +flowchart TD + A["List the clients that exist today"] --> C{"Every client .NET,
same solution, lockstep?"} + C -- yes --> K["Contracts
OpenAPI only as an output"] + C -- no --> B{"Any .NET client
at all?"} + B -- no --> O["OpenAPI
generated from the server's types"] + B -- yes --> D{"The only other clients are .NET
in another repository,
and they accept a package?"} + D -- yes --> P["Contracts, shipped as a package"] + D -- no --> H["Hybrid
Contracts is the source, OpenAPI generated from it"] +``` + +Steps 3 and 4 do not overlap: a .NET client in the solution alongside a partner or non-.NET client is +step 3, because the .NET client still has value objects and rules to lose. + +## Rules that follow + +- **Never regenerate a C# client from your own API's OpenAPI document when that client is in the same + solution.** It throws away value objects and rules to buy decoupling the solution does not have. +- **Never hand-maintain a parallel set of models** on either side. Two definitions of one wire shape + disagree eventually, and nothing fails when they start to. +- **The server always validates.** Sharing validators with a client is for user experience and + consistency. It is never a substitute for validating on the server, because the client is untrusted. +- **Moving from Contracts to Hybrid must be additive.** Because Contracts is already the source, adding + a non-.NET client means turning on generation of the OpenAPI document and a contract test. Nothing in + Contracts changes. Design for that, but do not build it until the client exists. + +## Contracts project constraints + +- **References only the domain library (value objects) and the runtime Purview packages** — + `Purview.ValueObjects`, `Purview.ZodSharp`, `Purview.Results`. Never the server, persistence, HTTP + clients or hosting, and never an ASP.NET Core bridge such as `Purview.ZodSharp.AspNetCore`; those + belong to the API host. A Contracts project that references the server is a server that every client + now ships. +- **Browser-safe when a WebAssembly client references it.** No file, network or process I/O, safe to + trim, and no reflection-dependent behaviour. The same applies transitively to the domain library, + because the client downloads it too. Make the build enforce it, not a reviewer — three mechanisms, + each catching what the others cannot: + - **Your repository's architecture policy** forbids `Microsoft.AspNetCore` packages + (`forbiddenPackagePrefixes`) and the `Microsoft.AspNetCore.App` framework reference + (`forbiddenFrameworkReferences`) in `Contracts` and `Core`. The playbook's own policy forbids the + packages in `Core` only; the rest arrives with the Build SDK's planned Blazor support, so add it + yourself until then. See `purview-architecture-testing`. + - **`true`** on both projects turns on the trim and AOT analyzers, + so reflection a trimmed client would break on is a warning at build time. `Purview.ValueObjects` + and `Purview.ZodSharp` already set it. + - **``** makes the platform-compatibility analyzer (`CA1416`) + flag an API that is unsupported in the browser, such as starting a process. + + The policy rules fail a test run. The two analyzers produce warnings, and the SDK does not turn on + `TreatWarningsAsErrors` for an ordinary project — set it on `Contracts` and `Core`, or the "clean + build has no warnings" rule is the only thing standing behind them. No analyzer proves the absence of + every kind of I/O; what they miss is a review obligation, and the client's own tests running in the + browser are the last check. +- **Deserialisation mode is a deliberate choice per direction.** The server reads requests through + `Create` (strict), because the client is untrusted. Record what the client uses for responses, and + why — strict catches a server that has drifted, and `Hydrate` trusts a server the client cannot + verify. + +## Version skew + +| Shipping | Strategy | Failure it prevents | What to do | +| -- | -- | -- | -- | +| Lockstep | Contracts, internal apps | A silent failure that looks like a bug, when an old cached client talks to a new server | The server stamps its build on responses; the client prompts the user to reload when the stamps differ | +| Independent | Hybrid, OpenAPI | A deployed client breaking without warning | Additive changes only; a breaking change gets a new route version; a contract test compares the generated document against the last published one and fails on a breaking difference | + +## Backend-for-Frontend + +A BFF is a host, not a layer, and it is held to the same justification test as anything else. +Microsoft's [Blazor Web App with OIDC and the BFF pattern](https://learn.microsoft.com/aspnet/core/blazor/security/blazor-web-app-with-oidc?pivots=with-bff-pattern) +is the authority on the mechanics; what follows is the rules and the failures behind them. + +### What earns it + +- **Keeping tokens out of the browser.** The BFF signs the user in (OpenID Connect), holds the access + and refresh tokens, and gives the browser only an authentication cookie. Script in the page — a + compromised dependency, an XSS hole — cannot read a token that never reached it. This prevents tokens + being stolen from browser storage. +- **It forwards with the user's access token, never its own identity.** The API validates that token + exactly as it would any other, so authorisation and the audit trail stay per-user. A BFF calling with + a client-credentials token turns every action into the BFF's and moves authorisation into the BFF. + +### In a Blazor solution, the BFF is the host + +The project that serves the WebAssembly client is the natural BFF: it already runs on the server, at +the client's origin. Do not add a separate BFF in front of it. + +- **It references the client project** (`Bff → WebApp`) because it serves the client's assets. The + client never references it; they talk over HTTP with the cookie. +- **Same origin is the point.** The client calls the BFF's own routes, the BFF forwards them to the API + (with YARP's `MapForwarder`, for example), and there is no CORS configuration and no third-party + cookie. +- **The client shares Contracts with the API directly.** The WebAssembly client references Contracts, + sends Contracts types, and runs the generated validators. The BFF in the middle passes the bytes + through untouched. + +### Rules that come with a cookie + +Moving the credential into a cookie trades token theft for a different attack, and the BFF has to +close it: + +- **Every forwarded call requires an anti-forgery signal.** The browser attaches the cookie to any + request to the BFF's origin, including one a hostile page triggers. Require a custom header the + client always sends, which a cross-site form cannot set, and reject calls without it — Duende.BFF's + `X-CSRF: 1` is the established form. `SameSite` narrows the attack; it does not replace the check. + This prevents cross-site request forgery. +- **The cookie is `HttpOnly`, `Secure` and `SameSite=Strict` or `Lax`.** `HttpOnly` is what keeps + script from reading it; without it the BFF protects nothing. +- **Data Protection keys are persisted and shared** when the BFF runs on more than one instance or + survives a restart. The cookie is encrypted with them, so per-instance keys sign users out at random. +- **Refresh happens on the server.** The BFF refreshes the access token before it expires and signs + the user out when refresh fails. The client never sees either token. +- **A large session moves to a server-side store.** Tokens kept inside the cookie make it large enough + to be split across several cookies, and the session cannot be revoked server-side. Use a ticket store + when either matters. + +### What it may reference + +- **A BFF that only forwards does not reference Contracts** and never reads payloads. Reading a payload + it does not own makes it a second consumer that has to change in lockstep for no benefit. +- **Prerendering is the common exception.** If the BFF prerenders client components that load data, + it must run those data calls on the server, and it references Contracts to do so. That is a real + reason; turning prerendering off for those components is the alternative, and the choice should be + written down. +- **A BFF that combines several API calls into one response owns its own contract** for those + responses, chosen with this same procedure. That is a separate justification. The forwarding-only + test — "a service layer that forwards" is not earned — still applies to everything else it does. +- **One BFF per client kind, not one per screen.** Web and mobile may each get one when their sign-in + or response needs differ. + +**None of this is enforced yet.** The architecture policy has no BFF responsibility; Blazor and BFF +support is planned for a future Build SDK release. Until then every rule in this section is a review +obligation, and a repository that adds a BFF project records it in its own policy — see +`project-responsibilities.md`. Even once it is in the policy, "Contracts only when it prerenders or +combines" stays a review obligation, because a dependency matrix cannot express a condition. + +## Worked examples + +| Clients today | Strategy | +| -- | -- | +| Blazor WebAssembly app, same solution, lockstep, signed in through a BFF | Contracts, referenced by the API and the WebAssembly client. The BFF is the host that serves the client, holds the tokens and forwards with the user's token; it references Contracts only if it prerenders data. OpenAPI generated for documentation. | +| Blazor WebAssembly app plus a React Native mobile app on its own release cadence | Hybrid. Mobile generates from the document and takes Zod rules from the exported schema. Additive-only versioning. A mobile BFF if sign-in differs. | +| Partner integrations only | OpenAPI, generated from the server's types. No Contracts project. | + +## See also + +- [Contracts and Data Exchange](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Contracts-And-Data-Exchange.md) + — `Create` and `Hydrate`, and the schema as the source of truth for a wire shape +- [zodsharp](https://github.com/purview-dev/zodsharp) — authoritative on `[ZodSchema]` and JSON Schema + export diff --git a/.agents/skills/purview-architecture/references/project-context-discovery.md b/.agents/skills/purview-architecture/references/project-context-discovery.md new file mode 100644 index 00000000..d40569a3 --- /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 00000000..d74d2cf4 --- /dev/null +++ b/.agents/skills/purview-architecture/references/project-responsibilities.md @@ -0,0 +1,119 @@ +# Project responsibilities, naming and dependency direction + +The catalogue in brief. The authoritative definition is +[`.config/architecture-policy.json`](https://github.com/purview-dev/engineering-playbook/blob/main/.config/architecture-policy.json), +and the full page — with diagrams — is +[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/Project-Responsibilities.md). +Do not restate either here or in another skill; link to them. + +## The catalogue + +`A → B` means A depends on B **at compile time**. + +| Responsibility | Also known as | Owns | May depend on | +| -- | -- | -- | -- | +| `Core` | `Domain` | Entities, value objects, invariants, domain events and errors | Shared | +| `Abstractions` | — | Cross-boundary capability contracts | Core, Shared | +| `Application` | `UseCases` | Use cases, workflows, orchestration | Core, Abstractions, Contracts, Shared | +| `Contracts` | — | Externally exposed request/response types; browser-safe when a WebAssembly client references it | Core, Shared | +| `Persistence` | — | DbContext, mappings, migration *definitions* | Core, Abstractions, Application, Shared | +| `Providers` | `Integrations` | Integration with a *named external platform* | Core, Abstractions, Application, Shared | +| `Adapters` | — | An *application-defined capability* via one technology | Core, Abstractions, Application, Shared | +| `Composition` | — | DI registration and implementation selection | Everything above | +| `API` | — | HTTP host | Composition, Contracts, ServiceDefaults, … | +| `Worker` | — | Background-processing host | Composition, ServiceDefaults, … | +| `Migrator` | — | *Executes* migrations and controlled seeding | Persistence, Composition, ServiceDefaults | +| `AppHost` | — | Aspire orchestration | The hosts it orchestrates | +| `ServiceDefaults` | — | Telemetry, health, resilience, discovery defaults | Shared | +| `WebApp` | — | Frontend, including a Blazor WebAssembly client that references Contracts | Contracts, ServiceDefaults, Shared | +| `Shared` | — | Small domain-independent primitives | *nothing* | + +**None of these is mandatory.** A small application with `Core` and one host has a complete and +correct architecture. The catalogue names responsibilities so you can tell when splitting one out +buys something — it is not a list of projects to create. + +### Backend-for-Frontend: guidance, not yet policy + +A BFF is a host responsibility that the policy does **not** define yet; Blazor and BFF support is +planned for a future Build SDK release. Until then it is guidance, and every rule below is a review +obligation: + +| Owns | May depend on | Split it out when | Leave it combined when | +| -- | -- | -- | -- | +| Sign-in and token custody for a browser or mobile client; forwards with the user's token. In Blazor, the host that serves the WebAssembly client | The WebAssembly client project, ServiceDefaults, Shared — and Contracts only when it prerenders data or combines responses | Tokens must be kept out of the browser, or several API calls must become one response | It would only forward calls that the client could make with a token it already holds safely | + +A repository that adds a BFF project today needs its own responsibility entry or a waiver in its +policy, or the architecture tests reject the project as unclassified. See `contract-strategy.md`. + +## When to split + +| Responsibility | Split it out when | Leave it combined when | +| -- | -- | -- | +| `Core` | A domain model exists | The app is a thin utility | +| `Abstractions` | Two implementations, or the consumer must not see the implementation | One implementation — keep the interface beside it | +| `Application` | Workflows are reused by more than one host | It would only forward CRUD | +| `Contracts` | The contract strategy is Contracts or Hybrid — see `contract-strategy.md` | The API host is the only consumer, which is the OpenAPI strategy || `Persistence` | Persistence exists at all | A single-project utility writing a file | +| `Providers` | Two platforms with different SDKs or lifecycles | One platform | +| `Adapters` | One capability, several technologies | One capability, one technology | +| `Composition` | Two hosts compose the same implementations | One host — compose in the host | +| `Migrator` | Schema changes are a deliberate deployment step | Development-only startup migration | +| `Shared` | A primitive is used by two or more responsibilities | Almost always | + +## Naming + +```text +[.] +``` + +| Situation | Name | +| -- | -- | +| One implementation | `Persistence` — do **not** qualify it just because it is SQL Server | +| Several, same dependencies and lifecycle | `Persistence` — one project, several classes | +| Several, genuinely different | `Persistence.SqlServer`, `Persistence.PostgreSql` | +| Several, with shared code | Add `Persistence` alongside the qualified projects | +| Several, nothing shared | Qualified projects only — no empty parent | + +Discouraged as project names, because each names a category rather than a responsibility: +`Infrastructure`, `Services`, `Managers`, `Models`, `Helpers`, `Common`, `Utils`, `Utilities`. + +`Infrastructure` is the common case. It is a useful *category* — covering Persistence, Providers and +Adapters — and a poor *project*, because every technical class qualifies for it. + +**Do not rename an established project** merely to match canonical terminology. A rename costs more +than the consistency is worth, and `Domain`/`UseCases`/`Integrations` are recognised aliases. + +## Providers versus Adapters + +Not a vendor distinction: + +- **Provider** — the requirement names the platform: "sync issues from GitHub". Swapping it changes + the product. +- **Adapter** — the requirement names the capability: "send an email". Swapping the technology + changes nothing the user sees. + +Azure Blob behind `IFileStore` is an Adapter. The Azure DevOps work-item API in a product that +integrates Azure DevOps is a Provider. + +## Dependency rules that catch people out + +1. **Composition is referenced by nothing** except a host. It selects implementations, so it depends + on them; the reverse would make the choice circular. +2. **Application never references a concrete infrastructure project.** It depends on an abstraction + and receives an implementation it cannot name. +3. **Sibling implementations stay independent.** If `Providers.GitHub` needs something from + `Providers.AzureDevOps`, that shared thing belongs in the unqualified project or an abstraction. +4. **Persistence owns migration definitions; Migrator executes them.** Production API startup does + not mutate a schema. +5. **Runtime is not compile time.** DI registration, an HTTP call and Aspire orchestration are not + assembly references. A Worker and an API can share Application without referencing each other. + +## Recommending this to a repository that predates it + +Do not propose a mass rename. Suggest, in order: + +1. Fix dependency *directions* that are actually wrong — those are bugs. +2. Split a project only where a named problem justifies it. +3. Record a waiver for an established name rather than renaming it, so the rule stays enforceable + without churn. + +A waiver excuses the name only; the project still classifies, so every dependency rule still applies. diff --git a/.agents/skills/purview-architecture/references/proportional-architecture.md b/.agents/skills/purview-architecture/references/proportional-architecture.md new file mode 100644 index 00000000..3ed7f553 --- /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 00000000..b1f222c5 --- /dev/null +++ b/.agents/skills/purview-buildsdk-adoption/SKILL.md @@ -0,0 +1,114 @@ +--- +name: purview-buildsdk-adoption +description: Adopt Purview.BuildSdk in a repository, or troubleshoot an adoption that is not behaving. Use when wiring the SDK into Directory.Build.props for the first time, when a property seems to have no effect, when the resolved version or namespace is wrong, or when a consuming repository builds but the SDK's automatic behaviour is not appearing. Covers the import chain, pre-import property ordering, and version detection from package.json. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Adopting Purview.BuildSdk + +The goal is a working adoption that does not break existing build behaviour. Prefer minimal, +targeted property changes over rewriting a repository's `Directory.Build.props`. + +For the full property list use `purview-buildsdk-configuration`; for auditing an existing +repository's shape, `purview-buildsdk-repository-review`. + +## 1. The import chain + +```xml + + + + + Acme.Sales + + + + +``` + +```xml + + + + +``` + +Both halves are required. A repository with only `Sdk.props` imported gets the evaluation-time +defaults — naming, identity, framework selection — but none of the targets, so validation, +packaging behaviour and the generated files never run. That combination usually presents as "the +properties look right but nothing happens". + +The SDK version comes from `global.json`, which the SDK itself bootstraps. + +## 2. Pre-import ordering is the first thing to check + +**A property that must affect `Sdk.props` evaluation has to be set before the import.** Setting it +in a `.csproj`, or below the import, is too late and silently does nothing — no error, no warning. +This is the single most common adoption failure. + +Set before the import: + +- `NamespacePrefix` +- `UsePackageJsonVersion`, `RootPackageJson` +- `EnableAssemblyNameGeneration` +- `AutoIncludeUsings` +- `PurviewSharedTestingOutputType`, `PurviewTestContextNoWarn` +- any repo-wide testing-framework selection you want every project to inherit + +Everything else is read in `Sdk.targets` and can be set in a project file. + +When a property appears to have no effect, confirm what MSBuild actually resolved before changing +anything else: + +```bash +dotnet msbuild -getProperty:NamespacePrefix -getProperty:RootNamespace +``` + +## 3. Version detection + +Versions come from `package.json`. Resolution order: + +1. An explicit `RootPackageJson`. +2. CI workspace variables. +3. The `.git` root. +4. A nearby `package.json`. + +On success both `Version` and `PackageVersion` are set from the `version` field. + +If the version looks wrong, do not guess at the discovery — make it fail loudly instead: + +```xml +Strict +``` + +`Strict` fails the build when discovery or resolution cannot succeed, rather than silently skipping +it and leaving the default `1.0.0`. Set `VersionDetectionLogEnabled=true` to see what it found. + +## 4. Naming and placement + +Most of the SDK's value is inferred from project names and locations, so an adoption that keeps a +bespoke structure gets correspondingly little. Confirm the repository follows the conventions rather +than introducing new structure to work around them — see `purview-buildsdk-project-layout`, and +`purview-buildsdk-repository-review` for auditing an existing layout. + +The highest-value single check: every test project is named `.Tests`. **The dot is +required.** A project named plainly `UnitTests` is not detected as a test project at all, and +silently gets no runner, no coverage, no `InternalsVisibleTo` and no category. + +## 5. Agent skills are a separate, explicit step + +The SDK does not install agent skills, and nothing it does during a build writes to an agent +directory. It reads which skills each referenced package ships and recommends the command that +installs them; the skills CLI does the installing. See `purview-buildsdk-skill-installation`. + +## Constraints + +- Prefer minimal, targeted property changes over broad `Directory.Build.props` rewrites. +- Do not disable `PurviewAutoSdkPack` unless the consumer explicitly asks to opt out. +- Do not restate an SDK default in a project file. Hand-configuration that merely repeats a default + is pure maintenance cost, and it hides the fact that the default later changed. +- Re-run the build — or the repository's canonical build command — after each change, and confirm + the fix before moving to the next one. diff --git a/.agents/skills/purview-buildsdk-configuration/SKILL.md b/.agents/skills/purview-buildsdk-configuration/SKILL.md new file mode 100644 index 00000000..d82768b3 --- /dev/null +++ b/.agents/skills/purview-buildsdk-configuration/SKILL.md @@ -0,0 +1,115 @@ +--- +name: purview-buildsdk-configuration +description: Configure Purview.BuildSdk from Directory.Build.props or a .csproj. Use when setting NamespacePrefix, choosing a testing or mocking framework, turning telemetry or SourceLink off, controlling version detection from package.json, tuning the repository bootstrap, making a library browser-safe for a Blazor WebAssembly client, configuring a BFF host, or working out whether a property must be set before the SDK import. Also use when the SDK "did something automatically" and you need the property that controls it. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Purview.BuildSdk configuration + +Use this skill when the question is "what property controls this, and where do I set it?" + +For *why* the SDK classified a project a particular way, use `purview-buildsdk-project-detection` +instead. For where projects and tests should live, use `purview-buildsdk-project-layout`. + +## First rule: know where a property must be set + +A property that changes `Sdk.props` evaluation must be set **before** the SDK import — in +`Directory.Build.props`, above the import. Setting it in a `.csproj` is too late and silently has no +effect. + +Pre-import properties include: + +- `NamespacePrefix` +- `UsePackageJsonVersion`, `RootPackageJson` +- `EnableAssemblyNameGeneration` +- `PurviewSharedTestingOutputType`, `PurviewTestContextNoWarn` +- `AutoIncludeUsings` +- `DisablePurviewBlazorWebAssemblyDefaults` +- repo-wide testing-framework selection you want every project to inherit + +Everything else is read in `Sdk.targets` and can be set in a project file. Prefer repo-wide defaults +in `Directory.Build.props` unless the setting is deliberately project-specific. + +## The properties you will reach for most + +| Property | Default | What it does | +| -- | -- | -- | +| `NamespacePrefix` | *(required)* | The root identity. Required unless `DisableNamespacePrefixCheck=true` | +| `TestingFramework` | `TUnit` | `TUnit`, `Xunit` or `None` | +| `SubstituteFramework` | `TUnitMocks` | `TUnitMocks`, `NSubstitute` or `None` | +| `TestDataFramework` | `Bogus` | `Bogus` or `None` | +| `TargetFramework` | `net10.0` | `netstandard2.0` when `IsRoslynComponent=true` | +| `IsPackable` | `false` | Opts a project into the packable defaults | +| `UsePackageJsonVersion` | `true` | `true`, `false` or `Strict` — version from `package.json` | +| `ExcludePurviewTelemetry` | `false` | Drops the telemetry source generator | +| `DisableSourceLink` | `false` | Drops SourceLink from packable projects | +| `EnableAssemblyNameGeneration` | `true` | `AssemblyName`/`PackageId` follow the resolved `RootNamespace` | +| `PurviewBrowserSafe` | *(unset)* | On a class library a WebAssembly client downloads: trim, AOT and browser-unsupported APIs fail the build | +| `IsBffProject` | name ends `Bff` | Classifies a BFF host; `false` opts out | +| `PurviewBrowserSafeReferenceCheckAsError` | `false` | Makes `PRBLZ0001` (a client reference that is not browser-safe) an error | + +The full set, grouped by area and with the behaviour each one changes, is in +[references/properties.md](references/properties.md). Read it rather than guessing a property name — +several of these have near-miss names that do nothing. + +## Derived properties to inspect, not re-derive + +When explaining what the SDK did, read the value it computed rather than reasoning from the folder +name: + +`PurviewLogicalProjectName`, `PurviewNamespacePrefix`, `PurviewProjectShortName`, `PurviewTestType`, +`RootNamespace`, `AssemblyName`, `PackageVersion`, `TestingType`, `TargetProjectName`, `RepoRoot`, +`RootPackageJson`, `IsBlazorHostProject`, `IsBffProject`, `PurviewBffExposesApi`, `IsAotCompatible`, +`WarningsAsErrors`. + +Ask MSBuild for one directly: + +```bash +dotnet msbuild -getProperty:RootNamespace +dotnet msbuild -getProperty:TestingType -getProperty:TargetProjectName +``` + +## Compiler-visible properties + +The SDK exports its properties to analyzers and generators as `build_property.`. When +authoring an analyzer, read the exported property instead of re-deriving SDK behaviour — the export +is the SDK's answer, and it stays correct when the rules change. The exported set is listed in +[references/properties.md](references/properties.md). + +## Version detection + +1. If `RootPackageJson` is set, that file is used. +2. Otherwise the repo root is discovered from CI variables, `.git`, or a nearby `package.json`. +3. On success, both `Version` and `PackageVersion` come from the `version` field. +4. `UsePackageJsonVersion=Strict` turns a failed discovery into a build failure instead of a silent + fallback. + +## Repository bootstrap + +The SDK bootstraps two repository-wide files from the package: `.editorconfig` and `global.json`. + +- `PurviewRepoBootstrapMode` — `IfMissing` (default), `Always`, `WarnOnDrift`, `Never` +- `DisableAutoCopySdkFiles` — the master off switch +- `PurviewRepoBootstrapCopyRetries` / `...RetryDelayMilliseconds` / `...FailureAsError` + +Every project in a solution writes those same destinations, so the write is race-tolerant by +design: an existing file is never overwritten unless asked, each write is staged into a temporary +file and renamed into place, retries are quiet, and only a failure that survives every retry is +reported. + +**The SDK writes nothing into an agent directory.** It used to mirror a package's `.agents/**` into +the repository; that is gone, along with `EnableAgentFolderInPackage`, `AgentPackDestinationFolder` +and the `PurviewAgentFolder*` properties. Skills have exactly one installer, the skills CLI — see +`purview-buildsdk-skill-installation`. + +## Guidance when changing SDK configuration + +1. Preserve existing defaults unless the task explicitly changes product behaviour. +2. Keep `README.md`, the property declaration, its validation and the shipped skills aligned — a + property documented in one place and absent from another is the failure mode this repository + keeps hitting. +3. A new property that affects import-time behaviour must say so in its documentation. +4. Add the property to the `CompilerVisibleProperty` list if an analyzer would benefit from it. diff --git a/.agents/skills/purview-buildsdk-configuration/references/properties.md b/.agents/skills/purview-buildsdk-configuration/references/properties.md new file mode 100644 index 00000000..1fb1f81b --- /dev/null +++ b/.agents/skills/purview-buildsdk-configuration/references/properties.md @@ -0,0 +1,168 @@ +# Purview.BuildSdk property reference + +Every user-facing property, grouped by what it changes. Defaults are what the SDK applies when the +consuming project supplies nothing. + +## Identity and naming + +| Property | Default | Behaviour | +| -- | -- | -- | +| `NamespacePrefix` | *(required)* | The root identity source. Required unless the check is disabled. Set before the SDK import | +| `DisableNamespacePrefixCheck` | `false` | Stops the build failing when `NamespacePrefix` is unset | +| `EnableAssemblyNameGeneration` | `true` | `AssemblyName` and the default `PackageId` follow the fully evaluated `RootNamespace`, or the full logical project name when suffix-stripping removed a segment (`Shared`, `ServiceDefaults`). Set `false` before the SDK import for standard project-name behaviour | +| `AssemblyIdentifierClassName` | *(derived)* | Renames the generated marker class | +| `DisableGenerateAssemblyIdentifierClass` | `false` (forced `true` for test and shared-testing projects) | Drops the empty marker class (`Assembly`, global namespace) that architecture tests use to pin an assembly by `typeof` rather than by name | +| `DisableGenerateAssemblyInfoClass` | `false` | Drops the generated `AssemblyInfo` | +| `DisableAutoInternalsVisibleTo` | `false` | Drops automatic friend-assembly generation | +| `DisableProjectFileNamingConventionCheck` | `false` | Stops validating that the `.csproj` filename matches its directory | +| `AutoIncludeUsings` | `true` | Controls the SDK-added global usings. Set before the SDK import | + +## Target framework and language + +| Property | Default | Behaviour | +| -- | -- | -- | +| `TargetFramework` | `net10.0` | Applied when neither `TargetFramework` nor `TargetFrameworks` is set. A project that explicitly declares `IsRoslynComponent=true` defaults to `netstandard2.0` | +| `IsRoslynComponent` | *(unset)* | When explicitly `true`: a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, SourceLink with `EmbedUntrackedSources=true`, no dependency file, telemetry exclusion, and `PrivateAssets=all` on `Microsoft.CodeAnalysis.*` references | +| `IsRoslynComponentOnly` | `true` for Roslyn components | Analyzer-only package: `IncludeBuildOutput=false`, `IncludeSymbols=false`, portable PDB packed beside the analyzer under `analyzers/dotnet/cs/`. Set `false` for a dual-role component using normal library symbol packaging | +| `DisableRoslynCompilerDefaultsValidation` | `false` | Opts out of `ValidateRoslynComponentCompilerSettings`, which fails the pack of a packable Roslyn component missing `LangVersion`, `Nullable`, `TreatWarningsAsErrors` or `EnforceExtendedAnalyzerRules` | +| `PackProjectReferencedSourceGenerators` | `true` | Packable projects include analyzer `ProjectReference` outputs under `analyzers/dotnet/cs/`. Use `Pack="false"` on one reference to opt that one out | + +## Style policy + +| Property | Default | Behaviour | +| -- | -- | -- | +| `DisablePurviewStylePolicyValidation` | `false` | Stops the build failing (`PRSGD0006`–`PRSGD0009`) when the repository `.editorconfig` overrides the modifier policy — `dotnet_style_require_accessibility_modifiers` other than `omit_if_default`, hiding `IDE0040`/`IDE1006`, disabling the Style category in bulk, weakening the `_camelCase` private-instance-field rule, or adding the accessibility rules to `NoWarn`. Entries the SDK injects itself are ignored | +| `PurviewTestContextNoWarn` | `CA1002;CA1012;CA1034;CA1047;CA1050;CA1051;CA1062;CA1064;CA1515;CA1707` | The production API-surface rules exempted in test and shared-testing projects. Test projects keep the strict style contract (`IDE0040`, field naming, formatting, `IDE1006`) but are context aware: public test classes, `Method_Scenario_Expectation` names, exposed fields and unvalidated helper parameters are allowed. Set before the SDK import | +| `DisablePurviewTestContextRuleSet` | `false` | Makes test and shared-testing projects enforce the production API-surface rules too | +| `PurviewPolicyExemptNoWarn` | *(derived)* | The SDK-injected `NoWarn` entries `ValidatePurviewStylePolicy` accepts | + +## Testing + +| Property | Default | Behaviour | +| -- | -- | -- | +| `TestingFramework` | `TUnit` | `TUnit`, `Xunit`, `None` | +| `SubstituteFramework` | `TUnitMocks` | `TUnitMocks`, `NSubstitute`, `None` | +| `TestDataFramework` | `Bogus` | `Bogus`, `None` | +| `CollectCoverage` | `true` for detected test projects | | +| `EnableStaticNativeInstrumentation` | `false` for test projects | | +| `EnableDynamicNativeInstrumentation` | `false` for test projects | | +| `TestingPlatformDotnetTestSupport`, `UseMicrosoftTestingPlatformRunner`, `EnableMicrosoftTestingPlatform` | enabled for TUnit test projects | Microsoft.Testing.Platform wiring | +| `PurviewSharedTestingOutputType` | `Library` | Forced onto `IsSharedTestingProject` projects, with `IsTestProject`/`IsTestingPlatformApplication` cleared, because the test packages otherwise flip them into an executable test host. Set `Exe` before the SDK import to keep the package-driven shape | + +## Packaging + +| Property | Default | Behaviour | +| -- | -- | -- | +| `IsPackable` | `false` | | +| `PurviewAutoSdkPack` | `true` | Packs `Sdk/**/*` into the package at the right root-level paths | +| `PurviewSkillsSourceFolder` | `/skills` | Where authored skills live | + +Packable-project defaults, applied only when the project supplied nothing: +`GenerateDocumentationFile=true`, `IncludeSymbols=true`, `SymbolPackageFormat=snupkg`, +`PublishRepositoryUrl=true`, `EmbedUntrackedSources=true`, `DebugType=portable`. Portable PDBs ship +in the `.snupkg`; the `.nupkg` gets none unless the project opts in. Roslyn-component-only packages +instead default `IncludeSymbols=false` and ship their PDB inside `analyzers/dotnet/cs/`. + +When the repository root is discoverable, the root `README.md` is packed automatically and +registered as `PackageReadmeFile`, unless one is configured already. + +Non-packable projects, including web apps, default `WarnOnPackingNonPackableProject=false` so a +solution-wide pack skips them silently. + +`ContinuousIntegrationBuild` is set only by real CI environment variables. Packability alone never +forces SourceLink's CI-mode dirty-repository checks. + +## Telemetry and SourceLink + +| Property | Default | Behaviour | +| -- | -- | -- | +| `ExcludePurviewTelemetry` | `false` | Removes `Purview.Telemetry.SourceGenerator` | +| `ExcludeMSTelemetryExtension` | `false` | Removes `Microsoft.Extensions.Telemetry.Abstractions`. Only relevant when `ExcludePurviewTelemetry` is also `false` — the whole group is skipped otherwise | +| `SourceLinkPackageName` | `Microsoft.SourceLink.GitHub` | | +| `DisableSourceLink` | `false` | | + +## Blazor WebAssembly, browser-safe libraries and BFF hosts + +| Property | Default | Behaviour | +| -- | -- | -- | +| `IsBffProject` | `true` for a web SDK project named `*Bff`, else `false` | Classifies a BFF host. `false` opts a `*Bff` project out; `true` opts another web project in | +| `IsBlazorHostProject` | *(computed)* | `true` for a web SDK project that references a WebAssembly client. Set `false` to opt out. Never implies `IsBffProject` | +| `PurviewAutoBlazorClientReference` | `true` | A host named `` references its `.Client` WebAssembly client automatically. Skipped when the reference is already declared | +| `DisablePurviewBlazorWebAssemblyDefaults` | `false` | Keeps the generic defaults on a WebAssembly client: packable when asked, Dockerfile container detection, the automatic `Shared` reference. Set before the SDK import | +| `PurviewBrowserSafe` | *(unset)* | `true` on a class library applies `IsAotCompatible`, `SupportedPlatform browser` and the browser-safe `WarningsAsErrors`, each only where the project set nothing | +| `PurviewBrowserSafeWarningsAsErrors` | `true` | `false` keeps the trim and AOT diagnostics as warnings; `CA1416` then falls back to the repository's severity (`suggestion` in the shipped `.editorconfig`) | +| `PurviewBrowserSafeWarningCodes` | `CA1416`, `IL2001`-`IL2128`, `IL3000`-`IL3005`, `IL3050`-`IL3059` | The ids a browser-safe library adds to `WarningsAsErrors` | +| `DisablePurviewBrowserSafeReferenceCheck` | `false` | Turns off `PRBLZ0001`, the check that every project a client (or browser-safe library) references sets `PurviewBrowserSafe` | +| `PurviewBrowserSafeReferenceCheckAsError` | `false` | Reports `PRBLZ0001` as an error | +| `PurviewBffExposesApi` | `true` when the BFF references `Microsoft.AspNetCore.OpenApi`, else `false` | Whether a BFF keeps the `Microsoft.AspNetCore.OpenApi.Generated` interceptor namespace | + +`PurviewBrowserSafe="true"` is also accepted as `ProjectReference` metadata, recording that a +reference was judged browser-safe when it cannot say so itself. + +## Version detection + +| Property | Default | Behaviour | +| -- | -- | -- | +| `UsePackageJsonVersion` | `true` | `true`, `false`, `Strict` | +| `RootPackageJson` | *(discovered)* | Explicit path to the `package.json` to read | +| `EnableVersionDetectionCache` | `true` | | +| `VersionDetectionCacheFile` | *(derived)* | | +| `VersionDetectionLogEnabled` | `false` | Logs the detected version | + +## Repository bootstrap + +| Property | Default | Behaviour | +| -- | -- | -- | +| `DisableAutoCopySdkFiles` | `false` | Master switch for SDK-managed repository file writes | +| `PurviewRepoBootstrapMode` | `IfMissing` | Also `Always`, `WarnOnDrift`, `Never` | +| `BootstrapEditorConfigToRepoRoot` | `true` | | +| `RepositoryEditorConfigFilePath` | *(derived)* | Overrides the destination `.editorconfig` | +| `BootstrapGlobalJsonToRepoRoot` | `true` | | +| `RepositoryGlobalJsonFilePath` | *(derived)* | Overrides the destination `global.json` | +| `PurviewBuildSdkVersionForGlobalJson` | restored package version, else the version it was packed as | | +| `PurviewRepoBootstrapCopyRetries` | `3` | | +| `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | `500` | | +| `PurviewRepoBootstrapCopyFailureAsError` | `true` | `false` downgrades a persistent write failure to a warning | +| `PurviewSuppressCopyRetryWarnings` | `true` | Demotes the built-in copy task's `MSB3026` retry notice to a message | +| `PurviewRepositoryRootMarkers` | `.git/;.github/;.gitattributes;LICENSE.md;AGENTS.md;.agents/;CLAUDE.md;.claude/;package.json` | Ordered markers identifying a repository root, most authoritative first | + +There is no agent-folder mirror. `EnableAgentFolderInPackage`, `AgentPackDestinationFolder`, +`PurviewAgentFolderSourcePath`, `PurviewAgentFolderCopy*`, `PurviewAgentSyncManifestPath` and +`DisableMirroredFileTrackingCheck` were removed: skills are installed by the skills CLI, and +nothing else was left for the mirror to deliver. + +## Skills + +Covered in full by `purview-buildsdk-skill-installation`: `PurviewSkillsRunner`, +`PurviewSkillsRunnerCandidates`, `PurviewSkillsCommand`, `PurviewSkillsPackage`, +`PurviewSkillsAgents`, `PurviewSkillsSource`, `PurviewSkillsInstallRoot`, `PurviewSkillsRepoRoot`, +`EnablePurviewSkillRecommendations`, `PurviewSkillRecommendationsInCI`. + +## Compiler-visible properties + +Exported to analyzers and generators as `build_property.`: + +- `UsePackageJsonVersion`, `RootPackageJson`, `RepoRoot`, `Version`, `PackageVersion` +- `NamespacePrefix`, `DisableNamespacePrefixCheck` +- `TestingFramework`, `SubstituteFramework`, `TestDataFramework` +- `ExcludePurviewTelemetry`, `ExcludeMSTelemetryExtension` +- `EnableAssemblyNameGeneration`, `DisableAutoInternalsVisibleTo`, `DisableGenerateAssemblyInfoClass`, + `DisableGenerateAssemblyIdentifierClass`, `AssemblyIdentifierClassName` +- `IsCSharpProject`, `IsTestProject`, `IsSharedTestingProject`, `IsSharedProject` +- `TestingType`, `TargetProjectName` +- `IsContainerProject`, `IsSdkProject`, `SdkProjectName`, `IsWebProject`, `IsWebSdkProject`, + `IsWorkerSdkProject`, `IsAspireHostProject`, `IsCLIProject` +- `IsBlazorWebAssemblyProject`, `IsRazorClassLibraryProject`, `IsBlazorHostProject`, `IsBffProject` +- `PurviewBrowserSafe`, `PurviewBrowserSafeWarningsAsErrors`, `DisablePurviewBrowserSafeReferenceCheck`, + `PurviewBrowserSafeReferenceCheckAsError`, `DisablePurviewBlazorWebAssemblyDefaults`, + `PurviewAutoBlazorClientReference`, `PurviewBffExposesApi` +- `EditorConfigFilePath`, `RepositoryEditorConfigFilePath`, `BootstrapEditorConfigToRepoRoot` +- `RepositoryGlobalJsonFilePath`, `BootstrapGlobalJsonToRepoRoot`, `DisableAutoCopySdkFiles` +- `PurviewRepoBootstrapMode`, `PurviewRepoBootstrapCopyRetries`, + `PurviewRepoBootstrapCopyRetryDelayMilliseconds`, `PurviewRepoBootstrapCopyFailureAsError` +- `PurviewSuppressCopyRetryWarnings` +- `PurviewSkillsSourceFolder`, + `PurviewSkillsAgents`, `PurviewSkillsInstallRoot`, `PurviewRepositoryRootMarkers`, + `PurviewSkillsRunner` +- `PurviewBuildSdkVersionForGlobalJson`, `CurrentYear`, `AutoGeneratedAssemblyInfoFile`, + `AutoGeneratedAssemblyIdentifierFile` diff --git a/.agents/skills/purview-buildsdk-project-detection/SKILL.md b/.agents/skills/purview-buildsdk-project-detection/SKILL.md new file mode 100644 index 00000000..8573ca40 --- /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 00000000..c65d1532 --- /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 00000000..85fde09c --- /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 00000000..dace44fc --- /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 00000000..6c19c3d4 --- /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 00000000..ce1fd294 --- /dev/null +++ b/.agents/skills/purview-buildsdk-repository-review/SKILL.md @@ -0,0 +1,111 @@ +--- +name: purview-buildsdk-repository-review +description: Review a repository that uses Purview.BuildSdk for naming, placement and test-structure alignment, and report what the SDK can and cannot do automatically as a result. Use when asked to audit, rationalise or health-check a repo against the SDK's conventions, when onboarding an existing codebase onto Purview.BuildSdk, or when deciding whether a layout quirk is worth changing. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Reviewing a repository against the SDK's conventions + +This is an audit, not a refactor. The deliverable is a prioritised list of findings and the cost of +each, so somebody else can decide what to change. + +For the conventions themselves use `purview-buildsdk-project-layout`; for why one project was +classified as it was, `purview-buildsdk-project-detection`. + +## The governing judgement + +**Deviation is only a finding when it costs the repository something.** The SDK infers behaviour +from naming and placement, so a deviation is interesting precisely to the extent that it turns an +automatic behaviour off. A layout that differs from the canonical one but still gets every automatic +behaviour is not debt, and reporting it as such wastes the reader's attention. + +Sort every finding into one of three buckets, and say which: + +| Bucket | Meaning | +| -- | -- | +| **Acceptable variance** | Differs from the canonical layout, costs nothing. Preserve it. | +| **Structural debt** | Blocks an automatic behaviour the repository would benefit from. | +| **Incremental opportunity** | Low-risk tidying worth doing when the area is touched anyway. | + +## 1. Read before concluding + +Inspect, in this order, before forming any opinion: + +- `Directory.Build.props` and `Directory.Build.targets` — where the SDK is imported, and what is set + before the import +- the solution entry point, for what is actually in the build +- `Directory.Packages.props`, for Central Package Management +- the project layout on disk + +## 2. Check the structural signals + +- Source projects under `src/`, test projects under `tests/`. +- `.csproj` filename equals its containing directory name — unless + `DisableProjectFileNamingConventionCheck=true`, which is itself a finding worth surfacing. +- Short project names, with `NamespacePrefix` carrying the repository identity. +- Test project names match `^.*\.([\w|_]\w*)Tests?$`. **The dot is required**; a project named + plainly `UnitTests` is not detected at all and silently gets no runner, no coverage, no + `InternalsVisibleTo` and no category. This is the single highest-value thing to check. +- Test suffixes come from the SDK's shipped `TestType` group where `internal` access is wanted — a + suffix outside it still runs, it just cannot see `internal` members. +- Shared and shared-testing projects use the **exact** recognised names. A near miss gets none of + the behaviour. + +## 3. Verify with MSBuild, do not infer from folder names + +A review that reasons from directory names will be wrong about at least one project. Ask: + +```bash +dotnet msbuild -getProperty:RootNamespace -getProperty:AssemblyName \ + -getProperty:IsTestProject -getProperty:TestingType -getProperty:TargetProjectName +``` + +Quote the computed values in the findings. "`Api.Tests` resolves `TestingType=` empty, so it has no +runner" is actionable; "`Api.Tests` looks like it might not be detected" is not. + +## 4. Review test readability + +- Subject-based suites: `{SubjectName}Tests`, with + `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}` methods. +- Non-subject-based suites — build, generator, workflow, package, full-system — named clearly for + their broader role. A forced `{Subject}Tests` on one of these is worse, not better. +- TUnit categories and display names used where a suite is large enough to need navigating. + +See `purview-buildsdk-testing`. + +## 5. Check the test dependencies are earned + +The SDK already supplies `TUnit`, `TUnit.Mocks`, `Bogus` and the Microsoft.Testing.Platform wiring +to every detected test project. Anything beyond that was a deliberate choice, and deliberate choices +go stale: + +- `TUnit.Aspire` and `Purview.Containers` are **not** universal defaults. Confirm each reference is + genuinely needed by that project rather than copied from a sibling. +- Where a project references `Testcontainers` directly, check whether a `Purview.Containers` typed + module now covers it — the module set grows, and a direct reference that was once necessary often + is not any more. +- A package the SDK already adds, restated in a project file, is maintenance cost with no effect. + +## 6. Explain consequences, not just mismatches + +For each finding, name the specific automatic behaviour that is lost: `RootNamespace`, +`AssemblyName`, `PackageId`, `TargetProjectName`, the automatic target-project reference, the +sibling `Shared*` reference, `InternalsVisibleTo` generation, the test runner and coverage wiring. + +"Project is not under `src/`" is not a finding. "Project is not under `src/`, so the test project's +four probe paths all miss and the `ProjectReference` has to be maintained by hand" is. + +## Reporting the result + +1. One sentence on whether the repository broadly fits the SDK's conventions. +2. The findings, ordered by impact, each with its bucket and the behaviour it costs. +3. The low-risk rationalisation steps, as concrete renames or moves. +4. An explicit split of what is **already automatic** versus what this repository has configured by + hand — that list is usually the most useful part of the review, because hand-configuration that + merely restates a default is pure maintenance cost. + +Do not propose a cross-cutting rename as a first step. Name the one change with the best +cost-to-benefit ratio and say what it unlocks. diff --git a/.agents/skills/purview-buildsdk-skill-installation/SKILL.md b/.agents/skills/purview-buildsdk-skill-installation/SKILL.md new file mode 100644 index 00000000..66c20a33 --- /dev/null +++ b/.agents/skills/purview-buildsdk-skill-installation/SKILL.md @@ -0,0 +1,166 @@ +--- +name: purview-buildsdk-skill-installation +description: Install, list and diagnose the agent skills a Purview.BuildSdk package ships. Use for the InstallPurviewSkills, ListPurviewSkills and RecommendPurviewSkills MSBuild targets, for any PRSKL0001-PRSKL0014 diagnostic, for choosing a package runner (npx, pnpm dlx, bunx, yarn dlx, deno), for skills-lock.json, or when a package ships skills but no agent can see them. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Installing the skills a package ships + +For *authoring* skills, use `purview-buildsdk-skill-tests`. + +## The one thing to understand first + +**The SDK writes nothing into an agent directory.** A package *ships* `.agents/skills//`, but +as metadata — it is what tells the SDK which skills the package offers and at which commit. Nothing +copies it out. + +Skills are installed by the skills CLI and by nothing else. That is a deliberate single-writer rule. +Earlier versions of this SDK mirrored a package's whole `.agents/**` folder into the consuming +repository, and the CLI's own universal install location is `.agents/skills/` — the same paths. The +mirror would silently overwrite a pinned install on the next build, while `PRSKL0011` compared a +lockfile ref against content the mirror had already replaced. The mirror was removed outright rather +than fenced off, because the CLI covers the whole job. + +Installing is therefore a separate, deliberately **explicit** step. It is never a side effect of a +restore, a build, a design-time build or a test run. + +```bash +dotnet msbuild -t:ListPurviewSkills # diagnostics only, installs nothing +dotnet msbuild -t:InstallPurviewSkills # delegates to the skills CLI +``` + +Neither target has `BeforeTargets` or `AfterTargets`. If skills are appearing or changing on their +own, something has been wired up that should not be. + +## How a skill is resolved, and why the version is always right + +There is deliberately **no custom manifest**. A package that ships skills already carries both +halves of the mapping, and `ResolvePurviewSkillSources` reads them: + +- `.agents/skills//` in the restored package says *which* skills it ships. +- The nuspec's ``, written by SourceLink for every package this + SDK builds, says which git repository and which **exact commit** produced them. + +So the resolved ref is the commit the *restored package version* was built from. An older +`PackageReference` gets that version's skills, not whatever is on the default branch. Compatibility +is exact by construction rather than by convention. + +## Installing from an explicit source + +For a skill you want but have no `PackageReference` for: + +```bash +dotnet msbuild -t:InstallPurviewSkills -p:PurviewSkillsSource=purview-dev/build-sdk +``` + +The caller owns the ref here — append `#` to pin it. This path needs no restore, so +`PRSKL0001` does not apply. + +## Diagnostics + +| Code | Meaning | Fix | +| -- | -- | -- | +| `PRSKL0001` | `project.assets.json` is missing | Restore. Resolution reads the restore graph and deliberately does not trigger a restore of its own | +| `PRSKL0002` | The package's nuspec has no GitHub repository URL, or no commit | The package cannot be pinned. It is reported rather than installed from a guessed branch | +| `PRSKL0003` | No repository root resolved | Pass `-p:PurviewSkillsInstallRoot=` | +| `PRSKL0004` | No skills CLI and no package runner found | Install Node, or set `PurviewSkillsCommand` | +| `PRSKL0005` | An authored skill is malformed | See `purview-buildsdk-skill-tests` | +| `PRSKL0010` | A skill a referenced package ships is missing | Warning. `npx skills add ...` | +| `PRSKL0011` | An installed skill is pinned to a different ref than the referenced package | Warning. Install the package's own commit | +| `PRSKL0012` | A skill cannot be verified | Message | +| `PRSKL0013` | An orphaned lockfile entry | Message | +| `PRSKL0014` | Recorded in the lockfile but never restored | Message | + +## Recommendations run during an ordinary build + +This is the **one** skill feature that does. `RecommendPurviewSkillsOnBuild` runs after `Build` and +delegates to `RecommendPurviewSkills`, which can also be invoked by name: + +```bash +dotnet msbuild -t:RecommendPurviewSkills +``` + +`EvaluatePurviewSkillState` joins the skills each restored package ships against the CLI's own +`skills-lock.json`. Both inputs are local files, so it stays offline and spawns no process. + +Four things keep it quiet, and all of them are load-bearing: + +- Design-time builds, CI builds, non-default TFM passes and + `EnablePurviewSkillRecommendations=false` are skipped outright. + `PurviewSkillRecommendationsInCI=true` re-enables CI. +- A fingerprint stamp at `/.purview/skill-recommendations.cache` means a given state is + reported once per repository, not once per project and again on every rebuild. The explicit target + ignores the stamp. +- Missing and drifted are **warnings** (fixable in one command); unverifiable and orphaned are + **messages**. No amount of skill advice can break a build using `TreatWarningsAsErrors`. +- A correctly installed set produces no output at all. + +Orphan detection is **owner-scoped on purpose**: a lockfile cannot record who asked for an entry, so +only an entry under an owner the project references — but from a repository it no longer references +— is reported. A skill from any other owner is the developer's own, and is never mentioned. + +### Drift never recommends `npx skills update` + +`update` installs the *newest* published skill, which is only correct when the newest skill happens +to describe the referenced package version. `PRSKL0011` names the referenced package's own commit in +a pinned `npx skills add` instead. + +## Package runners + +The CLI is published to npm, and every runner spells "run this npm package" differently. +`PurviewSkillsRunner` selects one, defaulting to `npx` because npx ships with Node itself. + +| Runner | Invocation | +| -- | -- | +| `npx` | `npx -y skills@1` | +| `bunx` | `bunx skills@1` | +| `bun` | `bun x skills@1` | +| `pnpm` | `pnpm dlx skills@1` | +| `yarn` | `yarn dlx skills@1` — Yarn 2+ only | +| `deno` | `deno run -A npm:skills@1` | + +An unknown runner falls through to ` $(PurviewSkillsPackage)`, so it still works. + +`DetectPurviewSkillsCli` probes `PATH` for `skills` first, then `$(PurviewSkillsRunner)`, then +`$(PurviewSkillsRunnerCandidates)`, honouring `PATHEXT` on Windows where these are `.cmd` shims. +`PATH` is read directly rather than by spawning a version check: deterministic, no process, and it +cannot hang on a cold download. **The runner actually found wins over the configured preference.** +`PurviewSkillsCommand` overrides the launcher entirely and skips detection. + +`ListPurviewSkills` reports runner availability but **never** errors on its absence — resolving and +reporting needs no Node tooling at all. + +## Always the repository root + +`skills-lock.json` and the agent directories belong to the repository, not to a project, so every +skill target runs from one resolved root: + +1. `PurviewSkillsInstallRoot` — an explicit override +2. `PurviewSkillsRepoRoot` — only when the **user** set it +3. the `PurviewRepositoryRootMarkers` walk +4. `PurviewSkillsRepoRoot` / `RepoRoot` as the SDK guessed them +5. CI workspace variables +6. SourceLink's git `SourceRoot` + +There is deliberately **no project-directory fallback**. That would scatter a lockfile and an agent +directory through the tree, one per project. An unresolved root is `PRSKL0003` on install, and +silence for recommendations — a build never fails over advice. + +The marker walk is described in [references/repository-root.md](references/repository-root.md); its +two-pass behaviour and its home-directory bound are both load-bearing. + +## Why the CLI rather than a reimplementation + +The CLI owns the agent-specific destinations and their discovery conventions, which differ per agent +and change independently of this SDK. It also owns `skills-lock.json`, so a repository can restore +the same set later with `npx skills experimental_install`. + +`PurviewSkillsAgents` (default `*`) narrows the target agents. + +The lockfile is read **directly** rather than by parsing `npx skills list --json`, because that +listing omits the `ref` — and the ref is the whole compatibility question. Reading a file also needs +no Node tooling, which is what keeps recommendations working on a machine that can build .NET but +has no Node. There is deliberately no competing lockfile. diff --git a/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md b/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md new file mode 100644 index 00000000..e90b695c --- /dev/null +++ b/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md @@ -0,0 +1,55 @@ +# The repository-root marker walk + +`FindPurviewRepositoryRoot` is an inline MSBuild task driven by `PurviewRepositoryRootMarkers`: + +```text +.git/;.github/;.gitattributes;LICENSE.md;AGENTS.md;.agents/;CLAUDE.md;.claude/;package.json +``` + +A trailing slash marks a directory. A directory marker still matches a **file** of that name, +because `.git` is a directory in a clone but a file in a worktree or a submodule, and both identify +a repository root. + +## Three things that are load-bearing + +### It cannot be an MSBuild intrinsic + +`GetDirectoryNameOfFileAbove` matches **files only** and returns nothing for `.git/`, `.github/`, +`.agents/` or `.claude/` — four of the nine markers. Verified, not assumed. + +### Two passes + +- **Pass 1** — the first marker in the list is *definitive* and is searched all the way up. `.git/` + *is* the repository root, so a monorepo sub-package with its own `package.json` does not win. +- **Pass 2** — every other marker resolves nearest-ancestor-first, with list order breaking ties + inside a single directory. Searching them all to the top would let a stray `.claude/` in a + developer's home directory outrank the real root. + +### The home directory is never a candidate + +The home directory and everything above it are excluded. Without that bound, a project outside any +repository resolves to `~` and installs into it — and a `.claude/` or a `.github/` very commonly +sits there. A repository *below* the home directory is unaffected, because it is below it. + +## Why the marker walk outranks the evaluation-time guess + +`PurviewSkillsRepoRoot` is computed during evaluation from `RepoRoot` and an `AGENTS.md` walk-up. +An `AGENTS.md` can legitimately sit in a subdirectory, and resolving to it would put a lockfile and +an agent directory somewhere that is not the repository. + +`_PurviewSkillsRepoRootWasSpecified` is captured in `Sdk.props` *before* defaulting, so a value the +**user** set still outranks the walk while a value the SDK guessed does not. + +## A target's Condition is evaluated before its DependsOnTargets + +A property a dependency computes is still empty in the depending target's `Condition`. That is why +the unresolved-root guard sits on the `EvaluatePurviewSkillState` task rather than on +`RecommendPurviewSkills` — and equally why the build hook's CI and design-time gating works at all. + +## The managed port + +`Purview.Skills.Testing` ships `SkillRepositoryRoot`, a managed port of the same walk with the same +default markers, so a test that validates a repository's skills resolves the same root the build +installs them into. The two are kept in step by `SkillRepositoryRootTests`, including an assertion +on the default marker list itself — both are defaults nobody sets, so nothing else would notice them +drifting apart. diff --git a/.agents/skills/purview-buildsdk-skill-tests/SKILL.md b/.agents/skills/purview-buildsdk-skill-tests/SKILL.md new file mode 100644 index 00000000..4043ac4f --- /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 00000000..69d4ee45 --- /dev/null +++ b/.agents/skills/purview-buildsdk-skill-tests/references/authoring.md @@ -0,0 +1,79 @@ +# Authoring a skill that fires, and keeps firing + +## The description is the whole activation decision + +`name` and `description` are the only things loaded at startup. Everything else — the body, the +references, the scripts — is invisible until an agent has already decided to activate the skill on +the strength of those two fields. + +So write the description for a *matcher*, not for a reader: + +- Say **what it does** and **when to use it**, in that order. +- Include the words a real task would contain: the tool names, the file names, the error codes, the + property names, the phrases a person would type. +- Name the thing the skill is *not*, when a neighbouring skill could be confused with it. + +| | | +| -- | -- | +| Poor | `Helps with the build SDK.` | +| Better | `Configure Purview.BuildSdk from Directory.Build.props or a .csproj. Use when setting NamespacePrefix, choosing a testing framework, or working out whether a property must be set before the SDK import.` | + +The limit is 1024 characters. Most of that budget is better spent on trigger words than on prose. + +## Naming for a shared namespace + +Skills install into one flat directory per agent, shared with every other skill a developer has +installed from anywhere. A generic name is a collision waiting to happen, and the loser is whichever +one installed second. + +- Prefix with the vendor and the product: `purview-buildsdk-configuration`, not `sdk-configuration`. +- `sdk-*`, `project-*`, `test-*`, `build-*` are all too common to use unprefixed. +- The name must be 1–64 characters, lowercase `a-z0-9` with single hyphens, no leading, trailing or + consecutive hyphen — and it must equal the folder name, because the CLI installs by the + frontmatter name while a human reads the folder. + +Renaming a skill means renaming the folder and the frontmatter together, and re-running the install. + +## Progressive disclosure in practice + +| Stage | Loaded | Budget | +| -- | -- | -- | +| Metadata | `name`, `description` | ~100 tokens, always | +| Instructions | the whole `SKILL.md` body | under 5000 tokens recommended; keep the file under 500 lines | +| Resources | `references/`, `scripts/`, `assets/` | only when the task reaches them | + +What belongs where: + +- **`SKILL.md`** — the decisions, the rules, the things that are wrong by default, and a pointer to + everything else. If a reader needs it to make the *first* correct move, it goes here. +- **`references/`** — long tables, full option lists, grammars, enumerations. Keep each file + focused; an agent loads the whole file. +- **`scripts/`** — executable code. Self-contained, or documenting its dependencies clearly, with + helpful error messages. +- **`assets/`** — templates, schemas, images, lookup data. + +Keep references one level deep. Avoid a chain where one reference sends an agent to another. + +## Links + +Use relative paths from the skill root: + +```markdown +See [the reference](references/REFERENCE.md) for detail. +Run `scripts/extract.py`. +``` + +Only the skill's own folder is installed, so a link that climbs out of it (`../other-skill/...`) is +broken on every machine except the one that authored it. To point at another skill, name it in prose +and let the agent find it by name. + +## Keeping a skill true + +A skill that describes a product goes stale the moment the product changes, and nothing about a +stale skill looks broken — it just gives confidently wrong advice. + +- Change the skill in the **same commit** as the behaviour it documents. +- Prefer stating the property name, the diagnostic code or the target name over paraphrasing the + behaviour; those are greppable when the code changes. +- Where a number or a list is authoritative somewhere else — a default, an enumeration — say where + it comes from, so a reader can check it. diff --git a/.agents/skills/purview-buildsdk-testing/SKILL.md b/.agents/skills/purview-buildsdk-testing/SKILL.md new file mode 100644 index 00000000..5a5a7d53 --- /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 00000000..35d032bc --- /dev/null +++ b/.agents/skills/purview-buildsdk-testing/references/treenode-filter.md @@ -0,0 +1,107 @@ +# `--treenode-filter` reference + +TUnit runs on Microsoft.Testing.Platform, not VSTest, so the VSTest `--filter` syntax does not +apply. Narrowing is done with `--treenode-filter`. + +## The path + +```text +//// +``` + +Each segment maps to one level of the tree: + +| Position | Matches | +| -- | -- | +| 1 | Assembly | +| 2 | Namespace | +| 3 | Class | +| 4 | Test name | + +```text +/*/*/LoginTests/* every test in LoginTests +/*/*/*/AcceptCookiesTest one test by name +/*/MyProject.Tests.Integration/*/* every test in a namespace +/*/*/*/* everything +``` + +## Operators + +### `*` — wildcard + +Matches a whole segment or part of one. + +```text +/*/*/LoginTests*/* +/*/*/MyProject.Tests.Api*/* +``` + +### `=` and `!=` — property predicates + +In the last segment, inside `[...]`. + +```text +/*/*/*/*[Category=Unit] +/*/*/*/*[Category!=Slow] +/*/*/*/*[Owner=*Team-Backend*] +``` + +### `&` and `|` — combining + +Inside a **single** bracket group, or a single parenthesised segment. + +```text +/*/*/*/*[(Category=Unit)&(Priority=High)] +/**[(Category=Unit)|(Priority=High)] +/*/*/(LoginTests)|(SignupTests)/* +``` + +### `**` — match all + +Any depth, but **only at the end** of the path. + +```text +/** +/MyAssembly/** +``` + +`/**/Path` is not valid. + +## Rules that catch people out + +- Only **one** property group `[...]` per path segment. `[Category=Smoke]|[Priority=High]` is not + valid; write `[(Category=Smoke)|(Priority=High)]`. +- `**` must be last. +- A segment left empty is not a wildcard — write `*`. + +## The `--` separator + +```bash +dotnet test --treenode-filter "/*/*/*/*[Category=Unit]" # preferred +dotnet test -- --treenode-filter "/*/*/*/*[Category=Unit]" # older SDKs +``` + +Newer .NET SDKs forward unrecognised arguments to the MTP runner directly. On an older SDK the `--` +separator is required, and omitting it produces an argument error rather than a filter. + +## Common selections + +| Intent | Filter | +| -- | -- | +| Everything | `/*/*/*/*` | +| All unit tests | `/*/*/*/*[Category=Unit]` | +| High-priority unit tests | `/*/*/*/*[(Category=Unit)&(Priority=High)]` | +| One class | `/*/*/MyFeatureTests/*` | +| A name pattern | `/*/*/*/MyScenario*` | +| Critical integration tests | `/*/MyProject.Tests.Integration/*/*[Priority=Critical]` | + +## Zero tests ran + +1. `--treenode-filter`, not `--filter`. +2. Try without the `--` separator first, then with it. +3. Broaden to `/*/*/*/*`, then narrow one segment at a time. +4. Confirm the project has `[Test]` methods. +5. Confirm the project is **detected** as a test project — + `dotnet msbuild -getProperty:IsTestProject`. A name without the dot, such as + `UnitTests` rather than `Api.UnitTests`, is not detected and gets no runner. +6. Confirm every property predicate sits in one bracket group. diff --git a/.agents/skills/purview-engineering-principles/SKILL.md b/.agents/skills/purview-engineering-principles/SKILL.md new file mode 100644 index 00000000..ba4dd9bd --- /dev/null +++ b/.agents/skills/purview-engineering-principles/SKILL.md @@ -0,0 +1,171 @@ +--- +name: purview-engineering-principles +description: >- + Use when setting up, reviewing or rationalising a .NET repository against the Purview engineering + conventions - convention over configuration, derived identity, architecture as tests, warnings as + errors, proving consumer-facing claims, documentation ownership, test data builders, agent + legibility, and tiering verification by risk. Each rule names the failure it prevents and whether a + build or a reviewer enforces it. +metadata: + product: Purview engineering playbook + repository: purview-dev/engineering-playbook +--- + +# Purview engineering principles + +Ten rules, each with the failure it prevents and the mechanism that makes it true. The distinction +that matters throughout: **a convention a build can check is checked by the build; a convention only +a reviewer can check is labelled a review obligation and never described as enforced.** A rule that +claims enforcement it does not have is worse than no rule, because people stop looking. + +## 1. Convention over configuration + +A repository declares as little as possible. One MSBuild SDK supplies the defaults, and a project +file is short because the SDK already knows what a project of that shape needs. + +Configuration copied between repositories diverges. When the rule lives in one package, fixing it +once fixes it everywhere; when it lives in eleven project files, it is fixed eleven times — or, more +often, once. + +*Enforced by:* `` in a single +`Directory.Build.props`. The SDK detects project type from the filename and the `Sdk` attribute. + +*At your scale:* a single-project repository still benefits. The import is two lines and removes the +target framework, language version, nullable context, analyzer set and package metadata. + +## 2. Identity is derived, never repeated + +`RootNamespace` comes from `$(NamespacePrefix).$(ProjectName)`, and `AssemblyName` follows the +resolved `RootNamespace`. A name appears once — in the folder and file name — and everything else is +computed. + +A hand-typed namespace drifts from the assembly it lives in, and the drift is invisible until +something reflects over types: an architecture test, a serializer, a DI scan. + +*Enforced by:* the SDK during `.props` evaluation, plus a build-time check +(`PurviewProjectFileNameMismatch`) when a `.csproj` filename does not match its parent directory. + +*At your scale:* always. It costs nothing and removes a class of bug. + +## 3. Architecture is a test, not a diagram + +The dependencies a layer may have are asserted by tests that read the compiled assemblies. A +`ProjectReference` pointing the wrong way fails the build. + +A diagram is a claim about the past. Dependency direction erodes one convenient reference at a time, +and every such reference looks reasonable in isolation. + +*Enforced by:* a test project that references every layer and inspects their metadata references. + +*At your scale:* small repositories can skip it. Adopt it the moment a **second layer** exists — +that is when the wrong direction first becomes possible. + +## 4. The build is the only authority + +Style is enforced by analyzers and `EnforceCodeStyleInBuild`, and the SDK's own style policy is +re-validated on every compile, so a repository cannot quietly downgrade a rule. + +*At your scale:* always. If a rule matters and cannot be checked, say so plainly. + +## 5. Warnings are not acceptable output + +A clean build has no warnings. One that will never be fixed is either fixed, suppressed *where it +occurs* with a stated justification, or turned off deliberately. + +Warnings are a budget. A build that emits twenty is a build where the twenty-first is invisible — and +the twenty-first is the one that mattered. + +*Enforced by:* severity, partly. The SDK's shipped `.editorconfig` sets many rules to `error`. The +rest is review, backed by the pipeline running `dotnet build` with the repository's configuration. + +*At your scale:* a small repository can tolerate `warning`; a large one cannot. Decide deliberately +rather than by accumulation. + +## 6. Every claim about a consumer is proved by a consumer + +When a package documents how it must be used — a target framework, an asset, a guard, a workaround — +something in the repository builds that documented consumer and asserts the documented outcome. + +Consumer-facing documentation is the part of a library least likely to be exercised by its own tests, +because **the library's own build is not a consumer**. It drifts silently and fails in somebody +else's repository. + +*Enforced by:* a verification script that packs, then builds throwaway consumer projects against the +produced packages, asserting the happy path *and* the documented guard errors. `purview-dev/containers` +is the worked example (`just verify-consumers`), deliberately excluded from the default unit filter +because it restores from nuget.org. + +*At your scale:* only once a package is consumed outside its own repository. Before that, the +repository's own tests already are the consumer. + +## 7. Documentation is a build artefact of the repository + +The wiki lives in the repository it describes, in `docs/wiki`, and the repository owns its +`mkdocs.yml`, its navigation and its `edit_uri`. + +Documentation that lives elsewhere is updated elsewhere. A page with no owner and no review path is +wrong within one release. + +*Enforced by:* review, plus the navigation file — a page absent from `_Sidebar.md` is not published. + +*At your scale:* always. Even a repository with one page should own that page. + +## 8. Everything a test needs is built by one builder + +Domain content in a test comes from `TestBuilder`. A test never constructs a domain object inline, +and it overrides **only** the value it is proving. + +Inline construction encodes a type's shape into every test that touches it, so one constructor change +becomes a repository-wide edit. It also hides which values the test actually depends on, because +every value looks deliberate. + +*Enforced by:* review. + +*At your scale:* adopt from the first test. Retrofitting is a mechanical edit across every test file, +and exactly the kind of edit that is postponed forever. + +## 9. An agent is a reader with no memory + +A repository states its purpose, its layout and its conventions in `AGENTS.md`. **Nothing important +is discoverable only by reading the code.** + +A coding agent has the repository and nothing else — no colleague to ask, no institutional memory, no +intuition about which of two plausible patterns is intended. It will confidently generalise from +whatever it finds first. That is also every new-joiner failure mode; the agent just makes the cost +visible sooner, because an agent does not ask. + +*Enforced by:* review, plus publishing skills so agent-facing guidance tracks the library version +rather than a repository's memory of it. + +*At your scale:* always. `AGENTS.md` is cheaper than the questions it prevents. + +## 10. A change is verified in proportion to its risk + +Checks are tiered, and a repository declares which tiers its pipeline runs. A pull request runs the +fast tier; the slow tier runs deliberately, locally or on a dedicated runner. + +A pipeline that runs everything on every pull request gets made optional. One that runs too little +gets ignored. Tiering is what keeps the gate credible. + +*Enforced by:* the pipeline's `TestFilter` and the test project's own **name** — the SDK categorises +a `*UnitTests` project as `Unit`, so the tier is a property of the project rather than of a +hand-written attribute. + +*At your scale:* one tier is fine until the slow tests exist. Then split by project name, not by +attribute. + +## Applying these in review + +Three questions catch most of it: + +1. **Is this configuration, or is it a default the SDK should own?** If two repositories would copy + it, it belongs in the SDK. +2. **Is this rule enforced, or merely stated?** If stated, say so where it is stated. +3. **Could an agent — or a new joiner — find this without being told?** If not, it goes in + `AGENTS.md` or the wiki. + +## Related skills + +`purview-architecture` for project-shape decisions, `purview-architecture-testing` for turning a +boundary into a failing build, `purview-telemetry` and `purview-persistence` for the per-concern +guidance. diff --git a/.agents/skills/purview-persistence/SKILL.md b/.agents/skills/purview-persistence/SKILL.md new file mode 100644 index 00000000..4667fa29 --- /dev/null +++ b/.agents/skills/purview-persistence/SKILL.md @@ -0,0 +1,106 @@ +--- +name: purview-persistence +description: >- + Use when a .NET project actually persists data and you are choosing a storage approach, deciding + whether to abstract EF Core, or selecting a GUID primary-key strategy. Gives provider-specific + guidance — SQL Server and PostgreSQL differ and must not be treated alike — and stays silent for + projects that do not persist anything. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" +--- + +# Purview persistence + +**Activation condition:** this skill applies only when persistence is real. If the project is a +stateless CLI, a pure library, or a service that calls an API and stores nothing, stop here — none of +this is relevant, and raising a database GUID strategy with that project is noise. + +Confirm persistence from the repository before proceeding (see `purview-architecture`): a data-access +`PackageReference`, a `DbContext`, a connection string, or an explicit requirement to store state. + +## 1. Shape the data access proportionally + +| Scale | Shape | +| -- | -- | +| Small | `DbContext` used directly from the code that needs it. No repository, no unit-of-work wrapper — `DbContext` already is both. | +| Medium | A boundary when domain rules must not know about EF Core, or when a query is complex enough to deserve a name. Often that is a few focused query types, not a generic `IRepository`. | +| Large | Independent infrastructure implementations where there genuinely are several, with the dependency direction enforced by a test. | + +**Do not wrap EF Core in a repository for testability.** `Purview.Containers` gives you a real +database in tests, which is both more faithful and less code than an abstraction built to be mocked. +A repository earns its place when you must swap the store or when `DbContext` is genuinely leaking +into domain decisions — not before. + +## 2. Local dependencies + +`Purview.Containers` provides Aspire resource integrations for the dependencies you need in +development and integration tests — including `MsSql`, `PostgreSql`, `MySql`, `Redis`, `Nats`, +`RabbitMq`, `Azurite` and an Azure Key Vault emulator. Prefer a real container over an in-memory +provider: the in-memory provider does not enforce the constraints that break you in production. + +## 3. GUID keys — only if GUIDs are keys + +Resolve these before recommending anything; most are answered by the repository: + +1. Are GUIDs primary keys, or merely external identifiers over an integer key? +2. Is the value generated by the application or by the database? +3. Which provider, and which version? +4. Does index locality matter at the expected write volume? +5. Must the identifier exist before the row is persisted? + +Question 5 usually decides it. If the application needs the identifier before `SaveChanges` — for an +outbox row, a correlation value, or to build an object graph client-side — the database cannot be the +generator, and the remaining questions are about *which* client-side strategy. + +### The rule that matters most + +**SQL Server and PostgreSQL do not benefit from the same sequential GUID implementation.** Their +index structures and their GUID comparison semantics differ, and a "sequential GUID" that helps one +can be no better than random on the other. Never carry a strategy from one to the other without +re-deriving it. + +- SQL Server specifics: `references/sqlserver.md` +- PostgreSQL specifics: `references/postgresql.md` +- Choosing between them, and when a custom generator is justified: `references/guid-generation.md` + +### Prefer built-in capability + +Use the framework or database capability when it is sufficient — `Guid.CreateVersion7()` on .NET 9+, +`NEWSEQUENTIALID()`, EF Core's own value generators, a provider's native UUIDv7 function. A custom +generator is a maintenance obligation and a correctness risk; introduce one only against a measured +requirement. + +**There is no Purview GUID generation package.** Do not reference one, and do not create one without +first establishing a concrete, measured need that the built-in options cannot meet. If such a +capability is added later, this skill is where the guidance for recommending it belongs. + +## 4. Value objects as keys + +`Purview.ValueObjects` gives strongly-typed identifiers that stop an `OrderId` being passed where a +`CustomerId` belongs. They need an EF Core value converter; configure it once in the model rather +than per property. + +This is about type safety at the boundary, not about the generation strategy — the two decisions are +independent. + +## 5. Migrations and schema + +- Migrations are code: reviewed, tested against a real container, and applied by a deliberate step — + not by the application at startup in production. +- Expect the schema to be ahead of or behind the code during a deployment; design changes to be + compatible in both directions. +- Keep the connection string out of source. Keep it out of telemetry too. + +## Checks before you answer + +- Have you confirmed persistence is actually required? +- If you are recommending a GUID strategy, do you know the provider *and* its version? +- Are you proposing an abstraction you can justify by something other than testability? +- Have you preferred a built-in capability over a custom generator? + +## See also + +- `purview-architecture` — context discovery and proportional structure +- `purview-telemetry` — instrument the data access too; it is where the latency is diff --git a/.agents/skills/purview-persistence/references/guid-generation.md b/.agents/skills/purview-persistence/references/guid-generation.md new file mode 100644 index 00000000..432053a2 --- /dev/null +++ b/.agents/skills/purview-persistence/references/guid-generation.md @@ -0,0 +1,93 @@ +# Choosing a GUID generation strategy + +The cross-provider decision procedure. Provider detail lives in `sqlserver.md` and `postgresql.md`. + +## Step 0 — is this question in scope? + +Ask nothing and recommend nothing unless **both** are true: + +- The project persists data, and +- GUIDs are (or are about to be) primary keys. + +A stateless CLI, a pure library, a service with an integer key, or a project using GUIDs only as +external correlation identifiers does not need this decision. Raising it anyway is the failure this +page is written to prevent. + +## Step 1 — must the identifier exist before persistence? + +This question decides more than any other. + +**Yes** — the application needs the value before `SaveChanges` for an outbox row, a correlation +identifier, an object graph built client-side, or an identifier returned before commit. The database +cannot be the generator. Go to step 2 with client-side options only. + +**No** — the database may generate it, and database generation is available to you. + +## Step 2 — which provider, and which version? + +Do not skip the version. It changes the available options materially: + +| Provider | Version matters because | +| -- | -- | +| SQL Server | `NEWSEQUENTIALID()` is long-standing; the comparison semantics that make byte-ordered UUIDs useless have never changed | +| PostgreSQL | Native `uuidv7()` exists only from 18; `gen_random_uuid()` is built in from 13 | +| .NET | `Guid.CreateVersion7()` exists from .NET 9 | + +**The two databases do not share an answer.** SQL Server compares `uniqueidentifier` with the last +six bytes most significant, so a byte-ordered UUIDv7 is effectively random to its clustered index. +PostgreSQL compares `uuid` in byte order, so UUIDv7 is genuinely sequential there. A strategy that is +optimal on one is unremarkable or wrong on the other. + +## Step 3 — does locality actually matter? + +Sequential keys exist to reduce index fragmentation and page splits under write pressure. If the +table takes tens of inserts a minute, this is not your problem and a random GUID is the simpler, +more private choice. + +Reach for a sequential strategy when you can point at write volume, measured fragmentation, page +splits, or buffer-pool pressure. "It's best practice" is not a measurement. + +Note also that on SQL Server the problem is often better solved by making the clustered index +something other than the GUID key — see `sqlserver.md`. + +## Step 4 — is unpredictability required? + +Sequential and time-ordered identifiers are guessable, and UUIDv7 discloses creation time to the +millisecond. Where an identifier appears in a URL, is handed to a third party, or acts as a +capability, use a random UUID — or keep the ordered value internal and expose a random surrogate. + +Never use `NEWSEQUENTIALID()` output as a secret. + +## Decision summary + +| Provider | Needs value before insert | Locality matters | Recommendation | +| -- | -- | -- | -- | +| SQL Server | No | Yes | `NEWSEQUENTIALID()` (EF Core's default for `Guid` keys) | +| SQL Server | Yes | Yes | EF Core `SequentialGuidValueGenerator` | +| SQL Server | Either | No | `Guid.NewGuid()` | +| PostgreSQL | Yes | Yes | `Guid.CreateVersion7()` with `ValueGeneratedNever()` | +| PostgreSQL | No | Yes | `uuidv7()` on 18+, otherwise generate v7 in the application | +| PostgreSQL | Either | No | `Guid.NewGuid()` or `gen_random_uuid()` | +| Any | Either | Either, but must be unpredictable | Random v4 | + +## Prefer built-in capability + +In order of preference: + +1. A framework capability — `Guid.CreateVersion7()`, EF Core's value generators. +2. A database capability — `NEWSEQUENTIALID()`, `uuidv7()`, `gen_random_uuid()`. +3. A reviewed, tested implementation of a published standard, when neither of the above is available + on your target versions. +4. A bespoke generator — only against a measured requirement the first three cannot meet. + +A hand-written sequential GUID generator is easy to get subtly wrong — endianness, clock regression, +counter rollover within a millisecond, and thread safety are all live hazards, and the failure mode +is duplicate or badly-ordered keys found in production. + +## There is no Purview GUID package + +No Purview library provides GUID generation today. Do not reference one and do not invent one. + +If a concrete need emerges that the built-in options cannot meet — established by measurement, not by +anticipation — that is the point at which creating one is worth discussing. Until then this guidance +is the deliverable, and this page is where a recommendation for such a package would be documented. diff --git a/.agents/skills/purview-persistence/references/postgresql.md b/.agents/skills/purview-persistence/references/postgresql.md new file mode 100644 index 00000000..cd557ecc --- /dev/null +++ b/.agents/skills/purview-persistence/references/postgresql.md @@ -0,0 +1,98 @@ +# PostgreSQL UUID keys + +Read this only when the provider is PostgreSQL and UUIDs are primary keys. The SQL Server answer is +different — see `sqlserver.md`. + +## Why PostgreSQL differs from SQL Server + +Two differences drive everything on this page: + +1. **PostgreSQL has no clustered index.** Tables are heaps; the primary key is a separate B-tree. A + random UUID therefore does not cause row-level page splits the way it does on a clustered SQL + Server table. The cost is in the index: random inserts touch scattered index pages, which hurts + cache locality and WAL volume, but it is a smaller and differently-shaped problem. +2. **`uuid` compares in byte order.** Unlike SQL Server, PostgreSQL orders UUIDs by their bytes. A + UUID that is sequential in byte layout *is* sequential to the index. + +The consequence: **UUIDv7 works here.** It is the natural answer on PostgreSQL and the wrong answer +on SQL Server. Never carry a sequential-GUID choice across the two. + +## The options + +### `Guid.CreateVersion7()` — .NET 9+, application-generated + +The default recommendation for new PostgreSQL schemas with UUID keys. + +```csharp +public sealed class Order +{ + public Guid Id { get; init; } = Guid.CreateVersion7(); +} +``` + +- Time-ordered in byte layout, so index locality is good. +- The value exists before persistence, so outbox rows, correlation and client-side graphs all work. +- Standard (RFC 9562), so it is portable and readable by other systems. +- Embeds a millisecond timestamp — which is also its disclosure caveat. + +Tell EF Core the application supplies it: + +```csharp +builder.Property(o => o.Id).ValueGeneratedNever(); +``` + +Without that, EF Core may treat the key as store-generated and skip your value. + +### Database-generated UUIDv7 — PostgreSQL 18+ + +PostgreSQL 18 added a native `uuidv7()` function. Earlier versions do not have it; on 13–17 you have +`gen_random_uuid()` (v4, from the built-in pgcrypto-derived function) and nothing native for v7. + +**Check the server version before recommending `uuidv7()`.** It is the single most common way this +guidance goes wrong — the function simply does not exist on the version most deployments are running. + +```sql +-- PostgreSQL 18 and later only +ALTER TABLE orders ALTER COLUMN id SET DEFAULT uuidv7(); +``` + +Prefer application-side generation anyway unless rows are inserted by paths that bypass the +application, because the value-before-persistence property is worth more than the marginal +difference. + +### `gen_random_uuid()` — database-generated v4 + +Available without an extension from PostgreSQL 13. Random, so index locality is poor, but +unpredictable and simple. + +### `Guid.NewGuid()` — application-generated v4 + +Random and unpredictable. Fine at low to moderate write volume, which covers most applications. + +## Npgsql and EF Core notes + +- `Guid` maps to `uuid` natively. Do not store UUIDs as `text` or `varchar` — it costs space, loses + index efficiency and defeats comparison. +- Npgsql's `uuid` mapping is byte-order faithful, so a v7 value sorts in the index as you expect. +- If the application generates the key, set `ValueGeneratedNever()` so EF Core does not fetch a + store-generated value that does not exist. +- A legacy database may have UUIDs in `text` columns. Migrating to `uuid` is usually worth it, but it + is a data migration, not a model change. + +## Choosing + +| Situation | Use | +| -- | -- | +| New schema, .NET 9+, UUID keys | `Guid.CreateVersion7()`, `ValueGeneratedNever()` | +| .NET 8 or earlier, high write volume | A reviewed UUIDv7 implementation, or accept v4 after measuring | +| Rows inserted outside the application, PostgreSQL 18+ | `uuidv7()` as a column default | +| Rows inserted outside the application, PostgreSQL 13–17 | `gen_random_uuid()` | +| Key must be unpredictable | `Guid.NewGuid()` or `gen_random_uuid()` — v7 leaks creation time | +| Low or moderate volume | v4 — the locality difference will not be your bottleneck | + +## The timestamp caveat + +A UUIDv7 exposes when the row was created, to the millisecond. Where identifiers are public, that can +leak signup times, order rates or activity patterns — and it makes identifiers partially guessable by +time window. Where that matters, use a random UUID for the public identifier and keep the v7 value +internal, or use a surrogate. diff --git a/.agents/skills/purview-persistence/references/sqlserver.md b/.agents/skills/purview-persistence/references/sqlserver.md new file mode 100644 index 00000000..c7282955 --- /dev/null +++ b/.agents/skills/purview-persistence/references/sqlserver.md @@ -0,0 +1,101 @@ +# SQL Server GUID keys + +Read this only when the provider is SQL Server and GUIDs are primary keys. The PostgreSQL answer is +different — see `postgresql.md`. + +## Why SQL Server is a special case + +A clustered index determines physical row order. A `uniqueidentifier` primary key is clustered by +default, so random GUIDs insert into the middle of the index, causing page splits, fragmentation and +a larger buffer-pool working set. On a write-heavy table this is measurable. + +The complication is that **SQL Server does not compare `uniqueidentifier` values the way the bytes +are laid out.** Its ordering weights the last six bytes (node field) most significantly, then earlier +groups. So a GUID that is sequential in big-endian byte order — including a standard UUIDv7 — is +**not** sequential under SQL Server's comparison. This is precisely why the SQL Server and PostgreSQL +answers diverge. + +## The options + +### `NEWSEQUENTIALID()` — database-generated + +A column default that produces values ascending in SQL Server's own ordering. Best index locality +available. + +```sql +ALTER TABLE [Orders] ADD CONSTRAINT [DF_Orders_Id] + DEFAULT NEWSEQUENTIALID() FOR [Id]; +``` + +EF Core, for a `Guid` key on SQL Server, configures this by default — `ValueGeneratedOnAdd` maps to +`NEWSEQUENTIALID()` through the default value-generation strategy. + +Trade-offs: + +- The value does not exist until the row is inserted. EF Core retrieves it, but you cannot build an + object graph around the key beforehand. +- Values are **not** unpredictable: the sequence restarts on service restart but is otherwise + guessable. Never use one as a security token or capability URL. +- It reveals rough insert ordering, which can be an information-disclosure concern on a public + surface. + +### EF Core's `SequentialGuidValueGenerator` — client-generated + +EF Core ships a value generator that produces GUIDs ordered for SQL Server's comparison semantics, +generated in the application. + +```csharp +builder.Property(o => o.Id) + .HasValueGenerator() + .ValueGeneratedOnAdd(); +``` + +Use this when the identifier must exist before `SaveChanges`. It keeps most of the index locality +while letting the application own the value. + +### `Guid.NewGuid()` — random + +Correct, portable and unpredictable; poor index locality. + +Appropriate when write volume is low (the fragmentation never matters), when the key is +non-clustered, or when unpredictability is a requirement. Do not dismiss it by default — for most +applications it is fine, and "most applications" includes more than people expect. + +### `Guid.CreateVersion7()` — .NET 9+ + +Ordered in byte layout, but **not** ordered under SQL Server's comparison. On SQL Server it behaves +roughly like a random GUID for clustered-index purposes. It is the right answer on PostgreSQL and the +wrong answer here, which is the trap this page exists to prevent. + +Use it on SQL Server only when you want the embedded timestamp for application reasons and accept +random-like insert locality. + +## Decoupling the key from the index + +Often the best answer is not a different GUID but a different index. Keep the GUID as the primary key +and make the clustered index something naturally ascending: + +```csharp +builder.HasKey(o => o.Id).IsClustered(false); +builder.HasIndex(o => o.CreatedAt).IsClustered(); +``` + +This removes the page-split problem entirely and leaves the key unpredictable. Consider it before +reaching for a sequential generator. + +## Choosing + +| Situation | Use | +| -- | -- | +| High insert volume, key may be database-generated | `NEWSEQUENTIALID()` | +| High insert volume, identifier needed before persisting | EF Core `SequentialGuidValueGenerator` | +| Key must be unpredictable (public surface, security boundary) | `Guid.NewGuid()`, with a non-clustered PK | +| Low or moderate volume | `Guid.NewGuid()` — do not optimise what does not hurt | +| You want a sortable timestamp in the key and accept the locality cost | `Guid.CreateVersion7()` | + +## Measure before optimising + +Index fragmentation from random GUIDs is real but frequently irrelevant. Before changing a key +strategy, confirm it matters: check page splits, fragmentation and buffer-pool pressure on the actual +table at the actual volume. Changing a primary key strategy on a live table is expensive; do it +against evidence. diff --git a/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md b/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md new file mode 100644 index 00000000..ee363c97 --- /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 00000000..54efd09f --- /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 00000000..21d9522c --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md @@ -0,0 +1,142 @@ +--- +name: purview-sourcegenerator-diagnostics +description: >- + Use when a Purview.SourceGeneratorFramework build reports a PSGFR, PSGF, PRSGD or TLB diagnostic, + when choosing which code fix applies, or when a source generator, diagnostic analyzer or code fix + provider is silently not running at all. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Purview.SourceGeneratorFramework diagnostics + +`Purview.SourceGeneratorFramework` ships `Purview.SourceGeneratorFramework.Analyzers` and +`Purview.SourceGeneratorFramework.CodeFixers` as analyzer assets inside the package, under +`analyzers/dotnet/cs/`. They are enabled automatically in any project that references the framework — +there is no separate package to add. + +Two compiler-analyzer families, plus a set of MSBuild-time diagnostics: + +- **Incremental generator best practice** — `PSGFR11`–`PSGFR33`: pipeline design, `CodeWriter` usage, + Roslyn component discovery, extension-class conventions. +- **C# 14 extension-member conventions** — `PSGFR34`–`PSGFR38`, with + `ReorganizeExtensionClassCodeFixProvider` and `ConvertToExtensionBlockCodeFixProvider`. +- **Merge and packaging** — `PSGFR39`–`PSGFR43`, where `PSGFR43` is the merge tool's blocked + public-surface code. + +## Start here when nothing runs + +A compiler host loads a generator, analyzer or code fix provider only when **three** conditions hold. +Missing any one means the component is **silently ignored** — no error, no output, a green build: + +1. **The type is public** (`PSGFR27`). +2. **The type is decorated** — `[Generator]` (`PSGFR26`), `[DiagnosticAnalyzer]` (`PSGFR25`), or + `[ExportCodeFixProvider]` (`PSGFR24`). +3. **The assembly is loaded as an analyzer** — packed under `analyzers/dotnet/cs/` in a package, or + referenced with `OutputItemType="Analyzer"` on a `ProjectReference`. + +A code fix provider additionally appears only when a diagnostic ID in its `FixableDiagnosticIds` is +actually produced by an analyzer loaded alongside it (`PSGFR28`). Visual Studio MEF-composes fix +providers when the analyzer set loads, so after adding or updating a fixer assembly you must restart +Visual Studio or reload the project before the fixes show up. + +## Build-time validation diagnostics + +These are MSBuild diagnostics, not compiler analyzers, so they are **not** tracked in +`AnalyzerReleases.*.md`: + +| Code | Raised by | Summary | +| --- | --- | --- | +| `PSGF0001` | `Purview.BuildSdk` | A Roslyn component did not produce (or declare) a source-generator analyzer file. | +| `PSGF0003` | `Purview.SourceGeneratorFramework` | A `PurviewGeneratorVisibleProperty` is not compiler-visible in the declaring project or its `Sdk/build`/`Sdk/buildTransitive` assets, so consumers cannot read `build_property.`. | +| `PRSGD0005` | `Purview.BuildSdk` | A file in the returned analyzer closure references an assembly that is neither in the closure nor a compiler-host assembly. | +| `PSGFR41` | merge tool | A component's public surface exposes framework types the merge internalizes. Reported by the analyzer at design time, and by the merge pass as `message` (default), `warning` or `error`. | +| `PSGFR43` | merge tool | The merged analyzer still exposes a public framework type, so it is not self-contained. Always an error; the merge fails with exit code 5. | + +Opt out with `PurviewSourceGeneratorFrameworkAnalyzerValidation=false` (`PRSGD0005`) or +`PurviewSourceGeneratorFrameworkGeneratorPropertyValidation=false` (`PSGF0003`). + +## Incremental pipeline rules + +| Rule | Summary | +| --- | --- | +| `PSGFR11` | Prefer `SyntaxProvider.ForAttributeWithMetadataName` over `CreateSyntaxProvider` for attribute-based detection. | +| `PSGFR12` | Use `IIncrementalGenerator` / `RegisterSourceOutput` instead of `ISourceGenerator`. | +| `PSGFR14` | Avoid `RegisterImplementationSourceOutput` unless implementation-only output is required. | +| `PSGFR15` | Pipeline model collection members should use sequence equality (`EquatableArray`). | +| `PSGFR30` | Prefer `static` lambdas in pipeline methods, so the compiler never allocates a closure on the per-item hot path. | +| `PSGFR31` | Prefer `GeneratorAttributeSyntaxContext.TargetSymbol` over `SemanticModel.GetDeclaredSymbol(ctx.TargetNode)`. | +| `PSGFR33` | Pipeline models must not retain Roslyn objects (`ISymbol`, `SyntaxNode`, `Location`, …); extract the information into value types. | + +## CodeWriter rules + +| Rule | Summary | +| --- | --- | +| `PSGFR16` | Prefer the nullable-context `Nullable()`/`MakeNullable()` overload, so annotations honour the target compilation. | +| `PSGFR17` | Consume scope-returning methods (`…Scope`, `IndentedScope`) with `using`. | +| `PSGFR18` | Prefer structured declaration APIs (`Class`, `Method`, `Property`, `Field`) over raw declaration text. | +| `PSGFR19` | Prefer structured statement APIs (`Return`, `MethodCall`, `Throw`, `Assignment`, `Using`, `Comment`) over raw statement text. | +| `PSGFR20` | Prefer the minimal `CodeWriter` overloads over hand-constructing `*DeclarationOptions` values. | +| `PSGFR21` | Prefer `HashDefines`/`HashDefinesScope` for `#if`/`#endif`. | +| `PSGFR22` | Prefer `PragmaDisable`/`OpenPragmasScope` for `#pragma warning`. | +| `PSGFR23` | Prefer structured `IfBlock`/`ElseIf`/`Else` over raw `if` block text. | +| `PSGFR29` | Do not embed a `CodeWriter` in a string; use `XmlCommentWriter.XmlInlineCode`. | +| `PSGFR32` | Avoid `NormalizeWhitespace` when generating source; use an indented text writer such as `CodeWriter`. | + +## Component, extension and merge rules + +| Rule | Summary | +| --- | --- | +| `PSGFR24` | `CodeFixProvider` is not marked `[ExportCodeFixProvider]`; Visual Studio will never discover it. | +| `PSGFR25` | `DiagnosticAnalyzer` is not marked `[DiagnosticAnalyzer]`; it will never run. | +| `PSGFR26` | A generator type is not marked `[Generator]`; it will never run. | +| `PSGFR27` | A Roslyn component type is not public; the compiler host cannot instantiate it. | +| `PSGFR28` | `FixableDiagnosticIds` names a diagnostic no analyzer in the compilation produces; the fix will never be shown. | +| `PSGFR34` | Prefer C# 14 `extension(Receiver)` blocks over classic static `this`-parameter extension methods. | +| `PSGFR35` | Extension class name must match the extended type (`{Receiver}Extensions`). | +| `PSGFR36` | Extension classes belong in the extended type's namespace, under an `Extensions` folder. | +| `PSGFR37` | One extension class per receiver type; split a class that extends several. | +| `PSGFR38` | Extension classes should carry `[EditorBrowsable(EditorBrowsableState.Never)]`. | +| `PSGFR39` | A non-packable Roslyn component opted out of the default self-contained analyzer output (`PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles=false`) while embedding the framework; otherwise the package embeds the loose framework DLL under `analyzers/`. | +| `PSGFR40` | In a Roslyn component (`IsRoslynComponent=true`), reference SGF types as inline code (`Type`) rather than a `cref`: copied documentation must not depend on cref resolution. | +| `PSGFR41` | A public member (or generic constraint) whose signature references an SGF type in a component whose framework implementation is merged. The merge internalizes every SGF type, so the signature ends up referring to an internal type. Make the member, or its declaring type, non-public. | +| `PSGFR42` | A disposable Testing-harness result (`DriverRunResult`) is produced and never disposed, leaking the collectible `AssemblyLoadContext` and `MetadataLoadContext` of a compiled run. `warning` when the run syntactically compiles to an assembly (a `Compile` options member, or `CompileToAssembly = true`), otherwise `info`. Awaiting an assertion, or calling a helper handed the result — an extension on it, or a method taking it as a parameter — is not a production: both hand the same instance back, so only a task-like await or a genuine producer is reported. | + +## Type-library and attribute-model diagnostics + +The bundled generators carry their own families: `TypeLibraryValidationAnalyzer` +(`TLB0001`–`TLB0019`, plus `TLB0021`) and the attribute-data-model validation analyzers. They are +documented on their own wiki pages, `Type-Library.md` and `Attribute-Data-Models.md`. + +## Code fixes + +Most rules above have a fix in `Purview.SourceGeneratorFramework.CodeFixers`: + +| Fix provider | Rule | +| --- | --- | +| `AddGeneratorAttributeCodeFixProvider` | `PSGFR26` | +| `AddDiagnosticAnalyzerAttributeCodeFixProvider` | `PSGFR25` | +| `AddExportCodeFixProviderAttributeCodeFixProvider` | `PSGFR24` | +| `MakeRoslynComponentPublicCodeFixProvider` | `PSGFR27` | +| `RemoveOrphanedFixableDiagnosticIdCodeFixProvider` | `PSGFR28` | +| `PreferTargetSymbolCodeFixProvider` | `PSGFR31` | +| `PreferStaticLambdaCodeFixProvider` | `PSGFR30` | +| `PreferNullableContextOverloadCodeFixProvider` (project-wide Fix All) | `PSGFR16` | +| `PipelineModelReferenceEqualityCollectionCodeFixProvider` | `PSGFR15` | +| `PreferStructuredCodeWriterIfBlockCodeFixProvider` | `PSGFR23` | +| `CodeWriterToStringCodeFixProvider` | `PSGFR29` | +| `PreferInlineCodeForFrameworkCrefCodeFixProvider` | `PSGFR40` | +| `MakeComponentSurfaceNonPublicCodeFixProvider` | `PSGFR41` | +| `DisposeTestResultCodeFixProvider` | `PSGFR42` | +| `ReorganizeExtensionClassCodeFixProvider` (renames, splits, moves, updates referencing files) | `PSGFR35`, `PSGFR36`, `PSGFR37` | +| `ConvertToExtensionBlockCodeFixProvider` | `PSGFR34` | +| `AddExtensionClassMetadataCodeFixProvider` | `PSGFR38` | +| `MakeTypeLibrarySpecNonPublicCodeFixProvider` | `TLB0021` | +| `AttributeDataModelSymbolPropertyCodeFixProvider` | attribute-data-model symbol properties | +| `TypeLibraryMemberAccessibilityCodeFixProvider`, `TypeLibraryMarkerDefaultInitializerCodeFixProvider`, `MakeTypeLibrarySpecPartialCodeFixProvider`, `RenameTypeLibrarySpecCodeFixProvider`, `TypeLibraryMemberTypeCodeFixProvider` | type-library rules | + +## Related skills + +`purview-sourcegenerator-codewriter` for the emission patterns the `CodeWriter` rules push you +towards, and `purview-sourcegenerator-testing` for proving a pipeline actually caches. diff --git a/.agents/skills/purview-sourcegenerator-packaging/SKILL.md b/.agents/skills/purview-sourcegenerator-packaging/SKILL.md new file mode 100644 index 00000000..92350db8 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-packaging/SKILL.md @@ -0,0 +1,245 @@ +--- +name: purview-sourcegenerator-packaging +description: >- + Use when packaging or referencing a source generator built on Purview.SourceGeneratorFramework - + the self-contained merged analyzer, GetSourceGeneratorAnalyzerFiles, embedding a generator in + another package, PurviewGeneratorVisibleProperty, analyzer-closure validation, Roslyn version + compatibility, and errors such as CS0433, CS8784 or MSB3073 from a loaded analyzer. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Packaging a source generator + +The rule everything else follows from: **a shipped Roslyn component carries its own copy of the +framework and references no loose `Purview.SourceGeneratorFramework.dll`.** Two generators built +against different framework versions must be able to load into one compiler host. A shared DLL under +`analyzers/` cannot give you that — whichever version loads first wins, and the other fails with +`MissingMethodException` or `CS0433`. + +The framework delivers this by IL-merging and internalizing its implementation into each component's +**intermediate** output, leaving the component's `bin` unmerged. + +## Referencing the framework from a component + +```xml + + true + + + + + +``` + +And from a consuming project, as an analyzer: + +```xml + +``` + +`Purview.BuildSdk` invokes `GetSourceGeneratorAnalyzerFiles` automatically — naming +`Targets="GetSourceGeneratorAnalyzerFiles"` explicitly still works but is not required. + +## The three consumption paths + +| Path | Trigger | What you get | +| --- | --- | --- | +| `GetSourceGeneratorAnalyzerFiles` (default) | a consuming project references the component as an analyzer | the **merged** component, from the content-addressed `purview-merged//`. `bin` stays unmerged, so an in-process test harness keeps shared framework type identity and `InternalsVisibleTo` access without `CS0433`. | +| `GetSourceGeneratorAnalyzerFiles` with `PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles=false` | opt-out | unmerged component **plus** the loose framework DLL from the package `lib/`. | +| `GetPurviewMergedAnalyzerFile` | the framework's own bundled-component pack, or a third-party package embedding the generator | the merged component; `bin` is never overwritten. | +| `GetPurviewMergedAnalyzerFileForPack` (pack-time, via `TargetsForTfmSpecificContentInPackage`) | packing a standalone packable generator | the merged DLL and PDB contributed straight to `analyzers/dotnet/cs`. `bin` is never mutated, so its shape does not depend on whether `build` or `pack` ran last. | + +Two properties exist and both should normally be left alone: + +- `PurviewEmbedSourceGeneratorFramework=false` — only for a project that *is* the framework + compile-time library. A published generator package must never disable embedding. +- `PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles=false` — only when the analyzer-files + consumer must keep the unmerged + loose-DLL shape (a generator shipped into one compiler process + alongside an incompatible framework version). + +`PSGFR39` errors when a **non-packable** component opts out while embedding the framework, because +that shape, packed through the GASF path, forces the loose DLL back under `analyzers/`. + +## A component that references another component + +A code-fix or analyzer component can reference the generator component normally (`ProjectReference`, +`ReferenceOutputAssembly` **not** `false`) when it needs the generator's internal diagnostic +identity. Both are `IsRoslynComponent`; only the generator references the framework. + +- The referenced component is returned as **its own merged assembly** and is never IL-merged into the + dependent one — that would duplicate its types, including `InternalsVisibleTo`-visible internals, + inside a second analyzer in the same host. +- The framework assembly is declared as copy-to-output content beside the generator's `bin`, and + content with `CopyToOutputDirectory` flows transitively through `ProjectReference`. That is what + lets Visual Studio load the dependent code-fix provider from its own `bin` without a + `FileNotFoundException` for the framework assembly. +- Visual Studio's project system resolves an `OutputItemType=Analyzer` project reference to the + referenced project's **unmerged** `bin` assembly and adds it to the compiler's analyzers — the + command-line build does not. `Purview.BuildSdk` removes that item before adding the resolved + closure, so Roslyn only ever receives the merged artifact. Without that removal the unmerged copy + drags `Purview.SourceGeneratorFramework` into the compiler host + (`CS8784 FileNotFoundException: Could not load file or assembly 'Purview.SourceGeneratorFramework'`) + and every generator registers twice. + +## Analyzer-closure validation + +`Purview.BuildSdk` validates the whole closure `GetSourceGeneratorAnalyzerFiles` returns: every +file's PE `AssemblyRef` must resolve to another file in the returned set, or to a compiler-host +assembly (`Microsoft.CodeAnalysis*`, `System.Composition.*`, `System.*`, `netstandard`, …). A missing +component artifact fails the build with `PRSGD0005`. + +Extend the permitted set with `` or the +`PurviewAnalyzerClosurePermittedReferences` property. Opt out with +`PurviewSourceGeneratorFrameworkAnalyzerValidation=false`. + +## Generator-read MSBuild properties + +A generator reading `build_property.` is only correct if `` is a +`CompilerVisibleProperty` **wherever the generator runs** — including in a consumer's project. +Declare what the component reads: + +```xml + + + +``` + +`PSGF0003` fails the build when a declared property is neither a `CompilerVisibleProperty` in the +project nor declared by the project's own `Sdk/build` or `Sdk/buildTransitive` assets — which is what +consumers actually receive. Opt out with +`PurviewSourceGeneratorFrameworkGeneratorPropertyValidation=false`. + +NuGet imports `buildTransitive` assets for **PackageReference** consumers only. Inside the package +repository every project uses `ProjectReference`, so `Purview.BuildSdk`'s +`ImportProjectReferencedBuildTransitiveAssets` registers the referenced project's +`Sdk/buildTransitive/*.props|*.targets` `CompilerVisibleProperty` items for in-repo consumers. Opt +out with `PurviewImportProjectReferenceBuildTransitive=false`. + +## Embedding a generator in another package + +Pack the **merged** assembly, so the outer package ships no loose framework DLL: + +```xml + + false + $(TargetsForTfmSpecificContentInPackage);PackMyGenerator + + + + + + + + + analyzers/dotnet/cs/ + + + +``` + +If the generator is also embedded at compile time for the outer package's consumers, the outer +package must make the framework's compiler-visible properties visible to them — build assets from +`Purview.SourceGeneratorFramework` are **not** copied into the outer package. Ship a `.props` that +declares the property and its `CompilerVisibleProperty` entry, packed under the outer package's own +ID so NuGet imports it: + +```xml + +``` + +## `IsExternalInit` + +The framework assembly defines `System.Runtime.CompilerServices.IsExternalInit` **publicly**, so the +bundled generators can emit `init`-based attribute types into any consumer compilation and the merge +has a single marker definition to internalize. + +**Do not declare your own.** A generator-local marker gives calls to the framework's `init` setters a +different required-custom-modifier identity from the setter definitions. The merge tool normalizes +those modifiers in a temporary copy for compatibility with legacy tooling, but removing the redundant +marker is the preferred configuration. + +## Roslyn version compatibility + +> The version of `Microsoft.CodeAnalysis.*` you compile the component against sets a **minimum +> compiler-host API requirement**. The consumer's `` has nothing to do with it — +> analyzer code runs inside the compiler/IDE host. + +| Roslyn package | Minimum Visual Studio | Language / .NET generation | +| ---: | --- | --- | +| 4.8 | VS 2022 17.8 | C# 12 / .NET 8 | +| 4.12 | VS 2022 17.12 | C# 13 / .NET 9 | +| 5.0 | VS 2026 18.0 | C# 14 / .NET 10 | + +**This framework is built against Roslyn 5.0**, so a host loading it must be Roslyn 5.0 or later +(.NET 10 SDK / Visual Studio 2026 18.0). The testing packages multi-target `net8.0`–`net10.0`, and +Roslyn 5.x ships `net8.0`/`net9.0` assets, so those test targets still load the runner. + +## A generator project to start from + +```xml + + + netstandard2.0 + latest + enable + true + false + true + true + + + + + + + + + + + + +``` + +`Purview.BuildSdk` already defaults most of this for a project it classifies as a Roslyn component, +so in an SDK repository the project file is usually far shorter. The exact Roslyn baseline is a +product-support decision. + +## Release gates + +Acceptance criteria for self-contained packaging — check metadata, never "the sample compiled": + +1. **No framework reference, no public framework types.** Every `analyzers/dotnet/cs/*.dll` has no + assembly reference to `Purview.SourceGeneratorFramework` and exposes no public type in a + framework-owned namespace (including the generated `Generators` attribute set and the + framework-emitted `Microsoft.CodeAnalysis.EmbeddedAttribute`). The one exception is the + component's own Roslyn entry points, which must stay public — Roslyn only instantiates public + components (`PSGFR27`). +2. **No loose framework DLL in any package**, and no `*.pdb` in the `.nupkg` — symbols ship only + through the `.snupkg`. +3. **Merged entry points survive**: each merged DLL still exposes its `IIncrementalGenerator`, + `DiagnosticAnalyzer` or `CodeFixProvider`. +4. **Installed-package consumer test**: a generator project referencing the framework by + `PackageReference` packs one self-contained DLL under `analyzers/dotnet/cs`, and a consumer that + installs it builds with the generator producing output. +5. **Two-version coexistence test**: build Generator A against the current framework and Generator B + against an intentionally binary-incompatible version, install both into one consumer, and build + with both package-reference orders. Both must run against their own embedded copy. + +## Going deeper + +The merge pass, the content-addressed artifact, framework type internalization and the properties +that tune it are in [references/merge-internals.md](references/merge-internals.md) — read it when a +merge fails, when a type leaks, or when you hit `MSB3073` / `CS0433` / `CS8784` from a loaded +analyzer. diff --git a/.agents/skills/purview-sourcegenerator-packaging/references/merge-internals.md b/.agents/skills/purview-sourcegenerator-packaging/references/merge-internals.md new file mode 100644 index 00000000..5d8b6c9e --- /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 00000000..373942fd --- /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 00000000..aaeff09e --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-testing/SKILL.md @@ -0,0 +1,404 @@ +--- +name: purview-sourcegenerator-testing +description: >- + Use when writing or fixing tests for source generators, diagnostic analyzers, code fixes or + refactorings built on Purview.SourceGeneratorFramework - picking the right runner and base class, + configuring SourceGeneratorTestOptions, querying produced code with CodeQuery, and asserting + incremental pipeline caching stage by stage. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Testing source generators, analyzers, code fixes and refactorings + +Use this skill whenever a task involves authoring, fixing, or modernising tests for Roslyn components +(generators, diagnostic analyzers, code fix providers, refactoring providers) built with +`Purview.SourceGeneratorFramework`. It covers the framework-agnostic test runner layer and the +`CodeQuery` syntax-lookup API. For the TUnit base classes and assertion extensions, also load the +`purview-sourcegenerator-tunit-tests` skill. + +Modernising an existing suite onto `CodeQuery` and adding a stage-by-stage cache test? The playbook +is in [references/test-modernisation.md](references/test-modernisation.md). + +## Picking the right runner + +| Roslyn type | Runner | +|---|---| +| `IIncrementalGenerator` / `ISourceGenerator` | `SourceGeneratorTestRunner` | +| `DiagnosticAnalyzer` | `DiagnosticAnalyzerTestRunner` | +| `CodeFixProvider` | `CodeFixTestRunner` (single) | +| — | `CodeFixTestRunner.RunFixAllAsync` (project-wide) | +| `CodeRefactoringProvider` | `RefactoringTestRunner` | + +TUnit projects should prefer the matching base class instead (see `purview-sourcegenerator-tunit-tests`): +`TUnitSourceGeneratorTestBase`, `TUnitDiagnosticAnalyzerTestBase`, `TUnitCodeFixTestBase`, +`TUnitRefactoringTestBase`. + +## Result types + +- `DriverRunResult` (generator) — `DriverResult`, `AllSyntaxTrees`/`PrimarySyntaxTrees`, + `CompilationResult.Compilation`, `GetGeneratedTree`, `GetSource`, `GetTypeByMetadataName`, `LogEntries`. +- `AnalyzerTestResult` — `Diagnostics`, `Compilation`. +- `CodeFixTestResult` — `Diagnostics`, `CodeActions`, `FixedSource`, `Compilation`, `ChangedSolution`. +- `CodeFixFixAllResult` — `Diagnostics`, `CodeActions`, `FixedSources`, `ChangedSolution`. +- `RefactorTestResult` — `CodeActions`, `FixedSources`, `ChangedSolution`, `Compilation`. + +Use `DriverRunResultExtensions` (`AssertNoCompilationErrors`, `AssertNoGenerationExceptions`, +`AssertSingleGeneratedSource`, `AssertGeneratedSourceContains`, …) for quick checks, but prefer +`CodeQuery` for structural assertions. + +## Querying produced code with `CodeQuery` + +Every result exposes a `CodeQuery` via extensions in `CodeQueryResultExtensions`: + +```csharp +result.Generated() // DriverRunResult: generated trees (default, generated-first) +result.Output() // DriverRunResult: entire output compilation (user + generated) +analyzerResult.Code() // AnalyzerTestResult: input compilation +codeFixResult.Code() // CodeFixTestResult: input compilation +codeFixResult.FixedCode() // CodeFixTestResult: parsed fixed source (or post-fix solution) +fixAllResult.FixedCode() // CodeFixFixAllResult: post-fix documents +refactorResult.FixedCode() // RefactorTestResult: post-refactor documents +``` + +Every `Get` has an accompanying `Has` (bool) and `TryGet` (out): `GetMethod`/`HasMethod`/`TryGetMethod`, +`GetClass`, `GetStruct`, `GetInterface`, `GetEnum`, `GetDelegate`, `GetRecord`, `GetProperty`, +`GetField`, `GetConstructor`, `GetNamespace`, `GetTypeDeclaration`, plus generic `Get`/`Has` +and `GetSyntaxTree`/`HasSyntaxTree`. `Get` throws `SyntaxNotFoundException` when nothing matches. + +Type lookups accept an optional generic arity — `GetClass(name, arity)` / `HasClass(name, arity)` — and the +`TypeReference`/`TypeIdentity` overloads match arity automatically from the identity, so +`new TypeIdentity("ResourceDefinition", ns, arity: 1)` finds `ResourceDefinition` without matching the +non-generic `ResourceDefinition`. + +Every `Get` returns a `CodeQueryResult` — the matched syntax node (`Node`) plus a query scoped to it +(`Query`), with implicit conversions to both the node and the scoped query. Use `.Node` for direct syntax +access, or chain member queries (the originating query is carried by the result, so it is not passed again). + +Types can be matched against `TypeReference`/`TypeIdentity`, resolved through the compilation's semantic +model (nullable value types are significant, so `int?` never matches `int`): + +```csharp +result.Generated().HasMethod("DoWork", TypeReference.Create(), TypeReference.Create().Nullable(), complexType); +result.Generated().HasReturnType("Compute", TypeReference.Create()); +result.Generated().GetMethod("Format").HasParameters(TypeReference.Create(), objectReference); +``` + +When a test expects a nullable annotation, prefer the test-only `query.MakeNullable(type)` extension (on a +`CodeQuery`, accepting a `TypeReference` or `TypeIdentity`). It resolves the annotation against the query's +compilation and, unlike `TypeReference.Nullable()`/`TypeIdentity.MakeNullable()`, does not trigger the +`PSGFR16` context-overload suggestion (tests have no generation context to pass). + +Member chaining from a type declaration (`MemberQueryExtensions`): + +```csharp +var service = result.Generated().GetClass("ServiceCollectionExtensions"); // or GetClass(name, "Namespace") +service.HasProperty("Count", intType); +service.HasIndexer(stringType, intType); +service.HasMethod("Add", intType, complexType); +service.HasMethodReturnType("Add", stringType); +service.HasConstructor(stringType); +service.HasAttribute("SomeAttribute"); +``` + +Node-inspection checks on scoped results: + +```csharp +result.Generated().GetClass("Service").HasAccessibility(Accessibility.Public); // resolves C# defaults +result.Generated().GetClass("Service").GetProperty("Name").HasSetterAccessibility(Accessibility.Private); +result.Generated().GetClass("ResourceDefinition", 1).HasGenericTypeParameters("TResourceType"); +result.Generated().GetClass("ResourceDefinition", 1).HasBaseType(new TypeIdentity("ResourceDefinition", ns)); +result.Generated().GetClass("Service").HasNestedType("Builder"); // GetNestedType(...) to fetch +result.Generated().GetClass("Service").IsInNamespace("Example.Models"); // IsInGlobalNamespace() for no namespace +result.Generated().IsInNamespace(cls.Node, "Example.Models"); // or on the query directly +``` + +## Configuring options and a reusable starting point + +`SourceGeneratorTestOptions` is the base record. Common knobs: + +- `AdditionalNamespaces` / `IncludeDefaultNamespaces` — namespaces prepended to test source. +- `AdditionalAssemblyTypes` / `AdditionalReferences` — assemblies referenced by the test compilation + (use `AdditionalAssemblyTypes = [typeof(SomeType)]` to pull in a whole assembly). +- `AdditionalSources` — extra source files added to every run. +- `AnalyzerConfigOptions` — `build_property.*` values; keys without the prefix are also exposed as MSBuild + properties. +- `DisableSourceGeneratorPropertyName` / `DisableSourceGeneratorValue` — generator disable toggle. +- `NullableContextOptions`, `OutputKind`, `LanguageVersion`, `CompileToAssembly`. +- `ValidateCodeWriterScopes`, `EnableLogging`. +- `ExcludeGeneratedSourceHintNames` — hides generated marker trees from `PrimarySyntaxTrees`. + +**Easy starting point recipe.** Derive an options record that seeds the namespaces and assemblies your +generator needs, so every test gets a working compilation with no boilerplate: + +```csharp +public sealed record MyGeneratorTestOptions : SourceGeneratorTestOptions +{ + public MyGeneratorTestOptions() + { + AdditionalNamespaces = AdditionalNamespaces.Add("My.Namespace"); + AdditionalAssemblyTypes = AdditionalAssemblyTypes.AddRange( + typeof(SomeDependencyType), + typeof(TypeIdentity) // the framework's Shared assembly, when needed + ); + DisableSourceGeneratorPropertyName = PropertyLibrary.DisableMyGenerator; + } +} +``` + +`Compile()` returns a copy with `CompileToAssembly = true`, preserving the derived options type. Use the +base class hooks `OnBeforeRun`/`OnBeforeRunAsync`/`OnAfterRun` to mutate sources/options per run (for +example to append a marker attribute source via `WithAdditionalSources`). + +When `CompileToAssembly` is enabled, emission is fully in-memory. On .NET 8+ the assembly loads into a +collectible `AssemblyLoadContext`, so the result is `IDisposable` — `using var result = ...` unloads it and +keeps repeated runs from polluting the default context. `result.CompilationResult.Assembly` is the runnable +assembly (generated code can execute); `result.CompilationResult.Metadata` / `.MetadataAssembly` give a +metadata-only reflection view (types, members, attributes) over the emitted assembly without executing code. + +**Always write `using var result = ...`.** `PSGFR42` ("Dispose test harness results with a using +declaration") flags a `DriverRunResult` that is produced and never disposed — including the indirect case +where a helper creates a result, projects a value out of it and returns only the projection. It is a +`warning` when the run syntactically compiles to an assembly (`.Compile()`, a `Compile` options member, or +`CompileToAssembly = true`) and `info` otherwise; the `DisposeTestResultCodeFixProvider` fix inserts the +`using`. A result that is returned to the caller, or disposed explicitly, is not flagged. Neither is awaiting +an assertion, nor calling a helper that is handed the result — an extension on it, or a method taking it as a +parameter: both hand the same instance back, so `await Assert.That(result).SomeAssertion()` and +`result.AssertNoGenerationExceptions()` are not flagged. + +## Best practices + +- **Deterministic output**: `WriteAutoGeneratedHeader` is timestamp-free; assert with + `ContainsGeneratedCode`/`GeneratesCode` (whitespace-flattened) or `CodeQuery`, never with timestamps. +- **Generator references**: to use a generated type in the test project AND pass the generator type to a + runner, reference the generator project twice — once `OutputItemType="Analyzer"` and once as a normal + reference. +- **Multi-target**: build generators against the Roslyn version that supports the test matrix; the + framework is built against Roslyn 5.0 (net8.0/net9.0 assets keep a .NET 8–10 matrix loading); keep + `System.Collections.Immutable` version pinned to the shared one. +- **Scope validation**: keep `PurviewSourceGeneratorFrameworkValidateCodeWriterScopes` enabled; it makes + undisposed `CodeWriter` scopes fail tests. +- **Prefer `CodeQuery` over string matching** for structural assertions (members, signatures, namespaces). +- **Dispose results**: `using var result = ...`, enforced by `PSGFR42`. +- **Cap parallelism**: the harness shares one `MetadataReference` set per process, so memory is largely flat + in the degree of parallelism, but the platform's auto-detected default is both slower and heavier than an + explicit ceiling. Wall clock saturates around four concurrent tests; prefer + `--maximum-parallel-tests 8` (or a `TestingPlatformCommandLineArguments` equivalent) for a project that + multi-targets and runs several TFM executables at once. + +## Incremental cache testing (`RunIncrementalAsync`) + +To prove the pipeline caches correctly stage-by-stage, use `SourceGeneratorTestRunner.RunIncrementalAsync` +(or `GenerateIncrementalAsync` on the TUnit base). It runs a sequence of source sets over a **single shared +`GeneratorDriver`** and captures each run's `TrackedSteps`, keyed by tracking name. The canonical reference +implementation is `IncrementalPipelineCacheTests` in `SourceGeneratorShared.UnitTests`; the end-to-end +generator variant is `ServiceRegistrationCacheTests` in +`SourceGeneratorFramework.ExampleGenerator.UnitTests`. Copy the pattern into your own test project — do +not expect the source repo's files locally. + +### What is being asserted and why + +Roslyn reports one `IncrementalStepRunReason` per step output on each run: + +- `New` — the step ran for the first time. +- `Modified` — the step ran and produced a different value than the previous run. +- `Unchanged` — the step ran but produced the same value. +- `Cached` — the step was skipped and its previous result reused from the incremental cache. + +A pipeline is "caching correctly" when an unchanged input keeps every stage `Cached`/`Unchanged`, and a +targeted change marks **only** the stages whose inputs actually changed `Modified` while unrelated stages +stay `Cached`. If a generator accidentally leaks `Compilation`, `SemanticModel`, `ISymbol`, +`SyntaxNode`, or `Location` into a pipeline model, unrelated stages will report `Modified`/`New` on rerun — +these tests fail the build and catch the regression. + +### The four scenarios every cache test should cover + +1. **First run → all `New`.** Nothing can be cached on the first run; this confirms every stage is tracked + under the expected name. +2. **Identical rerun → all `Cached`/`Unchanged`.** `RunIncrementalAsync(sources, ...)` runs the same source + set twice for exactly this case. This is the strongest "it caches" proof. +3. **Source-only change → only the source/attribute stage `Modified`.** Changing an attributed class must + mark `ForAttribute_*` (and downstream output) `Modified` while property/config stages stay `Cached`. +4. **Property-only change → only the property/configuration stage `Modified`.** Toggling an MSBuild + property (via `IncrementalRunInput.AnalyzerConfig`) must mark `GetMSBuildPropertyValue_*` / + `GetGenerationConfiguration` / `GetGenerationContext_*` `Modified` while `ForAttribute_*` stays `Cached`. + +### How the runner makes this possible + +`RunIncrementalAsync` creates **one** driver, enables incremental step tracking, and **reuses the same +`Compilation` instance for identical source sets** (keyed by prepared source text). Without that reuse, +Roslyn would see a fresh compilation on the second run and report stages `Modified`/`New` even though the +sources are byte-identical — the "cached" assertion would fail. + +### Log entries per run + +`IncrementalCacheRun.LogEntries` carries the framework log entries that run wrote, in order. The sink is +registered for the whole sequence and drained between runs, so entries never accumulate. A rerun whose +output step is cached logs nothing, which makes it a second signal alongside the step reasons: + +```csharp +await Assert.That(result.Runs[0].LogEntries).IsNotEmpty(); +await Assert.That(result.Runs[1].LogEntries).IsEmpty(); +``` + +`DriverRunResult.LogEntries` (the `RunAsync` path) is likewise in write order. Both are empty when +`EnableLogging` is disabled. + +### The `StepReasons` helper + +`IncrementalCacheRun.Steps` is `ImmutableDictionary>` +keyed by tracking name. Flatten each step's `Outputs` into the reasons list so assertions read cleanly: + +```csharp +static ImmutableDictionary> StepReasons(IncrementalCacheRun run) +{ + var builder = ImmutableDictionary.CreateBuilder>(); + foreach (var pair in run.Steps) + builder[pair.Key] = [.. pair.Value.SelectMany(step => step.Outputs.Select(static output => output.Reason))]; + return builder.ToImmutable(); +} +``` + +### Framework pipeline stage names + +- `GetMSBuildPropertyValue_{Property}` — `IncrementalPipeline.PropertyValueProvider`. +- `GetGenerationConfiguration` — `IncrementalPipeline.GenerationContextValueProvider`. +- `GetGenerationContext_{Capabilities}` — e.g. `GetGenerationContext_EmptyCapabilities`. +- `ForAttribute_{AttributeType}` — `IncrementalPipeline.ForAttributeWithMetadataName`. + +The framework reference (`IncrementalPipelineCacheTests`, framework-agnostic runner) shows all four +scenarios against `TestGenerator`/`DiagnosticTestGenerator`: + +```csharp +using System.Collections.Immutable; +using Purview.SourceGeneratorFramework.TestGenerators; +using StepReason = Microsoft.CodeAnalysis.IncrementalStepRunReason; + +public class IncrementalPipelineCacheTests +{ + const string AttributedSource = """ + [TestAttribute] + public partial class MyClass { } + """; + const string ChangedAttributedSource = """ + [TestAttribute] + public partial class AnotherClass { } + """; + const string TestAttributeSource = """ + [System.AttributeUsage(System.AttributeTargets.Class)] + public sealed class TestAttribute : System.Attribute { } + """; + + static SourceGeneratorTestOptions CreateOptions() => + new SourceGeneratorTestOptions() + .WithAdditionalSources(TestAttributeSource) + .WithExcludeGeneratedSourceHintNames("TestAttribute"); + + static ImmutableDictionary> StepReasons(IncrementalCacheRun run) { /* as above */ } + + [Test] + public async Task FirstRun_AllStagesAreNew(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner().RunIncrementalAsync( + [new IncrementalRunInput([AttributedSource])], + CreateOptions(), + cancellationToken); + + var reasons = StepReasons(result.Runs[0]); + await Assert.That(reasons).IsNotEmpty(); + await Assert.That(reasons.Values.SelectMany(r => r).All(r => r == StepReason.New)).IsTrue(); + } + + [Test] + public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner() + .RunIncrementalAsync([AttributedSource], CreateOptions(), cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second.Values.SelectMany(r => r).All(r => r is StepReason.Cached or StepReason.Unchanged)).IsTrue(); + } + + [Test] + public async Task SourceChange_MarksAttributeStageModified_PropertyStagesStayCached(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner().RunIncrementalAsync( + [new IncrementalRunInput([AttributedSource]), new IncrementalRunInput([ChangedAttributedSource])], + CreateOptions(), + cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second["ForAttribute_TestAttribute"]).Contains(StepReason.Modified); + await Assert.That(second["GetMSBuildPropertyValue_DisableTestGenerator"].All(r => r == StepReason.Cached)).IsTrue(); + } + + [Test] + public async Task PropertyChange_MarksPropertyStageModified_AttributeStageStaysCached(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner().RunIncrementalAsync( + [ + new IncrementalRunInput([AttributedSource]), + new IncrementalRunInput([AttributedSource], [("build_property.DisableTestGenerator", "true")]), + ], + CreateOptions(), + cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second["GetMSBuildPropertyValue_DisableTestGenerator"]).Contains(StepReason.Modified); + await Assert.That(second["ForAttribute_TestAttribute"].All(r => r == StepReason.Cached)).IsTrue(); + } +} +``` + +### End-to-end generator variant (`GenerateIncrementalAsync`) + +TUnit tests derive from the base class and call `GenerateIncrementalAsync` (which wires the +`OnBeforeRun`/`OnBeforeRunAsync` hooks and the derived options). `ServiceRegistrationCacheTests` in +`SourceGeneratorFramework.ExampleGenerator.UnitTests` is the reference: + +```csharp +public class ServiceRegistrationCacheTests + : TUnitSourceGeneratorTestBase +{ + const string Source = """ + namespace Test; + + [GenerateService] + public class MyService { } + """; + + [Test] + public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) + { + var result = await GenerateIncrementalAsync([Source], cancellationToken: cancellationToken); + + var second = StepReasons(result.Runs[1]); + string[] frameworkStages = + [ + "GetMSBuildPropertyValue_EmitServiceRegistrationInfo", + "GetGenerationConfiguration", + "GetGenerationContext_EmptyCapabilities", + "ForAttribute_GenerateServiceAttribute", + ]; + await Assert.That( + frameworkStages.All(stage => + second.TryGetValue(stage, out var reasons) + && reasons.All(r => r is StepReason.Cached or StepReason.Unchanged))).IsTrue(); + } +} +``` + +**Why the example filters to `frameworkStages`:** `ServiceRegistrationGenerator` emits its own +`GenerateServiceAttribute` via post-initialization output, which is regenerated as a new `SyntaxTree` each +run. That makes Roslyn's *internal* `ForAttributeWithMetadataName` `Compilation` step legitimately report +`Modified` on an identical rerun. Asserting on the framework-named stages (which stay `Cached`/`Unchanged`) +is the meaningful check. If your generator does not depend on its own post-init output, the stricter +"every tracked step is `Cached`/`Unchanged`" assertion (as in the framework `IncrementalPipelineCacheTests`) +is correct. + +Per-run MSBuild-property changes are supplied with `new IncrementalRunInput(sources, [("build_property.X", "value")])`. + +## License + +This project is licensed under the MIT license. \ No newline at end of file diff --git a/.agents/skills/purview-sourcegenerator-testing/references/test-modernisation.md b/.agents/skills/purview-sourcegenerator-testing/references/test-modernisation.md new file mode 100644 index 00000000..6db47803 --- /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 00000000..bc711c59 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md @@ -0,0 +1,230 @@ +--- +name: purview-sourcegenerator-tunit-tests +description: >- + Use when writing TUnit tests for source generators, diagnostic analyzers, code fixes or + refactorings built on Purview.SourceGeneratorFramework - choosing the correct TUnit base class and + method, customising SourceGeneratorTestOptions, using the TUnit assertion extensions, and + modernising existing tests. +metadata: + product: Purview.SourceGeneratorFramework.Testing.TUnit + repository: purview-dev/sourcegenerator-framework +--- + +# TUnit test authoring for Roslyn components + +Use this skill whenever a task involves authoring, fixing, or modernising **TUnit** tests for Roslyn +components built with `Purview.SourceGeneratorFramework`. It tells you which base class to derive from, +which method to call, how to customise options with an easy starting point, and how to use the TUnit +assertion extensions. For the framework-agnostic runner layer and the `CodeQuery` API, also load the +`purview-sourcegenerator-testing` skill. + +## Base class → method matrix + +| Roslyn type | Base class | Method to call | +|---|---|---| +| `IIncrementalGenerator` / `ISourceGenerator` | `TUnitSourceGeneratorTestBase` | `GenerateAsync(source, options, ct)` | +| `DiagnosticAnalyzer` | `TUnitDiagnosticAnalyzerTestBase` | `AnalyzeAsync(source, options, ct)` | +| `CodeFixProvider` (single fix) | `TUnitCodeFixTestBase` | `ApplyCodeFixAsync(source, options, ct)` | +| `CodeFixProvider` (fix-all) | `TUnitCodeFixTestBase` | `ApplyFixAllAsync(sources, options, ct)` | +| `CodeRefactoringProvider` | `TUnitRefactoringTestBase` | `RefactorAsync(source, options, ct)` | + +For cache tests, `TUnitSourceGeneratorTestBase` also exposes `GenerateIncrementalAsync(...)`. + +Framework-agnostic equivalents (no TUnit): `SourceGeneratorTestRunner`, `DiagnosticAnalyzerTestRunner`, +`CodeFixTestRunner`, `RefactoringTestRunner`. + +## Easy starting point: derive your options record + +Create a test-options record that seeds the namespaces and assemblies your component needs, then pass it +to every test via `new MyTestOptions()`: + +```csharp +public sealed record MyGeneratorTestOptions : SourceGeneratorTestOptions +{ + public MyGeneratorTestOptions() + { + AdditionalNamespaces = AdditionalNamespaces.Add("My.Namespace"); + AdditionalAssemblyTypes = AdditionalAssemblyTypes.AddRange( + typeof(SomeDependencyType), + typeof(TypeIdentity) // framework Shared assembly, when needed + ); + DisableSourceGeneratorPropertyName = "DisableMyGenerator"; + } +} + +public class MyGeneratorTests : TUnitSourceGeneratorTestBase +{ + [Test] + public async Task GeneratesExpectedSource(CancellationToken ct) => + await GenerateAsync("...source...", ct); +} +``` + +Use the base hooks to customise per-run: `OnBeforeRun`/`OnBeforeRunAsync` (mutate sources/options, e.g. +`options.WithAdditionalSources(markerAttributeSource)`) and `OnAfterRun`/`OnAfterRunAsync`. Use +`options.Compile()` to opt into `CompileToAssembly` while preserving the derived options type. + +Write `using var result = await GenerateAsync(...)`: `DriverRunResult` is `IDisposable`, and `PSGFR42` +flags a result that is produced and never disposed (a `warning` once the run compiles to an assembly, +`info` otherwise). It also catches the indirect case — a helper that creates a result, projects a value out +of it and returns only the projection. Awaiting an assertion, or calling a helper that is handed the result — +an extension on it, or a method taking it as a parameter — is not a production: both hand the same instance +back, so neither is flagged. + +For code fixes/refactorings, select a specific registered action via `CodeFixTestOptions.EquivalenceKey` +or `CodeActionIndex`, and `RefactorTestOptions.Span`/`NodeSelector` (e.g. +`NodeSelector = query => query.GetMethod("M")`). + +## TUnit assertion extensions + +All assertion extensions live under `Purview.SourceGeneratorFramework.Testing.TUnit.Assertions` +(globally imported by the package's props). `Assert.That(...)` calls are terminal and **return the value** +when awaited. + +- **`CodeQueryAssertions`** — return `CodeQueryResult` (the matched node via `.Node`, plus a query + scoped to it via `.Query`): `HasGeneratedMethod` (optionally with `TypeReference[]` parameter types), + `HasGeneratedMethodReturnType`, `HasGeneratedClass` (by name or `TypeReference`/`TypeIdentity` identity), + `HasGeneratedProperty`, `HasGeneratedField`, `HasGeneratedSyntaxTree`; `HasFixedMethod` for code-fix and + refactor results. The assertions operate on a `CodeQuery` directly, so pass any query — `result.Generated()` + (generated trees), `result.Output()` (whole compilation), or `result.FixedCode()` — or use the convenience + overloads on the test result types, which query the relevant code for you. +- **Scoped member chaining** — `HasPropertyOfType(name, type)`, `HasFieldOfType(name, type)`, + `HasMethodOfType(name, TypeReference[])`, `HasConstructorOfType(TypeReference[])`, and + `HasAttributeOfType(name)` chain from a scoped `CodeQueryResult` (e.g. the result of `HasGeneratedClass`) + and return the matched member. They are named to avoid colliding with the bool `Has*` predicates in + `MemberQueryExtensions`. +- **Fluent `.And` chains** — the node-producing assertions (`HasGeneratedClass`, `HasPropertyOfType`, + `HasMethodOfType`, `HasNestedType`) move the chain onto the matched node, so further assertions can be + appended with `.And`. Node-inspection assertions — `WithAccessibility`, `WithGetterAccessibility`, + `WithSetterAccessibility`, `WithBaseType`, `WithGenericTypeParameter(s)`, `IsInNamespace`, + `IsInGlobalNamespace` — keep the node on the chain: + ```csharp + var method = await Assert.That(query) + .HasGeneratedClass("Service") + .And.HasNestedType("Builder") + .And.WithAccessibility(Accessibility.Private) + .And.HasMethodOfType("Build", []); + ``` + Generic types are matched by arity — `HasGeneratedClass(name, arity)` or a `TypeIdentity` with arity — + so `new TypeIdentity("ResourceDefinition", ns, arity: 1)` finds `ResourceDefinition` + without matching the non-generic `ResourceDefinition`. +- **Nullable expected types in tests** — use the test-only `query.MakeNullable(type)` extension (on a + `CodeQuery`). It resolves the annotation against the query's compilation and, unlike + `TypeReference.Nullable()`/`TypeIdentity.MakeNullable()`, does not trip the `PSGFR16` context-overload + suggestion, since tests have no generation context to pass. + ```csharp + CodeQueryResult method = await Assert.That(result.Generated()).HasGeneratedMethod("DoWork", [intType, nullableInt]); + CodeQueryResult cls = await Assert.That(result.Generated()).HasGeneratedClass("Service"); + await Assert.That(cls.HasProperty("Count", intType)).IsTrue(); // chained member query + await Assert.That(cls.Node.Identifier.ValueText).IsEqualTo("Service"); // direct syntax access + await Assert.That(result.FixedCode()).HasFixedMethod("DoWork"); // code-fix / refactor results + + var query = result.Generated(); + var attributeClass = await Assert.That(query).HasGeneratedClass(hostKitAttribute); + await Assert.That(attributeClass).HasPropertyOfType("Name", query.MakeNullable(TypeLibrary.System.String)); + ``` +- **`DiagnosticAssertions`** — `HasDiagnostic(descriptor|id)`, `HasDiagnostics(count)`, + `DoesNotHaveDiagnostic`, `HasNoDiagnostics`, `HasNoErrorDiagnostics` on generator/analyzer/code-fix results. +- **`TypeIdentityAssertions`** — `HasSymbol(TypeIdentity)` / `HasSymbol("Namespace.Type")`. +- **`GeneratedCodeAssertionsExtensions`** — `GeneratesCode(expected)`, `ContainsGeneratedCode(expected)` + (whitespace-flattened string comparison). + +For structural assertions (members, signatures, namespaces) prefer `result.Generated()` + +`Get/Has/TryGet` from `CodeQuery` (see `purview-sourcegenerator-testing`). + +## Incremental cache tests (`GenerateIncrementalAsync`) + +`TUnitSourceGeneratorTestBase` exposes `GenerateIncrementalAsync`, which mirrors `RunIncrementalAsync` but +also wires the base class hooks (`OnBeforeRun`/`OnBeforeRunAsync`) and your derived options record. Use it to +prove the pipeline caches stage-by-stage. The reference is `ServiceRegistrationCacheTests` in +`SourceGeneratorFramework.ExampleGenerator.UnitTests`; the framework-agnostic twin with a full walkthrough is +in the `purview-sourcegenerator-testing` skill. + +Why the tests look the way they do: + +- `GenerateIncrementalAsync([Source])` runs the **same source twice** on a single shared driver. The first + run reports every stage `New`; the second must report `Cached`/`Unchanged` for unchanged stages — that is + the core "it caches" proof. +- `GenerateIncrementalAsync([new IncrementalRunInput([Source]), new IncrementalRunInput([changed])])` runs two + **different** source sets, so a source-only change must mark `ForAttribute_*` `Modified` while + property/configuration stages stay `Cached`. +- `new IncrementalRunInput([Source], [("build_property.X", "value")])` toggles an MSBuild property for one + run only, so a property-only change must mark `GetMSBuildPropertyValue_*`/`GetGenerationConfiguration`/ + `GetGenerationContext_*` `Modified` while `ForAttribute_*` stays `Cached`. +- `result.Runs[n].LogEntries` carries the framework log entries *that run* wrote, in order, so a rerun whose + output step is cached can be asserted to have logged nothing. Empty when `EnableLogging` is disabled. +- The `StepReasons(IncrementalCacheRun)` helper flattens each tracked step's `Outputs` into a + `ImmutableDictionary>` so assertions can address a stage + by name (see `purview-sourcegenerator-testing` for the helper body). +- If the generator's pipeline depends on its own post-initialization output (a self-referencing generated + attribute), Roslyn's internal `ForAttributeWithMetadataName` `Compilation` step is legitimately `Modified` + on rerun; assert on the framework-named stages (e.g. `ForAttribute_GenerateServiceAttribute`, + `GetGenerationConfiguration`, `GetGenerationContext_EmptyCapabilities`) rather than every tracked step. + +```csharp +public class ServiceRegistrationCacheTests + : TUnitSourceGeneratorTestBase +{ + const string Source = """ + namespace Test; + + [GenerateService] + public class MyService { } + """; + + [Test] + public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) + { + var result = await GenerateIncrementalAsync([Source], cancellationToken: cancellationToken); + + var second = StepReasons(result.Runs[1]); + string[] frameworkStages = + [ + "GetMSBuildPropertyValue_EmitServiceRegistrationInfo", + "GetGenerationConfiguration", + "GetGenerationContext_EmptyCapabilities", + "ForAttribute_GenerateServiceAttribute", + ]; + await Assert.That( + frameworkStages.All(stage => + second.TryGetValue(stage, out var reasons) + && reasons.All(r => r is StepReason.Cached or StepReason.Unchanged))).IsTrue(); + } + + [Test] + public async Task PropertyChange_MarksPropertyStageModified_AttributeStageStaysCached(CancellationToken cancellationToken) + { + var result = await GenerateIncrementalAsync( + [ + new IncrementalRunInput([Source]), + new IncrementalRunInput([Source], [(PropertyLibrary.EmitServiceRegistrationInfo, "true")]), + ], + cancellationToken: cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second["GetMSBuildPropertyValue_EmitServiceRegistrationInfo"]).Contains(StepReason.Modified); + await Assert.That(second["ForAttribute_GenerateServiceAttribute"].All(r => r is StepReason.Cached or StepReason.Unchanged)).IsTrue(); + } +} +``` + +Use `using StepReason = Microsoft.CodeAnalysis.IncrementalStepRunReason;` and your own stage names. + +## Modernising existing tests + +When converting legacy tests that do `result.GetGeneratedTree(...)` + `string.Contains(...)`: + +1. Replace tree-lookup + string matching with `result.Generated().GetClass/GetMethod/GetProperty(...)` and + the `Has*`/`TryGet*` family. +2. Replace signature string checks with `TypeReference` parameter/return-type matching. +3. Replace `Assert.That(text).Contains("...")` with the terminal assertion extensions that return nodes. +4. Verify options use a derived record (namespaces + assemblies) rather than repeating `AdditionalNamespaces` + per test. +5. Add a stage-by-stage cache test if the component has an incremental pipeline — first run `New`, + identical rerun `Cached`/`Unchanged`, and targeted changes mark only the affected stage `Modified`. + Use the inlined examples in this skill and in `purview-sourcegenerator-testing`'s "Incremental cache testing" + section, swapping in your own generator and stage names. + +## License + +This project is licensed under the MIT license. \ No newline at end of file diff --git a/.agents/skills/purview-telemetry/SKILL.md b/.agents/skills/purview-telemetry/SKILL.md new file mode 100644 index 00000000..6d831731 --- /dev/null +++ b/.agents/skills/purview-telemetry/SKILL.md @@ -0,0 +1,123 @@ +--- +name: purview-telemetry +description: >- + Use when adding, reviewing or scaling observability in a .NET project — logging, tracing, metrics, + OpenTelemetry wiring — or when deciding whether a project is "too small for telemetry". Recommends + Purview.Telemetry.SourceGenerator at every project size, keeps libraries free of global provider + configuration, and scales the export infrastructure rather than the instrumentation. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" +--- + +# Purview telemetry + +**Telemetry is a baseline, not an advanced topic.** Instrument from the first commit at every project +size. What changes with scale is the *export and operations* story — collectors, dashboards, sampling +— not whether the code emits anything. + +A project that defers telemetry until it becomes distributed acquires its observability at exactly +the moment it can least afford the refactor. + +## Instrumentation versus export + +Keep these separate; conflating them is what produces "we're too small for telemetry". + +| | What it is | Who owns it | +| -- | -- | -- | +| **Instrumentation** | The log, span and measurement the code emits | Every project, every size, from day one | +| **Export** | Where signals go: console, OTLP, a backend, a dashboard | The application host, sized to the operation | + +A small CLI is fully instrumented and exports to the console. That is not a reduced form of +telemetry; it is the correctly sized export for a CLI. + +## Use the generator + +`Purview.Telemetry.SourceGenerator` turns an attributed interface into logging, tracing and metrics +implementations plus a DI registration extension. One declaration, three signals, no hand-written +`ILogger` message templates or `ActivitySource` plumbing. + +Do **not** reinvent its API here. The three shipped skills are authoritative and travel with the +package: + +- `telemetry-sourcegenerator-quickstart` — adding the package, the first interface, DI registration, + and publishing meter/activity-source names to the host via `TelemetryNames` +- `telemetry-sourcegenerator-design` — designing telemetry interfaces well +- `telemetry-sourcegenerator-migration` — moving existing `ILogger`/`ActivitySource`/metrics code onto + the generator + +Recommend the generator whenever the project targets a compatible TFM and the team is willing to take +an analyzer-only package reference. Where it is not compatible, fall back to the standard .NET +abstractions below — never to a bespoke logging wrapper. + +## Libraries versus hosts + +This is the rule most often broken, and it is not negotiable. + +**A reusable library:** + +- Emits through `ILogger`, `ActivitySource` and `Meter` — or through generated telemetry + interfaces, which produce exactly those. +- Publishes its activity-source and meter names so a host can subscribe. The generator does this for + you through assembly metadata. +- **Never** calls `AddOpenTelemetry`, configures an exporter, sets a sampler, or requires a specific + backend. +- Never reads an OTLP endpoint from configuration itself. + +**The application host:** + +- Owns provider configuration, exporters, sampling and resource attributes. +- Subscribes to the sources its libraries publish — with the generator, by opting into the + `TelemetryNames` class and passing its arrays to the OpenTelemetry builder. + +A library that configures a global provider breaks every consumer that already configured one, and +silently wins or loses depending on initialisation order. + +## Sizing the export + +| Scale | Export and operations | +| -- | -- | +| **Small** | Console or a single OTLP endpoint. Structured logs, exceptions recorded on the span. No collector, no dashboards, no custom exporter, no distributed tracing infrastructure. | +| **Medium** | OTLP export, dependency instrumentation (HTTP, EF Core), trace propagation across the integrations, application metrics, health checks, a sampling decision made deliberately. | +| **Large** | End-to-end distributed tracing, standardised resource attributes, operational metrics, dashboards, alerting, SLIs, documented retention and sampling policy. | + +Full detail per level: `references/observability-levels.md`. + +Instrumentation is identical across all three rows. Only the right-hand column grows. + +## Baseline for any application + +- Structured logging — parameters as fields, never interpolated into the message. +- Errors and exceptions recorded as telemetry, with the exception attached to the span. +- Operation correlation, so one unit of work is one trace. +- OpenTelemetry-compatible instrumentation, so the backend stays a choice. +- Log level configurable at runtime. +- Export behaviour configurable per environment. +- Sensitive values — credentials, tokens, connection strings, personal data — never logged or placed + on span attributes or baggage. + +## Before you change anything + +1. **Inspect what exists.** Find current `ILogger` use, `ActivitySource`/`Meter` instances, any + `AddOpenTelemetry` call, a `ServiceDefaults` project. +2. **Preserve it.** Extend existing instrumentation; do not replace working telemetry to adopt a + library. +3. **Do not duplicate registrations.** One registration per source; adding a second is a silent + double-count on every metric. +4. **Size the configuration to the project**, using the table above. +5. **Check for leaking secrets** in anything you add. +6. **Keep instrumentation and export distinct** in what you recommend. + +## Do not + +- Omit telemetry because the application is small. +- Invent Purview telemetry APIs — read the three shipped skills instead. +- Configure a global provider from a library. +- Recommend a collector, dashboard stack or custom exporter for a small project. +- Put a user identifier, token or connection string into a log field, span attribute or baggage item. + +## See also + +- `purview-architecture` — establishing project context and scale before deciding +- [Purview.Telemetry.SourceGenerator](https://github.com/purview-dev/purview-telemetry-sourcegenerator) diff --git a/.agents/skills/purview-telemetry/references/observability-levels.md b/.agents/skills/purview-telemetry/references/observability-levels.md new file mode 100644 index 00000000..df97d785 --- /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 00000000..1fe32320 --- /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 00000000..e8b91109 --- /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 f8a47855..3fc1c2ce 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -17,11 +17,11 @@ "rollForward": false }, "purview.build": { - "version": "0.7.1", + "version": "0.8.0", "commands": [ "purview-build" ], "rollForward": false } } -} \ No newline at end of file +} diff --git a/.config/purview-build.json b/.config/purview-build.json index ed782b74..024bb3fb 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -18,6 +18,51 @@ "README.md", "purview-logo-light.png" ] + }, + // Every package ("*"). RequireExplicitContent (on by default) already rejects anything RequiredContent does not + // declare; these rules hold even if a RequiredContent glob is ever widened. Globs are + // Microsoft.Extensions.FileSystemGlobbing patterns: "*" stays within one path segment, "**" spans any number, + // including none. scripts/test-pack-validation-rules.ps1 injects a file for every rule into a scratch copy of + // each package and proves the validation fails; ReleaseGateTests proves no rule is removed. + "ForbiddenContent": { + "*": [ + // The generator and the refactorings ship IL-merged, with Purview.SourceGeneratorFramework internalized + // into them; a loose copy under analyzers/ means the merge did not run. + "analyzers/**/Purview.SourceGeneratorFramework*", + // The compiler supplies Roslyn to analyzers; a packed copy would shadow or conflict with it. + "analyzers/**/Microsoft.CodeAnalysis*", + // Compiler-host assemblies never appear outside analyzers/: anywhere else NuGet hands them to the + // application, which is how Purview.SourceGeneratorFramework.dll reaches app and Blazor WebAssembly + // output and breaks WebAssembly AOT compilation. + "Purview.SourceGeneratorFramework*", + "Microsoft.CodeAnalysis*", + "lib/**/Purview.SourceGeneratorFramework*", + "lib/**/Microsoft.CodeAnalysis*", + "ref/**/Purview.SourceGeneratorFramework*", + "ref/**/Microsoft.CodeAnalysis*", + "runtimes/**/Purview.SourceGeneratorFramework*", + "runtimes/**/Microsoft.CodeAnalysis*", + "build/**/Purview.SourceGeneratorFramework*", + "build/**/Microsoft.CodeAnalysis*", + "buildTransitive/**/Purview.SourceGeneratorFramework*", + "buildTransitive/**/Microsoft.CodeAnalysis*", + "buildMultiTargeting/**/Purview.SourceGeneratorFramework*", + "buildMultiTargeting/**/Microsoft.CodeAnalysis*", + "tools/**/Purview.SourceGeneratorFramework*", + "tools/**/Microsoft.CodeAnalysis*", + "content/**/Purview.SourceGeneratorFramework*", + "content/**/Microsoft.CodeAnalysis*", + "contentFiles/**/Purview.SourceGeneratorFramework*", + "contentFiles/**/Microsoft.CodeAnalysis*", + "staticwebassets/**/Purview.SourceGeneratorFramework*", + "staticwebassets/**/Microsoft.CodeAnalysis*", + // The generator and the refactorings (Purview.Telemetry.SourceGenerator.Refactorings) run only inside the + // compiler, so they ship under analyzers/ alone: under lib/, ref/ or runtimes/ every application would + // load them. + "lib/**/Purview.Telemetry.SourceGenerator*", + "ref/**/Purview.Telemetry.SourceGenerator*", + "runtimes/**/Purview.Telemetry.SourceGenerator*" + ] } }, "Release": { diff --git a/.editorconfig b/.editorconfig index b5117897..ea030585 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,4 @@ -# Purview.BuildSdk 1.1.0 - managed file, do not edit +# Purview.BuildSdk 1.2.0 - managed file, do not edit # # This file is written by Purview.BuildSdk and is replaced whenever the SDK version changes. The version # on the first line is how the SDK recognises a file it owns: a .editorconfig without that marker is diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index be121536..37e2717c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -7,11 +7,11 @@ root. Read it first and treat it as the source of truth for: - Project structure and layout - Build and validation workflow - Conventions (conventional commits, csharpier, `.slnx`, SDK behaviour) -- Source-generator and testing skills (`.agents/skills/`) +- Installed source-generator, testing and SDK skills (`.agents/skills/`, committed) - Benchmarking and performance documentation rules - Release process If you are working on source generators, analyzers, code fixes, refactorings, or tests for Roslyn -components, load the relevant skill listed under "Source-generator and testing skills" in `AGENTS.md` +components, load the relevant skill listed under "Installed skills" in `AGENTS.md` before starting. Fall back to searching the repository only when `AGENTS.md` does not match what you observe. \ No newline at end of file diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 724db0c9..02b061cd 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -34,5 +34,66 @@ jobs: - name: Build SampleApp run: dotnet build samples/SampleApp/SampleApp.slnx --configuration Release + - name: Build SampleApp.MultiProject + run: dotnet build samples/SampleApp.MultiProject/SampleApp.MultiProject.slnx --configuration Release + + # The samples reference the generator project as an analyzer, which is exactly how + # Purview.SourceGeneratorFramework.dll reached every sample's bin with SGF 1.2.1. Scan + # every sample project's build output for compiler-host assemblies. + - name: Scan the sample build output for compiler-host assemblies + shell: pwsh + run: >- + ./scripts/assert-no-compiler-host-assemblies.ps1 + -Path (Get-ChildItem samples/SampleApp, samples/SampleApp.MultiProject -Directory | + ForEach-Object { Join-Path $_.FullName bin } | Where-Object { Test-Path $_ }) + - name: Test SampleApp run: dotnet test samples/SampleApp/SampleApp.slnx --configuration Release --no-build + + packed-consumers: + name: Packed consumers (Native AOT, Blazor WebAssembly, Blazor Server) + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + # An extra feed for Purview.SourceGeneratorFramework and Purview.BuildSdk only, for versions + # that are not on nuget.org yet. Unset, the gate restores them from nuget.org alone. + TELEMETRY_PRERELEASE_FEED: ${{ vars.TELEMETRY_PRERELEASE_FEED }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + fetch-tags: true + + - uses: actions/setup-dotnet@v6 + with: + global-json-file: global.json + + # First, before any toolchain is installed: the generator is built against the + # Purview.SourceGeneratorFramework version Directory.Packages.props pins, with the + # Purview.BuildSdk version global.json pins, so nothing can be packed until both restore. + # This fails with what to do (publish them to nuget.org, or set the + # TELEMETRY_PRERELEASE_FEED repository variable) rather than skipping. + - name: Check the prerelease build dependencies restore + shell: pwsh + run: ./scripts/test-packed-consumers.ps1 -PreflightOnly + + # Native AOT links with clang and needs the zlib headers; the hosted image may not carry both. + - name: Install the Native AOT prerequisites + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends clang zlib1g-dev + + # RunAOTCompilation needs the Emscripten toolchain and the Mono AOT cross compiler. + # -WasmAot Require below makes a missing workload an error instead of a skipped step. + - name: Install the wasm-tools workload + run: dotnet workload install wasm-tools + + - name: Pack, then publish, run and leak-scan consumers of the package + shell: pwsh + run: >- + ./scripts/test-packed-consumers.ps1 -RuntimeIdentifier linux-x64 -WasmAot Require + -PackOutput artifacts/packed + + - name: Prove every pack-validation forbidden-content rule rejects a package + shell: pwsh + run: | + dotnet tool restore + ./scripts/test-pack-validation-rules.ps1 -PackagesFolder artifacts/packed diff --git a/.gitignore b/.gitignore index a9370176..7340a12d 100644 --- a/.gitignore +++ b/.gitignore @@ -199,3 +199,7 @@ node_modules/ BenchmarkDotNet.Artifacts/!scripts/* !build/ .tools/ + +# Agent skills are installed into the universal .agents/skills/ folder only, and that folder and +# skills-lock.json are committed. Per-agent folders (and Claude Code's local settings) stay out. +/.claude/ diff --git a/AGENTS.md b/AGENTS.md index e6b9353e..0f9f05a2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,8 +21,9 @@ code to generated telemetry interfaces) in a companion assembly. ## Prerequisites -- .NET 10 SDK (projects target `net10.0`; the source generator is a Roslyn component targeting - `netstandard2.0`) +- The .NET 11 SDK pinned in `global.json` (currently an RC, `rollForward: latestMajor`), plus the .NET 8, 9 and + 10 runtimes for the test legs. Projects target `net10.0` by default; the source generator is a Roslyn component + targeting `netstandard2.0`. Consumers need Visual Studio 2026 / the .NET 10 SDK or later (Roslyn 5.9.0+). - [Bun](https://bun.sh) for the `package.json`/`.build/*.ts` scripts - The `Purview.BuildSdk` MSBuild SDK (pinned in `global.json` under `msbuild-sdks`) - `csharpier` dotnet tool (pinned in `.config/dotnet-tools.json`) for linting/formatting @@ -79,6 +80,9 @@ These mirror the GitHub CI/CD workflows and are the supported way to run a full | `just pipeline-tests` | Build with tests enabled. | | `just pipeline-release` | Full release: build, test, pack, publish, GitHub release. | | `just pipeline-local-release` | Pack + publish to a local NuGet feed (see the Justfile note on argument quoting). | +| `just smoke-packed-consumers` | The packed-consumer gate: Native AOT, Blazor WebAssembly and Blazor Server consumers of the `.nupkg`. | +| `just verify-pack-rules` | Proves every `PackValidation` `ForbiddenContent` rule rejects an injected file. | +| `just scan-compiler-host-leaks ...` | Fails when build/publish output carries a generator, SGF or Roslyn assembly. | ### Benchmarks @@ -126,12 +130,15 @@ benchmarks/ └── Purview.Telemetry.Benchmarks/ # BenchmarkDotNet project (net48/net8.0/net9.0/net10.0) .build/ # TS scripts (build-pack.ts, update-version.ts) -.agents/ # Package-delivered skills/prompts/agents (gitignored mirror) +.agents/skills/ # Installed skills from other repos, committed (see "Installed skills") +skills-lock.json # The skills CLI lockfile for .agents/skills/, committed skills/ # Authored skills shipped by this package (see "Authoring skills") .config/ # dotnet-tools.json, lefthook.yml .github/workflows/ # pr.yml, release.yml (reusable purview-dev/build workflows) -scripts/ # setup-release.* (legacy changesets DSC scripts) +scripts/ # release-gate scripts (see "Quality gates"), legacy setup-release.* +└── packed-consumers/ # Native AOT, Blazor WebAssembly and Blazor Server consumers of the .nupkg docs/release-process.md # Release process documentation +docs/wiki/ # User documentation (Platform-Support.md: TFM/hosting matrix, .NET 11) ``` ## Build and validation workflow @@ -146,9 +153,46 @@ After making changes to the source generator: The sample projects enable `EmitCompilerGeneratedFiles`, so generated telemetry can be inspected in `obj/*/generated/` to sanity-check generator output. +## Quality gates + +Treat work as incomplete until every gate relevant to it passes. Before a release, all of them: + +- `just scrub`, then `just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/` + — restore, build with no new warnings, lint, tests, pack and pack validation (`RequiredContent` and + `ForbiddenContent`). +- `just smoke-packed-consumers` — packs the generator to an isolated feed and cache and proves the + `.nupkg` itself works: a Native AOT console (published with `-warnaserror`, then run, asserting the + emitted activities, metrics and log entries, log-level gating and multi-target methods), a Blazor + WebAssembly app (trimmed and `RunAOTCompilation`; no generated member trimmed away, every generated + method AOT-compiled) and a Blazor Server host (page rendered on a `TestServer`, telemetry asserted). + Every consumer's bin and publish output is leak-scanned. Set `LOCAL_NUGET_FEED_PATH` (or + `TELEMETRY_PRERELEASE_FEED`) while Purview.SourceGeneratorFramework or Purview.BuildSdk is not on + nuget.org yet. It needs the Native AOT toolchain and, for the WebAssembly AOT step, the `wasm-tools` + workload. +- `just verify-pack-rules` after any change to `PackValidation` in `.config/purview-build.json`. +- `just build-s && just test-s` — the samples, which reference the generator as an analyzer project. +- `just lint` — CSharpier reports no change (`just lint-fix` to apply it). + +Compiler-host assemblies (`Purview.SourceGeneratorFramework*`, `Microsoft.CodeAnalysis*`, and this +repository's `Purview.Telemetry.SourceGenerator*` generator and refactorings) must never reach an +application's build or publish output. `scripts/assert-no-compiler-host-assemblies.ps1` is the check; +any CI job that builds the samples or publishes an application must run it on what it wrote, and +`src/tests/Repository.UnitTests/ReleaseGateTests.cs` fails the test run when one does not. The same +tests keep the leak scan honest against crafted leaks, keep the `packed-consumers` job, the Justfile +recipes and every `ForbiddenContent` rule wired up, and require the consumers in +`scripts/packed-consumers/` to reference the generator as a package, never as a project. The full +table of gates is in [`docs/wiki/Release-Flow.md`](docs/wiki/Release-Flow.md#release-gates). + ### Test project details -- Integration tests target `net8.0;net9.0;net10.0`, plus `net48` on Windows only. +- Integration tests target `net8.0;net9.0;net10.0;net11.0`, plus `net48` on Windows only. The `net11.0` leg is the + only one whose references carry `System.Diagnostics.ActivitySourceFactory`, so the `UseActivitySourceFactory` + tests (`[RequiresActivitySourceFactory]`) run there and the fallback tests (`[RequiresNoActivitySourceFactory]`, + `TSG3023`) run on the earlier legs. +- Gate new emitted APIs on the compilation (`Compilation.GetTypeByMetadataName`, surfaced through + `TelemetryCapabilities`), not on the target framework: the System.Diagnostics.DiagnosticSource package brings .NET 11 + types to `net462`/`netstandard2.0`. The supported matrix and the list of .NET 11 features that are not generated + yet live in [`docs/wiki/Platform-Support.md`](docs/wiki/Platform-Support.md); update it with any such change. - Tests use `Purview.SourceGeneratorFramework.Testing.TUnit` (TUnit + the framework's `CodeQuery` syntax-lookup API and assertion extensions). There is no Verify/snapshot library; refactoring tests use the framework's `CodeRefactoringTestBase` snapshot approach. @@ -165,7 +209,7 @@ The sample projects enable `EmitCompilerGeneratedFiles`, so generated telemetry models). - **SDK**: projects import `Purview.BuildSdk` via `Directory.Build.props`/`.targets`. The repo sets `ExcludePurviewTelemetry=true` (it does not consume the telemetry package it generates) and - `NamespacePrefix=Purview.Telemetry` under `src/`. See `.agents/skills/sdk-*` for SDK behavior. + `NamespacePrefix=Purview.Telemetry` under `src/`. See `.agents/skills/purview-buildsdk-*` for SDK behavior. - **Analyzer release tracking**: the `TSG` rules are tracked in `src/src/SourceGenerator/AnalyzerReleases.{Shipped,Unshipped}.md` (wired as `AdditionalFiles`, so the Roslyn RS2000-series checks run under `EnforceExtendedAnalyzerRules=true`). Add new rules to @@ -173,19 +217,52 @@ The sample projects enable `EmitCompilerGeneratedFiles`, so generated telemetry `AnalyzerReleases.Shipped.md` at release time. `RS2003` is suppressed — see the comment in `src/src/SourceGenerator/SourceGenerator.csproj`. -## Skills from other repositories +## Installed skills (`.agents/skills/`) Specialist source-generator and SDK guidance — `CodeWriter` emission, incremental pipeline design, test runners and `CodeQuery`, `Purview.BuildSdk` configuration — is published by the repositories -that own it. Install what you need through the skills CLI; it lands in `.agents/skills/`, which is -gitignored and recorded in `skills-lock.json`: +that own it, and installed here by the skills CLI. The installed skills are **committed**: +`.agents/skills/**` and the CLI's `skills-lock.json` (source, skill path and content hash of every +entry) are both in source, so a clone has them without running anything. + +| Skill | Source | Load before | +| --- | --- | --- | +| `purview-sourcegenerator-codewriter` | `purview-dev/sourcegenerator-framework` | writing or reviewing emitter code (`CodeWriter`, scopes, `Emitters/`) | +| `purview-sourcegenerator-pipeline` | `purview-dev/sourcegenerator-framework` | changing the incremental pipeline or its value-equatable models | +| `purview-sourcegenerator-diagnostics` | `purview-dev/sourcegenerator-framework` | adding or changing a `TSG` diagnostic or the analyzer | +| `purview-sourcegenerator-packaging` | `purview-dev/sourcegenerator-framework` | changing how the generator is packed or merged | +| `purview-sourcegenerator-testing` | `purview-dev/sourcegenerator-framework` | writing generator, analyzer or refactoring tests (`CodeQuery`) | +| `purview-sourcegenerator-tunit-tests` | `purview-dev/sourcegenerator-framework` | the TUnit base classes and assertion extensions | +| `purview-buildsdk-*` (8 skills) | `purview-dev/build-sdk` | build/SDK configuration, project layout and detection, testing, skill installation and skill tests | +| `purview-architecture`, `purview-architecture-testing`, `purview-engineering-principles`, `purview-persistence`, `purview-telemetry`, `purview-testing-conventions` | `purview-dev/engineering-playbook` | solution shape, architecture tests, engineering conventions, persistence, telemetry and testing conventions | + +This repository's own skills in `skills/` are authored for **consumers** of its packages, not for use while working on this repository. They are never installed into this repository's `.agents/skills/`, which holds only skills installed from other repositories; `InstalledSkillTests` enforces this. + +Skills are installed into the universal `.agents/skills/` location **only**. Never install copies +into `.claude/` or any other per-agent folder; `/.claude/` is gitignored for that reason. Pass +`-a universal`, which writes `.agents/skills/` and nothing else: ```bash -npx skills add purview-dev/sourcegenerator-framework -npx skills add purview-dev/build-sdk +# add or update every skill from a source +npx -y skills@1 add purview-dev/sourcegenerator-framework -s '*' -a universal -y +npx -y skills@1 add purview-dev/build-sdk -s '*' -a universal -y +npx -y skills@1 add purview-dev/engineering-playbook -s '*' -a universal -y + +# restore the committed set from skills-lock.json +npx -y skills@1 experimental_install ``` -Do not edit anything under `.agents/skills/` — the CLI is its only writer. +Commit the changed `.agents/skills/**` and `skills-lock.json` together, in their own `chore` commit. +Do not edit anything under `.agents/skills/` by hand — the CLI is its only writer, and a hand edit +no longer matches the lockfile's hash. This repository's *own* skills are never installed here; they +live in `skills/` (below). `Purview.BuildSdk`'s `InstallPurviewSkills` target delegates to the same +CLI; `src/Directory.Build.props` sets `PurviewSkillsAgents=universal` so it is restricted the same +way rather than defaulting to every agent (`*`). None of the restored packages ship skills, so +`ListPurviewSkills` has nothing to recommend and the set above is chosen by hand. + +`src/tests/Repository.UnitTests/InstalledSkillTests.cs` keeps this honest: every `skills-lock.json` +entry has its `.agents/skills//SKILL.md`, every installed folder is in the lockfile, and no +`.claude/skills/` copy exists. ## Authoring skills diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..071bc377 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,121 @@ +# Changelog + +All notable changes to `Purview.Telemetry.SourceGenerator`. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions come from the +`version` field in `package.json`, which is the single source of truth. This file starts at +5.0.7; earlier releases are described by their [GitHub releases](https://github.com/purview-dev/telemetry-sourcegenerator/releases) +and, for breaking changes, by [Breaking Changes](docs/wiki/Breaking-Changes.md). + +## [5.0.7-prerelease.3] - 2026-10-28 + +The `TSG1011` message no longer offers an opt-out that never existed, and the define it named is +gone. Generated code is unchanged for every project that did not define it. + +### Changed + +- **`TSG1011` (unsupported target framework) has a new message.** It said that defining + `PURVIEW_TELEMETRY_NON_NULLABLE` suppresses the error. Nothing ever implemented that: the check reads + only `NET8_0_OR_GREATER` and `NET48_OR_GREATER`, so the diagnostic was raised regardless. The message + now says no telemetry is generated for the target, and that a project must target `net8.0`+ or + `net48`+, with a multi-targeted library referencing the package only from its supported targets. + The rule's ID, title, category and severity are unchanged, so the analyzer release files are too. + +### Removed + +- **`PURVIEW_TELEMETRY_NON_NULLABLE`.** Beyond the false `TSG1011` claim, its one effect was on the + generated marker attributes: it dropped `#nullable enable` and declared their optional members as + `string` rather than `string?`. The attributes now switch on `NET48_OR_GREATER` alone. A `net8.0`+ + project that defined it gets the `string?` declarations every other modern project gets, which + accept every value the plain `string` ones did. `net48` projects are unaffected. + +## [5.0.7-prerelease.2] - 2026-10-28 + +.NET 11 support. Generated code is unchanged unless you opt in to the new option. The one +exception is the opt-in `MeterNameGenerationType.OpenTelemetry` naming, which was fixed (see below). + +### Added + +- **`ActivitySourceFactory` (.NET 11).** + `[assembly: ActivitySourceGeneration(..., UseActivitySourceFactory = true)]` makes the generated + classes take a `System.Diagnostics.ActivitySourceFactory` constructor parameter and create their + source with `Create(new ActivitySourceOptions(name))`, instead of a static `ActivitySource`. + Register the factory with `services.AddTracing()`. The option is gated on the compilation resolving + the type (.NET 11, or System.Diagnostics.DiagnosticSource 11+). When the type is unavailable, + the static source is kept and the new warning **`TSG3023`** reports it. +- [Platform Support and .NET 11](docs/wiki/Platform-Support.md): the target-framework and hosting + matrix, the trim/AOT guarantees, the .NET 11 features and what is not supported yet. +- The integration tests run on a `net11.0` leg. +- The packed-consumer gate exercises `UseActivitySourceFactory` under Native AOT, Blazor + WebAssembly (trimmed and AOT) and Blazor Server, alongside a static source in the library. + +### Fixed + +- `MeterNameGenerationType.OpenTelemetry` placed the meter name between the interface prefix and + the instrument name (`product.my_app.products.record_count`). It is now the outermost namespace + (`my_app.products.product.record_count`), as documented. The default (`DotNet`) is unchanged. + **If you opted in, your instrument names change.** +- The README and wiki said OpenTelemetry naming prefixes instrument names with the meter name by + default. It never has; the docs now describe the interface-derived prefix + (`IProductTelemetry.RecordCount` becomes `product.record_count`), and tests pin the examples. +- Static interface members are ignored. They were implemented as instance methods and reported as + duplicates (`TSG1003`). This matters now because C# 15 allows them on every runtime. +- The packaged README no longer pins a package version. 5.0.6 shipped a README telling consumers to + install 5.0.5. `PackagedDocumentationTests` now fails on any version in a packaged document. +- `MessageTemplateHole` is internal. As a public type it exposed a Purview.SourceGeneratorFramework + type on the merged analyzer's surface (`PSGFR41`). `PurviewMergePublicSurfaceSeverity` is now + `error`, so such a leak fails the build. +- The benchmarks build again. They loaded a `Purview.SourceGeneratorFramework.Shared.dll` that no + framework version ships, and now use the merged generator. Their `net48` target compiles too. +## [5.0.7-prerelease.1] - 2026-10-28 + +No change to the generated code or to the package's contents: consumers of the package see the same +generator. This release changes how the package is built and what has to pass before it ships. + +### Fixed + +- `Purview.SourceGeneratorFramework.dll`, a compiler-host-only assembly, was copied into the build + output of every project that referenced the generator project as an analyzer. Built on `main`, all + eight sample projects' `bin` folders carried it, and an application built that way would ship it in + its `deps.json`, publish output and Blazor WebAssembly `_framework` folder, where WebAssembly AOT + compilation fails on it. The cause was Purview.SourceGeneratorFramework 1.2.1's build targets; + 1.2.2 fixes them, and the generator now builds against 1.2.2. The packed generator was never + affected: it ships the framework IL-merged under `analyzers/`. + +### Changed + +- Purview.SourceGeneratorFramework (and its testing packages) 1.2.1 → 1.2.2. +- Purview.BuildSdk 1.1.0 → 1.2.0 and Purview.Skills.Testing 1.1.0 → 1.2.0. The SDK refreshed its + managed `.editorconfig`; no rule changed for this repository, and no new diagnostic fired. + +### Added + +- **Packed-consumer gate** (`just smoke-packed-consumers`, `scripts/test-packed-consumers.ps1`). + Packs the generator to an isolated feed and cache, then builds consumers that reference the + package only - each also references a class library with its own generated telemetry: + - a **Native AOT** console, published with `-warnaserror` and run with an in-process + `ActivityListener`, `MeterListener` and capturing logger provider, asserting the multi-target + method, activity tags, baggage and events (including the OpenTelemetry exception event), every + metric measurement, the v1 and v2 log entries, log-level gating and `TelemetryNames` + aggregation; + - a **Blazor WebAssembly** app using the telemetry from a page and a service, published trimmed + and with `RunAOTCompilation=true`; the trimmed assemblies must keep every generated member and + the AOT compiler must have compiled every generated method; + - a **Blazor Server** (interactive server) host, rendered on an in-memory `TestServer` and checked + for the same telemetry. +- **Compiler-host leak scan** (`scripts/assert-no-compiler-host-assemblies.ps1`, + `just scan-compiler-host-leaks`): fails when build or publish output - by file name, or in a + `deps.json`, `runtimeconfig.json` or WebAssembly boot manifest - carries + `Purview.SourceGeneratorFramework*`, `Microsoft.CodeAnalysis*` or `Purview.Telemetry.SourceGenerator*`. + It runs on every consumer in the gate and on the samples' build output in CI. +- **Pack validation `ForbiddenContent` rules** in `.config/purview-build.json`, keeping compiler-host + assemblies out of every package folder an application receives, and a rule proof + (`just verify-pack-rules`, `scripts/test-pack-validation-rules.ps1`) that injects a file per rule + into scratch copies of the package and shows each one is rejected. +- `ReleaseGateTests` in `Repository.UnitTests`, which keep the gates honest and wired up. +- A `packed-consumers` job in `pr.yml`, and `SampleApp.MultiProject` built and leak-scanned in the + `samples` job. + +[5.0.7-prerelease.3]: https://github.com/purview-dev/telemetry-sourcegenerator/compare/v5.0.7-prerelease.2...v5.0.7-prerelease.3 +[5.0.7-prerelease.2]: https://github.com/purview-dev/telemetry-sourcegenerator/compare/v5.0.7-prerelease.1...v5.0.7-prerelease.2 +[5.0.7-prerelease.1]: https://github.com/purview-dev/telemetry-sourcegenerator/compare/v5.0.6...v5.0.7-prerelease.1 diff --git a/Directory.Packages.props b/Directory.Packages.props index dbe844eb..83d80a53 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,15 +2,15 @@ 5.9.0 1.76.0 - 1.2.1 - 10.0.13 + 1.2.2 + 10.0.14 10.11.0 - 1.18.0 + 1.20.1 - + @@ -26,7 +26,8 @@ - + + @@ -50,4 +51,16 @@ + + + + + diff --git a/Justfile b/Justfile index 939da973..4ff1cc3b 100644 --- a/Justfile +++ b/Justfile @@ -55,6 +55,27 @@ pipeline-local-release *args: echo "Running local release pipeline..." {{ pipeline_tool }} --Release:Mode=LocalNuGet {{ args }} +# Pack the generator to an isolated feed and cache, then build, publish and run throwaway consumers that reference +# the package only: a Native AOT console, a Blazor WebAssembly app (trimmed, plus RunAOTCompilation when wasm-tools is +# installed) and a Blazor Server host, each leak-scanned for compiler-host assemblies. +# +# The tests and samples reference the generator project; this is the gate that proves what ships. The CI +# `packed-consumers` job runs it for linux-x64. Purview.SourceGeneratorFramework and Purview.BuildSdk are restored +# from nuget.org, plus TELEMETRY_PRERELEASE_FEED or LOCAL_NUGET_FEED_PATH when set (or pass -PrereleaseFeed). +[group('Pipeline')] +smoke-packed-consumers *args: + echo "Running the packed consumer gate..." + pwsh -NoProfile -File scripts/test-packed-consumers.ps1 {{ args }} + +# Prove every PackValidation ForbiddenContent rule in .config/purview-build.json rejects a package: injects a matching +# file into scratch copies of the packed package and runs the real purview-build validation on them. Reads artifacts/, +# so pack first (`just pipeline-pack-validate`). `just pipeline-local-release` moves its packages to the local feed, so +# pass -PackagesFolder p:/_sync-projects/.local-nuget after it. +[group('Pipeline')] +verify-pack-rules *args: + echo "Proving the pack-validation forbidden-content rules..." + pwsh -NoProfile -File scripts/test-pack-validation-rules.ps1 {{ args }} + # Run the pipeline with tests enabled [group('Pipeline')] pipeline-tests *args: @@ -91,6 +112,12 @@ test filter="/*/*/*/*/" *args: echo "Running tests for {{ BLUE }}{{ test_solution }}{{ NORMAL }} with {{ YELLOW }}{{ build_configuration }}{{ NORMAL }}..." dotnet test --solution "{{ test_solution }}" --configuration "{{ build_configuration }}" --treenode-filter={{ filter }} {{ args }} +# Scan build or publish output for compiler-host assemblies (the generator, the refactorings, +# Purview.SourceGeneratorFramework, Roslyn) that must never reach an application. +[group('Build and Test')] +scan-compiler-host-leaks +paths: + pwsh -NoProfile -File scripts/assert-no-compiler-host-assemblies.ps1 -Path {{ paths }} + # Cleans the solution with the specified configuration (default: Release) [group('Build and Test')] clean *args: diff --git a/README.md b/README.md index 920d6bbb..7e39bc88 100644 --- a/README.md +++ b/README.md @@ -17,23 +17,47 @@ Generates [`ActivitySource`](https://learn.microsoft.com/en-us/dotnet/api/system **Consumer runtime targets:** - .NET Framework 4.8 -- .NET 8 or higher +- .NET 8 or higher, including .NET 11 + +**Hosting models:** console, ASP.NET Core, .NET Aspire, Native AOT, Blazor WebAssembly (trimmed and AOT-compiled) +and Blazor Server. The generated code uses no reflection and adds no trim or AOT warnings, and no compiler-host +assembly reaches your application's output. See +[Platform Support](https://purview.dev/docs/telemetry-sourcegenerator/platform-support/) for the full matrix. **Build toolchain requirement:** -- Visual Studio 2026 (18.x) or .NET 10 SDK (Roslyn 5.9.0+) +- Visual Studio 2026 (18.x) or .NET 10 SDK or later (Roslyn 5.9.0+) + +## What's new for .NET 11 + +- **`ActivitySourceFactory`** - opt in with + `[assembly: ActivitySourceGeneration("MyApp", UseActivitySourceFactory = true)]` and register the factory with + `services.AddTracing()`. The generated classes then create their sources from the injected .NET 11 factory instead + of a static `ActivitySource`. When the type isn't available, `TSG3023` reports it and the static source is kept. +- **C# 15 static interface members** are ignored rather than implemented. +- **Validated on .NET 11**: the tests run on `net11.0`, and Native AOT, Blazor WebAssembly and Blazor Server + consumers of the package are built on every release. ## Installation -Add to your `Directory.Build.props` or `.csproj` file: +```bash +dotnet add package Purview.Telemetry.SourceGenerator +``` + +`dotnet add package` writes the latest version (shown on the NuGet badge above) into your project. The +generator is a compile-time dependency only, so mark the reference as analyzer-only: ```xml - + all analyzers ``` +The `Version` attribute that `dotnet add package` adds stays on that element; with +[Central Package Management](https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management) +it lives in `Directory.Packages.props` instead. + ## Quick Start Define an interface with telemetry methods and the generator creates the implementation: @@ -84,17 +108,25 @@ services.AddEntityStoreTelemetry(); Then inject and use - a single method call emits an Activity, Log, and Metric simultaneously: ```csharp -public class EntityService(IEntityStoreTelemetry telemetry) +public sealed record Entity(int Id, float TotalValue, int LastUpdatedByUserId, int SizeInBytes); + +public interface IEntityRepository { - public async Task GetEntityAsync(int id, string serviceUrl, CancellationToken cancellationToken) + Task GetAsync(int id, CancellationToken cancellationToken); +} + +public class EntityService(IEntityStoreTelemetry telemetry, IEntityRepository repository) +{ + public async Task GetEntityAsync(int id, string serviceUrl, CancellationToken cancellationToken) { // Single call creates Activity AND logs AND increments counter using var activity = telemetry.GettingEntityFromStore(id, serviceUrl); - - var entity = await _repository.GetAsync(id, cancellationToken); - + + var stopwatch = Stopwatch.StartNew(); + var entity = await repository.GetAsync(id, cancellationToken); + // Adds event to activity AND logs duration - telemetry.GetDuration(activity, stopwatch.ElapsedMilliseconds); + telemetry.GetDuration(activity, (int)stopwatch.ElapsedMilliseconds); if (entity == null) { @@ -105,7 +137,7 @@ public class EntityService(IEntityStoreTelemetry telemetry) // Activity context addition telemetry.RetrievedEntity(activity, entity.TotalValue, entity.LastUpdatedByUserId); - + // Histogram only records size telemetry.RecordEntitySize(entity.SizeInBytes); @@ -141,10 +173,18 @@ public class EntityService(IEntityStoreTelemetry telemetry) - [Generated Output Examples](https://purview.dev/docs/telemetry-sourcegenerator/generated-output/) - [Multi-Targeting Guide](https://purview.dev/docs/telemetry-sourcegenerator/multi-targeting/) - [Logging Configuration](https://purview.dev/docs/telemetry-sourcegenerator/logging/) +- [Platform Support and .NET 11](https://purview.dev/docs/telemetry-sourcegenerator/platform-support/) ## Agent Skills -This repository ships with [Agent Skills](https://agentskills.io/specification) under `.agents/skills/` covering the source generator, its test framework, and the `Purview.BuildSdk` build SDK. See [`AGENTS.md`](AGENTS.md) — "Source-generator and testing skills" for the full list and when to load each one. +This repository publishes [Agent Skills](https://agentskills.io/specification) for using the generator - quick +start, interface design, migration, multi-targeting and diagnostics. Install them with: + +```bash +npx skills add purview-dev/telemetry-sourcegenerator +``` + +See [`AGENTS.md`](https://github.com/purview-dev/telemetry-sourcegenerator/blob/main/AGENTS.md) for the list. ## Sample Project @@ -229,32 +269,59 @@ v4 defaults to **OpenTelemetry semantic conventions** for generated telemetry na | ---------------- | ------------- | ------------ | -------- | | **ActivitySource Name** | Assembly name lowercased: `"myapp"` | Assembly name preserved: `"MyApp"` | ActivitySource names change casing | | **Tag/Baggage Keys** | Lowercased, smashed: `"entityid"` | snake_case: `"entity_id"` | Tag keys have underscores for word boundaries | -| **Metric Instrument Names** | Lowercased, smashed: `"recordhistogram"` | Hierarchical dot.separated: `"myapp.products.record.histogram"` | Includes meter name prefix + word boundaries | +| **Metric Instrument Names** | Lowercased, smashed: `"product.recordcount"` | Dot-separated namespaces, snake_case words: `"product.record_count"` | Word boundaries become underscores | | **Metric Tag Keys** | Lowercased, smashed: `"requestcount"` | snake_case: `"request_count"` | Metric tag keys have underscores | #### Examples +Given this interface: + +```csharp +[Meter("MyApp.Products")] +interface IProductTelemetry +{ + [Counter] + void RecordCount([InstrumentMeasurement] int count); +} +``` + **Before (v3/Legacy):** ```csharp // Generated code: new ActivitySource("myapp") // lowercase activity.SetTag("entityid", ...) // smashed compound -var meter = meterFactory.Create("MyApp.Products"); -meter.CreateCounter("recordcount") // smashed compound, no meter prefix +var meter = meterFactory.Create(new MeterOptions("MyApp.Products") { ... }); +meter.CreateCounter("product.recordcount") // smashed compound ``` -**After (v4 OpenTelemetry mode - DEFAULT):** +**After (v4 OpenTelemetry naming - DEFAULT):** ```csharp // Generated code: new ActivitySource("MyApp") // preserves casing activity.SetTag("entity_id", ...) // snake_case -var meter = meterFactory.Create("MyApp.Products"); -meter.CreateCounter("myapp.products.record.count") // hierarchical: meter + instrument +var meter = meterFactory.Create(new MeterOptions("MyApp.Products") { ... }); +meter.CreateCounter("product.record_count") // interface prefix + snake_case instrument +``` + +An instrument name is built from, in order: the assembly `[MeterGeneration(InstrumentPrefix = ...)]`, the +interface prefix, and the instrument name. The interface prefix is `[Meter(InstrumentPrefix = ...)]` when set, +otherwise it is derived from the interface name: the leading `I` and a trailing `Telemetry` or `Logs` are +removed, so `IProductTelemetry` becomes `product`. The instrument name is the attribute's `Name`, or the method +name in snake_case. + +**The meter name is not part of the instrument name by default.** The meter already identifies the instruments +it owns, and `MeterNameGenerationType` defaults to `DotNet`. Opt in to the meter name as the outermost namespace +with `MeterNameGenerationType.OpenTelemetry`, which also lowercases a meter name derived from the assembly name: + +```csharp +[assembly: MeterGeneration(MeterNameGenerationType = MeterNameGenerationType.OpenTelemetry)] + +// The interface above now generates "my_app.products.product.record_count". ``` -**Note**: In OpenTelemetry mode, instrument names automatically include the meter name prefix (converted to lowercase dot.separated), following OpenTelemetry best practices for hierarchical metric naming. +See [Metrics](https://purview.dev/docs/telemetry-sourcegenerator/metrics/#meter-naming) for the full rules. #### Reverting to v3 Naming (Legacy Mode) diff --git a/benchmarks/Purview.Telemetry.Benchmarks/BenchmarkHelpers.cs b/benchmarks/Purview.Telemetry.Benchmarks/BenchmarkHelpers.cs index 07e5d46c..fbdae414 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/BenchmarkHelpers.cs +++ b/benchmarks/Purview.Telemetry.Benchmarks/BenchmarkHelpers.cs @@ -48,7 +48,13 @@ public static (IMultiTargetTelemetry Generated, ManualMultiTargetTelemetry Manua var logger = CreateEnabledLogger(); var meterFactory = new SimpleMeterFactory(); +#if NETFRAMEWORK + // IMeterFactory is a .NET 8+ host concept, so on .NET Framework the generator emits a + // constructor without it and creates the Meter itself. + var generated = new MultiTargetTelemetryCore(logger); +#else var generated = new MultiTargetTelemetryCore(logger, meterFactory); +#endif var manual = new ManualMultiTargetTelemetry(logger, meterFactory); return (generated, manual); @@ -59,9 +65,14 @@ public static (IMultiTargetTelemetry Generated, ManualMultiTargetTelemetry Manua /// public static (IMetricsFewTagsTelemetry FewTags, IMetricsManyTagsTelemetry ManyTags) CreateMetricsTelemetry() { +#if NETFRAMEWORK + var fewTags = new MetricsFewTagsTelemetryCore(); + var manyTags = new MetricsManyTagsTelemetryCore(); +#else var meterFactory = new SimpleMeterFactory(); var fewTags = new MetricsFewTagsTelemetryCore(meterFactory); var manyTags = new MetricsManyTagsTelemetryCore(meterFactory); +#endif return (fewTags, manyTags); } @@ -76,7 +87,11 @@ ManualMetricsTelemetry Manual ) CreateMetricsBenchmarkTelemetry() { var meterFactory = new SimpleMeterFactory(); +#if NETFRAMEWORK + var generated = new MetricsBenchmarkTelemetryCore(); +#else var generated = new MetricsBenchmarkTelemetryCore(meterFactory); +#endif var manual = new ManualMetricsTelemetry(meterFactory); return (generated, manual); } @@ -136,8 +151,13 @@ ManualMultiTargetTelemetry Manual var loggerManual = CreateEnabledLogger(); var meterFactory = new SimpleMeterFactory(); +#if NETFRAMEWORK + var generatedV2 = new MultiTargetTelemetryCore(loggerV2); + var generatedV1 = new MultiTargetV1TelemetryCore(loggerV1); +#else var generatedV2 = new MultiTargetTelemetryCore(loggerV2, meterFactory); var generatedV1 = new MultiTargetV1TelemetryCore(loggerV1, meterFactory); +#endif var manual = new ManualMultiTargetTelemetry(loggerManual, meterFactory); return (generatedV2, generatedV1, manual); diff --git a/benchmarks/Purview.Telemetry.Benchmarks/Manual/ManualMetricsTelemetry.cs b/benchmarks/Purview.Telemetry.Benchmarks/Manual/ManualMetricsTelemetry.cs index 841efcc1..936bb7d9 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/Manual/ManualMetricsTelemetry.cs +++ b/benchmarks/Purview.Telemetry.Benchmarks/Manual/ManualMetricsTelemetry.cs @@ -20,7 +20,8 @@ public sealed class ManualMetricsTelemetry : IDisposable public ManualMetricsTelemetry(IMeterFactory meterFactory) { - ArgumentNullException.ThrowIfNull(meterFactory); + // Not ArgumentNullException.ThrowIfNull: the project also targets net48, which lacks it. + _ = meterFactory ?? throw new ArgumentNullException(nameof(meterFactory)); _meter = meterFactory.Create(new MeterOptions("benchmark-manual-metrics")); diff --git a/benchmarks/Purview.Telemetry.Benchmarks/Manual/ManualMultiTargetTelemetry.cs b/benchmarks/Purview.Telemetry.Benchmarks/Manual/ManualMultiTargetTelemetry.cs index 8e84cbb6..398befc1 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/Manual/ManualMultiTargetTelemetry.cs +++ b/benchmarks/Purview.Telemetry.Benchmarks/Manual/ManualMultiTargetTelemetry.cs @@ -44,8 +44,9 @@ public sealed class ManualMultiTargetTelemetry : IDisposable public ManualMultiTargetTelemetry(ILogger logger, IMeterFactory meterFactory) { - ArgumentNullException.ThrowIfNull(logger); - ArgumentNullException.ThrowIfNull(meterFactory); + // Not ArgumentNullException.ThrowIfNull: the project also targets net48, which lacks it. + _ = logger ?? throw new ArgumentNullException(nameof(logger)); + _ = meterFactory ?? throw new ArgumentNullException(nameof(meterFactory)); _logger = logger; _meter = meterFactory.Create(new MeterOptions("Purview.Telemetry.Benchmarks.Manual")); diff --git a/benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.Benchmarks.csproj b/benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.Benchmarks.csproj index 169ec3c0..b7c36866 100644 --- a/benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.Benchmarks.csproj +++ b/benchmarks/Purview.Telemetry.Benchmarks/Purview.Telemetry.Benchmarks.csproj @@ -8,37 +8,38 @@ true true true - - $(NoWarn);CA1707 + + $(NoWarn);CA1707;CA1515 - - - - - - diff --git a/docs/release-process.md b/docs/release-process.md index 3e99e21b..e76df165 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -99,11 +99,22 @@ is enabled by `Purview.BuildSdk`). ## Pre-release validation -Before a release, validate locally: - -1. `just pipeline-pr` — restore, build, lint, tests. -2. `just build-s && just test-s` — sample solution build and tests. -3. `just pipeline-local-release` — confirm the pack + local publish succeed. +Before a release, validate locally on a clean tree. Each step must pass. + +1. `just scrub` — delete `bin`/`obj`, clean, force-restore, stop build servers. +2. `just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/` — + restore, build, lint, tests, pack, pack validation (including `ForbiddenContent`), local publish. +3. `just smoke-packed-consumers` — the packed-consumer gate: Native AOT, Blazor WebAssembly (trimmed + and `RunAOTCompilation`) and Blazor Server consumers of the `.nupkg`, each leak-scanned for + compiler-host assemblies. +4. `just build-s && just test-s` — sample solution build and tests. +5. `just lint` — CSharpier reports no change. + +After changing `PackValidation`, also run `just verify-pack-rules` (pass `-PackagesFolder ` after +`just pipeline-local-release`, which moves the package out of `artifacts/`). + +What each gate proves, and where CI runs it, is in the wiki's +[Release Flow — Release gates](wiki/Release-Flow.md#release-gates). ## Releasing diff --git a/docs/wiki/Activities.md b/docs/wiki/Activities.md index 5fb84ed6..dadec38a 100644 --- a/docs/wiki/Activities.md +++ b/docs/wiki/Activities.md @@ -15,6 +15,34 @@ In v5 the ActivitySource name defaults to the **assembly name with casing preser > [!NOTE] > `purview` is only used as a fallback when no assembly name is available, in which case the `TSG3001` diagnostic is generated. +## Creating sources with `ActivitySourceFactory` (.NET 11) + +By default each generated class owns a `static readonly ActivitySource`. .NET 11 adds +[`ActivitySourceFactory`](https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-11/libraries#activity-tracing-configuration), +the tracing counterpart of `IMeterFactory`: registered by `services.AddTracing()` (Microsoft.Extensions.Diagnostics +11), it creates *local* sources that the container owns and that `AddTracing` rules can enable or disable. Opt in at +the assembly level: + +```csharp +[assembly: ActivitySourceGeneration("MyApp.Orders", UseActivitySourceFactory = true)] +``` + +The generated class then takes an `ActivitySourceFactory` constructor parameter (after `ILogger` and +`IMeterFactory`, when the interface also targets logging or metrics) and creates its source with +`activitySourceFactory.Create(new ActivitySourceOptions(name))`. Register the factory before resolving the telemetry: + +```csharp +builder.Services.AddTracing(); +builder.Services.AddOrderTelemetry(); +``` + +- The factory owns the source; the generated class never disposes it. +- The option is opt-in because it changes the generated constructor. Without it, nothing changes when you move to .NET 11. +- It is gated on the compilation, not the target framework: it needs `System.Diagnostics.ActivitySourceFactory`, which + ships in .NET 11 and in the System.Diagnostics.DiagnosticSource 11 package (net462, netstandard2.0, net10.0). When the + type cannot be resolved the generator keeps the static source and reports [`TSG3023`](Diagnostics.md#tsg3023). +- Listeners registered with `ActivitySource.AddActivityListener` (OpenTelemetry's included) still see factory-created sources. + ## Activity, Event, or Context There are three method types: @@ -121,6 +149,7 @@ Defines ActivitySource defaults at the assembly level. | `BaggageAndTagSeparator` | `string` | Separator used when generating prefixes. Default `.`. | | `LowercaseBaggageAndTagKeys` | `bool` | Whether tag/baggage names are lower-cased. Default `true`. | | `GenerateDiagnosticsForMissingActivity` | `bool` | Whether diagnostics (`TSG3014`/`TSG3015`) are raised when Activity parameters or return values are missing. Default `true`. | +| `UseActivitySourceFactory` | `bool` | .NET 11: create each generated source from the injected `System.Diagnostics.ActivitySourceFactory` instead of a static `ActivitySource` (see below). Default `false`. | ### `[Baggage]` diff --git a/docs/wiki/Breaking-Changes.md b/docs/wiki/Breaking-Changes.md index 0f9e58eb..3e881880 100644 --- a/docs/wiki/Breaking-Changes.md +++ b/docs/wiki/Breaking-Changes.md @@ -67,7 +67,7 @@ v4 defaults to **OpenTelemetry semantic conventions** for generated telemetry na | ActivitySource name | Assembly name lowercased | Assembly name casing preserved | `"myapp"` → `"MyApp"` | | Activity names | Method name lowercased | Method name casing preserved | `"getentity"` → `"GetEntity"` | | Tag/baggage keys | Lowercased, smashed compounds | snake_case with underscores | `"entityid"` → `"entity_id"` | -| Metric instrument names | Lowercased, smashed | Hierarchical with meter prefix | `"recordcount"` → `"myapp.products.record.count"` | +| Metric instrument names | Lowercased, smashed | Interface prefix + snake_case, no meter prefix | `"product.recordcount"` → `"product.record_count"` (`IProductTelemetry.RecordCount`) | | Metric tag keys | Lowercased, smashed | snake_case with underscores | `"requestcount"` → `"request_count"` | #### Examples diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md index a357f5d9..90443087 100644 --- a/docs/wiki/Contributing.md +++ b/docs/wiki/Contributing.md @@ -71,7 +71,7 @@ The repo mirrors its GitHub CI/CD pipelines locally with the reusable `purview-b ## Source-generator skills -Before doing specialist work, load the relevant skill from `.agents/skills/` — see `AGENTS.md` ("Source-generator and testing skills") for the full list and when to load each one. +Before doing specialist work, load the relevant skill from `.agents/skills/` — see `AGENTS.md` ("Installed skills") for the full list, when to load each one, and how to add or update them. The installed skills and `skills-lock.json` are committed; install only into `.agents/` (`-a universal`), never into `.claude/` or another per-agent folder. ## Release process diff --git a/docs/wiki/Diagnostics.md b/docs/wiki/Diagnostics.md index 3e4e92f4..a63f0927 100644 --- a/docs/wiki/Diagnostics.md +++ b/docs/wiki/Diagnostics.md @@ -26,7 +26,7 @@ The package ships a single Roslyn analyzer, `TelemetryDiagnosticAnalyzer`, which | `TSG1007` | Warning | `ExcludeTargets` results in an empty or invalid parameter set for a target. | | `TSG1008` | Warning | Activity parameter has no Activity target. A parameter of type `Activity` is present on a method with no `[Activity]`/`[Event]`/`[Context]` attribute. | | `TSG1010` | Error | Method target not registered on interface. A method carries an attribute for a target family that is not registered on the interface. | -| `TSG1011` | Error | Unsupported target framework. The compilation targets neither .NET 8+ nor .NET Framework 4.8+; define `PURVIEW_TELEMETRY_NON_NULLABLE` to opt out. | +| `TSG1011` | Error | Unsupported target framework. The compilation targets neither .NET 8+ nor .NET Framework 4.8+ (for example `netstandard2.0`), so no telemetry is generated for it. There is no opt-out. Target `net8.0` or later, or `net48` or later; a multi-targeted library references the package only from its supported targets (a `Condition` on the `PackageReference`), with the telemetry interfaces under the same condition. See [Platform Support](Platform-Support.md). | ## Logging diagnostics (TSG2xxx) @@ -67,6 +67,7 @@ The package ships a single Roslyn analyzer, `TelemetryDiagnosticAnalyzer`, which | `TSG3017` | Error | Status Description parameters are only valid on Events, not Activity or Context methods. | | `TSG3021` | Info | Exception event does not use OpenTelemetry standard name. An `[Event]` method records an exception under the OpenTelemetry exception rules but the event name is not the standard `"exception"` (suggest `[Event(Name = "exception")]`). Not raised when `UseRecordExceptionRules` is disabled, for `[Baggage]` exceptions, or when the exception parameter is excluded from the Activities target. | | `TSG3022` | Warning | Activity return type should be nullable. An Activity method returns non-nullable `Activity`; use `Activity?` because the Activity can be null when no listeners are active. | +| `TSG3023` | Warning | ActivitySourceFactory is not available. `UseActivitySourceFactory` is set but `System.Diagnostics.ActivitySourceFactory` (.NET 11, or System.Diagnostics.DiagnosticSource 11+) cannot be resolved; a static `ActivitySource` is generated instead. | ## Metrics diagnostics (TSG4xxx) @@ -135,6 +136,13 @@ The diagnostic is not raised when the exception is not recorded using the OpenTe Return `Activity?` so callers can handle the `null` case when no listeners are active. +### TSG3023 — ActivitySourceFactory is not available + +`[assembly: ActivitySourceGeneration(..., UseActivitySourceFactory = true)]` asks for sources created by +`System.Diagnostics.ActivitySourceFactory`, but the compilation cannot resolve that type, so a static `ActivitySource` +is generated instead. Target `net11.0`, or reference System.Diagnostics.DiagnosticSource 11 or later; or remove the +option. See [Creating sources with ActivitySourceFactory](Activities.md#creating-sources-with-activitysourcefactory-net-11). + ### TSG4002 — auto-increment counter with a measurement `[AutoCounter]` (or `[Counter(AutoIncrement = true)]`) must not declare a measurement parameter — the measurement is fixed to 1. diff --git a/docs/wiki/FAQ.md b/docs/wiki/FAQ.md index 19a29a64..d22ee587 100644 --- a/docs/wiki/FAQ.md +++ b/docs/wiki/FAQ.md @@ -187,22 +187,24 @@ telemetry.QueueDepth(() => _queue.Count); ### When do metric names include the meter name? -Instrument names are generated with the meter name as a lowercase dot-separated prefix when using `NamingConvention.OpenTelemetry` combined with `MeterNameGenerationType.OpenTelemetry`: +Only when you opt in with `[assembly: MeterGeneration(MeterNameGenerationType = MeterNameGenerationType.OpenTelemetry)]`. The default, `MeterNameGenerationType.DotNet`, prefixes instrument names with the interface name instead: ```csharp -[Meter("MyApp.Orders")] -interface IOrderMetrics +[Meter("MyApp.Products")] +interface IProductTelemetry { [Counter] - void OrderProcessed([InstrumentMeasurement]int count); + void RecordCount([InstrumentMeasurement] int count); } -// Generated name: "myapp.orders.order.processed" -// ^^^^^^^^^^^^^^^ meter prefix (lowercase) -// ^^^^^^^^^^^^^^^^ instrument name +// Default: "product.record_count" +// MeterNameGenerationType.OpenTelemetry: "my_app.products.product.record_count" +// ^^^^^^^^^^^^^^^ meter name (snake_case), outermost +// ^^^^^^^ interface prefix (IProductTelemetry) +// ^^^^^^^^^^^^ instrument name ``` -The default `MeterNameGenerationType.DotNet` does not add the meter-name prefix. See [Metrics](Metrics.md#meter-naming). +See [Instrument naming](Metrics.md#instrument-naming) for the full rules. ## Registering names diff --git a/docs/wiki/Generation.md b/docs/wiki/Generation.md index 69bdd3ad..bc37e1e1 100644 --- a/docs/wiki/Generation.md +++ b/docs/wiki/Generation.md @@ -55,7 +55,7 @@ public enum NamingConvention | --- | --- | --- | | ActivitySource name | Assembly name lowercased: `"myapp"` | Assembly name preserved: `"MyApp"` | | Tag / baggage keys | Lowercased, smashed: `"entityid"` | snake_case: `"entity_id"` | -| Metric instrument names | Lowercased, smashed: `"recordhistogram"` | Hierarchical dot.separated: `"myapp.products.record.histogram"` | +| Metric instrument names | Lowercased, smashed: `"product.recordcount"` | Interface prefix + snake_case: `"product.record_count"` (see [Instrument naming](Metrics.md#instrument-naming)) | | Metric tag keys | Lowercased, smashed: `"requestcount"` | snake_case: `"request_count"` | ```csharp diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index a816e041..9bf55de2 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -12,10 +12,13 @@ This approach allows for: ## Supported frameworks -- .NET 8 or higher +- .NET 8 or higher, including .NET 11 - .NET Framework 4.8 or higher -**Build toolchain requirement:** Visual Studio 2026 (18.x) or the .NET 10 SDK (Roslyn 5.9.0+). +**Build toolchain requirement:** Visual Studio 2026 (18.x) or the .NET 10 SDK or later (Roslyn 5.9.0+). + +Native AOT, trimmed and AOT-compiled Blazor WebAssembly, and Blazor Server are supported. See +[Platform Support and .NET 11](Platform-Support.md) for the full matrix, the trim/AOT guarantees and what's new for .NET 11. ## Documentation @@ -23,6 +26,7 @@ This approach allows for: - [Getting Started](Getting-Started.md) — install, first interface, and common patterns - [Installation](Installation.md) — supported frameworks, per-feature dependencies, and build configuration +- [Platform Support and .NET 11](Platform-Support.md) — support matrix, trim/AOT guarantees, .NET 11 features - [Sample Application](Sample-Application.md) — full .NET Aspire demo application ### Core features diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index b5c2f4fd..552a5a8e 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -7,13 +7,13 @@ This page covers supported frameworks, installation methods, per-feature depende **Consumer runtime targets:** - .NET Framework 4.8 -- .NET 8 or higher +- .NET 8 or higher, including .NET 11 **Build toolchain requirement:** -- Visual Studio 2026 (18.x) or the .NET 10 SDK (Roslyn 5.9.0+) +- Visual Studio 2026 (18.x) or the .NET 10 SDK or later (Roslyn 5.9.0+) -The generator itself is a Roslyn component targeting `netstandard2.0`, so it works in any supported compiler host. +The generator itself is a Roslyn component targeting `netstandard2.0`, so it works in any supported compiler host. See [Platform Support and .NET 11](Platform-Support.md) for the hosting models (Native AOT, Blazor WebAssembly, Blazor Server, Aspire) and the trim/AOT guarantees. ## Install the package @@ -45,9 +45,10 @@ Install-Package Purview.Telemetry.SourceGenerator | Telemetry type | Required reference | Notes | | --- | --- | --- | | Activities | `System.Diagnostics.DiagnosticSource` | Included in the SDK for .NET 5+ | -| Logging | `Microsoft.Extensions.Logging.Abstractions` | Required for `[Logger]` interfaces; `TSG2003` is raised if `ILogger` cannot be resolved | +| Logging | `Microsoft.Extensions.Logging.Abstractions` | Required for `[Logger]` interfaces; `TSG2003` is raised if `ILogger` cannot be resolved. Part of the shared framework on .NET 11, so no reference is needed there (an explicit one reports `NU1510`) | | Logging (v2) | `Microsoft.Extensions.Telemetry.Abstractions` | Required for state-based v2 generation (`LoggerMessageHelper`, `[LogProperties]`) | | Metrics | `System.Diagnostics.Metrics` | Included in the SDK for .NET 8+ | +| Activities from `ActivitySourceFactory` (opt-in) | `Microsoft.Extensions.Diagnostics` 11 (`services.AddTracing()`) | Only with `UseActivitySourceFactory`; see [Activities](Activities.md#creating-sources-with-activitysourcefactory-net-11) | ## Verifying the install @@ -96,14 +97,18 @@ Define this constant to disable logging generation entirely. This is useful when ``` -### `PURVIEW_TELEMETRY_NON_NULLABLE` +### Unsupported target frameworks -Define this constant to opt out of the `TSG1011` unsupported-target-framework check for non-net8+/net48+ compilations: +A compilation that targets neither `net8.0`+ nor `net48`+ (for example `netstandard2.0`) reports +[`TSG1011`](Diagnostics.md#tsg1011) and generates nothing. No define suppresses it. A library that +keeps such a target references the package only from its supported targets, and puts its telemetry +interfaces under the same condition: ```xml - - $(DefineConstants);PURVIEW_TELEMETRY_NON_NULLABLE - + + all + analyzers + ``` ## Project-type snippets diff --git a/docs/wiki/Metrics.md b/docs/wiki/Metrics.md index d749480e..1bda398e 100644 --- a/docs/wiki/Metrics.md +++ b/docs/wiki/Metrics.md @@ -20,7 +20,35 @@ The meter name is resolved in this order: interface IInventoryMetrics { } ``` -When the `NamingConvention.OpenTelemetry` naming convention is combined with `MeterNameGenerationType.OpenTelemetry`, instrument names are generated with the meter name as a dot-separated prefix (for example, `myapp.products.record.count`). +### Instrument naming + +An instrument name is built from up to four dot-separated parts, outermost first: + +1. The meter name - **only** with `[assembly: MeterGeneration(MeterNameGenerationType = MeterNameGenerationType.OpenTelemetry)]`. The default, `MeterNameGenerationType.DotNet`, leaves it out: the meter already identifies its instruments. +2. `MeterGenerationAttribute.InstrumentPrefix` (assembly), unless `MeterAttribute.IncludeAssemblyInstrumentPrefix` is `false`. +3. The interface prefix: `MeterAttribute.InstrumentPrefix` when set, otherwise derived from the interface name by removing the leading `I` and a trailing `Telemetry` or `Logs` (`IProductTelemetry` becomes `product`). +4. The instrument's `Name`, or the method name. + +With the default `NamingConvention.OpenTelemetry`, word boundaries in every part become underscores (`RecordCount` becomes `record_count`, `MyApp.Products` becomes `my_app.products`); `NamingConvention.Legacy` only lowercases them. `LowercaseInstrumentName = false` keeps every part as written. + +```csharp +[Meter("MyApp.Products")] +interface IProductTelemetry +{ + [Counter] + void RecordCount([InstrumentMeasurement] int count); +} +``` + +| Configuration | Instrument name | +| --- | --- | +| Defaults | `product.record_count` | +| `MeterNameGenerationType.OpenTelemetry` | `my_app.products.product.record_count` | +| `NamingConvention.Legacy` | `product.recordcount` | + +These three names are pinned by the `Generate_GivenTheDocumentedExample*` tests in `TelemetrySourceGeneratorMetricsTests.MeterNaming.cs`. + +`MeterNameGenerationType.OpenTelemetry` also lowercases a meter name that falls back to the assembly name. ## Initialisation diff --git a/docs/wiki/Platform-Support.md b/docs/wiki/Platform-Support.md new file mode 100644 index 00000000..9f33ff7d --- /dev/null +++ b/docs/wiki/Platform-Support.md @@ -0,0 +1,102 @@ +# Platform Support and .NET 11 + +This page lists the target frameworks and hosting models the generator supports, what the generated code guarantees +under trimming and Native AOT, and what .NET 11 changes. Every "Verified by" entry names a gate that runs on each +release; see [Release Flow](Release-Flow.md#release-gates). + +## What's new for .NET 11 + +- **`ActivitySourceFactory` (opt-in).** `[assembly: ActivitySourceGeneration(..., UseActivitySourceFactory = true)]` + makes the generated classes create their sources from the injected .NET 11 factory, registered with + `services.AddTracing()`, instead of a static `ActivitySource`. See + [Activities](Activities.md#creating-sources-with-activitysourcefactory-net-11). `TSG3023` reports the option when + the factory type is unavailable. +- **Static interface members are ignored.** C# 15 lets an interface declare non-virtual static members on any runtime. + The generator and analyzer now skip static members everywhere, so a static helper on a telemetry interface is no + longer implemented as an instance method or reported as a duplicate name (`TSG1003`). +- **Validated on .NET 11.** The integration tests run on a `net11.0` leg. The packed-consumer gate builds `net11.0` + Native AOT, Blazor WebAssembly (trimmed and AOT) and Blazor Server consumers of the `.nupkg`. +- **No reference to the logging abstractions is needed on `net11.0`.** .NET 11 ships + `Microsoft.Extensions.Logging.Abstractions`, `Microsoft.Extensions.Diagnostics.Abstractions` and + `Microsoft.Extensions.DependencyInjection.Abstractions` in the shared framework, and NuGet reports `NU1510` for an + explicit reference. + ([breaking change](https://learn.microsoft.com/en-us/dotnet/core/compatibility/extensions/11/extensions-in-shared-framework)) + +## Support matrix + +### Target frameworks + +| Consumer target | Activities | Logging v1 | Logging v2 | Metrics | Meters from `IMeterFactory` | `UseActivitySourceFactory` | Verified by | +| --- | --- | --- | --- | --- | --- | --- | --- | +| .NET Framework 4.8 (`net48`+) | Yes | Yes | Yes¹ | Yes | No (`new Meter(...)`) | With System.Diagnostics.DiagnosticSource 11+ | Integration tests (`net48` leg, Windows), `SampleApp.Net48` | +| .NET 8, .NET 9 | Yes | Yes | Yes¹ | Yes | Yes | Not supported² | Integration tests (`net8.0`, `net9.0` legs) | +| .NET 10 | Yes | Yes | Yes¹ | Yes | Yes | With System.Diagnostics.DiagnosticSource 11+ | Integration tests, `SampleApp`, `SampleApp.MultiProject` | +| .NET 11 | Yes | Yes | Yes¹ | Yes | Yes | Yes | Integration tests (`net11.0` leg), packed-consumer gate | +| `netstandard2.0` and other TFMs | Not supported: reported as [`TSG1011`](Diagnostics.md) (unsupported target framework). Multi-target the library to `net48`/`net8.0`+ instead. | | | | | | - | + +The `UseActivitySourceFactory` entries for `net48` and `net10.0` follow from the package's assets and are not run by a gate; only `net11.0` is. + +¹ Logging v2 needs `Microsoft.Extensions.Telemetry.Abstractions` (`LoggerMessageHelper`); without it the generator +uses v1. See [Logging Generation v2](Logging-Generation-v2.md). + +² The System.Diagnostics.DiagnosticSource 11 package has no `net8.0`/`net9.0` asset. Those targets would get its +`netstandard2.0` asset, and the package's build targets warn that the framework is unsupported. + +**Build toolchain:** Visual Studio 2026 (18.x) or the .NET 10 SDK or later (Roslyn 5.9.0+). The generator itself is a +`netstandard2.0` Roslyn component. This repository builds and validates with the .NET 11 SDK pinned in `global.json`. + +### Hosting and render models + +| Model | Status | Verified by | +| --- | --- | --- | +| Console / worker / library | Supported | `SampleApp.MultiProject` (`net10.0`), packed-consumer Native AOT console (`net11.0`) | +| ASP.NET Core | Supported | `SampleApp` API service (`net10.0`), packed-consumer Blazor Server host (`net11.0`) | +| .NET Aspire | Supported. Pass `TelemetryNames.ActivitySourceNames`/`MeterNames` to your service defaults. | `SampleApp` AppHost (`net10.0`) | +| Native AOT | Supported, with no trim or AOT warning from the generated code | Packed-consumer gate (a): published with `-warnaserror`, then run, and the emitted activities, metrics and logs asserted | +| Blazor WebAssembly, trimmed | Supported | Packed-consumer gate (b): published trimmed with `-warnaserror`; every generated member must survive ILLink | +| Blazor WebAssembly, AOT (`RunAOTCompilation`) | Supported | Packed-consumer gate (b): every generated method must be AOT-compiled to WebAssembly | +| Blazor Server (interactive server) | Supported | Packed-consumer gate (c): page rendered on a `TestServer`, telemetry asserted | +| .NET Framework 4.8 applications | Supported | `SampleApp.Net48`, `net48` integration tests | + +The packed-consumer gate consumes the `.nupkg` exactly as a user does, and each of its applications also references a +class library with its own generated telemetry. On every run it exercises both a factory-created (`UseActivitySourceFactory`) +source and a static one. + +## Trim and AOT guarantees + +- **No reflection, no dynamic code.** The generated classes call `ActivitySource`, `ILogger` and `Meter` APIs directly, + with no reflection, `Expression` compilation or runtime code generation. A library built with `IsAotCompatible=true` + and warnings as errors builds clean, and so does a Native AOT publish with `-warnaserror` (IL2xxx/IL3xxx). +- **Nothing from the compiler host reaches your output.** The generator, its refactorings and the IL-merged + Purview.SourceGeneratorFramework ship under `analyzers/` only. They never appear in an application's `bin`, + `deps.json`, publish folder or Blazor `_framework` folder. Pack validation and the compiler-host leak scan enforce this. +- **Dependency injection.** The generated `Add{Interface}()` extension registers the implementation with + `AddSingleton()`, which the DI container supports under trimming and Native AOT. +- **Blazor WebAssembly metrics.** On .NET 11 the Blazor WebAssembly SDK turns `MetricsSupport` on by default + (`BlazorWebAssemblyDiagnosticsEnabled=true`, in `Microsoft.NET.Sdk.BlazorWebAssembly.Current.targets`). If you set + `BlazorWebAssemblyDiagnosticsEnabled` or `MetricsSupport` to `false`, the trimmer removes metrics support. The + generated instruments still compile, but record nothing. Earlier SDKs default `MetricsSupport` to `false` for + WebAssembly, so set it to `true` there. + +## .NET 11 features + +| Feature | Generator support | Minimum | Source | +| --- | --- | --- | --- | +| `ActivitySourceFactory`, `services.AddTracing()` | Implemented: `UseActivitySourceFactory`, diagnostic `TSG3023` | `net11.0`, or System.Diagnostics.DiagnosticSource 11 + Microsoft.Extensions.Diagnostics 11 | [What's new in .NET 11 libraries](https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-11/libraries#activity-tracing-configuration) | +| C# 15 non-virtual static interface members on older runtimes | Implemented: static members are skipped | C# 15 (.NET 11 SDK) | [What's new in C# 15](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-15) | +| Logging and diagnostics abstractions in the shared framework | Documented: no reference is needed on `net11.0` | `net11.0` | [Breaking change](https://learn.microsoft.com/en-us/dotnet/core/compatibility/extensions/11/extensions-in-shared-framework) | +| Blazor WebAssembly diagnostics on by default | Documented above | .NET 11 SDK | `Microsoft.NET.Sdk.BlazorWebAssembly.Current.targets` in the .NET 11 SDK | + +## Not yet supported + +| Feature | Why it is not supported yet | Source | +| --- | --- | --- | +| Generic logging methods (the .NET 11 `[LoggerMessage]` generator supports them) | Generic interface methods still report `TSG1005`. Supporting them needs generic implementations across all three targets, not only logging. | [What's new in .NET 11 libraries](https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-11/libraries#generic-source-generated-logging-methods) | +| Parity with the new `[LoggerMessage]` diagnostics for `params`/`scoped` and `ref struct` parameters (SYSLIB1028, SYSLIB1029) | No equivalent `TSG` rule exists yet. Such parameters are not rejected up front. | [`DiagnosticDescriptors.cs` (release/11.0)](https://github.com/dotnet/runtime/blob/release/11.0/src/libraries/Microsoft.Extensions.Logging.Abstractions/gen/DiagnosticDescriptors.cs) | +| C# 15 union types as tag, baggage or log values | A union is a struct implementing `IUnion`. It is recorded as the boxed struct rather than its case value. Unwrapping it needs a design for each target. | [What's new in C# 15](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-15) | +| `ActivitySourceOptions` version, tags and `TelemetrySchemaUrl` for factory-created sources | Only the name is passed today, matching the static source. | [What's new in .NET 11 libraries](https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-11/libraries) | +| `AddTracing` rules from generated registrations | Rules are application configuration, so the generated `Add{Interface}()` does not call `AddTracing()`. Call it yourself. | [Distributed tracing configuration](https://learn.microsoft.com/en-us/dotnet/core/diagnostics/distributed-tracing-configuration) | +| `ActivityTraceFlags.RandomTraceId` / `Activity.HasRandomizedTraceId` | Nothing to generate: the runtime sets the flag on the activities it creates, and generated code never rewrites trace flags. | [dotnet/runtime#124509](https://github.com/dotnet/runtime/issues/124509) | +| New `System.Diagnostics.Metrics` APIs | .NET 11 adds none. The earlier `Gauge` (.NET 9) and `InstrumentAdvice` histogram buckets (.NET 9) are not generated yet either. | [What's new in .NET 11 libraries](https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-11/libraries) | +| `Activity.AddException` (.NET 9) | Exception events are built with the OpenTelemetry `exception.*` tags directly, which works on every target, `net48` included. | [`Activity.AddException`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activity.addexception) | +| A `Microsoft.Extensions.Telemetry.Abstractions` 11 line | None is published yet. Logging v2 uses the 10.x package on every target. | [dotnet/extensions releases](https://github.com/dotnet/extensions/releases) | diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index 078a04f8..092aec0d 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -13,7 +13,7 @@ Feature branch | Component | File | Purpose | | --- | --- | --- | -| PR gate | `.github/workflows/pr.yml` | Runs the reusable `purview-dev/build` `purview-build.yml` pipeline (restore, build, lint, tests) and builds/tests the sample solution. | +| PR gate | `.github/workflows/pr.yml` | Runs the reusable `purview-dev/build` `purview-build.yml` pipeline (restore, build, lint, tests), builds/tests the samples and leak-scans their output, and runs the packed-consumer gate (see [Release gates](#release-gates)). | | CD pipeline | `.github/workflows/release.yml` | Runs the reusable `purview-dev/build` `purview-release.yml` pipeline on push to `main`. | | Local pipeline | `Justfile` `pipeline-*` recipes | Mirror the CI/CD pipelines locally via the `Purview.Build` local tool (`dotnet purview-build`). | @@ -40,13 +40,55 @@ Feature branch | `just pipeline-local-release` | Packs and publishes to a local NuGet feed (see the Justfile note on argument quoting for the feed path). | | `just pipeline-release` | Full release pipeline (build, test, pack, publish, GitHub release). | +## Release gates + +Each gate proves one thing the others cannot. The tests and the samples reference the generator project, which is +fast and debuggable but is not what ships; the packed gates consume the `.nupkg` itself. A gate that has nothing to +check fails rather than passing. + +| Gate | What it proves | Local | CI | +| --- | --- | --- | --- | +| Build, lint and tests | The solution builds warning-free, CSharpier reports no change, and every test project passes. | `just build`, `just lint`, `just test` | `pr.yml` `build` | +| Pack and pack validation | The package packs; `purview-build.json` `PackValidation` finds exactly the declared content (`RequiredContent`, `RequireExplicitContent`) and none of the `ForbiddenContent`. | `just pipeline-pack-validate` | `pr.yml` `build` | +| Forbidden-content rule proof | Every `ForbiddenContent` rule rejects a package: a matching file is injected into scratch copies of the package and the real `purview-build` validation must fail, per package and per rule. | `just verify-pack-rules` | `pr.yml` `packed-consumers` | +| Packed consumers | The package, packed to an isolated feed and cache, works for consumers that reference it as a package - each with a class library of its own generated telemetry. (a) A **Native AOT** console publishes with `-warnaserror` and no IL2xxx/IL3xxx warning, then runs with an in-process `ActivityListener`, `MeterListener` and capturing logger provider and checks the multi-target method (Activity, Information log and counter from one call), tags, baggage, events and the OpenTelemetry exception event, every metric measurement, v1 and v2 logging, log-level gating and the `TelemetryNames` aggregation. (b) A **Blazor WebAssembly** app, using the telemetry from a page and a service, publishes trimmed with `-warnaserror` and with `RunAOTCompilation=true`: the trimmed assemblies keep every generated member, and the WebAssembly AOT compiler compiles every generated method. (c) A **Blazor Server** host renders its page on an in-memory `TestServer`, and the telemetry the page emitted is checked as in (a). (d) Every bin and publish folder is leak-scanned. | `just smoke-packed-consumers` | `pr.yml` `packed-consumers` (linux-x64, `-WasmAot Require`) | +| Compiler-host leak scan | No generator, refactorings, Purview.SourceGeneratorFramework or Roslyn assembly reaches build or publish output, by file name or in a `deps.json`, `runtimeconfig.json` or boot manifest (`blazor.boot.json`, `dotnet.boot.js`, `dotnet.js`). Purview.SourceGeneratorFramework 1.2.1 put its DLL in every sample's `bin` while every build passed. | `just scan-compiler-host-leaks ...` | the `samples` job (every sample's `bin`) and every consumer in `packed-consumers`; `ReleaseGateTests` fails a job that builds the samples or publishes without it | + +`ReleaseGateTests` (in `src/tests/Repository.UnitTests`, so `just test` runs it) guards the gates themselves: the leak +scan must catch each kind of leak it claims to, every workflow job that builds the samples or publishes must scan its +output, the `packed-consumers` job must run the gate and the rule proof, the Justfile recipes must run their scripts, +the consumers must reference the generator as a package and never the repository's projects, and `ForbiddenContent` +must keep every rule. + +### Prerelease build dependencies + +The generator is built against the Purview.SourceGeneratorFramework version `Directory.Packages.props` pins, with the +Purview.BuildSdk version `global.json` pins. Until those versions are on nuget.org, CI cannot restore them: the +`packed-consumers` job fails at its first step with what to do, rather than skipping. Publish them, or set the +`TELEMETRY_PRERELEASE_FEED` repository variable to a feed that has them. Locally, `just smoke-packed-consumers` also +reads `TELEMETRY_PRERELEASE_FEED` or `LOCAL_NUGET_FEED_PATH`, and uses that feed for those packages only. + +The consumers target `net11.0` and take their ASP.NET Core package versions from the `Packed consumers` group in +`Directory.Packages.props`, so bump those with the .NET 11 SDK in `global.json`. + ## Pre-release validation -Before a release, validate locally: +Run these on a clean tree before bumping and merging a release. Each must pass. + +```powershell +$env:LOCAL_NUGET_FEED_PATH = 'P:\_sync-projects\.local-nuget' # wherever the prerelease build dependencies are + +just scrub # delete bin/obj, clean, force-restore, stop build servers +just pipeline-local-release --PublishLocalNuGet:LocalFeedPath=p:/_sync-projects/.local-nuget/ + # restore, build, lint, tests, pack, pack validation, local publish +just smoke-packed-consumers # Native AOT, Blazor WebAssembly (trimmed and AOT) and Blazor Server +just build-s && just test-s # sample solution build and tests +just lint # CSharpier reports no change +``` -1. `just pipeline-pr` — restore, build, lint, tests. -2. `just build-s && just test-s` — sample solution build and tests. -3. `just pipeline-local-release` — confirm the pack + local publish succeed. +`just verify-pack-rules` re-proves the forbidden-content rules against the package in `artifacts/` (after +`just pipeline-local-release`, which moves it to the local feed, pass `-PackagesFolder `); run it after changing +`PackValidation`. ## Releasing diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index a38ff0c7..9f5302dd 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -1,6 +1,7 @@ - [Home](Home.md) - [Getting Started](Getting-Started.md) - [Installation](Installation.md) +- [Platform Support and .NET 11](Platform-Support.md) - [Generation](Generation.md) - [Activities](Activities.md) - [Logging](Logging.md) diff --git a/docs/wiki/index.md b/docs/wiki/index.md deleted file mode 100644 index 1ff2fcfd..00000000 --- a/docs/wiki/index.md +++ /dev/null @@ -1,6 +0,0 @@ -# Telemetry Source Generator - -Source-generated logging, metrics, activities, diagnostics, and telemetry integrations for .NET. - -[Documentation overview](Home.md){ .md-button .md-button--primary } -[Get started](Getting-Started.md){ .md-button } diff --git a/global.json b/global.json index 8b388e3d..054e74db 100644 --- a/global.json +++ b/global.json @@ -1,11 +1,11 @@ { "sdk": { - "version": "11.0.100-rc.2.26504.105", + "version": "11.0.100-rc.3.26522.148", "rollForward": "latestMajor", "allowPrerelease": true }, "msbuild-sdks": { - "Purview.BuildSdk": "1.1.0" + "Purview.BuildSdk": "1.2.0" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index 9be1412f..00000000 --- a/mkdocs.yml +++ /dev/null @@ -1,21 +0,0 @@ -site_name: Telemetry Source Generator -site_description: Developer documentation for the Purview Telemetry Source Generator -repo_url: https://github.com/purview-dev/telemetry-sourcegenerator -edit_uri: edit/main/docs/wiki/ -docs_dir: docs/wiki - -theme: - name: material - palette: - - media: "(prefers-color-scheme: light)" - scheme: default - primary: indigo - accent: cyan - - media: "(prefers-color-scheme: dark)" - scheme: slate - primary: indigo - accent: cyan - features: [navigation.instant, navigation.sections, navigation.top, search.highlight, search.suggest, content.code.copy] - -plugins: [search, techdocs-core] -markdown_extensions: [admonition, attr_list, md_in_html, pymdownx.details, pymdownx.superfences] diff --git a/package.json b/package.json index 6441b50a..9ff58b9c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "telemetry-sourcegenerator", - "version": "5.0.6", + "version": "5.1.0", "description": "Generates [`ActivitySource`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activitysource), [`ILogger`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.ilogger), and [`Metrics`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics) based on interface methods.", "license": "MIT", "readme": "README.md", diff --git a/samples/SampleApp.Net48/README.md b/samples/SampleApp.Net48/README.md index 65f5204a..978b3a15 100644 --- a/samples/SampleApp.Net48/README.md +++ b/samples/SampleApp.Net48/README.md @@ -43,7 +43,7 @@ consuming project. Because `NET48_OR_GREATER` is automatically defined by the SD ```csharp // In the injected ErrorAttribute.g.cs: -#if NET48_OR_GREATER || PURVIEW_TELEMETRY_NON_NULLABLE +#if NET48_OR_GREATER public string MessageTemplate { get; set; } #else public string? MessageTemplate { get; set; } @@ -54,17 +54,6 @@ The attribute files are decorated with `[Conditional("PURVIEW_TELEMETRY_ATTRIBUT sample defines `PURVIEW_TELEMETRY_ATTRIBUTES` in `Directory.Build.props` to keep the attribute usages visible to the generator's incremental pipeline. -### Opt-out for any project - -Projects that prefer non-nullable attribute files regardless of target framework can define -`PURVIEW_TELEMETRY_NON_NULLABLE` in their project file: - -```xml - - $(DefineConstants);PURVIEW_TELEMETRY_NON_NULLABLE - -``` - ## Project structure ``` diff --git a/scripts/assert-no-compiler-host-assemblies.ps1 b/scripts/assert-no-compiler-host-assemblies.ps1 new file mode 100644 index 00000000..81a93884 --- /dev/null +++ b/scripts/assert-no-compiler-host-assemblies.ps1 @@ -0,0 +1,159 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Fails when a build or publish output carries a compiler-host-only assembly. + +.DESCRIPTION + The telemetry source generator, its refactorings and the framework they are built on run inside the compiler. None + of them may reach an application: Purview.SourceGeneratorFramework.dll once leaked into app build and publish output + through analyzer-only references (Purview.SourceGeneratorFramework 1.2.1 put it in every sample's bin here), and + Blazor WebAssembly AOT compilation fails on it. A build stays green while it leaks, because nothing looks at what is + actually written to disk. + + This script looks. Every file under each -Path is checked by name, and every dependency manifest + (*.deps.json, *.runtimeconfig.json, blazor.boot.json, dotnet.boot.js, and dotnet.js, which embeds the boot manifest since + .NET 10 - including fingerprinted and Brotli/gzip compressed copies) is checked by content. A file, or a manifest entry, naming any of these fails the scan: + + Purview.SourceGeneratorFramework* + Microsoft.CodeAnalysis* + Purview.Telemetry.SourceGenerator* (the generator, and Purview.Telemetry.SourceGenerator.Refactorings) + + A path that does not exist, or that holds no files, fails too: a scan of nothing proves nothing. + + Used by scripts/test-packed-consumers.ps1 on every consumer bin and publish folder, by the samples CI job on the + sample build output, and by `just scan-compiler-host-leaks`. + +.PARAMETER Path + One or more build or publish output directories to scan, recursively. Space-separated when run with -File. + +.PARAMETER Allow + Wildcard patterns (PowerShell -like, matched against each file's path relative to the scanned directory, with + forward slashes) for third-party tools that legitimately carry Roslyn and are never loaded by the application - + for example '*BlazorDebugProxy/*', the WebAssembly debugging proxy Microsoft.AspNetCore.Components.WebAssembly.Server + publishes beside a Blazor server. Allowed files are counted and reported, never silently ignored. Keep each pattern + as narrow as the tool's own folder. + +.EXAMPLE + ./scripts/assert-no-compiler-host-assemblies.ps1 -Path samples/SampleApp/SampleApp.Web/bin + +.EXAMPLE + ./scripts/assert-no-compiler-host-assemblies.ps1 -Path artifacts/blazor-host -Allow '*BlazorDebugProxy/*' + +.EXAMPLE + pwsh -NoProfile -File scripts/assert-no-compiler-host-assemblies.ps1 -Path artifacts/aot-console bin/Release +#> +[CmdletBinding()] +param ( + [Parameter(Mandatory, Position = 0)] + [string[]] $Path, + + [string[]] $Allow = @(), + + # Collects the rest of `pwsh -File ... -Path a b` (space-separated): -File passes arguments as strings and does + # not parse PowerShell's comma array syntax. + [Parameter(ValueFromRemainingArguments, DontShow)] + [string[]] $AdditionalPath = @() +) + +$ErrorActionPreference = 'Stop' +$Path = @($Path) + @($AdditionalPath) + +# File-name prefixes. Published WebAssembly files carry a fingerprint (Name.abc123.wasm), which a prefix still matches. +$forbiddenNames = @( + 'Purview.SourceGeneratorFramework' + 'Microsoft.CodeAnalysis' + 'Purview.Telemetry.SourceGenerator' +) + +$forbiddenContent = [regex]::new( + (($forbiddenNames | ForEach-Object { [regex]::Escape($_) }) -join '|'), + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase +) + +# Dependency manifests, optionally fingerprinted (dotnet.boot.abc123.js) and optionally compressed (.br, .gz). Since .NET 10 +# the WebAssembly boot manifest is embedded in _framework/dotnet.js, so that file is a manifest too; dotnet.native.*.js and +# dotnet.runtime.*.js are the runtime itself and never list assemblies. +$manifestPattern = [regex]::new( + '(\.deps\.json|\.runtimeconfig\.json|^blazor\.boot(\.[a-z0-9]+)?\.json|^dotnet(\.boot)?(\.[a-z0-9]+)?\.js)(\.br|\.gz)?$', + [System.Text.RegularExpressions.RegexOptions]::IgnoreCase +) + +function Read-ManifestText([System.IO.FileInfo] $file) { + $stream = [System.IO.File]::OpenRead($file.FullName) + try { + $source = switch -Regex ($file.Name) { + '\.br$' { [System.IO.Compression.BrotliStream]::new($stream, [System.IO.Compression.CompressionMode]::Decompress) } + '\.gz$' { [System.IO.Compression.GZipStream]::new($stream, [System.IO.Compression.CompressionMode]::Decompress) } + default { $stream } + } + + $reader = [System.IO.StreamReader]::new($source) + try { return $reader.ReadToEnd() } finally { $reader.Dispose() } + } + finally { + $stream.Dispose() + } +} + +function Write-Failure([string] $message) { + if ($env:GITHUB_ACTIONS) { Write-Host "::error::$message" } else { Write-Host "ERROR: $message" -ForegroundColor Red } +} + +$violations = [System.Collections.Generic.List[string]]::new() + +foreach ($directory in $Path) { + if (-not (Test-Path -LiteralPath $directory -PathType Container)) { + $violations.Add("'$directory' does not exist or is not a directory, so it cannot be scanned.") + continue + } + + $root = (Resolve-Path -LiteralPath $directory).Path + $files = @(Get-ChildItem -LiteralPath $root -Recurse -File -Force) + if ($files.Count -eq 0) { + $violations.Add("'$root' holds no files, so the scan would prove nothing.") + continue + } + + $manifests = 0 + $allowed = 0 + foreach ($file in $files) { + $relative = [System.IO.Path]::GetRelativePath($root, $file.FullName) + $forwardSlashed = $relative.Replace('\', '/') + if ($Allow | Where-Object { $forwardSlashed -like $_ }) { + $allowed++ + continue + } + + foreach ($name in $forbiddenNames) { + if ($file.Name.StartsWith($name, [System.StringComparison]::OrdinalIgnoreCase)) { + $violations.Add("compiler-host file '$relative' in '$root'.") + break + } + } + + if ($manifestPattern.IsMatch($file.Name)) { + $manifests++ + $matched = $forbiddenContent.Matches((Read-ManifestText $file)) | + ForEach-Object Value | + Sort-Object -Unique + if ($matched) { + $violations.Add("manifest '$relative' in '$root' names $($matched -join ', ').") + } + } + } + + $allowedNote = $allowed -gt 0 ? (", {0} allowed by -Allow {1}" -f $allowed, ($Allow -join ', ')) : '' + Write-Host ("Scanned {0}: {1} file(s), {2} manifest(s){3}." -f $root, $files.Count, $manifests, $allowedNote) +} + +if ($violations.Count -gt 0) { + foreach ($violation in $violations) { Write-Failure $violation } + Write-Failure ( + "$($violations.Count) compiler-host leak(s) found. Source generators, analyzers and their dependencies run " + + 'inside the compiler and must never reach build or publish output; check analyzer-only references ' + + '(OutputItemType="Analyzer", ReferenceOutputAssembly="false", PrivateAssets="all") and the package layout.' + ) + exit 1 +} + +Write-Host 'No compiler-host assemblies or manifest references found.' -ForegroundColor Green diff --git a/scripts/packed-consumers/AotConsole/AotConsole.csproj b/scripts/packed-consumers/AotConsole/AotConsole.csproj new file mode 100644 index 00000000..d86dd8e4 --- /dev/null +++ b/scripts/packed-consumers/AotConsole/AotConsole.csproj @@ -0,0 +1,56 @@ + + + + Exe + net11.0 + PackedTelemetryAot + PackedTelemetry + enable + enable + true + true + false + true + true + true + true + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/packed-consumers/AotConsole/Program.cs b/scripts/packed-consumers/AotConsole/Program.cs new file mode 100644 index 00000000..a9a429af --- /dev/null +++ b/scripts/packed-consumers/AotConsole/Program.cs @@ -0,0 +1,61 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using PackedTelemetry; +using PackedTelemetry.Library; +using PackedTelemetryAot; +using Purview.Telemetry; + +// Only this project opts into the TelemetryNames class, which aggregates the names of every assembly in its reference +// graph - here its own and PackedTelemetry.Library's - from assembly metadata, at compile time. It is generated into +// the namespace named for the assembly, PackedTelemetryAot. +[assembly: TelemetryGeneration(GenerateTelemetryNamesClass = true)] + +const string OrderId = "ord-42"; +const string CustomerId = "cust-7"; +const double Total = 129.5; + +using var capture = new TelemetryCapture(Expectations.Names); + +ServiceCollection services = new(); +services.AddLogging(logging => logging.ClearProviders().SetMinimumLevel(LogLevel.Trace).AddProvider(capture)); +services.AddMetrics(); + +// .NET 11: the ActivitySourceFactory IOrderTelemetry's generated constructor takes (UseActivitySourceFactory). +services.AddTracing(); +services.AddOrderTelemetry(); +services.AddInventory(); +services.AddSingleton(); + +using (var provider = services.BuildServiceProvider()) +{ + provider.GetRequiredService().Place(OrderId, CustomerId, Total); +} + +var failures = Expectations.Verify(capture, OrderId, CustomerId, Total); + +foreach (var name in Expectations.Names) +{ + if (Array.IndexOf(TelemetryNames.ActivitySourceNames, name) < 0) + failures.Add( + $"TelemetryNames.ActivitySourceNames does not aggregate '{name}' ({string.Join(", ", TelemetryNames.ActivitySourceNames)})" + ); + + if (Array.IndexOf(TelemetryNames.MeterNames, name) < 0) + failures.Add( + $"TelemetryNames.MeterNames does not aggregate '{name}' ({string.Join(", ", TelemetryNames.MeterNames)})" + ); +} + +Console.WriteLine(Expectations.Dump(capture)); +if (failures.Count > 0) +{ + foreach (var failure in failures) + Console.Error.WriteLine($"MISMATCH: {failure}"); + + return 1; +} + +Console.WriteLine( + $"Native AOT: {capture.Activities.Count} activities, {capture.Measurements.Count} measurements and the expected log entries, as generated." +); +return 0; diff --git a/scripts/packed-consumers/BlazorServer/BlazorServer.csproj b/scripts/packed-consumers/BlazorServer/BlazorServer.csproj new file mode 100644 index 00000000..f981adc5 --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/BlazorServer.csproj @@ -0,0 +1,47 @@ + + + + net11.0 + PackedTelemetryServer + PackedTelemetry + enable + enable + true + true + + + + + + + + + + + + + + + + + + diff --git a/scripts/packed-consumers/BlazorServer/Components/App.razor b/scripts/packed-consumers/BlazorServer/Components/App.razor new file mode 100644 index 00000000..23ceaeba --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/Components/App.razor @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/scripts/packed-consumers/BlazorServer/Components/Pages/Orders.razor b/scripts/packed-consumers/BlazorServer/Components/Pages/Orders.razor new file mode 100644 index 00000000..6fe4bf61 --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/Components/Pages/Orders.razor @@ -0,0 +1,22 @@ +@page "/" +@inject OrderWorkflow Workflow + +

Orders

+ +

@_status

+ +@code { + public const string OrderId = "ord-server"; + public const string CustomerId = "cust-server"; + public const double Total = 64.25; + + string _status = "No order placed."; + + // Runs once while the page is rendered on the server for the request; the generated telemetry is emitted by the + // service, captured in-process by the host. + protected override void OnInitialized() + { + Workflow.Place(OrderId, CustomerId, Total); + _status = $"Placed {OrderId}."; + } +} diff --git a/scripts/packed-consumers/BlazorServer/Components/Routes.razor b/scripts/packed-consumers/BlazorServer/Components/Routes.razor new file mode 100644 index 00000000..0f0d9193 --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/Components/Routes.razor @@ -0,0 +1,5 @@ + + + + + diff --git a/scripts/packed-consumers/BlazorServer/Components/_Imports.razor b/scripts/packed-consumers/BlazorServer/Components/_Imports.razor new file mode 100644 index 00000000..c3ccf47c --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/Components/_Imports.razor @@ -0,0 +1,5 @@ +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using PackedTelemetry +@using PackedTelemetry.Components diff --git a/scripts/packed-consumers/BlazorServer/Program.cs b/scripts/packed-consumers/BlazorServer/Program.cs new file mode 100644 index 00000000..167292c7 --- /dev/null +++ b/scripts/packed-consumers/BlazorServer/Program.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.TestHost; +using PackedTelemetry; +using PackedTelemetry.Components; +using PackedTelemetry.Components.Pages; +using PackedTelemetry.Library; + +const string OrderId = Orders.OrderId; +const string CustomerId = Orders.CustomerId; +const double Total = Orders.Total; + +var selfTest = args.Contains("--self-test"); + +using var capture = new TelemetryCapture(Expectations.Names); + +var builder = WebApplication.CreateBuilder(args); +if (selfTest) + builder.WebHost.UseTestServer(); + +// Trace minimum, so the logging infrastructure filters nothing: the capture decides what is enabled. +builder.Logging.ClearProviders().SetMinimumLevel(LogLevel.Trace).AddProvider(capture); + +builder.Services.AddRazorComponents().AddInteractiveServerComponents(); + +// .NET 11: the ActivitySourceFactory IOrderTelemetry's generated constructor takes (UseActivitySourceFactory). +builder.Services.AddTracing(); +builder.Services.AddOrderTelemetry(); +builder.Services.AddInventory(); +builder.Services.AddSingleton(); + +var app = builder.Build(); +app.UseAntiforgery(); +app.MapRazorComponents().AddInteractiveServerRenderMode(); + +if (!selfTest) +{ + await app.RunAsync(); + return 0; +} + +await app.StartAsync(); +List failures = []; +try +{ + using var client = app.GetTestClient(); + using var response = await client.GetAsync("/"); + var html = await response.Content.ReadAsStringAsync(); + + if (!response.IsSuccessStatusCode) + failures.Add($"GET / returned {(int)response.StatusCode}: {html}"); + else if (!html.Contains($"Placed {OrderId}.", StringComparison.Ordinal)) + failures.Add($"the page did not render the placed order: {html}"); + else if (!html.Contains("_framework/blazor.web.js", StringComparison.Ordinal)) + failures.Add("the page is not an interactive Blazor page (no blazor.web.js)"); + else + Console.WriteLine($"GET / rendered 'Placed {OrderId}.' on the server"); +} +finally +{ + await app.StopAsync(); +} + +failures.AddRange(Expectations.Verify(capture, OrderId, CustomerId, Total)); + +Console.WriteLine(Expectations.Dump(capture)); +if (failures.Count > 0) +{ + foreach (var failure in failures) + Console.Error.WriteLine($"MISMATCH: {failure}"); + + return 1; +} + +Console.WriteLine("Blazor Server: the page rendered and emitted the generated activities, metrics and log entries."); +return 0; diff --git a/scripts/packed-consumers/BlazorWasm/App.razor b/scripts/packed-consumers/BlazorWasm/App.razor new file mode 100644 index 00000000..abdf5c3b --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/App.razor @@ -0,0 +1,8 @@ + + + + + +

Not found.

+
+
diff --git a/scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj b/scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj new file mode 100644 index 00000000..67a7de52 --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/BlazorWasm.csproj @@ -0,0 +1,57 @@ + + + + net11.0 + PackedTelemetryWasm + PackedTelemetry + enable + enable + true + false + true + true + true + true + + + + + + + + + + + + + + + + + + + diff --git a/scripts/packed-consumers/BlazorWasm/Pages/Orders.razor b/scripts/packed-consumers/BlazorWasm/Pages/Orders.razor new file mode 100644 index 00000000..d61deda8 --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/Pages/Orders.razor @@ -0,0 +1,30 @@ +@page "/" +@inject OrderWorkflow Workflow +@inject IOrderTelemetry Telemetry + +

Orders

+ + +

@_status

+ +@code { + int _placed; + string _status = "No order placed."; + + protected override void OnInitialized() => Place(); + + void Place() + { + var orderId = $"ord-wasm-{++_placed}"; + + // From the page: the generated telemetry directly. + Telemetry.OrdersInFlight(1); + Telemetry.OrderTotal(42, "GBP"); + + // From a service: the generated telemetry, and the library's. + Workflow.Place(orderId, "cust-wasm", 42); + + Telemetry.OrdersInFlight(-1); + _status = $"Placed {orderId}."; + } +} diff --git a/scripts/packed-consumers/BlazorWasm/Program.cs b/scripts/packed-consumers/BlazorWasm/Program.cs new file mode 100644 index 00000000..27bf497d --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/Program.cs @@ -0,0 +1,20 @@ +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using Microsoft.Extensions.DependencyInjection; +using PackedTelemetry; +using PackedTelemetry.Library; + +var builder = WebAssemblyHostBuilder.CreateDefault(args); +builder.RootComponents.Add("#app"); +builder.RootComponents.Add("head::after"); + +// The generated registrations: this assembly's AddOrderTelemetry, and the library's (through AddInventory). +builder.Services.AddMetrics(); + +// .NET 11: the ActivitySourceFactory IOrderTelemetry's generated constructor takes (UseActivitySourceFactory). +builder.Services.AddTracing(); +builder.Services.AddOrderTelemetry(); +builder.Services.AddInventory(); +builder.Services.AddSingleton(); + +await builder.Build().RunAsync(); diff --git a/scripts/packed-consumers/BlazorWasm/_Imports.razor b/scripts/packed-consumers/BlazorWasm/_Imports.razor new file mode 100644 index 00000000..c9225c89 --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/_Imports.razor @@ -0,0 +1,3 @@ +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using PackedTelemetry diff --git a/scripts/packed-consumers/BlazorWasm/wwwroot/index.html b/scripts/packed-consumers/BlazorWasm/wwwroot/index.html new file mode 100644 index 00000000..f5fbb256 --- /dev/null +++ b/scripts/packed-consumers/BlazorWasm/wwwroot/index.html @@ -0,0 +1,13 @@ + + + + + + Packed Purview Telemetry Blazor WebAssembly consumer + + + +
Loading...
+ + + diff --git a/scripts/packed-consumers/Library/IInventoryTelemetry.cs b/scripts/packed-consumers/Library/IInventoryTelemetry.cs new file mode 100644 index 00000000..094fee79 --- /dev/null +++ b/scripts/packed-consumers/Library/IInventoryTelemetry.cs @@ -0,0 +1,39 @@ +using System.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Purview.Telemetry; + +namespace PackedTelemetry.Library; + +/// +/// Telemetry generated in a class library: every consumer references this project, so the packed generator runs in +/// two assemblies of the same application, and the application aggregates this one's names. +/// +[ActivitySource("packed.inventory")] +[Meter("packed.inventory")] +public interface IInventoryTelemetry +{ + [Activity("stock.reserve")] + Activity? ReservingStock([Tag] string sku); + + [Counter("stock.reserved")] + void StockReserved([InstrumentMeasurement] int quantity, [Tag] string sku); +} + +/// Reserves stock, emitting the library's own telemetry. +public sealed class StockReserver(IInventoryTelemetry telemetry) +{ + public void Reserve(string sku, int quantity) + { + using var activity = telemetry.ReservingStock(sku); + telemetry.StockReserved(quantity, sku); + } +} + +public static class InventoryServiceCollectionExtensions +{ + /// + /// The library owns its registration: the generated AddInventoryTelemetry() is internal to it. + /// + public static IServiceCollection AddInventory(this IServiceCollection services) => + services.AddInventoryTelemetry().AddSingleton(); +} diff --git a/scripts/packed-consumers/Library/Library.csproj b/scripts/packed-consumers/Library/Library.csproj new file mode 100644 index 00000000..a545f4ff --- /dev/null +++ b/scripts/packed-consumers/Library/Library.csproj @@ -0,0 +1,37 @@ + + + + net11.0 + PackedTelemetry.Library + PackedTelemetry.Library + enable + enable + true + true + true + + + + + + + + + + + diff --git a/scripts/packed-consumers/Shared/Expectations.cs b/scripts/packed-consumers/Shared/Expectations.cs new file mode 100644 index 00000000..e4ce60b8 --- /dev/null +++ b/scripts/packed-consumers/Shared/Expectations.cs @@ -0,0 +1,226 @@ +using System.Text; +using Microsoft.Extensions.Logging; + +namespace PackedTelemetry; + +/// +/// What one call must emit through the packed generator's code. Shared by the +/// Native AOT console and the Blazor Server host, so both prove the same contract. +/// +public static class Expectations +{ + public const string OrdersName = "packed.orders"; + public const string InventoryName = "packed.inventory"; + + public static readonly string[] Names = [OrdersName, InventoryName]; + + /// Returns every mismatch between what was captured and what one order must emit; empty when it all matches. + public static List Verify(TelemetryCapture capture, string orderId, string customerId, double total) + { + List failures = []; + + void Check(bool condition, string failure) + { + if (!condition) + failures.Add(failure); + } + + // Activities. + var order = capture.Activities.Where(a => a.Source == OrdersName && a.Name == "order.place").ToArray(); + Check(order.Length == 1, $"expected one '{OrdersName}/order.place' activity, got {order.Length}"); + if (order.Length == 1) + { + var activity = order[0]; + Check( + activity.CreatedByFactory, + $"'{OrdersName}' was not created by the injected ActivitySourceFactory (UseActivitySourceFactory)" + ); + Check( + Get(activity.Tags, "order_id") == orderId, + $"order.place tag order_id is '{Get(activity.Tags, "order_id")}', not '{orderId}'" + ); + Check( + Get(activity.Baggage, "customer_id") == customerId, + $"order.place baggage customer_id is '{Get(activity.Baggage, "customer_id")}', not '{customerId}'" + ); + + var validated = activity.Events.Where(e => e.Name == "order.validated").ToArray(); + Check(validated.Length == 1, $"expected one order.validated event, got {validated.Length}"); + if (validated.Length == 1) + Check( + Get(validated[0].Tags, "line_count") == "3", + $"order.validated tag line_count is '{Get(validated[0].Tags, "line_count")}', not '3'" + ); + + var exception = activity.Events.Where(e => e.Name == "exception").ToArray(); + Check(exception.Length == 1, $"expected one exception event, got {exception.Length}"); + if (exception.Length == 1) + { + Check( + Get(exception[0].Tags, "exception.type") == "System.InvalidOperationException", + $"exception.type is '{Get(exception[0].Tags, "exception.type")}'" + ); + Check( + Get(exception[0].Tags, "exception.message") == "card declined", + $"exception.message is '{Get(exception[0].Tags, "exception.message")}'" + ); + Check( + Get(exception[0].Tags, "order_id") == orderId, + $"exception event tag order_id is '{Get(exception[0].Tags, "order_id")}'" + ); + } + } + + var reserve = capture.Activities.Where(a => a.Source == InventoryName && a.Name == "stock.reserve").ToArray(); + Check( + reserve.Length == 1, + $"expected one '{InventoryName}/stock.reserve' activity (the library's), got {reserve.Length}" + ); + if (reserve.Length == 1) + { + Check( + Get(reserve[0].Tags, "sku") == OrderWorkflow.Sku, + $"stock.reserve tag sku is '{Get(reserve[0].Tags, "sku")}'" + ); + Check( + !reserve[0].CreatedByFactory, + $"'{InventoryName}' does not opt in to UseActivitySourceFactory, so it must stay a static ActivitySource" + ); + } + + // Metrics. Instrument names carry the prefix the generator derives from the interface name (IOrderTelemetry -> + // "order", IInventoryTelemetry -> "inventory"); the meter name is only prepended with + // MeterNameGenerationType.OpenTelemetry, which these interfaces do not set. + var placed = Measurements(capture, OrdersName, "order.orders.placed"); + Check( + placed.Length == 1 && placed[0].Value == 1, + $"expected one orders.placed measurement of 1, got [{Values(placed)}]" + ); + + var totals = Measurements(capture, OrdersName, "order.order.total"); + Check( + totals.Length == 1 && totals[0].Value == total, + $"expected one order.total measurement of {total}, got [{Values(totals)}]" + ); + if (totals.Length == 1) + { + Check( + Get(totals[0].Tags, "currency") == "GBP", + $"order.total tag currency is '{Get(totals[0].Tags, "currency")}'" + ); + Check(totals[0].Unit == "GBP", $"order.total unit is '{totals[0].Unit}', not 'GBP'"); + } + + var inFlight = Measurements(capture, OrdersName, "order.orders.in_flight"); + Check(Values(inFlight) == "1, -1", $"expected orders.in_flight measurements [1, -1], got [{Values(inFlight)}]"); + + var reserved = Measurements(capture, InventoryName, "inventory.stock.reserved"); + Check( + reserved.Length == 1 && reserved[0].Value == 2, + $"expected one stock.reserved measurement of 2 (the library's), got [{Values(reserved)}]" + ); + if (reserved.Length == 1) + Check( + Get(reserved[0].Tags, "sku") == OrderWorkflow.Sku, + $"stock.reserved tag sku is '{Get(reserved[0].Tags, "sku")}'" + ); + + // Logs. Only this assembly's category: a host logs plenty of its own. + var logs = capture + .Logs.Where(l => l.Category.StartsWith("PackedTelemetry.", StringComparison.Ordinal)) + .ToArray(); + + var placing = logs.Where(l => l.EventName?.Contains("PlacingOrder", StringComparison.Ordinal) == true) + .ToArray(); + Check( + placing.Length == 1 && placing[0].Level == LogLevel.Information, + $"expected one Information PlacingOrder log entry, got {Describe(placing)}" + ); + if (placing.Length == 1) + { + Check( + placing[0].Message.Contains(orderId, StringComparison.Ordinal), + $"the PlacingOrder message '{placing[0].Message}' does not carry the order id" + ); + Check( + Get(placing[0].State, "OrderId") == orderId, + $"the PlacingOrder state OrderId is '{Get(placing[0].State, "OrderId")}'" + ); + } + + var validatedLogs = logs.Where(l => l.EventName?.Contains("OrderValidated", StringComparison.Ordinal) == true) + .ToArray(); + Check( + validatedLogs.Length == 0, + $"the Debug OrderValidated entry was logged although Debug is disabled (log-level gating): {Describe(validatedLogs)}" + ); + Check(capture.IsEnabledCalls > 0, "the generated logging never asked the logger whether a level was enabled"); + + var shipped = logs.Where(l => l.EventName?.Contains("OrderShipped", StringComparison.Ordinal) == true) + .ToArray(); + Check( + shipped.Length == 1 && shipped[0].Level == LogLevel.Information, + $"expected one Information OrderShipped (v2) log entry, got {Describe(shipped)}" + ); + if (shipped.Length == 1) + { + var state = string.Join(", ", shipped[0].State.Select(p => $"{p.Key}={p.Value}")); + Check( + shipped[0].State.Values.Contains("dhl") && shipped[0].State.Values.Contains("ups"), + $"the v2 OrderShipped state does not expand the carriers: {state}" + ); + } + + var failed = logs.Where(l => l.EventName?.Contains("OrderFailed", StringComparison.Ordinal) == true).ToArray(); + Check( + failed.Length == 1 && failed[0].Level == LogLevel.Error, + $"expected one Error OrderFailed log entry, got {Describe(failed)}" + ); + if (failed.Length == 1) + Check( + failed[0].Exception is InvalidOperationException, + "the OrderFailed log entry does not carry the exception" + ); + + return failures; + } + + /// Everything captured, for the console when an expectation fails. + public static string Dump(TelemetryCapture capture) + { + StringBuilder builder = new(); + foreach (var activity in capture.Activities) + { + builder.AppendLine( + $"activity {activity.Source}/{activity.Name} tags[{Pairs(activity.Tags)}] baggage[{Pairs(activity.Baggage)}]" + ); + foreach (var e in activity.Events) + builder.AppendLine($" event {e.Name} [{Pairs(e.Tags)}]"); + } + + foreach (var m in capture.Measurements) + builder.AppendLine($"metric {m.Meter}/{m.Instrument} ({m.Unit}) = {m.Value} [{Pairs(m.Tags)}]"); + + foreach ( + var log in capture.Logs.Where(l => l.Category.StartsWith("PackedTelemetry.", StringComparison.Ordinal)) + ) + builder.AppendLine($"log {log.Level} {log.Category} {log.EventName}: {log.Message} [{Pairs(log.State)}]"); + + builder.AppendLine($"IsEnabled calls: {capture.IsEnabledCalls}"); + return builder.ToString(); + } + + static CapturedMeasurement[] Measurements(TelemetryCapture capture, string meter, string instrument) => + [.. capture.Measurements.Where(m => m.Meter == meter && m.Instrument == instrument)]; + + static string Values(CapturedMeasurement[] measurements) => string.Join(", ", measurements.Select(m => m.Value)); + + static string Describe(CapturedLog[] logs) => + logs.Length == 0 ? "none" : string.Join("; ", logs.Select(l => $"{l.Level} {l.EventName}: {l.Message}")); + + static string Pairs(IReadOnlyDictionary pairs) => + string.Join(", ", pairs.Select(p => $"{p.Key}={p.Value}")); + + static string? Get(IReadOnlyDictionary values, string key) => + values.TryGetValue(key, out var value) ? value : null; +} diff --git a/scripts/packed-consumers/Shared/IOrderTelemetry.cs b/scripts/packed-consumers/Shared/IOrderTelemetry.cs new file mode 100644 index 00000000..6723c249 --- /dev/null +++ b/scripts/packed-consumers/Shared/IOrderTelemetry.cs @@ -0,0 +1,55 @@ +using System.Diagnostics; +using Purview.Telemetry; + +// .NET 11: every consumer assembly - and so IOrderTelemetry - creates its activity sources through the injected +// System.Diagnostics.ActivitySourceFactory, registered by services.AddTracing(). PackedTelemetry.Library does not opt +// in, so each application also runs a generated static ActivitySource, and the gate checks both shapes (trimmed, +// Native AOT and WebAssembly AOT). +[assembly: ActivitySourceGeneration("packed.orders", UseActivitySourceFactory = true)] + +namespace PackedTelemetry; + +/// +/// One interface, all three targets: compiled into every consumer, so each one runs the packed generator itself. +/// +/// +/// Names are explicit wherever the generator lets them be, so the assertions in follow +/// the attributes rather than a naming convention. The tag and baggage keys are left to the default +/// (OpenTelemetry snake_case) convention on purpose: that transformation is part of what ships. +/// +[ActivitySource("packed.orders")] +[Logger] +[Meter("packed.orders")] +public interface IOrderTelemetry +{ + /// Multi-target: starts an Activity, logs at Information and increments a counter. + [Activity("order.place")] + [Info] + [AutoCounter("orders.placed")] + Activity? PlacingOrder([Tag] string orderId, [Baggage] string customerId); + + /// + /// Multi-target: adds an ActivityEvent and logs at Debug. The consumers enable logging from Information only, + /// so the event must be recorded while the log entry must not be. + /// + [Event("order.validated")] + [Debug] + void OrderValidated(Activity? activity, [Tag] int lineCount); + + [Histogram("order.total", Unit = "GBP")] + void OrderTotal([InstrumentMeasurement] double total, [Tag] string currency); + + [UpDownCounter("orders.in_flight")] + void OrdersInFlight([InstrumentMeasurement] int delta); + + /// Forces the state-based (v2) log generation, with an expanded array. + [Info(GenerationMode = LoggerGenerationMode.V2)] + void OrderShipped(string orderId, [ExpandEnumerable] string[] carriers); + + /// + /// Multi-target: records the exception on the Activity (OpenTelemetry exception rules) and logs it at Error. + /// + [Event("exception")] + [Error] + void OrderFailed(Activity? activity, Exception exception, [Tag] string orderId); +} diff --git a/scripts/packed-consumers/Shared/OrderWorkflow.cs b/scripts/packed-consumers/Shared/OrderWorkflow.cs new file mode 100644 index 00000000..857d710d --- /dev/null +++ b/scripts/packed-consumers/Shared/OrderWorkflow.cs @@ -0,0 +1,33 @@ +using PackedTelemetry.Library; + +namespace PackedTelemetry; + +/// +/// The service every consumer calls: one order placed through the generated telemetry and the library's. +/// +public sealed class OrderWorkflow(IOrderTelemetry telemetry, StockReserver stock) +{ + public const string Sku = "SKU-1"; + + public void Place(string orderId, string customerId, double total) + { + telemetry.OrdersInFlight(1); + using (var activity = telemetry.PlacingOrder(orderId, customerId)) + { + telemetry.OrderValidated(activity, 3); + stock.Reserve(Sku, 2); + telemetry.OrderTotal(total, "GBP"); + telemetry.OrderShipped(orderId, ["dhl", "ups"]); + + try + { + throw new InvalidOperationException("card declined"); + } + catch (InvalidOperationException exception) + { + telemetry.OrderFailed(activity, exception, orderId); + } + } + telemetry.OrdersInFlight(-1); + } +} diff --git a/scripts/packed-consumers/Shared/TelemetryCapture.cs b/scripts/packed-consumers/Shared/TelemetryCapture.cs new file mode 100644 index 00000000..a503a5ab --- /dev/null +++ b/scripts/packed-consumers/Shared/TelemetryCapture.cs @@ -0,0 +1,173 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using System.Globalization; +using Microsoft.Extensions.Logging; + +namespace PackedTelemetry; + +public sealed record CapturedEvent(string Name, IReadOnlyDictionary Tags); + +/// +/// Whether the activity's source was created by an (.NET 11), which records itself +/// as the source's ; a static new ActivitySource(...) has no scope. +/// +public sealed record CapturedActivity( + string Source, + string Name, + IReadOnlyDictionary Tags, + IReadOnlyDictionary Baggage, + IReadOnlyList Events, + bool CreatedByFactory +); + +public sealed record CapturedMeasurement( + string Meter, + string Instrument, + string? Unit, + double Value, + IReadOnlyDictionary Tags +); + +public sealed record CapturedLog( + string Category, + LogLevel Level, + string? EventName, + string Message, + IReadOnlyDictionary State, + Exception? Exception +); + +/// +/// An in-process , and that +/// record everything the generated telemetry emits, so a consumer can assert on it. +/// +/// +/// The logger reports itself enabled from up, but records every Log call it +/// receives whatever the level. Register it with the logging minimum at , so the logging +/// infrastructure filters nothing: then a log entry below can only be absent because the +/// generated code asked IsEnabled and skipped it. +/// +public sealed class TelemetryCapture : ILoggerProvider +{ + readonly ActivityListener _activityListener; + readonly MeterListener _meterListener; + readonly ConcurrentQueue _activities = new(); + readonly ConcurrentQueue _measurements = new(); + readonly ConcurrentQueue _logs = new(); + int _isEnabledCalls; + + public TelemetryCapture(string[] names, LogLevel minimumLevel = LogLevel.Information) + { + MinimumLevel = minimumLevel; + + _activityListener = new() + { + ShouldListenTo = source => Array.IndexOf(names, source.Name) >= 0, + Sample = static (ref _) => ActivitySamplingResult.AllDataAndRecorded, + SampleUsingParentId = static (ref _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => + _activities.Enqueue( + new( + activity.Source.Name, + activity.OperationName, + ToDictionary(activity.TagObjects), + ToDictionary(activity.Baggage), + [.. activity.Events.Select(static e => new CapturedEvent(e.Name, ToDictionary(e.Tags)))], + activity.Source.Scope is ActivitySourceFactory + ) + ), + }; + ActivitySource.AddActivityListener(_activityListener); + + _meterListener = new() + { + InstrumentPublished = (instrument, listener) => + { + if (Array.IndexOf(names, instrument.Meter.Name) >= 0) + listener.EnableMeasurementEvents(instrument); + }, + }; + _meterListener.SetMeasurementEventCallback((i, m, t, _) => Record(i, m, t)); + _meterListener.SetMeasurementEventCallback((i, m, t, _) => Record(i, m, t)); + _meterListener.SetMeasurementEventCallback((i, m, t, _) => Record(i, m, t)); + _meterListener.SetMeasurementEventCallback((i, m, t, _) => Record(i, m, t)); + _meterListener.SetMeasurementEventCallback((i, m, t, _) => Record(i, (double)m, t)); + _meterListener.Start(); + } + + public LogLevel MinimumLevel { get; } + + public IReadOnlyCollection Activities => _activities; + + public IReadOnlyCollection Measurements => _measurements; + + public IReadOnlyCollection Logs => _logs; + + /// + /// How often a PackedTelemetry.* logger - one the generated code created - was asked whether a level was enabled. + /// + public int IsEnabledCalls => Volatile.Read(ref _isEnabledCalls); + + public ILogger CreateLogger(string categoryName) => new CapturingLogger(this, categoryName); + + public void Dispose() + { + _activityListener.Dispose(); + _meterListener.Dispose(); + } + + void Record(Instrument instrument, double value, ReadOnlySpan> tags) + { + Dictionary captured = []; + foreach (var tag in tags) + captured[tag.Key] = Format(tag.Value); + + _measurements.Enqueue(new(instrument.Meter.Name, instrument.Name, instrument.Unit, value, captured)); + } + + static Dictionary ToDictionary(IEnumerable> pairs) + { + Dictionary result = []; + foreach (var pair in pairs) + result[pair.Key] = Format(pair.Value); + + return result; + } + + static string? Format(object? value) => Convert.ToString(value, CultureInfo.InvariantCulture); + + sealed class CapturingLogger(TelemetryCapture owner, string category) : ILogger + { + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) + { + if (category.StartsWith("PackedTelemetry.", StringComparison.Ordinal)) + Interlocked.Increment(ref owner._isEnabledCalls); + + return logLevel >= owner.MinimumLevel; + } + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter + ) + { + Dictionary captured = []; + if (state is IEnumerable> pairs) + { + foreach (var pair in pairs) + captured[pair.Key] = Format(pair.Value); + } + + owner._logs.Enqueue( + new(category, logLevel, eventId.Name, formatter(state, exception), captured, exception) + ); + } + } +} diff --git a/scripts/test-pack-validation-rules.ps1 b/scripts/test-pack-validation-rules.ps1 new file mode 100644 index 00000000..fd161fdf --- /dev/null +++ b/scripts/test-pack-validation-rules.ps1 @@ -0,0 +1,179 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Proves every PackValidation ForbiddenContent rule in .config/purview-build.json actually fails a package. + +.DESCRIPTION + A forbidden-content glob that matches nothing - a typo, a wrong folder, a "*" where "**" was meant - passes + silently forever. This script proves each rule bites, with the real tool (purview-build, the version + .config/dotnet-tools.json pins) and the real rules. Each run validates copies of the packed packages in a scratch + repository that holds the repository's purview-build.json verbatim and an empty solution, so the pipeline's build is + a no-op and only pack validation does any work. RequireExplicitContent is turned off in every run, so the + explicit-content rule cannot be what fails one: only ForbiddenContent can. + + 1. Control: every package, untouched. Must pass, or the harness itself is broken. + 2. Per package: each package on its own, with one file per rule injected - a concrete path the rule's glob should + match (lib/**/Microsoft.CodeAnalysis* becomes lib/probe/Microsoft.CodeAnalysis.dll). Must fail, reporting every + rule. This proves the rules apply to every package id. + 3. Per rule: Purview.Telemetry.SourceGenerator with only that rule's file injected. Must fail, reporting that rule. + This proves each rule is triggered by its own file rather than by another rule's. + + The packages in -PackagesFolder are never modified. + +.PARAMETER PackagesFolder + Folder holding the packed .nupkg (and any .snupkg) files of the current version. Defaults to artifacts/, where + `just pipeline-pack-validate` and `just pack` write them. `just pipeline-local-release` moves them to the local feed, + so pass that folder after it. + +.PARAMETER KeepArtifacts + Leave the scratch repositories in place for inspection. +#> +[CmdletBinding()] +param ( + [string] $PackagesFolder = (Join-Path (Split-Path -Parent $PSScriptRoot) 'artifacts'), + [switch] $KeepArtifacts +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +Add-Type -AssemblyName System.IO.Compression.FileSystem + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$configPath = Join-Path $repositoryRoot '.config/purview-build.json' +$version = (Get-Content (Join-Path $repositoryRoot 'package.json') -Raw | ConvertFrom-Json).version +$config = Get-Content $configPath -Raw | ConvertFrom-Json + +$rules = @($config.PackValidation.ForbiddenContent.PSObject.Properties['*'].Value) +if ($rules.Count -eq 0) { throw "No ForbiddenContent rules for '*' in $configPath." } + +$packages = @(Get-ChildItem $PackagesFolder -Filter "*.$version.nupkg" -File | Sort-Object Name) +if ($packages.Count -eq 0) { + throw ( + "No $version packages in '$PackagesFolder'. Pack first (just pipeline-pack-validate), or pass -PackagesFolder: " + + 'just pipeline-local-release moves its packages to the local feed.' + ) +} + +# A concrete entry each glob must match: "**" spans one made-up folder, "*" becomes an assembly extension. +$cases = foreach ($rule in $rules) { + [pscustomobject]@{ Rule = $rule; Entry = ($rule -replace '\*\*', 'probe' -replace '\*', '.dll') } +} + +$scratch = Join-Path ([System.IO.Path]::GetTempPath()) "tsg-pack-rules-$([guid]::NewGuid().ToString('N').Substring(0, 8))" +$runs = 0 + +# Validates $nupkgs (and their .snupkg) with $entries injected into each, and returns the rules the tool reported. +function Invoke-Validation([string] $name, [System.IO.FileInfo[]] $nupkgs, [string[]] $entries) { + $root = Join-Path $scratch $name + $artifacts = Join-Path $root 'artifacts' + New-Item -ItemType Directory -Path (Join-Path $root '.config'), $artifacts -Force | Out-Null + Copy-Item $configPath (Join-Path $root '.config/purview-build.json') + Copy-Item (Join-Path $repositoryRoot '.config/dotnet-tools.json') (Join-Path $root '.config/dotnet-tools.json') + Copy-Item (Join-Path $repositoryRoot 'global.json'), (Join-Path $repositoryRoot 'package.json') $root + '' | Set-Content (Join-Path $root 'empty.slnx') -Encoding utf8 + & git -C $root init --quiet + + foreach ($nupkg in $nupkgs) { + $copy = Join-Path $artifacts $nupkg.Name + Copy-Item $nupkg.FullName $copy + $symbols = [System.IO.Path]::ChangeExtension($nupkg.FullName, '.snupkg') + if (Test-Path $symbols) { Copy-Item $symbols $artifacts } + + if ($entries) { + $archive = [System.IO.Compression.ZipFile]::Open($copy, [System.IO.Compression.ZipArchiveMode]::Update) + try { + foreach ($entry in $entries) { + $writer = [System.IO.StreamWriter]::new($archive.CreateEntry($entry).Open()) + try { $writer.Write('injected by scripts/test-pack-validation-rules.ps1') } finally { $writer.Dispose() } + } + } + finally { $archive.Dispose() } + } + } + + Push-Location $root + try { + & dotnet tool restore | Out-Null + $output = & dotnet purview-build ` + --Build:Solution=empty.slnx ` + --Build:ArtifactsFolder=artifacts ` + --Build:CleanArtifacts=false ` + --Build:RunPack=false ` + --Build:ValidatePack=true ` + --Build:RunTests=false ` + --Build:RunLint=false ` + --PackValidation:RequireExplicitContent=false ` + --Release:Mode=None 2>&1 | ForEach-Object { "$_" } + $exitCode = $LASTEXITCODE + } + finally { + Pop-Location + } + + $script:runs++ + # The console wraps long lines, so rejoin them before reading "Forbidden content '' must not be in ...". + $text = ($output -join "`n") -replace '\r?\n\s*', ' ' + $reported = @([regex]::Matches($text, "Forbidden content '(?[^']+)'") | ForEach-Object { $_.Groups['rule'].Value } | Sort-Object -Unique) + return [pscustomobject]@{ ExitCode = $exitCode; Reported = $reported; Output = ($output -join "`n") } +} + +$failures = [System.Collections.Generic.List[string]]::new() +$stopwatch = [System.Diagnostics.Stopwatch]::StartNew() +try { + Write-Host "Control: $($packages.Count) untouched package(s)..." -ForegroundColor Cyan + $control = Invoke-Validation 'control' $packages @() + if ($control.ExitCode -ne 0) { + Write-Host $control.Output + throw 'The control run failed on untouched packages, so the harness cannot prove anything. Fix that first.' + } + Write-Host ' passed' + + Write-Host "Per package: each with all $($cases.Count) rule file(s) injected..." -ForegroundColor Cyan + foreach ($package in $packages) { + $result = Invoke-Validation "package-$($package.BaseName)" @($package) @($cases.Entry) + $missed = @($cases.Rule | Where-Object { $result.Reported -notcontains $_ }) + if ($result.ExitCode -eq 0 -or $missed.Count -gt 0) { + $failures.Add("$($package.Name): exit $($result.ExitCode); rules not reported: $($missed -join ', ')") + Write-Host " NOT REJECTED $($package.Name) ($($missed.Count) rule(s) missed)" -ForegroundColor Red + } + else { + Write-Host " rejected $($package.Name) ($($result.Reported.Count) of $($cases.Count) rules)" + } + } + + $core = $packages | Where-Object { $_.Name -ieq "Purview.Telemetry.SourceGenerator.$version.nupkg" } + if (-not $core) { throw "Purview.Telemetry.SourceGenerator.$version.nupkg is not in '$PackagesFolder'." } + + Write-Host "Per rule: $($core.Name) with one rule's file injected..." -ForegroundColor Cyan + $index = 0 + foreach ($case in $cases) { + $index++ + $result = Invoke-Validation "rule-$index" @($core) @($case.Entry) + $others = @($result.Reported | Where-Object { $_ -ne $case.Rule }) + if ($result.ExitCode -eq 0 -or $result.Reported -notcontains $case.Rule) { + $failures.Add("$($case.Rule): injecting '$($case.Entry)' was not rejected by it (exit $($result.ExitCode)).") + Write-Host (" NOT REJECTED {0,-55} {1}" -f $case.Entry, $case.Rule) -ForegroundColor Red + } + else { + $also = $others.Count -gt 0 ? " (also matched: $($others -join ', '))" : '' + Write-Host (" rejected {0,-55} {1}{2}" -f $case.Entry, $case.Rule, $also) + } + } +} +catch { + $failures.Add($_.Exception.Message) +} +finally { + if ($KeepArtifacts) { Write-Host "Scratch repositories kept at $scratch" -ForegroundColor Yellow } + else { Remove-Item $scratch -Recurse -Force -ErrorAction SilentlyContinue } +} + +Write-Host ("{0} validation run(s) in {1:N1}s." -f $runs, $stopwatch.Elapsed.TotalSeconds) +if ($failures.Count -gt 0) { + foreach ($failure in $failures) { + if ($env:GITHUB_ACTIONS) { Write-Host "::error::$failure" } else { Write-Host "ERROR: $failure" -ForegroundColor Red } + } + exit 1 +} + +Write-Host "Every ForbiddenContent rule ($($cases.Count)) rejected its file, in every package ($($packages.Count))." -ForegroundColor Green diff --git a/scripts/test-packed-consumers.ps1 b/scripts/test-packed-consumers.ps1 new file mode 100644 index 00000000..23d99eaa --- /dev/null +++ b/scripts/test-packed-consumers.ps1 @@ -0,0 +1,645 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Proves the Purview.Telemetry.SourceGenerator package works for real consumers - Native AOT, Blazor WebAssembly + (trimmed and AOT-compiled) and a Blazor Server host - built from the .nupkg alone. + +.DESCRIPTION + The in-repository tests drive the generator in-process, and the samples reference the generator project as an + analyzer. Neither is what ships: an IL-merged generator and refactorings assembly under analyzers/, consumed through + a PackageReference. A ProjectReference build also hides the class of bug this gate exists for: + Purview.SourceGeneratorFramework.dll - a compiler-host-only assembly - leaked into the build output of every + project that referenced a generator (SGF 1.2.1 put it in all eight sample bins here), and Blazor WebAssembly AOT + compilation fails on it. + + This gate consumes the package exactly as a user does: + + 1. Preflight: checks Purview.SourceGeneratorFramework (the generator's build-time framework, pinned in + Directory.Packages.props) and Purview.BuildSdk (the MSBuild SDK, pinned in global.json) can be restored, and fails + early, with what to do, if either cannot. + 2. Packs the shipping package (the ids in .config/purview-build.json PackValidation.RequiredContent) to an isolated + feed, with an isolated NUGET_PACKAGES, so no previously extracted package of the same version can be reused. The + feed must hold exactly the expected package, and it may carry no compiler-host assembly outside analyzers/ and no + generator assembly under lib/, ref/ or runtimes/. + 3. Copies the consumers in scripts/packed-consumers/ outside the repository, beside guard + Directory.Build.props/.targets and Directory.Packages.props files that stop any inheritance, with a nuget.config + that resolves Purview.Telemetry.SourceGenerator from the isolated feed only. Every consumer references + PackedTelemetry.Library, a class library with its own generated telemetry, so the generator runs in two + assemblies of each application: + (a) AotConsole - a Native AOT console. Published with -warnaserror (any IL2xxx/IL3xxx fails it), then run: with an + in-process ActivityListener, MeterListener and a capturing logger provider it checks the multi-target method + (an Activity, an Information log entry and a counter from one call), activity tags, baggage and events + (including the OpenTelemetry exception event), every metric measurement and its tags, the v1 and v2 log + entries, log-level gating (a Debug entry must not be written when only Information is enabled), the + TelemetryNames aggregation of both assemblies, and that IOrderTelemetry's source was created by the .NET 11 + ActivitySourceFactory (UseActivitySourceFactory) while the library's stayed static. It exits non-zero on any + mismatch. + (b) BlazorWasm - a Blazor WebAssembly app using the generated telemetry from a page and a service. Published + trimmed with -warnaserror, then again with RunAOTCompilation=true. _framework must hold both assemblies; the + assemblies ILLink wrote must still define every generated telemetry member (the library is IsTrimmable, so it + is genuinely trimmed); and the AOT publish must have compiled every generated method to WebAssembly. + (c) BlazorServer - a Blazor Web App with interactive server rendering. Published, then run with --self-test: the + host starts on an in-memory TestServer, the page is requested and rendered on the server, and the same + telemetry checks as (a) run against what the page emitted. + 4. (d) Scans every consumer bin and publish folder with scripts/assert-no-compiler-host-assemblies.ps1. + + The temporary workspace is deleted afterwards unless -KeepArtifacts is passed. Packing restores the shipping + projects against the isolated cache, so they are restored again against the normal cache at the end. + +.PARAMETER Configuration + Configuration the package is packed in. Defaults to Release: this gate checks what ships. + +.PARAMETER RuntimeIdentifier + RID for the Native AOT console. Native AOT cannot cross-compile between operating systems, so it defaults to this + machine's RID (win-x64, linux-x64, osx-arm64, ...). + +.PARAMETER PrereleaseFeed + A NuGet feed (folder or URL) to restore Purview.SourceGeneratorFramework and Purview.BuildSdk from, in addition to + nuget.org - for versions that are not on nuget.org yet. The feed is used for those packages only. Defaults to + $env:TELEMETRY_PRERELEASE_FEED, then $env:LOCAL_NUGET_FEED_PATH. + +.PARAMETER WasmAot + Auto (default) runs the WebAssembly AOT publish when the wasm-tools workload is installed and says so when it is + skipped. Require fails when the workload is missing (CI installs it). Skip never runs it. + +.PARAMETER PackOutput + Also copy the packed .nupkg/.snupkg files here, so a later step can inspect exactly what this gate consumed - CI + runs scripts/test-pack-validation-rules.ps1 against them. + +.PARAMETER PreflightOnly + Run only the restore checks, so CI can fail on them before installing toolchains. + +.PARAMETER KeepArtifacts + Leave the temporary workspace in place for inspection. + +.EXAMPLE + just smoke-packed-consumers + +.EXAMPLE + ./scripts/test-packed-consumers.ps1 -RuntimeIdentifier linux-x64 -WasmAot Require -KeepArtifacts +#> +[CmdletBinding()] +param ( + [string] $Configuration = 'Release', + [string] $RuntimeIdentifier = [System.Runtime.InteropServices.RuntimeInformation]::RuntimeIdentifier, + [string] $PrereleaseFeed = ($env:TELEMETRY_PRERELEASE_FEED ? $env:TELEMETRY_PRERELEASE_FEED : $env:LOCAL_NUGET_FEED_PATH), + [ValidateSet('Auto', 'Require', 'Skip')] + [string] $WasmAot = 'Auto', + [string] $PackOutput, + [switch] $PreflightOnly, + [switch] $KeepArtifacts +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repositoryRoot = Split-Path -Parent $PSScriptRoot +$leakScan = Join-Path $PSScriptRoot 'assert-no-compiler-host-assemblies.ps1' +$consumerTemplates = Join-Path $PSScriptRoot 'packed-consumers' + +$version = (Get-Content (Join-Path $repositoryRoot 'package.json') -Raw | ConvertFrom-Json).version +$packageVersions = [xml] (Get-Content (Join-Path $repositoryRoot 'Directory.Packages.props') -Raw) +function Get-PackageVersion([string] $id) { + $value = $packageVersions.SelectSingleNode("//PackageVersion[@Include='$id']")?.GetAttribute('Version') + if (-not $value) { throw "Could not read the $id version from Directory.Packages.props." } + return $value +} +function Get-VersionProperty([string] $name) { + $value = $packageVersions.SelectSingleNode("//$name")?.InnerText + if (-not $value) { throw "Could not read the $name property from Directory.Packages.props." } + return $value +} + +$sgfVersion = Get-VersionProperty 'PurviewSGFVersion' +$extensionsTelemetryVersion = Get-VersionProperty 'MSExtensionVersion' +$aspNetCoreVersion = Get-PackageVersion 'Microsoft.AspNetCore.Components.WebAssembly' +$testHostVersion = Get-PackageVersion 'Microsoft.AspNetCore.TestHost' +if ($testHostVersion -ne $aspNetCoreVersion) { + throw "Microsoft.AspNetCore.TestHost ($testHostVersion) and Microsoft.AspNetCore.Components.WebAssembly ($aspNetCoreVersion) must share a version in Directory.Packages.props." +} +# The consumers target net11.0, so their Microsoft.Extensions.* packages are the .NET 11 ones, versioned with ASP.NET +# Core: services.AddTracing() - which registers the ActivitySourceFactory the generated code takes - is new in +# Microsoft.Extensions.Diagnostics 11. +$extensionsVersion = $aspNetCoreVersion + +$globalJson = Get-Content (Join-Path $repositoryRoot 'global.json') -Raw | ConvertFrom-Json +$sdk = $globalJson.sdk +$buildSdkVersion = $globalJson.'msbuild-sdks'.'Purview.BuildSdk' +if (-not $buildSdkVersion) { throw 'Could not read the Purview.BuildSdk pin from global.json msbuild-sdks.' } + +# The shipping packages are exactly the ids pack validation knows about. Each maps to a project under src/src by its +# id without "Purview.Telemetry." - Purview.Telemetry.SourceGenerator to src/src/SourceGenerator. +$packValidation = (Get-Content (Join-Path $repositoryRoot '.config/purview-build.json') -Raw | ConvertFrom-Json).PackValidation +$shipping = foreach ($id in $packValidation.RequiredContent.PSObject.Properties.Name) { + $folder = $id -ireplace '^purview\.telemetry\.', '' + $directory = Get-ChildItem (Join-Path $repositoryRoot 'src/src') -Directory | Where-Object { $_.Name -ieq $folder } + $project = $directory ? (Join-Path $directory.FullName "$($directory.Name).csproj") : $null + if (-not $project -or -not (Test-Path $project)) { + throw "No project for the shipping package '$id' (expected src/src/$folder/$folder.csproj)." + } + + [pscustomobject]@{ Id = $id; Project = $project } +} + +# The refactorings assembly is packed inside the generator package by an MSBuild task rather than a ProjectReference +# build, so it must be restored explicitly (NETSDK1004 otherwise). +$packInputs = @( + $shipping.Project + (Join-Path $repositoryRoot 'src/src/SourceGenerator.Refactorings/SourceGenerator.Refactorings.csproj') +) + +$workspace = Join-Path ([System.IO.Path]::GetTempPath()) "tsg-packed-consumers-$([guid]::NewGuid().ToString('N').Substring(0, 8))" +$feed = Join-Path $workspace 'feed' +$packages = Join-Path $workspace 'packages' +$logs = Join-Path $workspace 'logs' +$consumers = Join-Path $workspace 'consumers' +$output = Join-Path $workspace 'out' +$exe = $IsWindows ? '.exe' : '' + +$timings = [System.Collections.Generic.List[object]]::new() +$scanned = [System.Collections.Generic.List[string]]::new() +$previousNuGetPackages = $env:NUGET_PACKAGES +$repositoryRestoreTouched = $false + +function Write-Failure([string] $message) { + if ($env:GITHUB_ACTIONS) { Write-Host "::error::$message" } else { Write-Host "ERROR: $message" -ForegroundColor Red } +} + +function Invoke-Step([string] $name, [scriptblock] $body) { + if ($env:GITHUB_ACTIONS) { Write-Host "::group::$name" } else { Write-Host "`n=== $name" -ForegroundColor Cyan } + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + $status = 'passed' + try { + & $body + } + catch { + $status = 'FAILED' + throw + } + finally { + $stopwatch.Stop() + $timings.Add([pscustomobject]@{ Step = $name; Result = $status; Seconds = [math]::Round($stopwatch.Elapsed.TotalSeconds, 1) }) + if ($env:GITHUB_ACTIONS) { Write-Host '::endgroup::' } + } +} + +function Invoke-Skipped([string] $name, [string] $reason) { + Write-Host "`n=== $name - SKIPPED: $reason" -ForegroundColor Yellow + if ($env:GITHUB_ACTIONS) { Write-Host "::warning::$name skipped: $reason" } + $timings.Add([pscustomobject]@{ Step = $name; Result = 'skipped'; Seconds = 0 }) +} + +# Runs dotnet, tees its output to a log, and fails on a non-zero exit. -warnaserror already fails the publish on any +# trim or AOT warning; the log check is a second gate in case one is ever logged without failing the command. +function Invoke-Dotnet([string] $logName, [string[]] $arguments) { + $log = Join-Path $logs "$logName.log" + Write-Host "dotnet $($arguments -join ' ')" -ForegroundColor DarkGray + & dotnet @arguments 2>&1 | Tee-Object -FilePath $log | Out-Host + if ($LASTEXITCODE -ne 0) { throw "dotnet $($arguments[0]) failed with exit code $LASTEXITCODE (log: $log)." } + + $warnings = Select-String -Path $log -Pattern 'warning IL[23][0-9]{3}' | Select-Object -First 5 + if ($warnings) { + throw "dotnet $($arguments[0]) reported trim or AOT warnings:`n$($warnings.Line -join "`n")" + } +} + +function Assert-NoLeaks([string[]] $directories) { + $global:LASTEXITCODE = 0 + & $leakScan -Path $directories + if ($LASTEXITCODE -ne 0) { throw "The compiler-host leak scan failed for: $($directories -join ', ')." } + $scanned.AddRange($directories) +} + +function Assert-Contains([string] $directory, [string] $pattern, [string] $what) { + $found = @(Get-ChildItem $directory -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -match $pattern }) + if ($found.Count -eq 0) { throw "$what is missing from '$directory' (no file matches '$pattern')." } + Write-Host " found $what ($($found[0].Name))" +} + +# The generated telemetry, per assembly: the implementation type the generator writes for each interface, and the +# members a consumer reaches through that interface. The constructor and the meter initialisation are reached through +# dependency injection. +$generatedMembers = [ordered]@{ + PackedTelemetryWasm = [ordered]@{ + # IOrderTelemetry opts in to the .NET 11 ActivitySourceFactory, so its source is created per instance by + # InitializeActivitySource; the library's InventoryTelemetryCore keeps the static source. + 'PackedTelemetry.OrderTelemetryCore' = @( + '.ctor', 'InitializeMeters', 'InitializeActivitySource', 'PlacingOrder', 'OrderValidated', 'OrderTotal', + 'OrdersInFlight', 'OrderShipped', 'OrderFailed', '_activitySource', '_logger', '_meter' + ) + } + 'PackedTelemetry.Library' = [ordered]@{ + 'PackedTelemetry.Library.InventoryTelemetryCore' = @('.ctor', 'ReservingStock', 'StockReserved', '_activitySource', '_meter') + } +} + +function Open-Metadata([string] $path) { + $stream = [System.IO.File]::OpenRead($path) + $reader = [System.Reflection.PortableExecutable.PEReader]::new($stream) + return [pscustomobject]@{ + Stream = $stream + Reader = $reader + Metadata = [System.Reflection.Metadata.PEReaderExtensions]::GetMetadataReader($reader) + } +} + +function Close-Metadata($opened) { + $opened.Reader.Dispose() + $opened.Stream.Dispose() +} + +# Type full name -> member names (methods and fields), and method definition token -> "Type::Method". +function Read-Members([string] $path) { + $opened = Open-Metadata $path + try { + $metadata = $opened.Metadata + $types = @{} + $methods = @{} + foreach ($handle in $metadata.TypeDefinitions) { + $type = $metadata.GetTypeDefinition($handle) + $namespace = $metadata.GetString($type.Namespace) + $name = ($namespace ? "$namespace." : '') + $metadata.GetString($type.Name) + $members = [System.Collections.Generic.HashSet[string]]::new() + foreach ($method in $type.GetMethods()) { + $methodName = $metadata.GetString($metadata.GetMethodDefinition($method).Name) + [void] $members.Add($methodName) + $token = 0x06000000 -bor [System.Reflection.Metadata.Ecma335.MetadataTokens]::GetRowNumber( + [System.Reflection.Metadata.MethodDefinitionHandle] $method + ) + $methods[$token] = "${name}::$methodName" + } + foreach ($field in $type.GetFields()) { [void] $members.Add($metadata.GetString($metadata.GetFieldDefinition($field).Name)) } + $types[$name] = $members + } + + return [pscustomobject]@{ Types = $types; Methods = $methods } + } + finally { + Close-Metadata $opened + } +} + +# ILLink writes the assemblies it trimmed to obj///linked. A generated member missing there was +# trimmed away (or never generated), and the app would fail where it calls it. +function Assert-GeneratedMembersSurviveTrimming([string] $projectDirectory) { + $linked = Get-ChildItem (Join-Path $projectDirectory 'obj') -Recurse -Directory -Filter 'linked' | Select-Object -First 1 + if (-not $linked) { throw "ILLink wrote no linked assemblies under '$projectDirectory/obj': the publish did not trim." } + + foreach ($assembly in $generatedMembers.Keys) { + $path = Join-Path $linked.FullName "$assembly.dll" + if (-not (Test-Path $path)) { throw "ILLink did not write $assembly.dll to '$($linked.FullName)'." } + + $shape = Read-Members $path + foreach ($type in $generatedMembers[$assembly].Keys) { + if (-not $shape.Types.ContainsKey($type)) { throw "The trimmed $assembly.dll does not define the generated $type." } + $missing = @($generatedMembers[$assembly][$type] | Where-Object { -not $shape.Types[$type].Contains($_) }) + if ($missing.Count -gt 0) { + throw "Trimming removed generated members of $type from ${assembly}.dll: $($missing -join ', ')." + } + Write-Host " trimmed $assembly.dll keeps $type ($($generatedMembers[$assembly][$type].Count) members)" + } + } +} + +# The Mono AOT compiler records, per assembly, the method tokens it compiled to WebAssembly. Every generated telemetry +# method must be among them: a method that is not was left to the interpreter, or does not exist. +function Assert-GeneratedMethodsAotCompiled([string] $projectDirectory) { + foreach ($assembly in $generatedMembers.Keys) { + $tokensFile = Get-ChildItem (Join-Path $projectDirectory 'obj') -Recurse -File ` + -Filter "$($assembly.Replace('.', '_'))_dll_compiled_methods.txt" | Select-Object -First 1 + if (-not $tokensFile) { throw "The WebAssembly AOT compiler recorded no compiled methods for $assembly.dll." } + + $lines = @(Get-Content $tokensFile.FullName) + $aotInput = $lines[0].Trim() + if (-not (Test-Path $aotInput)) { throw "The AOT compiler's input assembly '$aotInput' does not exist." } + $shape = Read-Members $aotInput + $compiled = [System.Collections.Generic.HashSet[string]]::new() + foreach ($line in $lines | Select-Object -Skip 2) { + if (-not $line.Trim()) { continue } + $token = [System.Convert]::ToInt32($line.Trim(), 16) + if ($shape.Methods.ContainsKey($token)) { [void] $compiled.Add($shape.Methods[$token]) } + } + + foreach ($type in $generatedMembers[$assembly].Keys) { + $methods = @($generatedMembers[$assembly][$type] | Where-Object { -not $_.StartsWith('_') }) + $missing = @($methods | Where-Object { -not $compiled.Contains("${type}::$_") }) + if ($missing.Count -gt 0) { + throw "The WebAssembly AOT compiler did not compile these generated $type methods: $($missing -join ', ')." + } + Write-Host " AOT-compiled every generated $type method ($($methods.Count)) in $assembly.dll" + } + } +} + +function New-NuGetConfig([string] $path, [hashtable[]] $sources) { + $sourceXml = ($sources | ForEach-Object { "`t`t" }) -join "`n" + $mappingXml = ($sources | ForEach-Object { + $patterns = ($_.Patterns | ForEach-Object { "`t`t`t" }) -join "`n" + "`t`t`n$patterns`n`t`t" + }) -join "`n" + + @" + + + + + +$sourceXml + + + + + +$mappingXml + + +"@ | Set-Content $path -Encoding utf8 +} + +$exitCode = 0 +$totalStopwatch = [System.Diagnostics.Stopwatch]::StartNew() + +try { + New-Item -ItemType Directory -Path $feed, $packages, $logs, $consumers, $output -Force | Out-Null + + Write-Host "Packed-consumer gate: Purview.Telemetry.SourceGenerator $version ($Configuration), RID $RuntimeIdentifier" -ForegroundColor Cyan + Write-Host "Workspace: $workspace" + + # Guards at the workspace root: MSBuild stops at the first Directory.Build.props/.targets and + # Directory.Packages.props it finds walking up, so nothing above the workspace - a stray file in the temp folder, + # a user profile - can change how the consumers build. The consumers must see the package and nothing else. + foreach ($guard in 'Directory.Build.props', 'Directory.Build.targets') { + @" + + + +"@ | Set-Content (Join-Path $workspace $guard) -Encoding utf8 + } + + @' + + + + false + + +'@ | Set-Content (Join-Path $workspace 'Directory.Packages.props') -Encoding utf8 + + # The repository's SDK, without its MSBuild SDKs (Purview.BuildSdk): the consumers are plain projects. + [ordered]@{ sdk = [ordered]@{ version = $sdk.version; rollForward = $sdk.rollForward; allowPrerelease = $true } } | + ConvertTo-Json | Set-Content (Join-Path $workspace 'global.json') -Encoding utf8 + + # Pack-time sources: nuget.org for everything, plus the optional feed for the prerelease build dependencies only. + $prereleasePatterns = @('Purview.SourceGeneratorFramework', 'Purview.SourceGeneratorFramework.*', 'Purview.BuildSdk') + $packSources = @(@{ Key = 'nuget.org'; Value = 'https://api.nuget.org/v3/index.json'; Patterns = @('*') + $prereleasePatterns }) + if ($PrereleaseFeed) { + $packSources += @{ Key = 'prerelease'; Value = $PrereleaseFeed; Patterns = $prereleasePatterns } + } + $packConfig = Join-Path $workspace 'pack.nuget.config' + New-NuGetConfig $packConfig $packSources + + # Consumer sources: Purview.Telemetry.SourceGenerator from the package just packed and nowhere else - not nuget.org, + # not a local feed holding an older build of the same version. + $consumerConfig = Join-Path $consumers 'nuget.config' + New-NuGetConfig $consumerConfig @( + @{ Key = 'packed'; Value = $feed; Patterns = @('Purview.Telemetry.SourceGenerator') } + @{ Key = 'nuget.org'; Value = 'https://api.nuget.org/v3/index.json'; Patterns = @('*') } + ) + + # An isolated extraction directory: NuGet reuses an already-extracted package of the same id and version, so a + # stale copy from an earlier run - or an earlier SourceGeneratorFramework build - would be used instead. + $env:NUGET_PACKAGES = $packages + + Invoke-Step "Preflight: restore Purview.SourceGeneratorFramework $sgfVersion and Purview.BuildSdk $buildSdkVersion" { + $probe = Join-Path $workspace 'probe' + New-Item -ItemType Directory -Path $probe -Force | Out-Null + @" + + + net10.0 + + + + + + +"@ | Set-Content (Join-Path $probe 'probe.csproj') -Encoding utf8 + + & dotnet restore (Join-Path $probe 'probe.csproj') "-p:RestoreConfigFile=$packConfig" 2>&1 | + Tee-Object -FilePath (Join-Path $logs 'probe.log') | Out-Host + if ($LASTEXITCODE -ne 0) { + $from = ($packSources | ForEach-Object Value) -join ', ' + throw ( + "Purview.SourceGeneratorFramework $sgfVersion (Directory.Packages.props) or Purview.BuildSdk $buildSdkVersion " + + "(global.json) could not be restored from $from. The generator is built with both, so nothing can be " + + 'packed. If a version is not on nuget.org yet, publish it there, or point this gate at a feed that has ' + + 'it: -PrereleaseFeed , or set TELEMETRY_PRERELEASE_FEED (LOCAL_NUGET_FEED_PATH is used ' + + 'when set). Purview.BuildSdk is resolved by MSBuild through the normal NuGet configuration, so that feed ' + + 'must also be a configured source for the pack itself.' + ) + } + } + + if ($PreflightOnly) { + Write-Host 'The prerelease build dependencies restore; stopping after the preflight (-PreflightOnly).' -ForegroundColor Green + return + } + + if ($IsLinux -and -not (Get-Command clang -ErrorAction SilentlyContinue)) { + throw 'Native AOT on Linux needs clang and the zlib headers: sudo apt-get install -y clang zlib1g-dev.' + } + + Invoke-Step 'Pack the shipping package to the isolated feed' { + $script:repositoryRestoreTouched = $true + foreach ($project in $packInputs) { + Invoke-Dotnet "restore-$([System.IO.Path]::GetFileNameWithoutExtension($project))" @( + 'restore', $project, "-p:RestoreConfigFile=$packConfig", '-nr:false' + ) + } + + foreach ($package in $shipping) { + Invoke-Dotnet "pack-$($package.Id)" @( + 'pack', $package.Project, '--configuration', $Configuration, '--output', $feed, '--no-restore', + '-m:2', '-nr:false' + ) + } + + $expected = @($shipping.Id | ForEach-Object { "$_.$version.nupkg".ToLowerInvariant() } | Sort-Object) + $actual = @(Get-ChildItem $feed -Filter '*.nupkg' | ForEach-Object { $_.Name.ToLowerInvariant() } | Sort-Object) + $difference = Compare-Object $expected $actual + if ($difference) { + throw "The feed does not hold exactly the shipping packages:`n$($difference | Out-String)" + } + + # The bytes the consumers restore: a compiler-host assembly anywhere but analyzers/, or a generator assembly + # under lib/, ref/ or runtimes/, would reach every application that references the package. + Add-Type -AssemblyName System.IO.Compression.FileSystem + $hostOnly = '(^|/)(Purview\.SourceGeneratorFramework|Microsoft\.CodeAnalysis)[^/]*$' + $generatorOnly = '^(lib|ref|runtimes)/.*Purview\.Telemetry\.SourceGenerator[^/]*$' + foreach ($nupkg in Get-ChildItem $feed -Filter '*.nupkg') { + $archive = [System.IO.Compression.ZipFile]::OpenRead($nupkg.FullName) + try { + $entries = @($archive.Entries.FullName) + } + finally { $archive.Dispose() } + + $bad = @($entries | Where-Object { ($_ -match $hostOnly -and $_ -notmatch '^analyzers/') -or $_ -match $generatorOnly }) + if ($bad.Count -gt 0) { throw "$($nupkg.Name) ships compiler-host content outside analyzers/: $($bad -join ', ')." } + $looseFramework = @($entries | Where-Object { $_ -match '^analyzers/.*Purview\.SourceGeneratorFramework[^/]*$' }) + if ($looseFramework.Count -gt 0) { + throw "$($nupkg.Name) ships a loose framework assembly under analyzers/ (the IL merge did not run): $($looseFramework -join ', ')." + } + Write-Host " $($nupkg.Name): no compiler-host content outside analyzers/, and the generator is IL-merged" + } + + if ($PackOutput) { + New-Item -ItemType Directory -Path $PackOutput -Force | Out-Null + Copy-Item (Join-Path $feed '*') $PackOutput -Force + Write-Host " copied the package to $PackOutput" + } + } + + Copy-Item (Join-Path $consumerTemplates '*') $consumers -Recurse -Force + $consumerProperties = @( + "-p:TelemetryPackageVersion=$version" + "-p:MicrosoftExtensionsVersion=$extensionsVersion" + "-p:MicrosoftExtensionsTelemetryVersion=$extensionsTelemetryVersion" + "-p:AspNetCorePackageVersion=$aspNetCoreVersion" + "-p:RestoreConfigFile=$consumerConfig" + '-nr:false' + ) + $libraryBin = Join-Path $consumers 'Library/bin' + + Invoke-Step '(a) Native AOT console: publish and run' { + $project = Join-Path $consumers 'AotConsole/AotConsole.csproj' + $publish = Join-Path $output 'aot-console' + + # A framework-dependent build first: its bin holds the deps.json and runtimeconfig.json that list every runtime + # dependency, which is where an analyzer-only assembly that leaks shows up first. + Invoke-Dotnet 'aot-console-build' (@('build', $project, '--configuration', 'Release', '-warnaserror', '-m:2') + $consumerProperties) + Assert-Contains (Join-Path $consumers 'AotConsole/bin/Release/net11.0') '^PackedTelemetryAot\.deps\.json$' 'the build deps.json' + + Invoke-Dotnet 'aot-console-publish' (@( + 'publish', $project, '--configuration', 'Release', '--runtime', $RuntimeIdentifier, '--output', $publish, + '-warnaserror' + ) + $consumerProperties) + + & (Join-Path $publish "PackedTelemetryAot$exe") | Out-Host + if ($LASTEXITCODE -ne 0) { throw "The Native AOT console exited with ${LASTEXITCODE}: the generated telemetry is wrong." } + + Assert-NoLeaks @((Join-Path $consumers 'AotConsole/bin'), $libraryBin, $publish) + } + + function Assert-WasmOutput([string] $publish) { + $framework = Join-Path $publish 'wwwroot/_framework' + Assert-Contains $framework '^PackedTelemetryWasm\.[a-z0-9]+\.wasm$' 'the app assembly' + Assert-Contains $framework '^PackedTelemetry\.Library\.[a-z0-9]+\.wasm$' 'the library assembly' + } + + Invoke-Step '(b) Blazor WebAssembly: trimmed publish' { + $projectDirectory = Join-Path $consumers 'BlazorWasm' + $publish = Join-Path $output 'wasm-trimmed' + Invoke-Dotnet 'wasm-trimmed-publish' (@( + 'publish', (Join-Path $projectDirectory 'BlazorWasm.csproj'), '--configuration', 'Release', '--output', $publish, + '-warnaserror' + ) + $consumerProperties) + + Assert-WasmOutput $publish + Assert-GeneratedMembersSurviveTrimming $projectDirectory + Assert-NoLeaks @((Join-Path $projectDirectory 'bin'), $libraryBin, $publish) + } + + $wasmTools = $false + if ($WasmAot -ne 'Skip') { + Push-Location $workspace + try { $wasmTools = [bool] (& dotnet workload list 2>$null | Select-String -Pattern '^\s*wasm-tools\s') } + finally { Pop-Location } + } + + if ($WasmAot -eq 'Skip') { + Invoke-Skipped '(b) Blazor WebAssembly: RunAOTCompilation publish' 'disabled with -WasmAot Skip.' + } + elseif (-not $wasmTools -and $WasmAot -eq 'Auto') { + Invoke-Skipped '(b) Blazor WebAssembly: RunAOTCompilation publish' ( + 'the wasm-tools workload is not installed, so the WebAssembly AOT compile was NOT checked. ' + + 'Install it with `dotnet workload install wasm-tools` and rerun, or pass -WasmAot Require to make this an error.' + ) + } + else { + Invoke-Step '(b) Blazor WebAssembly: RunAOTCompilation publish' { + if (-not $wasmTools) { + throw 'The wasm-tools workload is not installed (-WasmAot Require): run `dotnet workload install wasm-tools`.' + } + + # A copy of the project, so the AOT build starts from clean obj/bin rather than the trimmed build's. + $projectDirectory = Join-Path $consumers 'BlazorWasmAot' + Copy-Item (Join-Path $consumerTemplates 'BlazorWasm') $projectDirectory -Recurse -Force + $publish = Join-Path $output 'wasm-aot' + Invoke-Dotnet 'wasm-aot-publish' (@( + 'publish', (Join-Path $projectDirectory 'BlazorWasm.csproj'), '--configuration', 'Release', + '--output', $publish, '-p:RunAOTCompilation=true', '-warnaserror' + ) + $consumerProperties) + + Assert-WasmOutput $publish + Assert-Contains (Join-Path $publish 'wwwroot/_framework') '^dotnet\.native\.[a-z0-9]+\.wasm$' 'the AOT-compiled runtime' + Assert-GeneratedMembersSurviveTrimming $projectDirectory + Assert-GeneratedMethodsAotCompiled $projectDirectory + Assert-NoLeaks @((Join-Path $projectDirectory 'bin'), $libraryBin, $publish) + } + } + + Invoke-Step '(c) Blazor Server: publish, render the page on a TestServer and check its telemetry' { + $project = Join-Path $consumers 'BlazorServer/BlazorServer.csproj' + $publish = Join-Path $output 'blazor-server' + Invoke-Dotnet 'blazor-server-publish' (@( + 'publish', $project, '--configuration', 'Release', '--output', $publish, '-warnaserror' + ) + $consumerProperties) + + Push-Location $publish + try { + & dotnet (Join-Path $publish 'PackedTelemetryServer.dll') --self-test | Out-Host + if ($LASTEXITCODE -ne 0) { throw "The Blazor Server self-test exited with ${LASTEXITCODE}: the page or its telemetry is wrong." } + } + finally { + Pop-Location + } + + Assert-NoLeaks @((Join-Path $consumers 'BlazorServer/bin'), $libraryBin, $publish) + } +} +catch { + $exitCode = 1 + Write-Failure "Packed-consumer gate failed: $($_.Exception.Message)" + if (Test-Path $logs) { Write-Host "Logs: $logs" } +} +finally { + if ($null -eq $previousNuGetPackages) { Remove-Item Env:\NUGET_PACKAGES -ErrorAction SilentlyContinue } + else { $env:NUGET_PACKAGES = $previousNuGetPackages } + + # Packing restored the shipping projects against the isolated cache that is about to be deleted; restore them + # against the normal cache, so a later --no-restore build does not point at a missing folder. + if ($repositoryRestoreTouched) { + Write-Host "`nRestoring the shipping projects against the normal package cache..." -ForegroundColor Cyan + foreach ($project in $packInputs) { + & dotnet restore $project -nr:false | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Failure "Restoring $project against the normal cache failed; run dotnet restore." } + } + } + + $totalStopwatch.Stop() + Write-Host "`nPacked-consumer gate timings:" -ForegroundColor Cyan + $timings | Format-Table -AutoSize | Out-Host + Write-Host ("Total: {0:N1}s" -f $totalStopwatch.Elapsed.TotalSeconds) + if ($scanned.Count -gt 0) { Write-Host "Leak-scanned $($scanned.Count) folder(s)." } + + if ($KeepArtifacts) { + Write-Host "Artifacts kept at $workspace" -ForegroundColor Yellow + } + else { + Remove-Item $workspace -Recurse -Force -ErrorAction SilentlyContinue + } +} + +if ($exitCode -ne 0) { exit $exitCode } + +Write-Host 'Packed-consumer gate passed.' -ForegroundColor Green diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 00000000..3bd69121 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,125 @@ +{ + "version": 1, + "skills": { + "purview-architecture": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-architecture/SKILL.md", + "computedHash": "d194bd0f30091ffab6ef35c29582e56511d2daca32fdbb1942c50134198f9270" + }, + "purview-architecture-testing": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-architecture-testing/SKILL.md", + "computedHash": "5907c6bf28788b755b752041e2f0cd88222604a949988d487c25940b6284fce4" + }, + "purview-buildsdk-adoption": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-adoption/SKILL.md", + "computedHash": "ca2e13fc8074281f8ca2b510b742ae99db28f97d78f5fb36a6f27936a91941fc" + }, + "purview-buildsdk-configuration": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-configuration/SKILL.md", + "computedHash": "88b4a655efdc058b9a10adb1b5cbcd44e89bff6ed8c858e066092e5be0ca3e0b" + }, + "purview-buildsdk-project-detection": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-project-detection/SKILL.md", + "computedHash": "7c30062fe592105b7a2539384843759521c5a3f90ace8f7c60f0e6ae2405b0b1" + }, + "purview-buildsdk-project-layout": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-project-layout/SKILL.md", + "computedHash": "101f09c92cf97e93387b926c48adab9598a0cf0a64a4932d41085ab0a0078cfa" + }, + "purview-buildsdk-repository-review": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-repository-review/SKILL.md", + "computedHash": "d366821995ff410caa436ccb996eaa6429f1ba51ba985b210d74ec3963f6c06f" + }, + "purview-buildsdk-skill-installation": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-skill-installation/SKILL.md", + "computedHash": "b61cce9f0f213469c3acab4dd5ebaec450ee2be730680b166c9718c901aedffe" + }, + "purview-buildsdk-skill-tests": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-skill-tests/SKILL.md", + "computedHash": "807b64466de0a871e5f8e678e7686c48a06d8bb9d42a03448f604a35d40d3935" + }, + "purview-buildsdk-testing": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-testing/SKILL.md", + "computedHash": "d6a2ac1167faf68e8c27493de81c2e5b0d8183e523de4411aca5f5b9249c8a9c" + }, + "purview-engineering-principles": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-engineering-principles/SKILL.md", + "computedHash": "fd38d374b816ecf77964c41486e7c734ea34998c3f2a5538c90a2ba793905994" + }, + "purview-persistence": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-persistence/SKILL.md", + "computedHash": "0523ecc210bf3571e2fccb859128a499fd80694e9d4089b377a8d39d73c028f4" + }, + "purview-sourcegenerator-codewriter": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-codewriter/SKILL.md", + "computedHash": "f24ae680340784e62236d044cbc3658d5ca7bdc3cea3fc44f2bb5cf75a84f5e4" + }, + "purview-sourcegenerator-diagnostics": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-diagnostics/SKILL.md", + "computedHash": "c3c7920701e0e688f34ddece9e448472e010e69767e8c08914638128d34a8f17" + }, + "purview-sourcegenerator-packaging": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-packaging/SKILL.md", + "computedHash": "e0e6372e92aefc8df026a73010d9e7436b0a8c2378a96fdebd8b4de9e025d4a0" + }, + "purview-sourcegenerator-pipeline": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-pipeline/SKILL.md", + "computedHash": "7d48394d7e3890f288949c79f040646207f34af9ae85827a63d056bae1b1b985" + }, + "purview-sourcegenerator-testing": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-testing/SKILL.md", + "computedHash": "d88bf2353073013ff9ddf9d3fe1a2e9415eb60d319bbd053baa105f50fa3f889" + }, + "purview-sourcegenerator-tunit-tests": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-tunit-tests/SKILL.md", + "computedHash": "14809d3727643e9a779d590b6627b3283af7b36c835e902cb80162552cb45b4f" + }, + "purview-telemetry": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-telemetry/SKILL.md", + "computedHash": "08ba26e1379dfa2bb826ddfd011e95ac84cd583b865f0b89673d7302b2c2c27d" + }, + "purview-testing-conventions": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-testing-conventions/SKILL.md", + "computedHash": "388b0a79b565ed45a8513d77e8ed6e3d4491f96b43328cb09933cec7811c10f6" + } + } +} diff --git a/skills/purview-telemetry-design/SKILL.md b/skills/purview-telemetry-design/SKILL.md index c8df5f81..0b93e5f2 100644 --- a/skills/purview-telemetry-design/SKILL.md +++ b/skills/purview-telemetry-design/SKILL.md @@ -58,7 +58,9 @@ Default to the OpenTelemetry naming convention (`NamingConvention.OpenTelemetry` - ActivitySource names: preserve assembly casing (e.g., `MyApp`). - Activity names: descriptive, sentence-case-ish, no spaces: `placing-order`. - Log message templates: human-readable, with `{PascalCase}` placeholders. -- Metric instrument names: hierarchical, dot-separated, lowercase: `myapp.orders.placed`. +- Metric instrument names: dot-separated, snake_case words. The generator prefixes them with the interface name, not + the meter name: `IOrderTelemetry.OrdersPlaced` becomes `order.orders_placed`. Opt in to a meter-name prefix with + `MeterNameGenerationType.OpenTelemetry`. - Tag/baggage keys: `snake_case`. Only use `NamingConvention.Legacy` for exact v3 backward compatibility. diff --git a/skills/purview-telemetry-diagnostics/SKILL.md b/skills/purview-telemetry-diagnostics/SKILL.md index fec24d27..b16d8735 100644 --- a/skills/purview-telemetry-diagnostics/SKILL.md +++ b/skills/purview-telemetry-diagnostics/SKILL.md @@ -25,8 +25,9 @@ diagnostics in four families: | `TSG4xxx` | metrics | Every rule is tracked in `AnalyzerReleases.Shipped.md` / `AnalyzerReleases.Unshipped.md` with its ID, -category and severity, validated by the Roslyn release-tracking analyzers on every build. A new or -changed diagnostic must be recorded there in the same change. +category and severity, validated by the Roslyn release-tracking analyzers on every build. A new +rule, or a changed category or severity, must be recorded there in the same change. The files do not +carry the message, so a reworded message is recorded in the CHANGELOG instead. ## General — `TSG1xxx` @@ -42,7 +43,7 @@ changed diagnostic must be recorded there in the same change. | `TSG1007` | Warning | `ExcludeTargets` leaves a target with an empty or invalid parameter set. | | `TSG1008` | Warning | An `Activity`-typed parameter on a method with no `[Activity]`/`[Event]`/`[Context]`. | | `TSG1010` | Error | A method carries an attribute for a target family the interface does not register. | -| `TSG1011` | Error | The compilation targets neither .NET 8+ nor .NET Framework 4.8+. Define `PURVIEW_TELEMETRY_NON_NULLABLE` to opt out. | +| `TSG1011` | Error | The compilation targets neither .NET 8+ nor .NET Framework 4.8+, so nothing is generated. There is no opt-out: target `net8.0`+ or `net48`+, and reference the package only from those targets of a multi-targeted library. | ## Logging — `TSG2xxx` @@ -83,6 +84,7 @@ changed diagnostic must be recorded there in the same change. | `TSG3017` | Error | Status-description parameters on an Activity or Context method — Events only. | | `TSG3021` | Info | An `[Event]` records an exception under the OpenTelemetry rules but is not named `"exception"`. | | `TSG3022` | Warning | An Activity method returns non-nullable `Activity`. | +| `TSG3023` | Warning | `UseActivitySourceFactory` is set but `System.Diagnostics.ActivitySourceFactory` (.NET 11, or DiagnosticSource 11+) cannot be resolved; a static `ActivitySource` is generated instead. Target `net11.0` or remove the option. | ## Metrics — `TSG4xxx` @@ -156,6 +158,21 @@ measured the cost. `[AutoCounter]` — equivalently `[Counter(AutoIncrement = true)]` — must declare no measurement parameter. The measurement is fixed at 1. +### `TSG1011` — unsupported target framework + +Raised for a compilation that targets neither `net8.0`+ nor `net48`+ (typically `netstandard2.0`). +It cannot be suppressed by a define; no telemetry is generated for that target. A library that has +to keep such a target references the generator only from its supported ones: + +```xml + +``` + +The telemetry interfaces then need the same condition (or an `#if`), because the attributes are +generated only where the generator runs. + ### `TSG1003` — duplicate method names The method name becomes a member on the generated implementation class, so two methods cannot share diff --git a/skills/purview-telemetry-quickstart/SKILL.md b/skills/purview-telemetry-quickstart/SKILL.md index 1743ffe3..f0957a1d 100644 --- a/skills/purview-telemetry-quickstart/SKILL.md +++ b/skills/purview-telemetry-quickstart/SKILL.md @@ -14,21 +14,30 @@ This skill helps you bootstrap telemetry generation in a C# project using `Purvi ## Installation -Add the analyzer package to your project: +Add the analyzer package to your project. `dotnet add package` writes the latest version; never copy a version +number from documentation, it goes stale: + +```bash +dotnet add package Purview.Telemetry.SourceGenerator +``` + +Then make the reference analyzer-only (keep the `Version` that `dotnet add package` wrote, or set it in +`Directory.Packages.props` under Central Package Management): ```xml - + all analyzers ``` -Also add runtime dependencies for the telemetry types you use: - -- `System.Diagnostics.DiagnosticSource` for Activities -- `Microsoft.Extensions.Logging.Abstractions` for ILogger -- `System.Diagnostics.Metrics` for metrics (built-in on .NET 6+) +Consumers can target .NET Framework 4.8 or .NET 8 and later, .NET 11 included, and the generated code works under +Native AOT and in trimmed or AOT-compiled Blazor WebAssembly. Runtime dependencies for the telemetry types you use: +- Activities: `System.Diagnostics.DiagnosticSource` (in-box on .NET). +- Logging: `Microsoft.Extensions.Logging.Abstractions`. It is in the shared framework on .NET 11, so do not reference + it there (NuGet reports `NU1510`). Add `Microsoft.Extensions.Telemetry.Abstractions` for the faster v2 logging. +- Metrics: `System.Diagnostics.Metrics` (in-box on .NET 8+). Register `services.AddMetrics()` for the `IMeterFactory`. ## Define a telemetry interface Create a `public interface` and decorate it with class-level attributes. The generator creates the implementation and a DI registration extension. @@ -105,6 +114,16 @@ The generator creates an extension method named `Add{InterfaceNameWithoutI}()`: services.AddOrderServiceTelemetry(); ``` +On .NET 11 you can opt in to creating activity sources from the injected `ActivitySourceFactory`. This changes the +generated constructor, so also register the factory: + +```csharp +[assembly: ActivitySourceGeneration("MyApp.Orders", UseActivitySourceFactory = true)] + +// in the host +services.AddTracing(); +``` + Inject the interface into your service: ```csharp @@ -136,5 +155,5 @@ Libraries need no configuration: they publish their names through the attribute, ## Next steps -- To migrate existing `ILogger`, `ActivitySource`, or metrics code, use `telemetry-sourcegenerator-migration`. -- For design guidance and best practices, use `telemetry-sourcegenerator-design`. +- To migrate existing `ILogger`, `ActivitySource`, or metrics code, use the `purview-telemetry-migration` skill. +- For design guidance and best practices, use the `purview-telemetry-design` skill. diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 0e4df7fb..7da3ffa0 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -14,6 +14,18 @@ causing duplicate-symbol errors during host builds. --> false + + error + + universal diff --git a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md index 7b845f48..e79915cb 100644 --- a/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGenerator/AnalyzerReleases.Unshipped.md @@ -5,3 +5,4 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------- +TSG3023 | Activity.Usage | Warning | [ActivitySourceFactory is not available](https://purview.dev/docs/telemetry-sourcegenerator/diagnostics/#tsg3023) diff --git a/src/src/SourceGenerator/Analyzers/TelemetryDiagnosticAnalyzer.cs b/src/src/SourceGenerator/Analyzers/TelemetryDiagnosticAnalyzer.cs index cb01bece..3ebb69e1 100644 --- a/src/src/SourceGenerator/Analyzers/TelemetryDiagnosticAnalyzer.cs +++ b/src/src/SourceGenerator/Analyzers/TelemetryDiagnosticAnalyzer.cs @@ -76,7 +76,12 @@ static void AnalyzeNamedType(SymbolAnalysisContext context) var result = PipelineHelpers.BuildActivityTarget(interfaceSymbol, compilation, token); if (result.HasValue && result.Value is { } activityTarget) { - var diagnostics = TelemetryRules.GetActivityDiagnostics(activityTarget, interfaceSymbol, token); + var diagnostics = TelemetryRules.GetActivityDiagnostics( + activityTarget, + interfaceSymbol, + compilation, + token + ); foreach (var diagnostic in diagnostics) context.ReportDiagnostic(diagnostic.ToDiagnostic()); } diff --git a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Fields.cs b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Fields.cs index 456037a1..8b96e252 100644 --- a/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Fields.cs +++ b/src/src/SourceGenerator/Emitters/ActivitySourceTargetClassEmitter.Fields.cs @@ -19,6 +19,12 @@ static void EmitFields(ActivityOutputContext output, CodeWriter writer, SourcePr activitySourceName = PropertyLibrary.Activities.DefaultActivitySourceName; } + if (output.Context.Capabilities.UseActivitySourceFactory) + { + EmitFactoryCreatedActivitySource(output, writer, activitySourceName!); + return; + } + writer.Field( new( PropertyLibrary.Activities.ActivitySourceFieldName, @@ -32,4 +38,80 @@ static void EmitFields(ActivityOutputContext output, CodeWriter writer, SourcePr } ); } + + /// + /// .NET 11: the source is created per instance by the injected ActivitySourceFactory, which owns it, so + /// the generated class never disposes it. The constructor calls the initialisation method before any generated + /// method can run. For an Activities-only interface this emitter writes that constructor; otherwise the target + /// that owns the constructor (Logging, then Metrics) adds the factory parameter and the call. + /// + static void EmitFactoryCreatedActivitySource( + ActivityOutputContext output, + CodeWriter writer, + string activitySourceName + ) + { + writer.Field( + new( + PropertyLibrary.Activities.ActivitySourceFieldName, + TypeLibrary.System.Diagnostics.ActivitySource.AsTypeReference() + ) + { + Initializer = "default!", + } + ); + + if (output.Target.GenerationType == GenerationType.Activities) + { + writer.NewLine(); + using ( + writer.ConstructorScope( + new ConstructorDeclarationOptions( + output.Target.ClassNameToGenerate, + TypeDeclarationAccessibility.Public + ) + { + Parameters = [ConstructorEmitter.ActivitySourceFactoryParameter()], + } + ) + ) + { + ConstructorEmitter.EmitActivitySourceInitializationCall(writer); + } + } + + var factoryParameter = PropertyLibrary.Activities.ActivitySourceFactoryParameterName; + + writer.NewLine(); + using ( + writer.MethodScope( + new MethodDeclarationOptions( + PropertyLibrary.Activities.ActivitySourceInitializationMethod, + TypeLibrary.System.Void.AsTypeReference(), + TypeDeclarationAccessibility.Private + ) + { + Parameters = [ConstructorEmitter.ActivitySourceFactoryParameter()], + } + ) + ) + { + writer.IfBlock( + factoryParameter + " == null", + body => body.Throw($"new global::System.ArgumentNullException(nameof({factoryParameter}))") + ); + + writer.NewLine(); + + writer + .Write(PropertyLibrary.Activities.ActivitySourceFieldName) + .Write(" = ") + .Write(factoryParameter) + .Write(".Create(new ") + .Write(TypeLibrary.System.Diagnostics.ActivitySourceOptions) + .Write("(") + .Write(activitySourceName.Wrap()) + .Line("));"); + } + } } diff --git a/src/src/SourceGenerator/Emitters/ConstructorEmitter.cs b/src/src/SourceGenerator/Emitters/ConstructorEmitter.cs index c1b1a993..52b86fba 100644 --- a/src/src/SourceGenerator/Emitters/ConstructorEmitter.cs +++ b/src/src/SourceGenerator/Emitters/ConstructorEmitter.cs @@ -63,9 +63,37 @@ GenerationContext generationContext ); } + if (NeedsActivitySourceFactory(generationType, generationContext)) + builder.Add(ActivitySourceFactoryParameter()); + return builder.ToImmutable(); } + /// + /// Whether the constructor receives an ActivitySourceFactory: the interface targets Activities and the + /// assembly opted in to (and can resolve) the .NET 11 factory. + /// + public static bool NeedsActivitySourceFactory( + GenerationType generationType, + GenerationContext generationContext + ) => generationType.HasFlag(GenerationType.Activities) && generationContext.Capabilities.UseActivitySourceFactory; + + /// The ActivitySourceFactory activitySourceFactory constructor parameter. + public static ParameterDeclarationOptions ActivitySourceFactoryParameter() => + new( + PropertyLibrary.Activities.ActivitySourceFactoryParameterName, + TypeLibrary.System.Diagnostics.ActivitySourceFactory + ); + + /// Writes the call that creates the activity source from the injected factory. + public static void EmitActivitySourceInitializationCall(CodeWriter writer) => + writer + .Write(PropertyLibrary.Activities.ActivitySourceInitializationMethod) + .Write('(') + .Write(PropertyLibrary.Activities.ActivitySourceFactoryParameterName) + .Write(");") + .NewLine(); + static void EmitBody( GenerationType generationType, CodeWriter writer, @@ -91,5 +119,8 @@ GenerationContext generationContext writer.Write(");").NewLine(); } + + if (NeedsActivitySourceFactory(generationType, generationContext)) + EmitActivitySourceInitializationCall(writer); } } diff --git a/src/src/SourceGenerator/Emitters/GeneratedTypesEmitter.cs b/src/src/SourceGenerator/Emitters/GeneratedTypesEmitter.cs index 8fa0b538..58aa3399 100644 --- a/src/src/SourceGenerator/Emitters/GeneratedTypesEmitter.cs +++ b/src/src/SourceGenerator/Emitters/GeneratedTypesEmitter.cs @@ -194,17 +194,14 @@ public static void EmitAll(IncrementalGeneratorPostInitializationContext context /// /// Writes the header for marker-attribute files. Nullable annotations are only enabled on modern /// targets — the injected templates declare plain string members for - /// NET48_OR_GREATER/PURVIEW_TELEMETRY_NON_NULLABLE consumers — and CS8625 is - /// suppressed because optional string parameters intentionally default to . + /// NET48_OR_GREATER consumers — and CS8625 is suppressed because optional string + /// parameters intentionally default to . /// static void WriteMarkerFileHeader(CodeWriter writer) { writer.AutoGeneratedHeader(nullableDirective: NullableDirectiveMode.Disable); writer - .HashDefines( - "!NET48_OR_GREATER && !PURVIEW_TELEMETRY_NON_NULLABLE", - hashWriter => hashWriter.Line("#nullable enable") - ) + .HashDefines("!NET48_OR_GREATER", hashWriter => hashWriter.Line("#nullable enable")) .PragmaDisable("CS8625"); } @@ -383,8 +380,8 @@ static void WritePublicProperty( } /// - /// Writes a public nullable-capable string property inside the NET48_OR_GREATER/ - /// PURVIEW_TELEMETRY_NON_NULLABLE preprocessor guard used by the marker attributes. + /// Writes a public nullable-capable string property inside the NET48_OR_GREATER + /// preprocessor guard used by the marker attributes. /// static void WriteNullableStringProperty(CodeWriter writer, string name, string summary) { @@ -394,7 +391,7 @@ static void WriteNullableStringProperty(CodeWriter writer, string name, string s #endif writer.HashDefines( - "NET48_OR_GREATER || PURVIEW_TELEMETRY_NON_NULLABLE", + "NET48_OR_GREATER", hashWriter => hashWriter .XmlSummary(summary) @@ -775,6 +772,16 @@ static void WriteActivitySourceGenerationAttribute(CodeWriter writer, TypeIdenti "Determines whether diagnostics are generated for missing activity definitions.", "true" ); + WritePublicProperty( + body, + "UseActivitySourceFactory", + TypeLibrary.System.Boolean, + "Determines whether each generated activity source is created by the dependency-injected " + + "System.Diagnostics.ActivitySourceFactory (.NET 11, or System.Diagnostics.DiagnosticSource 11 " + + "and later) instead of as a static ActivitySource. The factory must be registered, for example " + + "with services.AddTracing(). Ignored, and reported as TSG3023, when the factory type is unavailable.", + "false" + ); }, "Specifies the default activity source generation behaviour for an assembly." ); diff --git a/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.InitializationMethod.cs b/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.InitializationMethod.cs index 88bd3433..eb105b5e 100644 --- a/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.InitializationMethod.cs +++ b/src/src/SourceGenerator/Emitters/MeterTargetClassEmitter.InitializationMethod.cs @@ -1,3 +1,4 @@ +using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Purview.Telemetry.SourceGenerator.Helpers; using Purview.Telemetry.SourceGenerator.Records; @@ -57,29 +58,41 @@ static void EmitInlineConstructor(MeterOutputContext output, CodeWriter writer, { var target = output.Target; var supportsIMeterFactory = output.Context.Capabilities.SupportsIMeterFactory; + var needsActivitySourceFactory = ConstructorEmitter.NeedsActivitySourceFactory( + target.GenerationType, + output.Context + ); context.CancellationToken.ThrowIfCancellationRequested(); writer.NewLine(); + var parameters = ImmutableArray.CreateBuilder(); + if (supportsIMeterFactory) + parameters.Add( + new ParameterDeclarationOptions( + PropertyLibrary.Metrics.MeterFactoryParameterName, + TypeLibrary.System.Diagnostics.Metrics.IMeterFactory.AsTypeReference() + ) + ); + + // Activities + Metrics: Metrics owns the constructor, so it also receives the .NET 11 ActivitySourceFactory. + if (needsActivitySourceFactory) + parameters.Add(ConstructorEmitter.ActivitySourceFactoryParameter()); + using ( writer.ConstructorScope( new ConstructorDeclarationOptions(target.ClassNameToGenerate, TypeDeclarationAccessibility.Public) { - Parameters = supportsIMeterFactory - ? - [ - new ParameterDeclarationOptions( - PropertyLibrary.Metrics.MeterFactoryParameterName, - TypeLibrary.System.Diagnostics.Metrics.IMeterFactory.AsTypeReference() - ), - ] - : [], + Parameters = parameters.ToImmutable(), } ) ) { EmitInitializationBodyContent(output, writer); + + if (needsActivitySourceFactory) + ConstructorEmitter.EmitActivitySourceInitializationCall(writer); } } diff --git a/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.cs b/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.cs index 421c4b89..67177c12 100644 --- a/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.cs +++ b/src/src/SourceGenerator/Generators/TelemetrySourceGenerator.cs @@ -95,7 +95,8 @@ CancellationToken token //SupportsNullableAnnotations: parseOptions is null //|| parseOptions.LanguageVersion >= LanguageVersion.CSharp8, SupportsIMeterFactory: parseOptions is null - || !parseOptions.PreprocessorSymbolNames.Contains("NET48_OR_GREATER") + || !parseOptions.PreprocessorSymbolNames.Contains("NET48_OR_GREATER"), + UseActivitySourceFactory: SharedHelpers.UseActivitySourceFactory(compilation, token) ); } } diff --git a/src/src/SourceGenerator/Helpers/DiagnosticLibrary.Activities.cs b/src/src/SourceGenerator/Helpers/DiagnosticLibrary.Activities.cs index bb35475f..ace7ca3e 100644 --- a/src/src/SourceGenerator/Helpers/DiagnosticLibrary.Activities.cs +++ b/src/src/SourceGenerator/Helpers/DiagnosticLibrary.Activities.cs @@ -236,6 +236,18 @@ public static class Activities isBlocking: false ); + public static readonly ReportableDiagnostic ActivitySourceFactoryNotAvailable = ReportableDiagnostic.Create( + new DiagnosticDescriptor( + id: "TSG3023", + title: "ActivitySourceFactory is not available", + messageFormat: $"UseActivitySourceFactory is set, but System.Diagnostics.ActivitySourceFactory cannot be resolved. It needs .NET 11, or System.Diagnostics.DiagnosticSource 11 or later. A static {TypeLibrary.System.Diagnostics.ActivitySource.Name} is generated instead.", + defaultSeverity: DiagnosticSeverity.Warning, + category: Categories.Activity.Usage, + isEnabledByDefault: true + ), + isBlocking: false + ); + // The trailing '{1}' placeholder carries an optional hint supplied by the rule (for example when // the 'Name' was applied to a logging attribute); rules pass string.Empty when there is no // additional context. diff --git a/src/src/SourceGenerator/Helpers/DiagnosticLibrary.General.cs b/src/src/SourceGenerator/Helpers/DiagnosticLibrary.General.cs index f596644d..147daf8c 100644 --- a/src/src/SourceGenerator/Helpers/DiagnosticLibrary.General.cs +++ b/src/src/SourceGenerator/Helpers/DiagnosticLibrary.General.cs @@ -142,8 +142,9 @@ public static class General id: "TSG1011", title: "Unsupported target framework", messageFormat: "Purview Telemetry Source Generator requires .NET 8.0 or higher, or .NET Framework 4.8 or higher. " - + "The current target framework is not supported. " - + "If this is incorrect, define PURVIEW_TELEMETRY_NON_NULLABLE to suppress this error and opt out of nullable reference type annotations.", + + "The current target framework is not supported, so no telemetry is generated for it. " + + "Target net8.0 or later, or net48 or later; a library that also targets another framework " + + "(such as netstandard2.0) must reference the generator only from its supported targets.", defaultSeverity: DiagnosticSeverity.Error, category: Categories.Usage, isEnabledByDefault: true diff --git a/src/src/SourceGenerator/Helpers/InstrumentMethodModelBuilder.cs b/src/src/SourceGenerator/Helpers/InstrumentMethodModelBuilder.cs index 88eec8fc..52549fea 100644 --- a/src/src/SourceGenerator/Helpers/InstrumentMethodModelBuilder.cs +++ b/src/src/SourceGenerator/Helpers/InstrumentMethodModelBuilder.cs @@ -64,7 +64,7 @@ CancellationToken token if (string.IsNullOrWhiteSpace(instrumentName)) instrumentName = method.Name; - (instrumentName, prefix) = ResolveInstrumentName( + (instrumentName, prefix, var meterPrefix) = ResolveInstrumentName( instrumentName!, prefix, lowercaseInstrumentName, @@ -91,7 +91,7 @@ CancellationToken token FieldName: fieldName, InstrumentMeasurementType: instrumentMeasurementType, IsObservable: instrumentAttribute?.IsObservable == true, - MetricName: prefix + instrumentName!, + MetricName: meterPrefix + prefix + instrumentName!, InstrumentAttribute: instrumentAttribute, Parameters: parameters, Tags: tagParameters, @@ -113,7 +113,16 @@ CancellationToken token return [.. methodTargets]; } - static (string InstrumentName, string? Prefix) ResolveInstrumentName( + /// + /// Resolves the casing of an instrument name and its prefixes. The full name is + /// MeterPrefix + Prefix + InstrumentName. + /// + /// + /// MeterPrefix is returned separately rather than folded into either of the other two: Prefix is + /// shared by every instrument on the interface (so it must not accumulate), and the meter name is the outermost + /// namespace, so it belongs ahead of the interface prefix rather than between it and the instrument name. + /// + static (string InstrumentName, string? Prefix, string MeterPrefix) ResolveInstrumentName( string instrumentName, string? prefix, bool lowercaseInstrumentName, @@ -123,13 +132,13 @@ string meterName ) { if (!lowercaseInstrumentName) - return (instrumentName, prefix); + return (instrumentName, prefix, string.Empty); if (isLegacy) { // Legacy: Just lowercase without word-boundary splitting #pragma warning disable CA1308 // Intentional lowercase for legacy compatibility - return (instrumentName.ToLowerInvariant(), prefix?.ToLowerInvariant()); + return (instrumentName.ToLowerInvariant(), prefix?.ToLowerInvariant(), string.Empty); #pragma warning restore CA1308 } @@ -140,16 +149,14 @@ string meterName if (!string.IsNullOrEmpty(prefix)) prefix = Utilities.ConvertToSeparatedLowercase(prefix!, '_'); - // For OpenTelemetry convention only: Prepend meter name as namespace with dot separator - // e.g., meter "myapp.products" + instrument "pricing_page_requests" -> "myapp_products.pricing_page_requests" + // MeterNameGenerationType.OpenTelemetry only (the default is DotNet): prepend the meter name as the + // outermost namespace, e.g. meter "MyApp.Products" + interface IProductTelemetry + method RecordCount + // -> "my_app.products.product.record_count". var meterNameGenType = meterGenerationAttribute?.MeterNameGenerationType ?? 1; // Default to DotNet - if (meterNameGenType == 0 && !string.IsNullOrWhiteSpace(meterName)) // OpenTelemetry only - { - var meterPrefix = Utilities.ConvertToSeparatedLowercase(meterName, '_'); - instrumentName = $"{meterPrefix}.{instrumentName}"; - } + if (meterNameGenType == 0 && !string.IsNullOrWhiteSpace(meterName)) + return (instrumentName, prefix, Utilities.ConvertToSeparatedLowercase(meterName, '_') + "."); - return (instrumentName, prefix); + return (instrumentName, prefix, string.Empty); } static ImmutableArray GetInstrumentParameters( diff --git a/src/src/SourceGenerator/Helpers/PipelineHelpers.cs b/src/src/SourceGenerator/Helpers/PipelineHelpers.cs index 25da9266..72e6c742 100644 --- a/src/src/SourceGenerator/Helpers/PipelineHelpers.cs +++ b/src/src/SourceGenerator/Helpers/PipelineHelpers.cs @@ -1,4 +1,4 @@ -using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis; using Purview.Telemetry.SourceGenerator.Records; namespace Purview.Telemetry.SourceGenerator.Helpers; @@ -41,12 +41,25 @@ internal static string GenerateParameterName(string name, string? prefix, bool l internal static bool GetSkipOnNullOrEmptyValue(TagOrBaggageAttributeRecord? tagOrBaggageAttribute) => tagOrBaggageAttribute?.SkipOnNullOrEmpty == true; + /// + /// The interface's instance methods: the members a generated class implements. + /// + /// + /// Static members are not part of the contract a class implements, so they are skipped. An interface could + /// already declare them on .NET Core 3.0+ (static helpers) and .NET 7+ (static abstract/static + /// virtual); C# 15 lets a .NET Framework or netstandard2.0 interface declare non-virtual static members too, + /// so a static helper on a telemetry interface is now possible everywhere the generator runs. + /// internal static IEnumerable GetAllInterfaceMethods( INamedTypeSymbol interfaceSymbol, CancellationToken token ) { token.ThrowIfCancellationRequested(); - return interfaceSymbol.GetMembers().OfType(); + return GetInstanceMethods(interfaceSymbol); } + + /// + internal static IEnumerable GetInstanceMethods(INamedTypeSymbol interfaceSymbol) => + interfaceSymbol.GetMembers().OfType().Where(static method => !method.IsStatic); } diff --git a/src/src/SourceGenerator/Helpers/PropertyLibrary.cs b/src/src/SourceGenerator/Helpers/PropertyLibrary.cs index c50b8308..6d5318bb 100644 --- a/src/src/SourceGenerator/Helpers/PropertyLibrary.cs +++ b/src/src/SourceGenerator/Helpers/PropertyLibrary.cs @@ -53,6 +53,8 @@ public static class Activities public const string DefaultActivitySourceName = "purview"; public const int DefaultActivityKind = 0; public const string ActivitySourceFieldName = "_activitySource"; + public const string ActivitySourceInitializationMethod = "InitializeActivitySource"; + public const string ActivitySourceFactoryParameterName = "activitySourceFactory"; public const string ActivityVariableName = "activity"; public const string ParentIdParameterName = "parentId"; public const string StartTimeParameterName = "startTime"; diff --git a/src/src/SourceGenerator/Helpers/SharedHelpers.Activities.cs b/src/src/SourceGenerator/Helpers/SharedHelpers.Activities.cs index 65fad1e2..b82e84b1 100644 --- a/src/src/SourceGenerator/Helpers/SharedHelpers.Activities.cs +++ b/src/src/SourceGenerator/Helpers/SharedHelpers.Activities.cs @@ -10,6 +10,30 @@ partial class SharedHelpers CancellationToken token ) => GetActivitySourceGenerationAttribute(compilation.Assembly, token); + /// + /// Whether the compilation can resolve System.Diagnostics.ActivitySourceFactory (.NET 11, or + /// System.Diagnostics.DiagnosticSource 11+ on any target). + /// + public static bool IsActivitySourceFactoryAvailable(Compilation compilation) => + compilation.GetTypeByMetadataName(TypeLibrary.System.Diagnostics.ActivitySourceFactory.MetadataFullName) + is not null + && compilation.GetTypeByMetadataName(TypeLibrary.System.Diagnostics.ActivitySourceOptions.MetadataFullName) + is not null; + + /// + /// Whether the assembly opted in with [ActivitySourceGeneration(UseActivitySourceFactory = true)]. + /// + public static bool IsActivitySourceFactoryRequested(Compilation compilation, CancellationToken token) => + GetActivitySourceGenerationAttribute(compilation, token)?.UseActivitySourceFactory == true; + + /// + /// Whether generated activity sources are created by an injected ActivitySourceFactory: requested and + /// available. A request the compilation cannot honour falls back to a static ActivitySource (and the analyzer + /// reports TSG3023). + /// + public static bool UseActivitySourceFactory(Compilation compilation, CancellationToken token) => + IsActivitySourceFactoryRequested(compilation, token) && IsActivitySourceFactoryAvailable(compilation); + public static ActivitySourceAttributeData? GetActivitySourceAttribute(ISymbol symbol, CancellationToken token) { if ( diff --git a/src/src/SourceGenerator/Helpers/TypeLibraryGenerator.Activities.cs b/src/src/SourceGenerator/Helpers/TypeLibraryGenerator.Activities.cs index 91138ceb..17021dcc 100644 --- a/src/src/SourceGenerator/Helpers/TypeLibraryGenerator.Activities.cs +++ b/src/src/SourceGenerator/Helpers/TypeLibraryGenerator.Activities.cs @@ -11,6 +11,17 @@ partial class TypeLibraryGenerator [TypeRef(SystemDiagnosticsNamespace)] static readonly TypeIdentity ActivitySource = default; + /// + /// .NET 11 (System.Diagnostics.DiagnosticSource 11): the dependency-injected creator of activity sources, + /// the tracing counterpart of IMeterFactory. Emitted only when the compilation can resolve it. + /// + [TypeRef(SystemDiagnosticsNamespace)] + static readonly TypeIdentity ActivitySourceFactory = default; + + /// .NET 10 (System.Diagnostics.DiagnosticSource 10): the options an activity source is created from. + [TypeRef(SystemDiagnosticsNamespace)] + static readonly TypeIdentity ActivitySourceOptions = default; + [TypeRef(SystemDiagnosticsNamespace)] static readonly TypeIdentity ActivityEvent = default; diff --git a/src/src/SourceGenerator/Records/AttributeDataModels.cs b/src/src/SourceGenerator/Records/AttributeDataModels.cs index e8483c66..d8242081 100644 --- a/src/src/SourceGenerator/Records/AttributeDataModels.cs +++ b/src/src/SourceGenerator/Records/AttributeDataModels.cs @@ -27,7 +27,8 @@ readonly partial record struct ActivitySourceGenerationAttributeData( [Argument("lowercaseBaggageAndTagKeys")] [Property(DefaultValue = true)] bool LowercaseBaggageAndTagKeys, [Argument("generateDiagnosticsForMissingActivity")] [Property(DefaultValue = true)] - bool GenerateDiagnosticsForMissingActivity + bool GenerateDiagnosticsForMissingActivity, + [Property(DefaultValue = false)] bool UseActivitySourceFactory ); [Generate("Purview.Telemetry.ActivityAttribute")] diff --git a/src/src/SourceGenerator/Records/GenericRecords.cs b/src/src/SourceGenerator/Records/GenericRecords.cs index 9b8c9ca2..cfa49976 100644 --- a/src/src/SourceGenerator/Records/GenericRecords.cs +++ b/src/src/SourceGenerator/Records/GenericRecords.cs @@ -24,7 +24,18 @@ readonly record struct TargetGeneration( bool RaiseMissingInterfaceSource = false ); -public readonly record struct MessageTemplateHole( +/// +/// A hole ({Name} or {0}) parsed from a log message template. +/// +/// +/// Internal, like every other pipeline model here. It was the generator's only public non-component type, and its +/// positional properties have init accessors whose required modifier references +/// System.Runtime.CompilerServices.IsExternalInit - a type Purview.SourceGeneratorFramework defines publicly and +/// the merge internalizes - so a public declaration put a framework type on the shipped analyzer's public surface +/// (PSGFR41). Nothing outside this assembly uses it: the template tests parse into their own +/// TestMessageTemplateHole. +/// +readonly record struct MessageTemplateHole( string? Name, int? Ordinal, int? Alignment, @@ -46,11 +57,9 @@ public readonly void Validate() } /// - /// Projects the regex matches into the hole records. Internal because the returned - /// EquatableArray is a framework type the merge internalizes in the shipped analyzer (seen by - /// PSGFR41); the BuildSdk grants the test assemblies access to internals. + /// Projects the regex matches into the hole records. /// - internal static EquatableArray FromMatches(MatchCollection matches) + public static EquatableArray FromMatches(MatchCollection matches) { List? holes = null; if (matches != null) diff --git a/src/src/SourceGenerator/Records/TelemetryCapabilities.cs b/src/src/SourceGenerator/Records/TelemetryCapabilities.cs index 3b537260..a46d5c86 100644 --- a/src/src/SourceGenerator/Records/TelemetryCapabilities.cs +++ b/src/src/SourceGenerator/Records/TelemetryCapabilities.cs @@ -8,7 +8,19 @@ namespace Purview.Telemetry.SourceGenerator.Records; /// internalizes in the shipped analyzer (seen by PSGFR41); the BuildSdk grants the test /// assemblies access to internals. /// +/// +/// Whether meters are created through an injected IMeterFactory (.NET 8+; never .NET Framework). +/// +/// +/// Whether activity sources are created through an injected System.Diagnostics.ActivitySourceFactory: the +/// assembly opted in with [ActivitySourceGeneration(UseActivitySourceFactory = true)] and the +/// compilation can resolve the type (.NET 11, or System.Diagnostics.DiagnosticSource 11+). Gated on the symbol rather +/// than the target framework because the package brings the type to net462 and netstandard2.0 consumers too. It lives +/// here, rather than on the activity target, because the constructor that receives the factory is emitted by +/// whichever target owns the constructor. +/// sealed record TelemetryCapabilities( //bool SupportsNullableAnnotations, - bool SupportsIMeterFactory + bool SupportsIMeterFactory, + bool UseActivitySourceFactory = false ) : IGenerationCapabilities; diff --git a/src/src/SourceGenerator/Records/TelemetryRules.Activities.cs b/src/src/SourceGenerator/Records/TelemetryRules.Activities.cs index 9ac29c5b..37ad6025 100644 --- a/src/src/SourceGenerator/Records/TelemetryRules.Activities.cs +++ b/src/src/SourceGenerator/Records/TelemetryRules.Activities.cs @@ -14,11 +14,26 @@ static partial class TelemetryRules public static ImmutableArray GetActivityDiagnostics( ActivitySourceTarget target, INamedTypeSymbol interfaceSymbol, + Compilation compilation, CancellationToken token ) { var diagnostics = ImmutableArray.CreateBuilder(); + // TSG3023: the factory was requested but the compilation cannot resolve it, so the generator falls + // back to a static ActivitySource. + if ( + target.ActivitySourceGenerationAttribute?.UseActivitySourceFactory == true + && !SharedHelpers.IsActivitySourceFactoryAvailable(compilation) + ) + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.Activities.ActivitySourceFactoryNotAvailable.Descriptor, + isBlocking: false, + interfaceSymbol + ) + ); + // TSG3001: no activity source specified. if (string.IsNullOrWhiteSpace(target.ActivitySourceName)) diagnostics.Add( diff --git a/src/src/SourceGenerator/Records/TelemetryRules.Logging.cs b/src/src/SourceGenerator/Records/TelemetryRules.Logging.cs index a0115bcf..58ff3330 100644 --- a/src/src/SourceGenerator/Records/TelemetryRules.Logging.cs +++ b/src/src/SourceGenerator/Records/TelemetryRules.Logging.cs @@ -29,9 +29,8 @@ CancellationToken token var interfaceGenerationMode = loggerAttribute?.GenerationModeOrNull ?? loggerGenerationAttribute?.GenerationModeOrNull ?? 0; - var methods = interfaceSymbol - .GetMembers() - .OfType() + var methods = PipelineHelpers + .GetInstanceMethods(interfaceSymbol) .Where(m => !TypeHelpers.HasAttribute(m, TypeLibrary.Purview.Telemetry.ExcludeAttribute) && m.Arity == 0); foreach (var method in methods) diff --git a/src/src/SourceGenerator/Records/TelemetryRules.cs b/src/src/SourceGenerator/Records/TelemetryRules.cs index 9b3fc045..8fe31e97 100644 --- a/src/src/SourceGenerator/Records/TelemetryRules.cs +++ b/src/src/SourceGenerator/Records/TelemetryRules.cs @@ -57,6 +57,7 @@ public static ImmutableArray GetAllSupportedDescriptors() DiagnosticLibrary.Activities.StatusDescriptionParameterInvalidType.Descriptor, DiagnosticLibrary.Activities.ExceptionEventNotStandardName.Descriptor, DiagnosticLibrary.Activities.ActivityReturnTypeShouldBeNullable.Descriptor, + DiagnosticLibrary.Activities.ActivitySourceFactoryNotAvailable.Descriptor, DiagnosticLibrary.Metrics.NoInstrumentDefined.Descriptor, DiagnosticLibrary.Metrics.DoesNotReturnVoid.Descriptor, DiagnosticLibrary.Metrics.AutoIncrementCountAndMeasurementParam.Descriptor, @@ -175,11 +176,8 @@ CancellationToken token // Gather methods grouped by name (for TSG1003 and per-method checks). Dictionary> methodsByName = new(StringComparer.Ordinal); - foreach (var member in interfaceSymbol.GetMembers()) + foreach (var method in PipelineHelpers.GetInstanceMethods(interfaceSymbol)) { - if (member is not IMethodSymbol method) - continue; - if (!methodsByName.TryGetValue(method.Name, out var list)) { list = []; @@ -416,11 +414,8 @@ CancellationToken token } Dictionary> methodsByName = new(StringComparer.Ordinal); - foreach (var member in interfaceSymbol.GetMembers()) + foreach (var method in PipelineHelpers.GetInstanceMethods(interfaceSymbol)) { - if (member is not IMethodSymbol method) - continue; - if (!methodsByName.TryGetValue(method.Name, out var list)) { list = []; diff --git a/src/src/SourceGenerator/SourceGenerator.csproj b/src/src/SourceGenerator/SourceGenerator.csproj index 46f17ef6..0634e1ab 100644 --- a/src/src/SourceGenerator/SourceGenerator.csproj +++ b/src/src/SourceGenerator/SourceGenerator.csproj @@ -14,8 +14,10 @@ analyzer exposes them through TelemetryRules.GetAllSupportedDescriptors(), which the release-tracking analyzer cannot follow, so every shipped rule is reported as unsupported. The rules are still tracked as shipped in AnalyzerReleases.Shipped.md. + RS2002 is the same check for AnalyzerReleases.Unshipped.md, so it is disabled for the same reason: + an unshipped rule (TSG3023) is reported as unsupported until it moves to the shipped file. --> - $(NoWarn);IDE0005;EnableGenerateDocumentationFile;RS2003; + $(NoWarn);IDE0005;EnableGenerateDocumentationFile;RS2002;RS2003; Purview Telemetry Source Generator .NET Source Generator for interface-based telemetry generating tracing, logs, and metrics. logs;log;logger;logging;source-generator;high-performance-logging;otel;open-telemetry;telemetry;traces;tracing;metric;metrics;meter;meters;instrumentation;instruments;events;distributed-traces;distributed-tracing;melt;dotnet;aspnet; diff --git a/src/tests/Repository.UnitTests/InstalledSkillTests.cs b/src/tests/Repository.UnitTests/InstalledSkillTests.cs new file mode 100644 index 00000000..8c67fad6 --- /dev/null +++ b/src/tests/Repository.UnitTests/InstalledSkillTests.cs @@ -0,0 +1,169 @@ +using System.Text.Json; + +namespace Purview.Telemetry.Repository; + +/// +/// Guards the skills installed from other repositories. They are installed by the skills CLI into the +/// universal .agents/skills/ folder only, and that folder and the CLI's skills-lock.json +/// are committed, so the two must agree and no per-agent copy or instruction file may appear beside them. +/// +/// +/// SkillTests validates this repository's own skills in skills/; these are someone else's, +/// so their content is not checked here, only that the installed set is the recorded set. +/// +public sealed class InstalledSkillTests +{ + static readonly Lazy RepositoryRoot = new(FindRepositoryRoot); + + [Test] + public async Task Lockfile_GivenEveryEntry_HasItsSkillInstalledUnderAgents(CancellationToken cancellationToken) + { + // Arrange + var recorded = await ReadLockfileSkillsAsync(cancellationToken); + + // Act + var missing = recorded + .Where(static name => !File.Exists(Path.Combine(AgentsSkillsFolder(), name, "SKILL.md"))) + .ToArray(); + + // Assert + await Assert.That(recorded).IsNotEmpty().Because("skills-lock.json records no installed skills"); + await Assert + .That(missing) + .IsEmpty() + .Because( + "skills-lock.json records skills that are not installed in .agents/skills/. Restore them with " + + $"`npx -y skills@1 experimental_install`: {string.Join(", ", missing)}" + ); + } + + [Test] + public async Task AgentsSkills_GivenEveryInstalledSkill_IsRecordedInTheLockfile(CancellationToken cancellationToken) + { + // Arrange + var recorded = await ReadLockfileSkillsAsync(cancellationToken); + + // Act + var unrecorded = Directory + .GetDirectories(AgentsSkillsFolder()) + .Select(static path => Path.GetFileName(path)) + .Where(name => !recorded.Contains(name, StringComparer.Ordinal)) + .ToArray(); + + // Assert + await Assert + .That(unrecorded) + .IsEmpty() + .Because( + "every folder in .agents/skills/ must be written by the skills CLI, which records it in " + + $"skills-lock.json: {string.Join(", ", unrecorded)}" + ); + } + + [Test] + public async Task ClaudeFolder_GivenTheInstalledSkills_HoldsNoCopy(CancellationToken cancellationToken) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + var claudeSkills = Path.Combine(RepositoryRoot.Value, ".claude", "skills"); + + // Act + var exists = Directory.Exists(claudeSkills); + + // Assert + await Assert + .That(exists) + .IsFalse() + .Because( + "skills are installed into .agents/skills/ only. A .claude/skills/ folder means the CLI ran without " + + "`-a universal`; delete it and reinstall with `-a universal`" + ); + } + + [Test] + public async Task AgentsSkills_GivenTheAuthoredSkills_HoldsNoCopyOfThem(CancellationToken cancellationToken) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + var authored = Directory + .GetDirectories(Path.Combine(RepositoryRoot.Value, "skills")) + .Select(static path => Path.GetFileName(path)) + .ToArray(); + + // Act + var copied = authored + .Where(static name => Directory.Exists(Path.Combine(AgentsSkillsFolder(), name))) + .ToArray(); + + // Assert + await Assert + .That(copied) + .IsEmpty() + .Because( + "this repository's own skills live in skills/ only; a copy in .agents/skills/ would drift from " + + $"the authored one: {string.Join(", ", copied)}" + ); + } + + [Test] + public async Task Repository_GivenAgentInstructions_HasNoClaudeMd(CancellationToken cancellationToken) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + string[] candidates = + [ + Path.Combine(RepositoryRoot.Value, "CLAUDE.md"), + Path.Combine(RepositoryRoot.Value, ".github", "CLAUDE.md"), + ]; + + // Act + var present = candidates.Where(File.Exists).ToArray(); + + // Assert + await Assert + .That(present) + .IsEmpty() + .Because( + "this repository is not agent-specific: AGENTS.md is its one instruction file, so a CLAUDE.md " + + $"must not be added: {string.Join(", ", present)}" + ); + } + + static string AgentsSkillsFolder() => Path.Combine(RepositoryRoot.Value, ".agents", "skills"); + + static async Task ReadLockfileSkillsAsync(CancellationToken cancellationToken) + { + await using var stream = File.OpenRead(Path.Combine(RepositoryRoot.Value, "skills-lock.json")); + using var lockfile = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + + return + [ + .. lockfile + .RootElement.GetProperty("skills") + .EnumerateObject() + .Select(static property => property.Name) + .Order(StringComparer.Ordinal), + ]; + } + + static string FindRepositoryRoot() + { + for ( + var directory = new DirectoryInfo(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent + ) + { + if ( + File.Exists(Path.Combine(directory.FullName, "AGENTS.md")) + && File.Exists(Path.Combine(directory.FullName, "skills-lock.json")) + && Directory.Exists(Path.Combine(directory.FullName, "src", "src")) + ) + return directory.FullName; + } + + throw new InvalidOperationException( + $"No repository root above '{AppContext.BaseDirectory}' carries AGENTS.md, skills-lock.json and src/src." + ); + } +} diff --git a/src/tests/Repository.UnitTests/PackagedDocumentationTests.cs b/src/tests/Repository.UnitTests/PackagedDocumentationTests.cs new file mode 100644 index 00000000..e06cd03c --- /dev/null +++ b/src/tests/Repository.UnitTests/PackagedDocumentationTests.cs @@ -0,0 +1,150 @@ +using System.Text.RegularExpressions; + +namespace Purview.Telemetry.Repository; + +/// +/// Guards the documentation that ships inside the packages: every Sdk/*.md file under src/src, which +/// Purview.BuildSdk packs at the package root (the generator's Sdk/README.md is a symbolic link to the +/// repository README.md). +/// +/// +/// A packaged document is frozen into every version of the package it ships in, and nothing in the pipeline rewrites +/// it: just update-version is a manual step. 5.0.6 shipped a README telling consumers to install 5.0.5. So the +/// packaged documents carry no package version at all - dotnet add package and the NuGet badge supply it - and +/// these tests fail when one creeps back in. +/// +public sealed partial class PackagedDocumentationTests +{ + const string PackageId = "Purview.Telemetry.SourceGenerator"; + + static readonly Lazy RepositoryRoot = new(FindRepositoryRoot); + + public static IEnumerable> PackagedDocuments() => + Directory + .GetFiles(Path.Combine(FindRepositoryRoot(), "src", "src"), "*.md", SearchOption.AllDirectories) + .Where(static path => Path.GetFileName(Path.GetDirectoryName(path)) == "Sdk") + .Select(path => Path.GetRelativePath(FindRepositoryRoot(), path).Replace('\\', '/')) + .Order(StringComparer.Ordinal) + .Select(static path => (Func)(() => path)); + + [Test] + public async Task PackagedDocuments_GivenTheRepository_IncludeTheGeneratorReadme( + CancellationToken cancellationToken + ) + { + // Arrange + const string readme = "src/src/SourceGenerator/Sdk/README.md"; + + // Act + var documents = PackagedDocuments().Select(static document => document()).ToArray(); + var packaged = await File.ReadAllTextAsync(Path.Combine(RepositoryRoot.Value, readme), cancellationToken); + var repository = await File.ReadAllTextAsync( + Path.Combine(RepositoryRoot.Value, "README.md"), + cancellationToken + ); + + // Assert + await Assert.That(documents).Contains(readme).Because("the discovery found no packaged README to check"); + await Assert + .That(packaged) + .IsEqualTo(repository) + .Because( + $"{readme} must resolve to the repository README.md. A checkout without symbolic link support " + + "(git config core.symlinks false) leaves the link's target path in the file, and that is what " + + "would be packed as the package README" + ); + } + + [Test] + [MethodDataSource(nameof(PackagedDocuments))] + public async Task PackagedDocument_GivenItsText_CarriesNoHardCodedPackageVersion( + string document, + CancellationToken cancellationToken + ) + { + // Arrange + var lines = await File.ReadAllLinesAsync(Path.Combine(RepositoryRoot.Value, document), cancellationToken); + + // Act + var pinned = lines + .Select(static (line, index) => (Line: line, Number: index + 1)) + .Where(static entry => PinsAVersion(entry.Line)) + .Select(static entry => $"line {entry.Number}: {entry.Line.Trim()}") + .ToArray(); + + // Assert + await Assert + .That(pinned) + .IsEmpty() + .Because( + $"{document} ships inside the package and is never rewritten, so a version in it goes stale with the " + + $"next release. Leave the version to `dotnet add package` or the NuGet badge: {string.Join("; ", pinned)}" + ); + } + + [Test] + [Arguments("", true)] + [Arguments("", true)] + [Arguments("dotnet add package Purview.Telemetry.SourceGenerator --version 5.0.6", true)] + [Arguments("Install-Package Purview.Telemetry.SourceGenerator -Version 5.0.6", true)] + [Arguments("**Current Version:** 5.0.6", true)] + [Arguments("dotnet add package Purview.Telemetry.SourceGenerator", false)] + [Arguments("", false)] + [Arguments("Benchmarked on 13th Gen Intel Core i9-13900KF, .NET SDK 10.0.401.", false)] + [Arguments("- Visual Studio 2026 (18.x) or .NET 10 SDK (Roslyn 5.9.0+)", false)] + public async Task PinsAVersion_GivenALine_RecognisesAPackageVersion( + string line, + bool expected, + CancellationToken cancellationToken + ) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var pins = PinsAVersion(line); + + // Assert + await Assert.That(pins).IsEqualTo(expected); + } + + /// + /// A version written beside this package's id, in a Version="..." attribute, after a --version / + /// -Version switch, or as the **Current Version:** marker .build/update-version.ts rewrites. + /// + static bool PinsAVersion(string line) => + VersionAttribute().IsMatch(line) + || VersionSwitch().IsMatch(line) + || line.Contains("**Current Version:**", StringComparison.Ordinal) + || (line.Contains(PackageId, StringComparison.Ordinal) && SemanticVersion().IsMatch(line)); + + [GeneratedRegex(@"\bVersion\s*=\s*""\d", RegexOptions.IgnoreCase)] + private static partial Regex VersionAttribute(); + + [GeneratedRegex(@"(^|\s)--?version\s+\d", RegexOptions.IgnoreCase)] + private static partial Regex VersionSwitch(); + + [GeneratedRegex(@"\b\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\b")] + private static partial Regex SemanticVersion(); + + static string FindRepositoryRoot() + { + for ( + var directory = new DirectoryInfo(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent + ) + { + if ( + File.Exists(Path.Combine(directory.FullName, "AGENTS.md")) + && File.Exists(Path.Combine(directory.FullName, "package.json")) + && Directory.Exists(Path.Combine(directory.FullName, "src", "src")) + ) + return directory.FullName; + } + + throw new InvalidOperationException( + $"No repository root above '{AppContext.BaseDirectory}' carries AGENTS.md, package.json and src/src." + ); + } +} diff --git a/src/tests/Repository.UnitTests/ReleaseGateTests.cs b/src/tests/Repository.UnitTests/ReleaseGateTests.cs new file mode 100644 index 00000000..dfd065dc --- /dev/null +++ b/src/tests/Repository.UnitTests/ReleaseGateTests.cs @@ -0,0 +1,574 @@ +using System.IO.Compression; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Xml.Linq; +using CliWrap; +using CliWrap.Buffered; + +namespace Purview.Telemetry.Repository; + +/// +/// Guards the release gates themselves: the compiler-host leak scan must catch every kind of leak it claims to, every +/// CI job that builds or publishes an application must run it, the packed-consumer gate must stay wired into CI and the +/// Justfile, its consumers must reference the package rather than the repository, and pack validation must keep +/// forbidding compiler-host content outside analyzers/. +/// +/// +/// +/// A gate that passes vacuously is worse than none, because it is trusted. The leak scan +/// (scripts/assert-no-compiler-host-assemblies.ps1) exists because Purview.SourceGeneratorFramework 1.2.1 put +/// Purview.SourceGeneratorFramework.dll into the build output of every project that referenced the generator as +/// an analyzer - all eight sample bins here - while every build stayed green. These tests run the scan against crafted +/// output, so a regex or file-pattern change that stops it seeing a leak fails here rather than in a consumer's Blazor +/// WebAssembly AOT build. +/// +/// +/// The scan is a PowerShell script, as the CI jobs run it, so these tests run it through pwsh as a child +/// process. They do not skip when pwsh is missing: the gate cannot run without it either. +/// +/// +public sealed partial class ReleaseGateTests +{ + const string LeakScanScript = "scripts/assert-no-compiler-host-assemblies.ps1"; + const string PackedConsumersScript = "scripts/test-packed-consumers.ps1"; + const string PackRulesScript = "scripts/test-pack-validation-rules.ps1"; + + static readonly Lazy RepositoryRoot = new(FindRepositoryRoot); + + /// A file written into otherwise clean build output. + public sealed record OutputFile(string Description, string RelativePath, string Content, bool Brotli = false) + { + public override string ToString() => Description; + } + + public static IEnumerable> Leaks() => + [ + () => new("the generator framework assembly", "Purview.SourceGeneratorFramework.dll", "MZ"), + () => new("a Roslyn assembly in a subfolder", "refs/Microsoft.CodeAnalysis.CSharp.dll", "MZ"), + () => new("the generator assembly", "Purview.Telemetry.SourceGenerator.dll", "MZ"), + () => + new( + "a fingerprinted WebAssembly refactorings assembly", + "wwwroot/_framework/Purview.Telemetry.SourceGenerator.Refactorings.ab12cd34ef.wasm", + "wasm" + ), + () => + new( + "a compressed WebAssembly generator", + "wwwroot/_framework/Purview.Telemetry.SourceGenerator.ab12cd34ef.wasm.br", + "br" + ), + () => + new( + "a deps.json library entry", + "PackedApp.deps.json", + """{"libraries":{"Purview.SourceGeneratorFramework/1.2.2":{"type":"package"}}}""" + ), + () => + new( + "a runtimeconfig.json reference", + "PackedApp.runtimeconfig.json", + """{"runtimeOptions":{"configProperties":{"Microsoft.CodeAnalysis.Leak":true}}}""" + ), + () => + new( + "a blazor.boot.json asset", + "wwwroot/_framework/blazor.boot.json", + """{"resources":{"assembly":{"Purview.Telemetry.SourceGenerator.wasm":"sha256-x"}}}""" + ), + () => + new( + "a fingerprinted dotnet.boot.js asset", + "wwwroot/_framework/dotnet.boot.ab12cd34ef.js", + """export const config = {"assembly":[{"name":"Purview.Telemetry.SourceGenerator.Refactorings.wasm"}]};""" + ), + () => + new( + "a Brotli-compressed dotnet.js boot manifest", + "wwwroot/_framework/dotnet.js.br", + """const config = {"assembly":[{"name":"Purview.SourceGeneratorFramework.wasm"}]};""", + Brotli: true + ), + ]; + + [Test] + [MethodDataSource(nameof(Leaks))] + public async Task LeakScan_GivenACompilerHostLeak_FailsAndNamesTheFile( + OutputFile leak, + CancellationToken cancellationToken + ) + { + // Arrange + using var output = CleanOutput(); + Write(output.Path, leak); + + // Act + var result = await RunLeakScanAsync([output.Path], cancellationToken); + + // Assert + await Assert + .That(result.ExitCode) + .IsEqualTo(1) + .Because($"the scan missed {leak}:{Environment.NewLine}{result}"); + await Assert + .That(result.StandardOutput) + .Contains(Path.GetFileName(leak.RelativePath)) + .Because("a failure must name the leaking file"); + } + + [Test] + public async Task LeakScan_GivenCleanBuildAndPublishOutput_Passes(CancellationToken cancellationToken) + { + // Arrange + using var output = CleanOutput(); + + // Act + var result = await RunLeakScanAsync([output.Path], cancellationToken); + + // Assert + await Assert + .That(result.ExitCode) + .IsEqualTo(0) + .Because($"clean output was rejected:{Environment.NewLine}{result}"); + await Assert.That(result.StandardOutput).Contains("3 manifest(s)"); + } + + [Test] + public async Task LeakScan_GivenSeveralPaths_ScansEveryOne(CancellationToken cancellationToken) + { + // Arrange + using var clean = CleanOutput(); + using var leaking = CleanOutput(); + Write(leaking.Path, new("a leak in the second path", "Microsoft.CodeAnalysis.dll", "MZ")); + + // Act - space-separated, exactly as the Justfile recipe passes them through pwsh -File. + var result = await RunLeakScanAsync([clean.Path, leaking.Path], cancellationToken); + + // Assert + await Assert + .That(result.ExitCode) + .IsEqualTo(1) + .Because($"the second path was not scanned:{Environment.NewLine}{result}"); + } + + /// + /// A Blazor server publish can carry Microsoft's WebAssembly debugging proxy, whose expression evaluator ships + /// Roslyn. -Allow exempts that folder alone: a leak anywhere else in the same output must still fail. + /// + [Test] + public async Task LeakScan_GivenAnAllowedToolFolder_SkipsItButStillFailsALeakElsewhere( + CancellationToken cancellationToken + ) + { + // Arrange + using var allowedOnly = CleanOutput(); + Write(allowedOnly.Path, new("the debug proxy's Roslyn", "BlazorDebugProxy/Microsoft.CodeAnalysis.dll", "MZ")); + using var alsoLeaking = CleanOutput(); + Write(alsoLeaking.Path, new("the debug proxy's Roslyn", "BlazorDebugProxy/Microsoft.CodeAnalysis.dll", "MZ")); + Write(alsoLeaking.Path, new("a real leak", "Purview.SourceGeneratorFramework.dll", "MZ")); + + // Act + var allowed = await RunLeakScanAsync([allowedOnly.Path], cancellationToken, ["*BlazorDebugProxy/*"]); + var leaking = await RunLeakScanAsync([alsoLeaking.Path], cancellationToken, ["*BlazorDebugProxy/*"]); + + // Assert + await Assert + .That(allowed.ExitCode) + .IsEqualTo(0) + .Because($"the allowed folder was not skipped:{Environment.NewLine}{allowed}"); + await Assert.That(allowed.StandardOutput).Contains("1 allowed by -Allow"); + await Assert + .That(leaking.ExitCode) + .IsEqualTo(1) + .Because($"-Allow hid a leak outside its folder:{Environment.NewLine}{leaking}"); + await Assert.That(leaking.StandardOutput).Contains("Purview.SourceGeneratorFramework.dll"); + } + + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task LeakScan_GivenNothingToScan_FailsRatherThanPassingVacuously( + bool directoryExists, + CancellationToken cancellationToken + ) + { + // Arrange + using TemporaryDirectory output = new(); + var path = directoryExists ? output.Path : System.IO.Path.Combine(output.Path, "missing"); + + // Act + var result = await RunLeakScanAsync([path], cancellationToken); + + // Assert + await Assert + .That(result.ExitCode) + .IsEqualTo(1) + .Because($"scanning nothing passed:{Environment.NewLine}{result}"); + } + + /// + /// Every CI job that publishes an application, or builds the samples, must scan what it wrote. A job added later + /// without the scan would be a gate that cannot see the leak this repository has already shipped once - and the + /// samples are exactly where it was. + /// + [Test] + public async Task Workflows_GivenAJobThatBuildsApplications_ScanItsOutputForCompilerHostAssemblies( + CancellationToken cancellationToken + ) + { + // Arrange + var workflows = Directory.GetFiles( + System.IO.Path.Combine(RepositoryRoot.Value, ".github", "workflows"), + "*.yml" + ); + + // Act + List building = []; + List unscanned = []; + foreach (var workflow in workflows) + { + var text = await File.ReadAllTextAsync(workflow, cancellationToken); + foreach (var (job, body) in Jobs(text)) + { + if ( + !body.Contains("dotnet publish", StringComparison.Ordinal) + && !body.Contains("dotnet build samples/", StringComparison.Ordinal) + ) + continue; + + var name = $"{System.IO.Path.GetFileName(workflow)}:{job}"; + building.Add(name); + if (!body.Contains("./" + LeakScanScript, StringComparison.Ordinal)) + unscanned.Add(name); + } + } + + // Assert + await Assert + .That(building) + .IsNotEmpty() + .Because("no workflow job builds the samples or publishes, so the job parsing is broken"); + await Assert + .That(unscanned) + .IsEmpty() + .Because($"these jobs build applications without running {LeakScanScript}: {string.Join(", ", unscanned)}"); + } + + [Test] + public async Task PrWorkflow_GivenThePackedConsumersJob_RunsTheGateAndTheRuleProof( + CancellationToken cancellationToken + ) + { + // Arrange + var text = await File.ReadAllTextAsync( + System.IO.Path.Combine(RepositoryRoot.Value, ".github", "workflows", "pr.yml"), + cancellationToken + ); + + // Act + var job = Jobs(text).SingleOrDefault(static j => j.Name == "packed-consumers").Body ?? string.Empty; + + // Assert + await Assert.That(job).Contains($"./{PackedConsumersScript} -PreflightOnly"); + await Assert.That(job).Contains("-WasmAot Require"); + await Assert.That(job).Contains($"./{PackRulesScript}"); + await Assert.That(job).Contains("dotnet workload install wasm-tools"); + } + + [Test] + [Arguments("smoke-packed-consumers", PackedConsumersScript)] + [Arguments("verify-pack-rules", PackRulesScript)] + [Arguments("scan-compiler-host-leaks", LeakScanScript)] + public async Task Justfile_GivenAReleaseGateRecipe_RunsItsScript( + string recipe, + string script, + CancellationToken cancellationToken + ) + { + // Arrange + var text = await File.ReadAllTextAsync( + System.IO.Path.Combine(RepositoryRoot.Value, "Justfile"), + cancellationToken + ); + + // Act + var match = Regex.Match( + text, + $@"^{Regex.Escape(recipe)}\b[^\n]*:\r?\n(?(?:[ \t]+[^\n]*\r?\n?)+)", + RegexOptions.Multiline + ); + + // Assert + await Assert.That(match.Success).IsTrue().Because($"the Justfile has no '{recipe}' recipe"); + await Assert.That(match.Groups["body"].Value).Contains($"pwsh -NoProfile -File {script}"); + } + + /// + /// The packed-consumer gate proves what ships only while its consumers reference the package: a ProjectReference + /// to the repository's generator would hand them the unmerged generator and its bin folder instead. + /// + [Test] + public async Task PackedConsumers_GivenEveryProject_ReferenceTheGeneratorAsAPackageOnly( + CancellationToken cancellationToken + ) + { + // Arrange + var consumers = System.IO.Path.Combine(RepositoryRoot.Value, "scripts", "packed-consumers"); + var projects = Directory.GetFiles(consumers, "*.csproj", SearchOption.AllDirectories); + + // Act + List problems = []; + foreach (var project in projects) + { + var document = XDocument.Parse(await File.ReadAllTextAsync(project, cancellationToken)); + var name = System.IO.Path.GetRelativePath(consumers, project); + + var generator = document + .Descendants("PackageReference") + .SingleOrDefault(static r => (string?)r.Attribute("Include") == "Purview.Telemetry.SourceGenerator"); + if (generator is null) + problems.Add($"{name} has no Purview.Telemetry.SourceGenerator PackageReference"); + else if ( + (string?)generator.Attribute("PrivateAssets") != "all" + || (string?)generator.Attribute("IncludeAssets") != "analyzers" + ) + problems.Add( + $"{name} does not reference the generator as the README says (PrivateAssets/IncludeAssets)" + ); + + foreach (var reference in document.Descendants("ProjectReference")) + { + var include = (string?)reference.Attribute("Include") ?? string.Empty; + var resolved = System.IO.Path.GetFullPath( + System.IO.Path.Combine(System.IO.Path.GetDirectoryName(project)!, include) + ); + if (!resolved.StartsWith(consumers + System.IO.Path.DirectorySeparatorChar, StringComparison.Ordinal)) + problems.Add($"{name} references {include}, outside scripts/packed-consumers"); + } + } + + // Assert + await Assert.That(projects).IsNotEmpty(); + await Assert.That(problems).IsEmpty().Because(string.Join("; ", problems)); + } + + /// + /// Compiler-host assemblies must stay forbidden everywhere NuGet hands files to an application, and the generator + /// and refactorings everywhere an application loads assemblies from. scripts/test-pack-validation-rules.ps1 + /// proves each rule rejects a package; this proves nobody removes one. + /// + [Test] + public async Task PackValidation_GivenEveryPackage_ForbidsCompilerHostContentOutsideAnalyzers( + CancellationToken cancellationToken + ) + { + // Arrange + var json = await File.ReadAllTextAsync( + System.IO.Path.Combine(RepositoryRoot.Value, ".config", "purview-build.json"), + cancellationToken + ); + using var document = JsonDocument.Parse( + json, + new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true } + ); + string[] hostOnly = ["Purview.SourceGeneratorFramework*", "Microsoft.CodeAnalysis*"]; + string[] generatorOnly = ["Purview.Telemetry.SourceGenerator*"]; + string[] applicationFolders = + [ + "lib", + "ref", + "runtimes", + "build", + "buildTransitive", + "buildMultiTargeting", + "tools", + "content", + "contentFiles", + "staticwebassets", + ]; + string[] assemblyFolders = ["lib", "ref", "runtimes"]; + + // Act + var rules = document + .RootElement.GetProperty("PackValidation") + .GetProperty("ForbiddenContent") + .GetProperty("*") + .EnumerateArray() + .Select(static rule => rule.GetString()) + .ToHashSet(StringComparer.Ordinal); + + // Assert + string[] expected = + [ + .. hostOnly, + .. hostOnly.Select(static name => $"analyzers/**/{name}"), + .. applicationFolders.SelectMany(folder => hostOnly.Select(name => $"{folder}/**/{name}")), + .. assemblyFolders.SelectMany(folder => generatorOnly.Select(name => $"{folder}/**/{name}")), + ]; + var missing = expected.Where(rule => !rules.Contains(rule)).ToArray(); + await Assert + .That(missing) + .IsEmpty() + .Because($"ForbiddenContent[\"*\"] no longer forbids: {string.Join(", ", missing)}"); + } + + /// + /// The merge that makes the shipped analyzers self-contained reports a public type exposing a + /// Purview.SourceGeneratorFramework type (PSGFR41) as a plain message by default, which is how + /// MessageTemplateHole shipped. As an error it fails the build, so the setting must not be dropped. + /// + [Test] + public async Task BuildProps_GivenTheMergedAnalyzers_FailTheBuildOnAPublicSurfaceLeak( + CancellationToken cancellationToken + ) + { + // Arrange + var document = XDocument.Parse( + await File.ReadAllTextAsync( + System.IO.Path.Combine(RepositoryRoot.Value, "src", "Directory.Build.props"), + cancellationToken + ) + ); + + // Act + var severities = document + .Descendants("PurviewMergePublicSurfaceSeverity") + .Select(static element => element.Value.Trim()) + .ToArray(); + var disabled = document.Descendants("PurviewMergePublicSurfaceValidation").Select(static e => e.Value.Trim()); + + // Assert + await Assert.That(severities).IsEquivalentTo(["error"]); + await Assert.That(disabled).DoesNotContain("false"); + } + + static TemporaryDirectory CleanOutput() + { + TemporaryDirectory output = new(); + Write(output.Path, new("the app", "PackedApp.dll", "MZ")); + Write( + output.Path, + new( + "clean deps.json", + "PackedApp.deps.json", + """{"libraries":{"Microsoft.Extensions.Logging/10.0.13":{"type":"package"},"Microsoft.Extensions.Telemetry.Abstractions/10.11.0":{"type":"package"}}}""" + ) + ); + Write(output.Path, new("clean runtimeconfig", "PackedApp.runtimeconfig.json", """{"runtimeOptions":{}}""")); + Write( + output.Path, + new( + "clean boot manifest", + "wwwroot/_framework/dotnet.js", + """const config = {"assembly":[{"name":"PackedApp.wasm"},{"name":"PackedTelemetry.Library.wasm"}]};""" + ) + ); + Write(output.Path, new("the runtime", "wwwroot/_framework/dotnet.native.ab12cd34ef.js", "// runtime")); + return output; + } + + static void Write(string root, OutputFile file) + { + var path = System.IO.Path.Combine(root, file.RelativePath.Replace('/', System.IO.Path.DirectorySeparatorChar)); + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(path)!); + var bytes = Encoding.UTF8.GetBytes(file.Content); + if (!file.Brotli) + { + File.WriteAllBytes(path, bytes); + return; + } + + using var stream = File.Create(path); + using BrotliStream brotli = new(stream, CompressionLevel.Optimal); + brotli.Write(bytes); + } + + static async Task RunLeakScanAsync( + string[] paths, + CancellationToken cancellationToken, + string[]? allow = null + ) + { + var result = await Cli.Wrap("pwsh") + .WithArguments([ + "-NoProfile", + "-NonInteractive", + "-File", + System.IO.Path.Combine(RepositoryRoot.Value, LeakScanScript), + "-Path", + .. paths, + .. AllowArguments(allow), + ]) + .WithEnvironmentVariables(static env => env.Set("GITHUB_ACTIONS", null)) + .WithValidation(CommandResultValidation.None) + .ExecuteBufferedAsync(cancellationToken); + + return new(result.ExitCode, result.StandardOutput, result.StandardError); + } + + static string[] AllowArguments(string[]? allow) => allow is null ? [] : ["-Allow", .. allow]; + + sealed record ScanResult(int ExitCode, string StandardOutput, string StandardError) + { + public override string ToString() => $"exit {ExitCode}{Environment.NewLine}{StandardOutput}{StandardError}"; + } + + /// The jobs of a workflow, split on their two-space-indented keys under jobs:. + static IEnumerable<(string Name, string Body)> Jobs(string workflow) + { + var jobs = workflow.IndexOf("\njobs:", StringComparison.Ordinal); + if (jobs < 0) + yield break; + + var headers = JobHeader().Matches(workflow[jobs..]); + for (var i = 0; i < headers.Count; i++) + { + var start = headers[i].Index; + var end = i + 1 < headers.Count ? headers[i + 1].Index : workflow.Length - jobs; + yield return (headers[i].Groups["name"].Value, workflow.Substring(jobs + start, end - start)); + } + } + + [GeneratedRegex(@"^ (?[A-Za-z0-9_-]+):[ \t]*\r?$", RegexOptions.Multiline)] + private static partial Regex JobHeader(); + + static string FindRepositoryRoot() + { + for ( + var directory = new DirectoryInfo(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent + ) + { + if ( + File.Exists(System.IO.Path.Combine(directory.FullName, "AGENTS.md")) + && File.Exists(System.IO.Path.Combine(directory.FullName, LeakScanScript)) + ) + return directory.FullName; + } + + throw new InvalidOperationException( + $"No repository root above '{AppContext.BaseDirectory}' carries both AGENTS.md and {LeakScanScript}." + ); + } + + sealed class TemporaryDirectory : IDisposable + { + public string Path { get; } = + Directory + .CreateDirectory( + System.IO.Path.Combine(System.IO.Path.GetTempPath(), $"tsg-leak-scan-{Guid.NewGuid():N}") + ) + .FullName; + + public void Dispose() + { + try + { + Directory.Delete(Path, recursive: true); + } + catch (IOException) { } + } + } +} diff --git a/src/tests/Repository.UnitTests/Repository.UnitTests.csproj b/src/tests/Repository.UnitTests/Repository.UnitTests.csproj index ac16b2ea..132ee441 100644 --- a/src/tests/Repository.UnitTests/Repository.UnitTests.csproj +++ b/src/tests/Repository.UnitTests/Repository.UnitTests.csproj @@ -1,8 +1,8 @@ + skills/ folder and its release gates - not about the runtime's reach, so running them once is the + whole job, and Purview.Skills.Testing ships net10.0 only. --> net10.0 false @@ -11,5 +11,7 @@ + + diff --git a/src/tests/SourceGenerator.IntegrationTests/Analyzers/UnsupportedTargetFrameworkTests.cs b/src/tests/SourceGenerator.IntegrationTests/Analyzers/UnsupportedTargetFrameworkTests.cs new file mode 100644 index 00000000..b35eab2c --- /dev/null +++ b/src/tests/SourceGenerator.IntegrationTests/Analyzers/UnsupportedTargetFrameworkTests.cs @@ -0,0 +1,114 @@ +using System.Globalization; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Purview.Telemetry.SourceGenerator.Infra; + +namespace Purview.Telemetry.SourceGenerator.Analyzers; + +/// +/// TSG1011 is decided by the compilation's preprocessor symbols, which the framework's analyzer +/// harness does not expose, so these tests build the compilation themselves with the symbols the SDK +/// defines for each target framework. +/// +public class UnsupportedTargetFrameworkTests +{ + /// The exact TSG1011 message. It names the supported targets and offers no opt-out. + const string ExpectedMessage = + "Purview Telemetry Source Generator requires .NET 8.0 or higher, or .NET Framework 4.8 or higher. " + + "The current target framework is not supported, so no telemetry is generated for it. " + + "Target net8.0 or later, or net48 or later; a library that also targets another framework " + + "(such as netstandard2.0) must reference the generator only from its supported targets."; + + const string TelemetryInterface = """ + + namespace Testing; + + [Purview.Telemetry.ActivitySource("testing")] + public interface ITestActivities + { + } + + """; + + [Test] + public async Task Analyze_GivenNetStandardCompilation_RaisesTSG1011WithSupportedTargetsMessage( + CancellationToken cancellationToken + ) + { + // Arrange + var compilation = CreateCompilation("NETSTANDARD", "NETSTANDARD2_0", "NETSTANDARD2_0_OR_GREATER"); + + // Act + var diagnostics = await AnalyzeAsync(compilation, cancellationToken); + + // Assert + var messages = diagnostics + .Where(static diagnostic => diagnostic.Id == "TSG1011") + .Select(static diagnostic => diagnostic.GetMessage(CultureInfo.InvariantCulture)) + .ToArray(); + + await Assert.That(messages.Length).IsEqualTo(1); + await Assert.That(messages[0]).IsEqualTo(ExpectedMessage); + } + + [Test] + public async Task Analyze_GivenNetStandardCompilationDefiningRemovedOptOut_StillRaisesTSG1011( + CancellationToken cancellationToken + ) + { + // Arrange: PURVIEW_TELEMETRY_NON_NULLABLE was documented as suppressing TSG1011, but never did. + var compilation = CreateCompilation("NETSTANDARD", "NETSTANDARD2_0", "PURVIEW_TELEMETRY_NON_NULLABLE"); + + // Act + var diagnostics = await AnalyzeAsync(compilation, cancellationToken); + + // Assert + await Assert.That(diagnostics.Any(static diagnostic => diagnostic.Id == "TSG1011")).IsTrue(); + } + + [Test] + [Arguments("NET;NET8_0;NET8_0_OR_GREATER;NETCOREAPP")] + [Arguments("NETFRAMEWORK;NET48;NET48_OR_GREATER")] + public async Task Analyze_GivenSupportedTargetFramework_DoesNotRaiseTSG1011( + string preprocessorSymbols, + CancellationToken cancellationToken + ) + { + // Arrange + var compilation = CreateCompilation(preprocessorSymbols.Split(';')); + + // Act + var diagnostics = await AnalyzeAsync(compilation, cancellationToken); + + // Assert + await Assert.That(diagnostics.Any(static diagnostic => diagnostic.Id == "TSG1011")).IsFalse(); + } + + static CSharpCompilation CreateCompilation(params string[] preprocessorSymbols) + { + CSharpParseOptions parseOptions = new(LanguageVersion.Latest, preprocessorSymbols: preprocessorSymbols); + var references = AppDomain + .CurrentDomain.GetAssemblies() + .Where(static assembly => !assembly.IsDynamic && !string.IsNullOrEmpty(assembly.Location)) + .Select(static assembly => MetadataReference.CreateFromFile(assembly.Location)); + + return CSharpCompilation.Create( + "UnsupportedTargetFrameworkTests", + [ + CSharpSyntaxTree.ParseText(TelemetryTestAttributeSource.Attributes, parseOptions), + CSharpSyntaxTree.ParseText(TelemetryInterface, parseOptions), + ], + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + } + + static async Task> AnalyzeAsync( + CSharpCompilation compilation, + CancellationToken cancellationToken + ) => + await compilation + .WithAnalyzers([new TelemetryDiagnosticAnalyzer()], options: null) + .GetAnalyzerDiagnosticsAsync(cancellationToken) + .ConfigureAwait(false); +} diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/MarkerAttributeNullabilityTests.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/MarkerAttributeNullabilityTests.cs new file mode 100644 index 00000000..2833d83c --- /dev/null +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/MarkerAttributeNullabilityTests.cs @@ -0,0 +1,48 @@ +using Microsoft.CodeAnalysis.CSharp; + +namespace Purview.Telemetry.SourceGenerator.Generators; + +/// +/// The marker attributes switch between string and string? on NET48_OR_GREATER +/// alone. They read the post-initialization output directly, because the generator test options +/// exclude the marker attributes from the generated-source queries. +/// +public class MarkerAttributeNullabilityTests +{ + [Test] + public async Task Generate_GivenAnyCompilation_GuardsMarkerNullabilityOnNet48Only( + CancellationToken cancellationToken + ) + { + // Arrange + const string hintName = "ActivityAttribute.g.cs"; + + // Act + var activityAttribute = GetMarkerSource(hintName, cancellationToken); + + // Assert + await Assert.That(activityAttribute).Contains("#if !NET48_OR_GREATER"); + await Assert.That(activityAttribute).Contains("#if NET48_OR_GREATER"); + await Assert.That(activityAttribute).DoesNotContain("PURVIEW_TELEMETRY_NON_NULLABLE"); + } + + static string GetMarkerSource(string hintName, CancellationToken cancellationToken) + { + var compilation = CSharpCompilation.Create( + "MarkerAttributeNullabilityTests", + [CSharpSyntaxTree.ParseText("namespace Testing;", cancellationToken: cancellationToken)], + [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)], + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + var runResult = CSharpGeneratorDriver + .Create(new TelemetrySourceGenerator()) + .RunGenerators(compilation, cancellationToken) + .GetRunResult(); + + return runResult + .Results.SelectMany(static result => result.GeneratedSources) + .Single(source => string.Equals(source.HintName, hintName, StringComparison.Ordinal)) + .SourceText.ToString(); + } +} diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.ActivitySourceFactory.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.ActivitySourceFactory.cs new file mode 100644 index 00000000..98d3d7af --- /dev/null +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorActivityTests.ActivitySourceFactory.cs @@ -0,0 +1,232 @@ +using Purview.Telemetry.SourceGenerator.Infra; + +namespace Purview.Telemetry.SourceGenerator.Activities; + +/// +/// .NET 11's System.Diagnostics.ActivitySourceFactory, opted into with +/// [assembly: ActivitySourceGeneration(..., UseActivitySourceFactory = true)]. The factory path only runs on the +/// net11.0 leg, where the test compilation can resolve the type; the fallback only on the earlier legs. +/// +partial class TelemetrySourceGeneratorActivityTests +{ + const string FactoryOptIn = """ + [assembly: ActivitySourceGeneration("testing-activity-source", UseActivitySourceFactory = true)] + """; + + /// The initialisation method every factory-created source gets, as a code snapshot. + const string InitializeActivitySourceSnapshot = """ + private void InitializeActivitySource(global::System.Diagnostics.ActivitySourceFactory activitySourceFactory) + { + if (activitySourceFactory == null) + { + throw new global::System.ArgumentNullException(nameof(activitySourceFactory)); + } + + _activitySource = activitySourceFactory.Create(new global::System.Diagnostics.ActivitySourceOptions("testing-activity-source")); + } + """; + + [Test] + [RequiresActivitySourceFactory] + public async Task Generate_GivenUseActivitySourceFactoryOnAnActivityOnlyInterface_CreatesTheSourceFromTheFactory( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = $$""" + using System.Diagnostics; + + {{FactoryOptIn}} + + namespace Testing; + + [ActivitySource] + public interface ITestActivities + { + [Activity] + Activity? Work([Tag] int id); + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + var generated = generationResult.GetSource("TestActivitiesCore.Activity.g.cs"); + await Assert + .That(generated) + .ContainsGeneratedCode("private global::System.Diagnostics.ActivitySource _activitySource = default!;") + .Because("a factory-created source is per instance, not static"); + await Assert + .That(generated) + .ContainsGeneratedCode( + """ + public TestActivitiesCore(global::System.Diagnostics.ActivitySourceFactory activitySourceFactory) + { + InitializeActivitySource(activitySourceFactory); + } + """ + ) + .Because("an Activities-only interface has no other target to own the constructor"); + await Assert.That(generated).ContainsGeneratedCode(InitializeActivitySourceSnapshot); + await Assert.That(generated).DoesNotContain("static readonly global::System.Diagnostics.ActivitySource"); + await Assert.That(generationResult).DoesNotHaveDiagnostic("TSG3023"); + } + + [Test] + [RequiresActivitySourceFactory] + public async Task Generate_GivenUseActivitySourceFactoryWithLogging_TheLoggerConstructorTakesTheFactory( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = $$""" + using System.Diagnostics; + + {{FactoryOptIn}} + + namespace Testing; + + [ActivitySource] + [Logger] + public interface ITestTelemetry + { + [Activity] + [Info] + Activity? Work([Tag] int id); + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + await Assert + .That(generationResult.GetSource("TestTelemetryCore.Logging.g.cs")) + .ContainsGeneratedCode( + """ + public TestTelemetryCore(global::Microsoft.Extensions.Logging.ILogger logger, global::System.Diagnostics.ActivitySourceFactory activitySourceFactory) + { + _logger = logger; + InitializeActivitySource(activitySourceFactory); + } + """ + ) + .Because("Logging owns the constructor of a multi-target interface, so it receives the factory"); + await Assert + .That(generationResult.GetSource("TestTelemetryCore.Activity.g.cs")) + .ContainsGeneratedCode(InitializeActivitySourceSnapshot); + } + + [Test] + [RequiresActivitySourceFactory] + public async Task Generate_GivenUseActivitySourceFactoryWithMetrics_TheMetricsConstructorTakesBothFactories( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = $$""" + using System.Diagnostics; + + {{FactoryOptIn}} + + namespace Testing; + + [ActivitySource] + [Meter("testing-meter")] + public interface ITestTelemetry + { + [Activity] + [AutoCounter] + Activity? Work([Tag] int id); + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + var metrics = generationResult.GetSource("TestTelemetryCore.Metric.g.cs"); + await Assert + .That(metrics) + .ContainsGeneratedCode( + "public TestTelemetryCore(global::System.Diagnostics.Metrics.IMeterFactory meterFactory, global::System.Diagnostics.ActivitySourceFactory activitySourceFactory)" + ) + .Because("Metrics owns the constructor when there is no Logging target"); + await Assert.That(metrics).ContainsGeneratedCode("InitializeActivitySource(activitySourceFactory);"); + } + + [Test] + [RequiresActivitySourceFactory] + public async Task Generate_GivenTheFactoryIsAvailableButNotRequested_KeepsTheStaticSource( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.Diagnostics; + + [assembly: ActivitySourceGeneration("testing-activity-source")] + + namespace Testing; + + [ActivitySource] + public interface ITestActivities + { + [Activity] + Activity? Work([Tag] int id); + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + var generated = generationResult.GetSource("TestActivitiesCore.Activity.g.cs"); + await Assert + .That(generated) + .ContainsGeneratedCode( + "private static readonly global::System.Diagnostics.ActivitySource _activitySource = new global::System.Diagnostics.ActivitySource(\"testing-activity-source\");" + ) + .Because( + "the factory is opt-in: an existing consumer's constructor must not change when it moves to .NET 11" + ); + await Assert.That(generated).DoesNotContain("ActivitySourceFactory"); + } + + [Test] + [RequiresNoActivitySourceFactory] + public async Task Generate_GivenUseActivitySourceFactoryWithoutTheType_FallsBackToAStaticSourceAndReportsTSG3023( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = $$""" + using System.Diagnostics; + + {{FactoryOptIn}} + + namespace Testing; + + [ActivitySource] + public interface ITestActivities + { + [Activity] + Activity? Work([Tag] int id); + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + var generated = generationResult.GetSource("TestActivitiesCore.Activity.g.cs"); + await Assert + .That(generated) + .ContainsGeneratedCode( + "private static readonly global::System.Diagnostics.ActivitySource _activitySource = new global::System.Diagnostics.ActivitySource(\"testing-activity-source\");" + ); + await Assert.That(generated).DoesNotContain("ActivitySourceFactory"); + await Assert.That(generationResult).HasDiagnostic("TSG3023"); + } +} diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorMetricsTests.MeterNaming.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorMetricsTests.MeterNaming.cs index a0ea76b7..32237bea 100644 --- a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorMetricsTests.MeterNaming.cs +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorMetricsTests.MeterNaming.cs @@ -370,6 +370,133 @@ await Assert .Because("the instrument name must be generated for the DotNet-convention meter name"); } + /// + /// The interface every naming example in README.md, docs/wiki/Metrics.md and docs/wiki/FAQ.md uses, so the + /// documented names are pinned by the generator itself. + /// + const string DocumentedProductTelemetry = """ + [Meter("MyApp.Products")] + interface IProductTelemetry + { + [Counter] + void RecordCount([InstrumentMeasurement] int count); + } + """; + + [Test] + public async Task Generate_GivenTheDocumentedExampleWithDefaults_PrefixesTheInterfaceNameButNotTheMeterName( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = $""" + namespace Testing; + + {DocumentedProductTelemetry} + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + var generated = generationResult.GetSource("ProductTelemetryCore.Metric.g.cs"); + await Assert + .That(generated) + .ContainsGeneratedCode("\"product.record_count\"") + .Because( + "the default (OpenTelemetry naming, MeterNameGenerationType.DotNet) is the interface-derived prefix and the snake_case method name, without the meter name" + ); + await Assert + .That(generated) + .ContainsGeneratedCode("\"MyApp.Products\"") + .Because("the meter keeps the name it was given"); + await Assert.That(generated).DoesNotContain("my_app.products."); + } + + [Test] + public async Task Generate_GivenTheDocumentedExampleWithOpenTelemetryMeterNames_PrependsTheMeterName( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = $""" + [assembly: MeterGeneration(MeterNameGenerationType = MeterNameGenerationType.OpenTelemetry)] + + namespace Testing; + + {DocumentedProductTelemetry} + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + await Assert + .That(generationResult.GetSource("ProductTelemetryCore.Metric.g.cs")) + .ContainsGeneratedCode("\"my_app.products.product.record_count\"") + .Because( + "MeterNameGenerationType.OpenTelemetry prepends the snake_cased meter name as the outermost namespace, ahead of the interface prefix" + ); + } + + [Test] + public async Task Generate_GivenTheDocumentedExampleWithLegacyNaming_LowercasesWithoutWordBoundaries( + CancellationToken cancellationToken + ) + { + // Arrange + // Interface-level, because the test options already declare the assembly-level [TelemetryGeneration]. + const string source = $""" + namespace Testing; + + [TelemetryGeneration(NamingConvention = NamingConvention.Legacy)] + {DocumentedProductTelemetry} + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + await Assert + .That(generationResult.GetSource("ProductTelemetryCore.Metric.g.cs")) + .ContainsGeneratedCode("\"product.recordcount\"") + .Because("the legacy convention lowercases the name and the interface prefix without splitting words"); + } + + [Test] + public async Task Generate_GivenOpenTelemetryMeterNamesAndSeveralInstruments_PrefixesEachInstrumentOnce( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + [assembly: MeterGeneration(MeterNameGenerationType = MeterNameGenerationType.OpenTelemetry)] + + namespace Testing; + + [Meter("orders")] + interface IOrderTelemetry + { + [AutoCounter] + void Placed(); + + [AutoCounter] + void Shipped(); + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + var generated = generationResult.GetSource("OrderTelemetryCore.Metric.g.cs"); + await Assert.That(generated).ContainsGeneratedCode("\"orders.order.placed\""); + await Assert + .That(generated) + .ContainsGeneratedCode("\"orders.order.shipped\"") + .Because("the meter name must not accumulate across the interface's instruments"); + } + [Test] public async Task Generate_GivenInterfaceMeterNameOverridesAssemblyDefault(CancellationToken cancellationToken) { diff --git a/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorTests.StaticInterfaceMembers.cs b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorTests.StaticInterfaceMembers.cs new file mode 100644 index 00000000..5481493c --- /dev/null +++ b/src/tests/SourceGenerator.IntegrationTests/Generators/TelemetrySourceGeneratorTests.StaticInterfaceMembers.cs @@ -0,0 +1,89 @@ +using Purview.Telemetry.SourceGenerator.Infra; + +namespace Purview.Telemetry.SourceGenerator; + +/// +/// Static interface members are not part of what a class implements. An interface could already declare them on +/// .NET Core 3.0+ and .NET 7+ (static abstract); C# 15 lets one declare non-virtual static members on any +/// runtime, .NET Framework included, so a helper on a telemetry interface must be left alone by the generator and the +/// analyzer. The .NET Framework leg is skipped because the C# version these compilations use predates that. +/// +partial class TelemetrySourceGeneratorTests +{ + [Test] + [SkipOnNetFramework] + public async Task Generate_GivenAStaticInterfaceMember_ImplementsOnlyTheInstanceMethods( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.Diagnostics; + + namespace Testing; + + [ActivitySource("testing-activity-source")] + [Logger] + [Meter("testing-meter")] + public interface ITestTelemetry + { + [Activity] + [Info] + [AutoCounter] + Activity? Work([Tag] int id); + + static string Describe(int id) => "work " + id; + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + var implementation = generationResult.Generated().GetClass("TestTelemetryCore", "Testing"); + await Assert.That(implementation.HasMethod("Work")).IsTrue(); + await Assert + .That(implementation.HasMethod("Describe")) + .IsFalse() + .Because("a static interface member has nothing for the class to implement"); + await Assert + .That(generationResult) + .DoesNotHaveDiagnostic("TSG1001") + .Because("a static helper is not a method the generator must infer a target for"); + } + + [Test] + [SkipOnNetFramework] + public async Task Generate_GivenAStaticOverloadOfATelemetryMethod_DoesNotReportDuplicateNames( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + using System.Diagnostics; + + namespace Testing; + + [ActivitySource("testing-activity-source")] + public interface ITestActivities + { + [Activity] + Activity? Work([Tag] int id); + + static Activity? Work(ITestActivities telemetry, string id) => telemetry.Work(int.Parse(id)); + } + """; + + // Act + using var generationResult = await GenerateAsync(source, cancellationToken: cancellationToken); + + // Assert + await Assert + .That(generationResult) + .DoesNotHaveDiagnostic("TSG1003") + .Because("only instance methods share a generated implementation, so a static overload is no duplicate"); + await Assert + .That(generationResult.Generated().GetClass("TestActivitiesCore", "Testing").HasMethod("Work")) + .IsTrue(); + } +} diff --git a/src/tests/SourceGenerator.IntegrationTests/Infra/ActivitySourceFactoryAvailabilityAttributes.cs b/src/tests/SourceGenerator.IntegrationTests/Infra/ActivitySourceFactoryAvailabilityAttributes.cs new file mode 100644 index 00000000..b57420e9 --- /dev/null +++ b/src/tests/SourceGenerator.IntegrationTests/Infra/ActivitySourceFactoryAvailabilityAttributes.cs @@ -0,0 +1,40 @@ +using System.Diagnostics; + +namespace Purview.Telemetry.SourceGenerator.Infra; + +/// +/// Whether the runtime the tests run on - and so the references the test compilations are built from - provides +/// System.Diagnostics.ActivitySourceFactory (.NET 11). The generator gates the factory on the same symbol. +/// +static class ActivitySourceFactoryAvailability +{ + public static bool IsAvailable { get; } = + typeof(ActivitySource).Assembly.GetType("System.Diagnostics.ActivitySourceFactory") is not null; +} + +/// +/// Runs a test only where ActivitySourceFactory exists: the net11.0 leg of the test project. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = false)] +sealed class RequiresActivitySourceFactoryAttribute : SkipAttribute +{ + public RequiresActivitySourceFactoryAttribute() + : base("System.Diagnostics.ActivitySourceFactory needs .NET 11; this leg runs an earlier runtime.") { } + + public override Task ShouldSkip(TestRegisteredContext context) => + Task.FromResult(!ActivitySourceFactoryAvailability.IsAvailable); +} + +/// +/// Runs a test only where ActivitySourceFactory does not exist: every leg before net11.0, which is where +/// the fallback (and TSG3023) apply. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = false)] +sealed class RequiresNoActivitySourceFactoryAttribute : SkipAttribute +{ + public RequiresNoActivitySourceFactoryAttribute() + : base("This test covers the fallback for runtimes without System.Diagnostics.ActivitySourceFactory.") { } + + public override Task ShouldSkip(TestRegisteredContext context) => + Task.FromResult(ActivitySourceFactoryAvailability.IsAvailable); +} diff --git a/src/tests/SourceGenerator.IntegrationTests/SourceGenerator.IntegrationTests.csproj b/src/tests/SourceGenerator.IntegrationTests/SourceGenerator.IntegrationTests.csproj index 69a09101..8936a7df 100644 --- a/src/tests/SourceGenerator.IntegrationTests/SourceGenerator.IntegrationTests.csproj +++ b/src/tests/SourceGenerator.IntegrationTests/SourceGenerator.IntegrationTests.csproj @@ -4,8 +4,10 @@ - - net8.0;net9.0;net10.0; + + net8.0;net9.0;net10.0;net11.0; net48;$(TargetFrameworks)