From 9c6cce7a174e5c5a144ded55936ee4c1c9bfddfc Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Tue, 10 Nov 2026 14:49:39 +0000 Subject: [PATCH 01/12] feat: added grouping and url display support --- .editorconfig | 2 +- .vscode/settings.json | 3 + CHANGELOG.md | 14 + Directory.Packages.props | 10 +- Justfile | 2 + Justfile.skills.just | 11 + README.md | 3 + docs/wiki/Attributes-Reference.md | 7 +- docs/wiki/Configuration-and-Options.md | 3 + docs/wiki/Diagnostics.md | 24 +- docs/wiki/Enablement.md | 2 + docs/wiki/Examples.md | 5 +- docs/wiki/Groups-and-URLs.md | 194 +++++ docs/wiki/Release-Notes.md | 7 + global.json | 2 +- package.json | 4 +- skills-lock.json | 14 +- skills/purview-resourcekit-authoring/SKILL.md | 42 +- .../purview-resourcekit-diagnostics/SKILL.md | 28 +- skills/purview-resourcekit-options/SKILL.md | 19 +- .../AppModels/Groups/DataGroup.cs | 4 + .../AppModels/Groups/PlatformGroup.cs | 5 + .../AppModels/Groups/ServicesGroup.cs | 4 + .../AppModels/Resources/AzureStorageKit.cs | 2 + .../AppModels/Resources/ExampleAPIKit.cs | 6 +- .../AppModels/Resources/PostgresKit.cs | 2 + .../AppModels/Resources/RedisKit.cs | 2 + .../AppModels/ExampleHostKit.cs | 21 +- .../AppModels/Resources/ExampleAPIKit.cs | 6 + src/src/Example.ServiceDefaults/Platform.cs | 11 + .../IResourceBuilderExtensions.cs | 85 ++ src/src/ResourceKit/HostKitBase.cs | 262 +++++- src/src/ResourceKit/ResourceKitBase.cs | 15 + src/src/ResourceKit/ResourceKitGroup.cs | 256 ++++++ .../ResourceKit/ResourceKitGroupResource.cs | 24 + src/src/ResourceKit/ResourceKitUrl.cs | 163 ++++ src/src/ResourceKit/Sdk/README.md | 36 +- .../AnalyzerReleases.Unshipped.md | 11 + .../Helpers/CodeGenEmiiter.Attributes.cs | 313 +++++++ .../Helpers/CodeGenEmiiter.HostKit.cs | 77 +- .../Helpers/CodeGenEmiiter.ResourceGroup.cs | 239 ++++++ .../Helpers/CodeGenEmiiter.ResourceKit.cs | 40 + .../Helpers/CodeGenEmiiter.cs | 63 ++ .../Helpers/DiagnosticLibrary.cs | 109 +++ .../Helpers/GeneratorTrackingNames.cs | 4 + .../Helpers/ResourceGroupOrdering.cs | 44 + .../Helpers/ResourceKitRules.cs | 505 ++++++++++- .../Helpers/SourceGenLibrary.cs | 86 +- .../Helpers/TypeLibraryGenerator.cs | 38 + src/src/SourceGeneration/HostKitGenerator.cs | 12 +- .../Models/AttributeDataModels.cs | 43 + .../Models/KitGenerationModels.cs | 41 +- .../ResourceKitDiagnosticAnalyzer.cs | 61 +- .../ExampleAppHostIntegrationTests.cs | 60 ++ .../ResourceKitGroupTests.cs | 806 ++++++++++++++++++ .../ResourceKitUrlTests.cs | 219 +++++ .../ResourceKitSourceGeneratorTestBase.cs | 5 +- .../ResourceKitSourceGeneratorTestOptions.cs | 2 + .../GeneratorCachingTests.cs | 34 + .../ResourceGroupGenerationTests.cs | 768 +++++++++++++++++ .../ResourceUrlGenerationTests.cs | 326 +++++++ .../Helpers/ResourceGroupOrderingTests.cs | 90 ++ 62 files changed, 5244 insertions(+), 52 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 Justfile.skills.just create mode 100644 docs/wiki/Groups-and-URLs.md create mode 100644 src/src/Example.AppHost/AppModels/Groups/DataGroup.cs create mode 100644 src/src/Example.AppHost/AppModels/Groups/PlatformGroup.cs create mode 100644 src/src/Example.AppHost/AppModels/Groups/ServicesGroup.cs create mode 100644 src/src/ResourceKit/ResourceKitGroup.cs create mode 100644 src/src/ResourceKit/ResourceKitGroupResource.cs create mode 100644 src/src/ResourceKit/ResourceKitUrl.cs create mode 100644 src/src/SourceGeneration/Helpers/CodeGenEmiiter.ResourceGroup.cs create mode 100644 src/src/SourceGeneration/Helpers/ResourceGroupOrdering.cs create mode 100644 src/tests/ResourceKit.UnitTests/ResourceKitGroupTests.cs create mode 100644 src/tests/ResourceKit.UnitTests/ResourceKitUrlTests.cs create mode 100644 src/tests/SourceGeneration.IntegrationTests/ResourceGroupGenerationTests.cs create mode 100644 src/tests/SourceGeneration.IntegrationTests/ResourceUrlGenerationTests.cs create mode 100644 src/tests/SourceGeneration.UnitTests/Helpers/ResourceGroupOrderingTests.cs diff --git a/.editorconfig b/.editorconfig index f8b3050..138c5a4 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,4 @@ -# Purview.BuildSdk 1.3.0 - managed file, do not edit +# Purview.BuildSdk 1.3.2 - 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/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..86ae89e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "dotnet.defaultSolution": "src/ResourceKit.slnx" +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index bb50cb9..304f464 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,20 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `[ResourceDefinition]` (named property or constructor argument), override it from configuration (`:Mode`, `::Mode`), and `Build` gates on it before `IsEnabled` and `IsResourceEnabled(builder)`, so publish-only and local-development-only resources need no code. +- **Resource groups.** `[ResourceGroup]` marks a partial class as a named group, which can nest under + another (`[ResourceGroup]` or `Parent = typeof(TParent)`), and `[InGroup]` adds a resource kit to one or more groups. + A group's `IsEnabled` and `Mode` (`:Groups:`) gate every kit in it and in its child + groups. In run mode each group is a `ResourceKitGroupResource` dashboard node that the kit's primary + group parents it to, and the node is excluded from the manifest. The host kit gains a typed `Groups` + accessor. Manual host kits use `ResourceKitGroup` and `AddResource(kit, groups)`. A resource that is not a kit, + such as DbGate beside SQL Server, joins a group's node with `builder.WithResourceGroup(group)`. +- **Named dashboard URLs.** `[ResourceUrl(name, url)]` adds a URL, absolute or relative to an endpoint, and + `[EndpointUrl(endpoint)]` renames an endpoint's own URL. Each becomes a bindable `ResourceKitUrl` option + (`::Urls:`). Unlike Aspire's relative `WithUrl("/path")`, which rewrites every + endpoint URL, a relative `ResourceKitUrl` adds one URL for one endpoint. Manual kits override + `ResourceKitBase.Urls`, and any builder can call `WithResourceKitUrls(...)`. +- **Diagnostics SG0021–SG0031** for groups and URLs, including cycle detection. No group can become its own + ancestor or member, at declaration, generation or runtime. ### Changed diff --git a/Directory.Packages.props b/Directory.Packages.props index 44ad6a1..5614bcd 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,9 +2,9 @@ true 5.9.0 - 1.76.0 - 1.3.0 - 5.2.0 + 1.81.16 + 1.3.1 + 5.2.1 10.0.13 10.11.0 1.20.1 @@ -52,8 +52,8 @@ - - + + diff --git a/Justfile b/Justfile index 7f9053d..89bd12b 100644 --- a/Justfile +++ b/Justfile @@ -1,5 +1,7 @@ set quiet +import "Justfile.skills.just" + export TESTINGPLATFORM_EXITCODE_IGNORE := "8" export DOTNET_CLI_TELEMETRY_OPTOUT := "1" export DOTNET_SKIP_FIRST_TIME_EXPERIENCE := "1" diff --git a/Justfile.skills.just b/Justfile.skills.just new file mode 100644 index 0000000..65601cd --- /dev/null +++ b/Justfile.skills.just @@ -0,0 +1,11 @@ +skills_project := solution + +# Update agent skills: re-add every skills-lock.json source, refreshing installed skills and adding new ones +[group('Agent skills')] +skills-update *args: + dotnet msbuild "{{ skills_project }}" "-t:UpdatePurviewSkills;FixPurviewSkills" -v:n {{ args }} + +# Check that only the skill directories of the agents in PurviewSkillsAgents exist +[group('Agent skills')] +skills-check *args: + dotnet msbuild "{{ skills_project }}" -t:CheckPurviewSkills -v:n {{ args }} diff --git a/README.md b/README.md index 2cd6520..0ff5927 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,8 @@ From your `[HostKit]` and `[ResourceDefinition]` declarations, ResourceKit gener - a host resource base class, - resource properties on the host, - host + per-resource options (when enabled), +- typed resource groups (`[ResourceGroup]`, `[InGroup]`) that switch sets of resources on or off and nest them in the dashboard, +- named dashboard URLs (`[ResourceUrl]`, `[EndpointUrl]`) that options can override, - an AppHost extension method to build/configure/register your host kit. ## Documentation @@ -98,6 +100,7 @@ From your `[HostKit]` and `[ResourceDefinition]` declarations, ResourceKit gener - [Documentation](https://purview.dev/docs/aspire-resourcekit/) - [Getting started](docs/wiki/Getting-Started.md) - [Configuration and options](docs/wiki/Configuration-and-Options.md) +- [Resource groups and dashboard URLs](docs/wiki/Groups-and-URLs.md) - [Diagnostics and troubleshooting](docs/wiki/Diagnostics.md) - [Examples: generated vs manual](docs/wiki/Examples.md) - [Release notes](docs/wiki/Release-Notes.md) diff --git a/docs/wiki/Attributes-Reference.md b/docs/wiki/Attributes-Reference.md index e4d7b37..5fc320c 100644 --- a/docs/wiki/Attributes-Reference.md +++ b/docs/wiki/Attributes-Reference.md @@ -1,8 +1,10 @@ # Attributes reference -ResourceKit ships two public attributes in the `Purview.Aspire.ResourceKit` namespace. Both are +ResourceKit's public attributes live in the `Purview.Aspire.ResourceKit` namespace. All of them are generated into the consuming compilation by the bundled source generator, so you do not reference a -separate attribute package. +separate attribute package. `[HostKit]` and `[ResourceDefinition]` are described here. The grouping and +URL attributes (`[ResourceGroup]`, `[ResourceGroup]`, `[InGroup]`, `[ResourceUrl]` and `[EndpointUrl]`) are +described in [Groups and URLs](Groups-and-URLs.md). ## `HostKitAttribute` (`[HostKit]`) @@ -133,4 +135,5 @@ you declare it yourself — see [Examples](Examples.md). ## See also - [Generated Output](Generated-Output.md) — what the generator emits from these attributes. +- [Groups and URLs](Groups-and-URLs.md) — `[ResourceGroup]`, `[InGroup]`, `[ResourceUrl]`, `[EndpointUrl]`. - [Diagnostics](Diagnostics.md) — full diagnostic reference. \ No newline at end of file diff --git a/docs/wiki/Configuration-and-Options.md b/docs/wiki/Configuration-and-Options.md index 5f9cb3f..d8a950d 100644 --- a/docs/wiki/Configuration-and-Options.md +++ b/docs/wiki/Configuration-and-Options.md @@ -94,13 +94,16 @@ Typical keys (example): - `ShopHostKit:API:Name` - `ShopHostKit:API:IsEnabled` - `ShopHostKit:API:Mode` +- `ShopHostKit:API:Urls:Health:IsEnabled` (a declared `[ResourceUrl("Health", ...)]`) - `ShopHostKit:Mode` +- `ShopHostKit:Groups:Data:IsEnabled`, `:Mode`, `:ShowInDashboard` (a `[ResourceGroup]`) Configuration is bound by the generated extension method: - Host options bind to `ShopHostKitOptions.SectionName` (for example `ShopHostKit`) and `ValidateOnStart()` is called. - Resource options are nested by generated resource property name. +- Group options are nested under `Groups`, by group property name. See [Groups and URLs](Groups-and-URLs.md). ## Disable a resource diff --git a/docs/wiki/Diagnostics.md b/docs/wiki/Diagnostics.md index db33f42..394aac4 100644 --- a/docs/wiki/Diagnostics.md +++ b/docs/wiki/Diagnostics.md @@ -33,19 +33,31 @@ exiting the constructor" warning does not apply. | SG0018 | Warning | A project resource kit does not add the declared project via `AddProject()` | | SG0019 | Warning | A project resource kit declares an explicit base class that does not use `ProjectResource` | | SG0020 | Error | An `OptionsHelper.Assign` action sets more than one property path | - -See [Attributes Reference](Attributes-Reference.md) for the rules tied to attribute styles, and -[Project Resources](Project-Resources.md) for SG0018/SG0019 specifics. +| SG0021 | Error | An `[InGroup]` type argument, or a group's `Parent`, is not a `[ResourceGroup]` class | +| SG0022 | Error | A resource group is its own ancestor | +| SG0023 | Error | Two resource groups share a name (case-insensitive) or a property name | +| SG0024 | Warning | A resource kit marks more than one group as `Primary` | +| SG0025 | Error | A resource group declares a base type | +| SG0026 | Error | Two URLs on one resource kit or group share a name | +| SG0027 | Error | An endpoint URL is declared on a resource, or group, without endpoints | +| SG0028 | Warning | A relative URL is declared on a resource, or group, without endpoints | +| SG0029 | Error | A resource group declares its parent more than once | +| SG0030 | Error | A resource group is generic, or nested in a generic type | +| SG0031 | Error | A resource group is also a resource kit, or carries `[InGroup]` | + +See [Attributes Reference](Attributes-Reference.md) for the rules tied to attribute styles, +[Project Resources](Project-Resources.md) for SG0018/SG0019 specifics, and +[Groups and URLs](Groups-and-URLs.md) for SG0021–SG0031. ## Execution-only vs generation-blocking rules -SG0017, SG0018, and SG0019 are **execution-only** rules. They report problems that break the resource at +SG0017, SG0018, SG0019, SG0024 and SG0028 are **execution-only** rules. They report problems that break the resource at runtime (an unset builder property, a project that is never registered, or a base class that cannot build a project) but they do **not** prevent source generation. They are reported as warnings so generation always proceeds — for example, a resource kit whose `BuildResource` does not yet register its declared project via `AddProject()` is still generated (and the host kit is still emitted) so the user can -complete the override instead of losing the whole output. Only Error-severity rules (SG0001–SG0016) block -generation. +complete the override instead of losing the whole output. Only Error-severity rules block generation. +SG0024 keeps the first primary group, and SG0028's URL is skipped at runtime. ## `OptionsHelper.Assign` action with multiple property paths (SG0020) diff --git a/docs/wiki/Enablement.md b/docs/wiki/Enablement.md index c5f8004..273a230 100644 --- a/docs/wiki/Enablement.md +++ b/docs/wiki/Enablement.md @@ -49,6 +49,8 @@ Because it is an option, configuration can override either the host-wide mode or - The host `Mode` gates the whole host kit: every resource is disabled before it is built. - A resource `Mode` gates that resource only. +- A resource group's `IsEnabled` and `Mode` gate every kit in the group and in its child groups, in the + same step as the host `Mode`. See [Groups and URLs](Groups-and-URLs.md#enablement). An excluded kit never runs `BuildResource(...)` or `ConfigureResource()`. Its host property is still constructed (so `HostKit.KeyVault` is non-null), but `IsEnabled` is `false` and any `ResourceBuilder` diff --git a/docs/wiki/Examples.md b/docs/wiki/Examples.md index b9317fc..696933e 100644 --- a/docs/wiki/Examples.md +++ b/docs/wiki/Examples.md @@ -57,10 +57,13 @@ options object to a destination resource: ```csharp [ResourceDefinition(Platform.ResourceKits.API)] +[InGroup] +[ResourceUrl("Health", Platform.EndpointsDefinitions.Health, DisplayText = "Health")] +[EndpointUrl("https", DisplayText = "API")] sealed partial class ExampleAPIKit { protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => - builder.AddProject(Name).WithUrl("/health", "Health"); + builder.AddProject(Name); protected override void ConfigureResource() { diff --git a/docs/wiki/Groups-and-URLs.md b/docs/wiki/Groups-and-URLs.md new file mode 100644 index 0000000..efe1c5f --- /dev/null +++ b/docs/wiki/Groups-and-URLs.md @@ -0,0 +1,194 @@ +# Resource groups and dashboard URLs + +Two ways to manage resource kits beyond one kit at a time: + +- **Resource groups** gather kits under a name. A group can be switched off, or given a run mode, with one + setting. In run mode it appears as a node in the Aspire dashboard, with its kits nested underneath. +- **Named URLs** declare the links the dashboard shows for a kit or a group. Options can switch each one + off or change it. + +Both features are built on Aspire APIs (`WithParentRelationship`, `WithUrls`, `ResourceUrlAnnotation`), so +they need no Aspire version beyond the one ResourceKit already requires. + +## Resource groups + +A group is a class marked `[ResourceGroup]`. A kit joins one or more groups with `[InGroup]`. A group +nests under a parent with `[ResourceGroup]`, or with `Parent = typeof(TParent)`; the two forms are +equivalent, and a group may use only one of them (SG0029): + +```csharp +[ResourceGroup("platform")] +sealed partial class PlatformGroup; + +[ResourceGroup("data")] // or [ResourceGroup("data", Parent = typeof(PlatformGroup))] +sealed partial class DataGroup; + +[ResourceDefinition("postgres")] +[InGroup] +[InGroup] +sealed partial class PostgresKit { /* ... */ } +``` + +| `[ResourceGroup]` argument | Default | Purpose | +| --- | --- | --- | +| `Name` | Class name without a `ResourceGroup` or `Group` suffix | The group name, unique among groups (SG0023). It also names the group's dashboard node; if a resource already has that name (a `minder` group holding a `minder` database), the node is named `minder-group` instead. | +| `Parent` / `TParent` | none | The parent group: another `[ResourceGroup]` class (SG0021). It must not form a cycle (SG0022), and only one may be given (SG0029). | +| `ShowInDashboard` | `true` | Whether the group appears as a dashboard node. | +| `Mode` | `All` | The run mode in which the group, and therefore every kit in it, takes part. | + +The generator derives the group class from `ResourceKitGroup`, so the class must be `partial` +and must not declare a base type (SG0025). It must not be generic (SG0030), and it cannot also be a resource kit or carry +`[InGroup]` (SG0031): groups nest through their parent only. The host kit gets a typed `Groups` accessor: + +```csharp +foreach (var resource in HostKit.Groups.Data.Resources.Where(static r => r.IsEnabled)) +{ + // ... +} +``` + +A group must be at least as accessible as the host kit, just like a resource kit. + +### Enablement + +A kit is built only if it is enabled itself **and** every group it belongs to, plus every ancestor of +those groups, is enabled with a mode that covers the current run. The host kit applies this check before +it builds the kit, in the same place it applies the host kit's own `Mode`. So `IsEnabled` and +`IsResourceEnabled` keep their existing meaning: a kit that a group switches off never reaches +`IsResourceEnabled`. + +```text +ExampleHostKit:Groups:Data:IsEnabled=false # disables postgres, redis and storage +ExampleHostKit:Groups:Platform:Mode=Publish # every group under Platform is publish-only +ExampleHostKit:Groups:Data:ShowInDashboard=false # data's kits move up to the platform node +``` + +### The dashboard + +In run mode, at the end of `Configure` (after every `ConfigureResource`, so a resource added there is never +shadowed), each group that has at least one built kit is added as a `ResourceKitGroupResource`. Its +name is the group name, or `-group` (then `-group-2`) when another resource already has it. That +resource has no lifecycle of its own and shows as `Running`, with a folder icon. It is excluded from the +publishing manifest, and in publish mode it is not added at all. + +- Each kit is parented to its **primary** group's node. The primary group is the first `[InGroup]`, or + the one marked `[InGroup(Primary = true)]`. Only one group can be primary (SG0024). +- If the primary group is hidden (`ShowInDashboard = false`), the kit is parented to the nearest + ancestor group that is shown. +- A child group's node is parented to its parent group's node. +- A kit whose resource already has a parent keeps it, because the dashboard shows one parent per resource. + The **top** of that parent chain is nested in the group instead. So an integration that builds its own + tree, such as AspireC4 (`aspirec4` → `aspirec4-likec4` + `aspirec4-er`), lands in the group with its + shape intact. Two cases are left alone: a chain that ends at another kit's resource (that kit decides + where its tree goes), and a chain that already ends in a group node. A parent cycle that some other code + created is left as it is; the walk stops rather than loops. + +### No group can contain itself + +ResourceKit never lets a group become its own ancestor or member, at any level: + +| Level | Guarantee | +| --- | --- | +| Declaration | A group whose parent chain leads back to itself (including `Parent = typeof(Self)` and `ResourceGroup`) is SG0022. A group can't also be a kit or a group member (SG0031), and a kit can't be in itself, because a kit isn't a group (SG0021). | +| Analysis | The cycle check walks each type's original definition and keeps a set of visited types, so it ends after at most one step per declared group. A self-expanding generic such as `ResourceGroup>>` on `G` is SG0030. | +| Generation | Any of these errors stops generation. Ordering groups parents-first leaves out anything that can never be ready instead of looping on it. | +| Runtime | `ResourceKitGroup.Parent` is set only in the constructor, before the instance can be anyone's ancestor, so the chain can't loop. `Children`, `Resources` and `GetGroups` are read-only views. Every walk is iterative, and walking down visits each group once, so a deep hierarchy can't exhaust the stack. | +| Dashboard | Group nodes are parented only to group nodes. A kit, or the top of a parent chain a kit already belongs to, is parented only to a group node. The chain is walked with a visited set, so a parent cycle created by other code ends the walk instead of looping. | + +### Placing other resources in a group + +Some integrations add a resource next to a kit's own, such as DbGate, pgweb or Redis Commander. Those +resources aren't kits, so on their own they sit at the root of the dashboard. `WithResourceGroup` puts one in a group: + +```csharp +protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) +{ + var sql = builder.AddSqlServer(Name); + + // WithDbGate returns the SQL Server builder; the DbGate builder is passed to the callback. + sql.WithDbGate(dbGate => dbGate.WithResourceGroup(HostKit.Groups.Persistence)); + + return sql; +} +``` + +- Call it from `BuildResource` or `ConfigureResource`. The resource is placed when the host kit finishes + `Configure`; calling `WithResourceGroup` after that throws. +- The usual rules apply. A resource that already has a parent keeps it, and the top of its chain is + nested instead. A resource included in two groups stays in the first. If the group is hidden, the + resource goes to the nearest shown ancestor group. +- `WithResourceGroup` only affects where the resource appears in the dashboard. Whether it is added at all is up to + the kit that adds it. +- A group with only included resources still gets a node. A group's own dashboard node can't be included + (`ArgumentException`). +- To nest the resource under the kit's resource instead, use Aspire directly: + `sql.WithDbGate(dbGate => dbGate.WithParentRelationship(sql))`. + +## Named URLs + +```csharp +[ResourceDefinition("api")] +[ResourceUrl("Health", "/health", DisplayText = "Health")] +[ResourceUrl("Scalar", "/scalar", Endpoint = "http", DisplayLocation = UrlDisplayLocation.DetailsOnly)] +[EndpointUrl("https", DisplayText = "API")] +sealed partial class ExampleAPIKit { /* ... */ } +``` + +- **`[ResourceUrl(name, url)]`** adds a link. + - An absolute URL is shown as it is. + - A relative URL is resolved against the endpoint named by `Endpoint`. Without one, it uses the + `https` endpoint, then the `http` endpoint, then the first endpoint. + - Only **one** URL is added. This differs from Aspire's own `WithUrl("/health")`, which rewrites the + path of *every* endpoint URL on the resource. +- **`[EndpointUrl(endpoint)]`** changes the URL Aspire already shows for an endpoint: its `DisplayText` + and `DisplayLocation`. +- A URL's name is the key for its options. An `[EndpointUrl]` is named after its endpoint in PascalCase, + so `otlp-grpc` becomes `OtlpGrpc`. Names must be unique on a kit or group (SG0026). + +Each URL becomes a `ResourceKitUrl` option, so you can switch it off or change it without touching code: + +```text +ExampleHostKit:API:Urls:Health:IsEnabled=false +ExampleHostKit:API:Urls:Https:DisplayText=Service +ExampleHostKit:Groups:Platform:Urls:Docs:Url=https://wiki.example.com/platform +``` + +A group has no endpoints, so it can only show absolute URLs. On a group, `[EndpointUrl]` or a URL with +an `Endpoint` is an error (SG0027), and a relative URL is a warning (SG0028); the URL is skipped at +runtime. The same rules apply to a kit whose resource type has no endpoints, such as a `ParameterResource`. + +At runtime, a URL whose endpoint cannot be found is skipped and a warning is logged. The other URLs +still apply. + +## Manual host kits + +Neither feature depends on the generator. A hand-written host kit uses the runtime types directly: + +```csharp +sealed class ExampleHostKit : HostKitBase +{ + public ExampleHostKit() + { + PlatformGroup = new("platform") { Urls = [new() { Url = "https://example.com", DisplayText = "Docs" }] }; + DataGroup = new("data", PlatformGroup); + + Postgres = new(this); + AddResource(Postgres, DataGroup); // the first group is the primary group + } +} + +sealed class ExampleAPIKit(ExampleHostKit hostKit) + : ResourceKitBase(hostKit, "api") +{ + protected override IEnumerable Urls => + [new() { Url = "/health", DisplayText = "Health" }, new() { Endpoint = "https", DisplayText = "API" }]; +} +``` + +Any other Aspire builder can use `builder.WithResourceKitUrls(urls)` directly. + +## See also + +- [Attributes Reference](Attributes-Reference.md) +- [Enablement](Enablement.md) +- [Diagnostics](Diagnostics.md) diff --git a/docs/wiki/Release-Notes.md b/docs/wiki/Release-Notes.md index 170dd18..526e31b 100644 --- a/docs/wiki/Release-Notes.md +++ b/docs/wiki/Release-Notes.md @@ -10,6 +10,13 @@ the [GitHub releases page](https://github.com/purview-dev/aspire-resourcekit/rel - **Breaking: .NET 10 is the minimum.** The `net8.0` and `net9.0` targets are gone; a consuming AppHost must be on `net10.0`, and the package ships a single `lib/net10.0` asset. See [Supported target frameworks](Versioning.md#supported-target-frameworks). +- **Resource groups.** `[ResourceGroup]` and `[InGroup]` gather kits under named, nestable groups. + A group can be switched off or given a mode from configuration, and in run mode it appears as a node in the dashboard + with its kits nested underneath. See [Groups and URLs](Groups-and-URLs.md). +- **Named dashboard URLs.** `[ResourceUrl]` and `[EndpointUrl]` declare a kit's or group's dashboard links as + options. A relative URL adds one link for one endpoint, instead of rewriting every endpoint URL as + `WithUrl("/path")` does. +- **Diagnostics SG0021-SG0031** for groups and URLs. A group can never become its own ancestor or member. ## 1.0.0 diff --git a/global.json b/global.json index 4d9bb41..bc35f10 100644 --- a/global.json +++ b/global.json @@ -5,7 +5,7 @@ "allowPrerelease": true }, "msbuild-sdks": { - "Purview.BuildSdk": "1.3.0", + "Purview.BuildSdk": "1.3.2", "Aspire.AppHost.Sdk": "13.6.4" }, "test": { diff --git a/package.json b/package.json index 7a9fbb9..ce2ca0a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aspire-resourcekit", - "version": "1.1.0", + "version": "1.1.1", "description": "Source-generator-powered framework for structuring .NET Aspire AppHost resource composition as strongly typed, test-friendly classes.", "keywords": [], "homepage": "https://purview.dev/projects/aspire-resourcekit/", @@ -16,4 +16,4 @@ "type": "git", "url": "git+https://github.com/purview-dev/aspire-resourcekit.git" } -} +} \ No newline at end of file diff --git a/skills-lock.json b/skills-lock.json index b2eddc3..d67c948 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -17,13 +17,13 @@ "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-adoption/SKILL.md", - "computedHash": "ca2e13fc8074281f8ca2b510b742ae99db28f97d78f5fb36a6f27936a91941fc" + "computedHash": "0edb1df7a5e8a73eb13bfc96e539290e047085227164741188d7c1f3b5f1a623" }, "purview-buildsdk-configuration": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-configuration/SKILL.md", - "computedHash": "88b4a655efdc058b9a10adb1b5cbcd44e89bff6ed8c858e066092e5be0ca3e0b" + "computedHash": "a31a7ca7ca019e32af682ae9822fc58cffcfe43917fa7cc873e028a9fae8ba27" }, "purview-buildsdk-project-detection": { "source": "purview-dev/build-sdk", @@ -41,13 +41,13 @@ "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-repository-review/SKILL.md", - "computedHash": "d366821995ff410caa436ccb996eaa6429f1ba51ba985b210d74ec3963f6c06f" + "computedHash": "2bbde3c3033fad06fa87910a47aa63f6e6510776f86fc5b96a670616ff8f3f6a" }, "purview-buildsdk-skill-installation": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-skill-installation/SKILL.md", - "computedHash": "b61cce9f0f213469c3acab4dd5ebaec450ee2be730680b166c9718c901aedffe" + "computedHash": "247d936615f53fd4bef7b1c45d9eca29bfafec85285903230b933cce4aaeaa38" }, "purview-buildsdk-skill-tests": { "source": "purview-dev/build-sdk", @@ -55,6 +55,12 @@ "skillPath": "skills/purview-buildsdk-skill-tests/SKILL.md", "computedHash": "807b64466de0a871e5f8e678e7686c48a06d8bb9d42a03448f604a35d40d3935" }, + "purview-buildsdk-skills-justfile": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-skills-justfile/SKILL.md", + "computedHash": "cbb93f6f3508326630c47570b56c33a702da086293c17f9a18aca68340b79894" + }, "purview-buildsdk-testing": { "source": "purview-dev/build-sdk", "sourceType": "github", diff --git a/skills/purview-resourcekit-authoring/SKILL.md b/skills/purview-resourcekit-authoring/SKILL.md index f77fefc..bc1644f 100644 --- a/skills/purview-resourcekit-authoring/SKILL.md +++ b/skills/purview-resourcekit-authoring/SKILL.md @@ -4,7 +4,8 @@ description: >- Use when composing an Aspire AppHost with Purview.Aspire.ResourceKit - declaring a [HostKit] and [ResourceDefinition] resource kits, choosing the generic or non-generic attribute style, the Build/BuildResource and Configure/ConfigureResource lifecycle, project resources through - AddProject, and registering the generated AddAspireResourceKit extension. + AddProject, grouping kits with [ResourceGroup]/[InGroup], named dashboard URLs with + [ResourceUrl]/[EndpointUrl], and registering the generated AddAspireResourceKit extension. metadata: product: Purview.Aspire.ResourceKit repository: purview-dev/aspire-resourcekit @@ -19,7 +20,7 @@ one resource kit per resource, and a generated extension method that wires them dotnet add package Purview.Aspire.ResourceKit ``` -Requires **.NET 10**: the package ships a single `lib/net10.0` asset, and the two attributes below are +Requires **.NET 10**: the package ships a single `lib/net10.0` asset, and the attributes below are generated into the consuming compilation by the bundled source generator — there is no separate attribute package to reference. @@ -194,6 +195,43 @@ the combined result is assigned back to `IsEnabled`. A `false` result skips both and `ConfigureResource()`. The `purview-resourcekit-options` skill covers the full model, including why a kit disabled through options is never re-enabled by the hook. +## Groups and dashboard URLs + +Group related kits with a `[ResourceGroup]` class, and add a kit to a group with `[InGroup]`. A +kit can be in several groups, and its first group, or the one marked `Primary = true`, is where the +dashboard nests it: + +```csharp +[ResourceGroup("platform")] sealed partial class PlatformGroup; +[ResourceGroup("data")] sealed partial class DataGroup; // or Parent = typeof(PlatformGroup) + +[ResourceDefinition("postgres")] +[InGroup] +[ResourceUrl("Docs", "https://example.com/postgres")] // absolute: shown as-is +sealed partial class PostgresKit { /* ... */ } + +[ResourceDefinition("api")] +[ResourceUrl("Health", "/health", DisplayText = "Health")] // one URL, against https then http +[EndpointUrl("https", DisplayText = "API")] // renames the endpoint's own URL +sealed partial class ApiKit { /* ... */ } +``` + +- A group class must be `partial`, non-generic and must have no base type: the generator derives it from + `ResourceKitGroup` (`SG0025`, `SG0030`). A class is a kit or a group, never both (`SG0031`), and a + parent chain can never loop back (`SG0022`). It must be at least as accessible as the host kit. +- In run mode, at the end of `Configure`, each group with a built kit is added as a `ResourceKitGroupResource` + node, named after the group, or `-group` when a resource already has that name. The node is + excluded from the manifest. A kit whose resource already has a parent (its own + `WithParentRelationship`, or an integration such as AspireC4 that builds its own tree) keeps it, and the + top of that chain is nested in the group instead, unless the chain ends at another kit or a group node. +- A resource an integration adds beside a kit's own (DbGate, pgweb) isn't a kit. Place it with + `sql.WithDbGate(dbGate => dbGate.WithResourceGroup(HostKit.Groups.Persistence))`. `WithDbGate` returns the SQL builder, + so the callback is the only place to reach DbGate's. +- Prefer `[ResourceUrl]` to `WithUrl("/path")`. Aspire's relative `WithUrl` rewrites the path of + **every** endpoint URL; `[ResourceUrl]` adds one URL. +- `HostKit.Groups.Data.Resources` lists a group's kits for cross-resource wiring in `ConfigureResource`. +- Manual host kits pass groups to `AddResource(kit, group, ...)` and override `Urls`. + ## Growing a composition Start with one resource kit, add one class per resource, and use the generated options to toggle diff --git a/skills/purview-resourcekit-diagnostics/SKILL.md b/skills/purview-resourcekit-diagnostics/SKILL.md index 8f46254..a29a638 100644 --- a/skills/purview-resourcekit-diagnostics/SKILL.md +++ b/skills/purview-resourcekit-diagnostics/SKILL.md @@ -44,10 +44,21 @@ assigned. | `SG0018` | Warning | A project resource kit does not add the declared project via `AddProject()`. | | `SG0019` | Warning | A project resource kit's explicit base does not use `ProjectResource`. | | `SG0020` | Error | An `OptionsHelper.Assign` action sets more than one property path. | +| `SG0021` | Error | An `[InGroup]` type, or a group's `Parent`, is not a `[ResourceGroup]` class in this compilation. | +| `SG0022` | Error | A resource group is its own ancestor. | +| `SG0023` | Error | Two groups share a name (case-insensitive, as the dashboard compares names) or a property name. | +| `SG0024` | Warning | A kit marks more than one group `Primary`; the first is used. | +| `SG0025` | Error | A `[ResourceGroup]` class declares a base type. | +| `SG0026` | Error | Two URLs on one kit or group share a name. | +| `SG0027` | Error | An `[EndpointUrl]`, or a URL with an `Endpoint`, on a resource or group without endpoints. | +| `SG0028` | Warning | A relative URL on a resource or group without endpoints; it is skipped at runtime. | +| `SG0029` | Error | A group declares its parent more than once (`ResourceGroup` and `Parent`, or both attributes). | +| `SG0030` | Error | A group is generic, or nested in a generic type. | +| `SG0031` | Error | A group is also a resource kit, or carries `[InGroup]`. | ## Execution-only versus generation-blocking -`SG0017`, `SG0018` and `SG0019` are **execution-only**. They report things that break the resource at +`SG0017`, `SG0018`, `SG0019`, `SG0024` and `SG0028` are **execution-only**. They report things that break the resource at runtime — an unset builder property, a project that is never registered, a base that cannot build a project — but they deliberately do **not** stop generation, and are warnings for that reason. @@ -55,7 +66,7 @@ The point is practical: a resource kit whose `BuildResource` does not yet regist project is still generated, and the host kit still emitted, so you can finish the override instead of losing the whole output to a half-written method. -Only the Error-severity rules, `SG0001`–`SG0016`, block generation. +Only the Error-severity rules block generation. ## Fast triage @@ -105,6 +116,19 @@ sealed partial class SqlServerKit } ``` +### `SG0021`–`SG0031` — groups and URLs + +- `SG0021`: the type in `[InGroup]`, or in `Parent = typeof(T)`, must be a `[ResourceGroup]` class declared + in the same project. +- `SG0022` / `SG0031`: a group can never contain itself. Nest groups through one parent, as + `[ResourceGroup]` or `Parent = typeof(TParent)` (not both: `SG0029`), and keep kits and groups as + separate classes. +- `SG0025`: remove the base type from the group class; the generator supplies `ResourceKitGroup`. +- `SG0026`: give each `[ResourceUrl]` a unique name. `[EndpointUrl]` takes its name from the endpoint, so two + `[EndpointUrl("https")]` also collide. +- `SG0027` / `SG0028`: a group, or a kit for a resource without endpoints such as a `ParameterResource`, can + only show an **absolute** URL. + ### `SG0020` — one property path per `Assign` action ```csharp diff --git a/skills/purview-resourcekit-options/SKILL.md b/skills/purview-resourcekit-options/SKILL.md index acf38a1..33c5115 100644 --- a/skills/purview-resourcekit-options/SKILL.md +++ b/skills/purview-resourcekit-options/SKILL.md @@ -3,8 +3,9 @@ name: purview-resourcekit-options description: >- Use when configuring Purview.Aspire.ResourceKit through options - the generated host and resource options shape, extending them with custom properties, selecting a run mode with Mode, section-name - resolution, disabling a resource with IsEnabled, overriding enablement with IsResourceEnabled, and - building args or environment variables with OptionsHelper. + resolution, disabling a resource with IsEnabled, disabling a resource group, overriding a declared + dashboard URL, overriding enablement with IsResourceEnabled, and building args or environment + variables with OptionsHelper. metadata: product: Purview.Aspire.ResourceKit repository: purview-dev/aspire-resourcekit @@ -40,6 +41,20 @@ Host options bind to `SectionName` and `ValidateOnStart()` is called; resource o generated resource property name. Typical keys: `ShopHostKit:API:Name`, `ShopHostKit:API:IsEnabled`, `ShopHostKit:API:Mode`, `ShopHostKit:Mode`. +Two further option families appear when they are declared: + +- **Resource groups.** `[ResourceGroup]` adds `ShopHostKit:Groups::{IsEnabled|Mode|ShowInDashboard}`. + A kit builds only when its own gates pass **and** every group it belongs to, and every ancestor of + those groups, is enabled for the run. The host kit checks this before `IsResourceEnabled`, in the same + step as the host `Mode`, so `IsEnabled` and `IsResourceEnabled` keep their meaning. +- **Named URLs.** `[ResourceUrl("Health", ...)]` and `[EndpointUrl("https")]` add + `ShopHostKit:API:Urls::{Url|DisplayText|Endpoint|DisplayLocation|IsEnabled}`. Each one is a + `ResourceKitUrl`. An endpoint URL is named after its endpoint in PascalCase (`otlp-grpc` → `OtlpGrpc`). + +```text +--ShopHostKit:Groups:Data:IsEnabled=false # every kit in Data and its child groups +--ShopHostKit:API:Urls:Health:IsEnabled=false +``` ## Extending them Generated options are nested `sealed partial` classes, so you add your own properties on the host kit diff --git a/src/src/Example.AppHost/AppModels/Groups/DataGroup.cs b/src/src/Example.AppHost/AppModels/Groups/DataGroup.cs new file mode 100644 index 0000000..f82ab11 --- /dev/null +++ b/src/src/Example.AppHost/AppModels/Groups/DataGroup.cs @@ -0,0 +1,4 @@ +namespace Purview.Aspire.ResourceKit.Example.AppHost.AppModels.Groups; + +[ResourceGroup(Platform.ResourceGroups.Data)] +sealed partial class DataGroup; diff --git a/src/src/Example.AppHost/AppModels/Groups/PlatformGroup.cs b/src/src/Example.AppHost/AppModels/Groups/PlatformGroup.cs new file mode 100644 index 0000000..e1b18f3 --- /dev/null +++ b/src/src/Example.AppHost/AppModels/Groups/PlatformGroup.cs @@ -0,0 +1,5 @@ +namespace Purview.Aspire.ResourceKit.Example.AppHost.AppModels.Groups; + +[ResourceGroup(Platform.ResourceGroups.Platform)] +[ResourceUrl("Docs", Platform.ResourceGroups.DocsUrl, DisplayText = "ResourceKit docs")] +sealed partial class PlatformGroup; diff --git a/src/src/Example.AppHost/AppModels/Groups/ServicesGroup.cs b/src/src/Example.AppHost/AppModels/Groups/ServicesGroup.cs new file mode 100644 index 0000000..e8159c1 --- /dev/null +++ b/src/src/Example.AppHost/AppModels/Groups/ServicesGroup.cs @@ -0,0 +1,4 @@ +namespace Purview.Aspire.ResourceKit.Example.AppHost.AppModels.Groups; + +[ResourceGroup(Platform.ResourceGroups.Services, Parent = typeof(PlatformGroup))] +sealed partial class ServicesGroup; diff --git a/src/src/Example.AppHost/AppModels/Resources/AzureStorageKit.cs b/src/src/Example.AppHost/AppModels/Resources/AzureStorageKit.cs index 9153378..a75463f 100644 --- a/src/src/Example.AppHost/AppModels/Resources/AzureStorageKit.cs +++ b/src/src/Example.AppHost/AppModels/Resources/AzureStorageKit.cs @@ -1,9 +1,11 @@ using Aspire.Hosting.Azure; using Microsoft.Extensions.Hosting; +using Purview.Aspire.ResourceKit.Example.AppHost.AppModels.Groups; namespace Purview.Aspire.ResourceKit.Example.AppHost.AppModels.Resources; [ResourceDefinition(Platform.ResourceKits.AzureStorage)] +[InGroup] sealed partial class AzureStorageKit { public IResourceBuilder Blobs { get; private set; } diff --git a/src/src/Example.AppHost/AppModels/Resources/ExampleAPIKit.cs b/src/src/Example.AppHost/AppModels/Resources/ExampleAPIKit.cs index 9be8ff9..08f49a1 100644 --- a/src/src/Example.AppHost/AppModels/Resources/ExampleAPIKit.cs +++ b/src/src/Example.AppHost/AppModels/Resources/ExampleAPIKit.cs @@ -1,12 +1,16 @@ using System.ComponentModel.DataAnnotations; +using Purview.Aspire.ResourceKit.Example.AppHost.AppModels.Groups; namespace Purview.Aspire.ResourceKit.Example.AppHost.AppModels.Resources; [ResourceDefinition(Platform.ResourceKits.API)] +[InGroup] +[ResourceUrl("Health", Platform.EndpointsDefinitions.Health, DisplayText = "Health")] +[EndpointUrl("https", DisplayText = "API")] sealed partial class ExampleAPIKit { protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => - builder.AddProject(Name).WithUrl("/health", "Health"); + builder.AddProject(Name); protected override void ConfigureResource() { diff --git a/src/src/Example.AppHost/AppModels/Resources/PostgresKit.cs b/src/src/Example.AppHost/AppModels/Resources/PostgresKit.cs index 60fd253..f8d417a 100644 --- a/src/src/Example.AppHost/AppModels/Resources/PostgresKit.cs +++ b/src/src/Example.AppHost/AppModels/Resources/PostgresKit.cs @@ -1,8 +1,10 @@ using Aspire.Hosting.Azure; +using Purview.Aspire.ResourceKit.Example.AppHost.AppModels.Groups; namespace Purview.Aspire.ResourceKit.Example.AppHost.AppModels.Resources; [ResourceDefinition(Platform.ResourceKits.Postgres)] +[InGroup] sealed partial class PostgresKit { public IResourceBuilder Database { get; private set; } diff --git a/src/src/Example.AppHost/AppModels/Resources/RedisKit.cs b/src/src/Example.AppHost/AppModels/Resources/RedisKit.cs index f38a44e..8818df4 100644 --- a/src/src/Example.AppHost/AppModels/Resources/RedisKit.cs +++ b/src/src/Example.AppHost/AppModels/Resources/RedisKit.cs @@ -1,8 +1,10 @@ using Aspire.Hosting.Azure; +using Purview.Aspire.ResourceKit.Example.AppHost.AppModels.Groups; namespace Purview.Aspire.ResourceKit.Example.AppHost.AppModels.Resources; [ResourceDefinition(Platform.ResourceKits.Redis)] +[InGroup] sealed partial class RedisKit { protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) diff --git a/src/src/Example.ManualAppHost/AppModels/ExampleHostKit.cs b/src/src/Example.ManualAppHost/AppModels/ExampleHostKit.cs index 98f10ee..491b72f 100644 --- a/src/src/Example.ManualAppHost/AppModels/ExampleHostKit.cs +++ b/src/src/Example.ManualAppHost/AppModels/ExampleHostKit.cs @@ -16,8 +16,21 @@ sealed class ExampleHostKit : HostKitBase public PostgresKit Postgres { get; init; } + public ResourceKitGroup PlatformGroup { get; init; } + + public ResourceKitGroup DataGroup { get; init; } + + public ResourceKitGroup ServicesGroup { get; init; } + public ExampleHostKit() { + PlatformGroup = new(Platform.ResourceGroups.Platform) + { + Urls = [new() { Url = Platform.ResourceGroups.DocsUrl, DisplayText = "ResourceKit docs" }], + }; + DataGroup = new(Platform.ResourceGroups.Data, PlatformGroup); + ServicesGroup = new(Platform.ResourceGroups.Services, PlatformGroup); + AzureStorage = new(this); ExampleAPI = new(this); KeyVault = new(this); @@ -25,11 +38,11 @@ public ExampleHostKit() Redis = new(this); Postgres = new(this); - AddResource(AzureStorage); - AddResource(ExampleAPI); + AddResource(AzureStorage, DataGroup); + AddResource(ExampleAPI, ServicesGroup); AddResource(KeyVault); AddResource(PublishMarker); - AddResource(Redis); - AddResource(Postgres); + AddResource(Redis, DataGroup); + AddResource(Postgres, DataGroup); } } diff --git a/src/src/Example.ManualAppHost/AppModels/Resources/ExampleAPIKit.cs b/src/src/Example.ManualAppHost/AppModels/Resources/ExampleAPIKit.cs index a9ecc1e..6e4b0b0 100644 --- a/src/src/Example.ManualAppHost/AppModels/Resources/ExampleAPIKit.cs +++ b/src/src/Example.ManualAppHost/AppModels/Resources/ExampleAPIKit.cs @@ -3,6 +3,12 @@ namespace Purview.Aspire.ResourceKit.Example.ManualAppHost.AppModels.Resources; sealed partial class ExampleAPIKit(ExampleHostKit hostKit) : ResourceKitBase(hostKit, Platform.ResourceKits.API) { + protected override IEnumerable Urls => + [ + new() { Url = Platform.EndpointsDefinitions.Health, DisplayText = "Health" }, + new() { Endpoint = "https", DisplayText = "API" }, + ]; + protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => builder.AddProject(Name); diff --git a/src/src/Example.ServiceDefaults/Platform.cs b/src/src/Example.ServiceDefaults/Platform.cs index 43ba8aa..1048253 100644 --- a/src/src/Example.ServiceDefaults/Platform.cs +++ b/src/src/Example.ServiceDefaults/Platform.cs @@ -20,6 +20,17 @@ public static class ResourceKits public const string PublishMarker = "publish-marker"; } + public static class ResourceGroups + { + public const string Platform = "platform"; + + public const string Data = "data"; + + public const string Services = "services"; + + public const string DocsUrl = "https://purview.dev/projects/aspire-resourcekit/"; + } + public static class EndpointsDefinitions { public const string Health = "/health"; diff --git a/src/src/ResourceKit/Extensions/Aspire/Hosting/ApplicationModel/IResourceBuilderExtensions.cs b/src/src/ResourceKit/Extensions/Aspire/Hosting/ApplicationModel/IResourceBuilderExtensions.cs index 4b63c77..26cf24b 100644 --- a/src/src/ResourceKit/Extensions/Aspire/Hosting/ApplicationModel/IResourceBuilderExtensions.cs +++ b/src/src/ResourceKit/Extensions/Aspire/Hosting/ApplicationModel/IResourceBuilderExtensions.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using Microsoft.Extensions.Logging; namespace Aspire.Hosting.ApplicationModel; @@ -6,6 +7,16 @@ namespace Aspire.Hosting.ApplicationModel; [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1034:Nested types should not be visible")] public static class IResourceBuilderExtensions { + static readonly Action UrlNotApplied = LoggerMessage.Define< + string, + string, + string + >( + LogLevel.Warning, + new(1, nameof(UrlNotApplied)), + "The URL '{Url}' for resource '{Resource}' was not applied: no endpoint URL matches '{Endpoint}'." + ); + extension(IResourceBuilder builder) where T : IResourceWithEnvironment { @@ -97,4 +108,78 @@ IEnumerable> values return resourceBuilder; } } + + extension(IResourceBuilder builder) + where T : IResource + { + /// + /// Places a resource that is not a resource kit, such as an admin tool an integration adds beside a kit's + /// resource, in a resource group's dashboard node. + /// + /// The host kit type. + /// The group to show the resource in. + /// The . + /// + /// + /// Call it from BuildResource or ConfigureResource, for example + /// sql.WithDbGate(dbGate => dbGate.WithResourceGroup(HostKit.Groups.Persistence)). The resource is + /// nested when the host kit finishes Configure, following the same rules as a kit: a resource that + /// already has a parent keeps it and the top of its chain is nested instead, and a resource placed in more + /// than one group stays in the first. When the group is not shown, the resource goes to the nearest shown + /// ancestor group; in publish mode, where there are no group nodes, nothing is placed. + /// + /// + /// This only decides where the resource is shown. The group's IsEnabled and Mode gate resource + /// kits, which decide whether to add the resource at all. + /// + /// + /// Thrown when the resource is a group's own dashboard node. + /// Thrown after the host kit has placed its groups. + public IResourceBuilder WithResourceGroup(ResourceKitGroup group) + where THostKit : class, IHostKit + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(group); + + group.Include(builder.Resource); + return builder; + } + + /// + /// Adds named URLs to the resource, resolving relative URLs against its endpoints when the dashboard + /// computes the resource's URLs. + /// + /// The URLs to apply. Disabled URLs are skipped. + /// The . + /// + /// Unlike WithUrl("/path"), which replaces the path of every endpoint URL, a relative + /// adds one URL for one endpoint. See . + /// + public IResourceBuilder WithResourceKitUrls(IEnumerable urls) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(urls); + + ResourceKitUrl[] items = [.. urls.Where(static u => u.IsEnabled)]; + if (items.Length == 0) + return builder; + + // The resource builder doesn't know the endpoint URLs yet, so we defer the application of the URLs until + // the dashboard computes the resource's URLs. + return builder.WithUrls(context => + { + var unresolved = ResourceKitUrl.Apply(items, context.Urls); + foreach (var url in unresolved) + { + UrlNotApplied( + context.Logger, + url.Url ?? "", + context.Resource.Name, + url.Endpoint ?? "", + null + ); + } + }); + } + } } diff --git a/src/src/ResourceKit/HostKitBase.cs b/src/src/ResourceKit/HostKitBase.cs index b4580e9..4126b58 100644 --- a/src/src/ResourceKit/HostKitBase.cs +++ b/src/src/ResourceKit/HostKitBase.cs @@ -1,5 +1,6 @@ -using System.Collections.Immutable; +using System.Collections.Immutable; using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; namespace Purview.Aspire.ResourceKit; @@ -10,15 +11,33 @@ namespace Purview.Aspire.ResourceKit; public abstract class HostKitBase : IHostKit where THostKit : class, IHostKit { + // The relationship type WithParentRelationship records, and the dashboard nests by. + const string ParentRelationshipType = "Parent"; + readonly List> _resources = []; + readonly List> _groups = []; + readonly HashSet> _registeredGroups = [with(ReferenceEqualityComparer.Instance)]; + readonly Dictionary, List>> _memberships = + [ + with(ReferenceEqualityComparer.Instance), + ]; bool _resourcesSealed; + // Held from Build to Configure, which adds the group dashboard nodes once and then releases it. + IDistributedApplicationBuilder? _builder; + /// /// Gets the resources managed by this host kit. /// /// The resources will be empty until is called. protected ImmutableArray> Resources { get; private set; } = []; + /// + /// Gets the resource groups registered with this host kit, parents before their children. + /// + /// The groups will be empty until is called. + protected ImmutableArray> ResourceGroups { get; private set; } = []; + /// /// Gets the execution mode in which this host kit, and therefore every resource it composes, participates. /// @@ -34,14 +53,80 @@ public abstract class HostKitBase : IHostKit /// /// The resource to add. /// Thrown when resources have been sealed. - public void AddResource(IResourceKit resource) - { - if (_resourcesSealed) - throw new InvalidOperationException("Resources cannot be added after the host kit has been sealed."); + public void AddResource(IResourceKit resource) => AddResource(resource, []); + /// + /// Adds a resource to the host kit as a member of . + /// + /// The resource to add. + /// + /// The groups the resource belongs to. The first is its primary group, which decides where the resource is + /// shown in the dashboard. Groups, and their ancestors, are registered with the host kit if they are not + /// already. + /// + /// Thrown when resources have been sealed. + public void AddResource(IResourceKit resource, params ReadOnlySpan> groups) + { + ThrowIfSealed(); ArgumentNullException.ThrowIfNull(resource); _resources.Add(resource); + + foreach (var group in groups) + { + ArgumentNullException.ThrowIfNull(group, nameof(groups)); + + AddGroup(group); + group.AddMember(resource); + + if (!_memberships.TryGetValue(resource, out var memberships)) + _memberships[resource] = memberships = []; + + if (!memberships.Contains(group)) + memberships.Add(group); + } + } + + /// + /// Registers a group, and its ancestors, with the host kit. + /// + /// The group to register. + /// + /// Only needed for a group no resource is added to; + /// registers the groups it is given. + /// + /// Thrown when resources have been sealed. + public void AddGroup(ResourceKitGroup group) + { + ThrowIfSealed(); + ArgumentNullException.ThrowIfNull(group); + + // Stops at the first ancestor already registered, so each group is visited once however groups arrive. + for (var current = group; current is not null; current = current.Parent) + { + if (!_registeredGroups.Add(current)) + break; + + _groups.Add(current); + } + } + + /// + /// Gets the groups a resource belongs to, primary group first. + /// + /// The resource. + /// The resource's groups, or an empty list when it belongs to none. + public IReadOnlyList> GetGroups(IResourceKit resource) + { + ArgumentNullException.ThrowIfNull(resource); + + return _memberships.TryGetValue(resource, out var groups) ? groups.AsReadOnly() : []; + } + + void ThrowIfSealed() + { + if (_resourcesSealed) + throw new InvalidOperationException("Resources cannot be added after the host kit has been sealed."); } bool SealResources() @@ -51,6 +136,7 @@ bool SealResources() _resourcesSealed = true; Resources = [.. _resources]; + ResourceGroups = [.. _groups.OrderBy(static g => g.Depth)]; return true; } @@ -67,17 +153,181 @@ public virtual void Build(IDistributedApplicationBuilder builder) foreach (var resource in Resources) { - if (!modeEnabled) + if (!modeEnabled || !GetGroups(resource).All(g => g.IsEnabledFor(builder))) resource.IsEnabled = false; resource.Build(builder); } + + _builder = builder; } /// + /// + /// After every resource is configured, the groups' dashboard nodes are added and resources are parented to + /// them. Doing this last means a resource added in ConfigureResource, as well as in + /// BuildResource, can never be shadowed by a group node of the same name. + /// public virtual void Configure() { foreach (var resource in Resources) resource.Configure(); + + if (_builder is not null) + { + BuildGroupDashboardResources(_builder); + _builder = null; + + // Placement has happened, so a later Include could only be silently ignored. + foreach (var group in ResourceGroups) + group.CloseIncludes(); + } + } + + // A group's node is named after the group when that name is free. Aspire resource names are unique and + // case-insensitive, and a group is often named after something it contains (a "minder" group holding a + // "minder" database), so a taken name falls back to "-group", then "-group-2", and so on. + static string DashboardResourceName(IDistributedApplicationBuilder builder, string groupName) + { + HashSet taken = new(builder.Resources.Select(static r => r.Name), StringComparer.OrdinalIgnoreCase); + if (!taken.Contains(groupName)) + return groupName; + + var candidate = $"{groupName}-group"; + for (var suffix = 2; taken.Contains(candidate); suffix++) + candidate = $"{groupName}-group-{suffix}"; + + return candidate; + } + + void BuildGroupDashboardResources(IDistributedApplicationBuilder builder) + { + if (!builder.ExecutionContext.IsRunMode || ResourceGroups.IsEmpty) + return; + + AddGroupNodes(builder); + PlaceResourcesInGroupNodes(); + } + + void AddGroupNodes(IDistributedApplicationBuilder builder) + { + // ResourceGroups is ordered parents first, so a parent's node always exists before its children's. + foreach (var group in ResourceGroups) + { + if (!group.ShowInDashboard || !group.IsEnabledFor(builder) || !group.HasDashboardContent()) + continue; + + var node = builder + .AddResource(new ResourceKitGroupResource(DashboardResourceName(builder, group.Name))) + .WithInitialState( + new() + { + ResourceType = ResourceKitGroupResource.ResourceTypeName, + State = new(KnownResourceStates.Active, KnownResourceStateStyles.Success), + IconName = group.Icon ?? ResourceKitGroupResource.IconName, + Properties = [], + } + ) + .ExcludeFromManifest(); + + // URLs go on as annotations, not in the initial snapshot: the orchestrator replaces a snapshot's URLs + // with the ones it computes from the resource's annotations. A group has no endpoints, so only an + // absolute URL can be shown. + foreach (var url in group.Urls.Where(static u => u.IsEnabled && u.IsAbsolute)) + { + node.WithAnnotation( + new ResourceUrlAnnotation + { + Url = url.Url!, + DisplayText = url.DisplayText, + DisplayLocation = url.DisplayLocation, + } + ); + } + + var parentNode = group.Parent?.NearestDashboardResource(); + if (parentNode is not null) + node.WithParentRelationship(parentNode); + + group.DashboardResource = node; + } + } + + void PlaceResourcesInGroupNodes() + { + List<(IResource Resource, IResourceBuilder Node)> placements = []; + HashSet kitResources = new(ReferenceEqualityComparer.Instance); + foreach (var resource in Resources) + { + if (!resource.IsEnabled || resource is not IResourceBuilder resourceBuilder) + continue; + + kitResources.Add(resourceBuilder.Resource); + + var groups = GetGroups(resource); + var parentNode = groups.Count == 0 ? null : groups[0].NearestDashboardResource(); + if (parentNode is not null) + placements.Add((resourceBuilder.Resource, parentNode)); + } + + // Then the resources groups included explicitly, after every kit, so a kit is always placed by its own + // membership; parents first, so a resource included in two groups stays in the first one listed. + foreach (var group in ResourceGroups) + { + var parentNode = group.NearestDashboardResource(); + if (parentNode is null) + continue; + + foreach (var included in group.IncludedResources) + placements.Add((included, parentNode)); + } + + // First the resources with no parent of their own, so a kit that another kit's resource hangs off is + // placed before anything walks up to it. + foreach (var (resource, node) in placements) + { + if (ParentOf(resource) is null) + resource.Annotations.Add(new ResourceRelationshipAnnotation(node.Resource, ParentRelationshipType)); + } + + // Then the kits that already have a parent. The dashboard shows one parent per resource, so the kit + // keeps its own, and the top of its chain is nested instead: an integration that builds its own tree + // (AspireC4's diagram group, a database under its server) lands in the group with its shape intact. + foreach (var (resource, node) in placements) + { + if (ParentOf(resource) is null) + continue; + + var root = TopOfParentChain(resource); + + // Already in a group, or under another kit, which decides where its own tree goes. + if (root is null || root is ResourceKitGroupResource || kitResources.Contains(root)) + continue; + + root.Annotations.Add(new ResourceRelationshipAnnotation(node.Resource, ParentRelationshipType)); + } + } + + static IResource? ParentOf(IResource resource) => + resource + .Annotations.OfType() + .FirstOrDefault(static a => a.Type == ParentRelationshipType) + ?.Resource; + + // The ancestor with no parent. The chain belongs to whoever built it, so it is walked defensively: a + // resource seen twice is a cycle, and a cycle has no top to nest, so null is returned. + static IResource? TopOfParentChain(IResource resource) + { + HashSet seen = new(ReferenceEqualityComparer.Instance) { resource }; + var current = resource; + while (ParentOf(current) is { } parent) + { + if (!seen.Add(parent)) + return null; + + current = parent; + } + + return current; } } diff --git a/src/src/ResourceKit/ResourceKitBase.cs b/src/src/ResourceKit/ResourceKitBase.cs index 476e510..d53eccd 100644 --- a/src/src/ResourceKit/ResourceKitBase.cs +++ b/src/src/ResourceKit/ResourceKitBase.cs @@ -59,6 +59,17 @@ public string Name /// protected virtual ResourceKitMode Mode => ResourceKitMode.All; + /// + /// Gets the named URLs the dashboard shows for this resource. + /// + /// + /// Defaults to none. Generated resource kits override this member with the URLs declared by + /// [ResourceUrl] and [EndpointUrl], as bound from options; manual resource kits can override it + /// directly. The URLs are applied after returns, through + /// . + /// + protected virtual IEnumerable Urls => []; + /// /// Gets the underlying Aspire resource builder for this resource. /// @@ -116,6 +127,10 @@ public void Build(IDistributedApplicationBuilder builder) return; ResourceBuilder = BuildResource(builder); + + ResourceKitUrl[] urls = [.. Urls.Where(static u => u.IsEnabled)]; + if (urls.Length > 0) + ResourceBuilder.WithResourceKitUrls(urls); } /// diff --git a/src/src/ResourceKit/ResourceKitGroup.cs b/src/src/ResourceKit/ResourceKitGroup.cs new file mode 100644 index 0000000..3653e87 --- /dev/null +++ b/src/src/ResourceKit/ResourceKitGroup.cs @@ -0,0 +1,256 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; + +namespace Purview.Aspire.ResourceKit; + +/// +/// A named group of resource kits, used to enable, disable or scope a set of resources with one switch and +/// to nest them under one node in the Aspire dashboard. +/// +/// +/// +/// Groups nest through . A resource kit can belong to several groups; it is built only +/// when every group it belongs to, and every ancestor of those groups, is enabled for the current run (see +/// ). The first group a resource kit is added with is its primary group, +/// and decides where the kit is shown in the dashboard. +/// +/// +/// Generated groups ([ResourceGroup]) derive from this class and bind , +/// , and from options. Manual host kits can +/// create instances directly and pass them to . +/// +/// +/// The host kit type. +public class ResourceKitGroup + where THostKit : class, IHostKit +{ + readonly List> _children = []; + readonly List> _resources = []; + readonly List _included = []; + bool _includesClosed; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The group name. It also names the group's dashboard resource, so it must be a valid Aspire resource name. + /// When another resource already has the name, the node is named <name>-group instead; see + /// . + /// + /// The optional parent group. + public ResourceKitGroup(string name, ResourceKitGroup? parent = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + Name = name; + Parent = parent; + Depth = parent is null ? 0 : parent.Depth + 1; + Children = _children.AsReadOnly(); + Resources = _resources.AsReadOnly(); + IncludedResources = _included.AsReadOnly(); + + // The only place a parent link is made. This instance does not exist until the constructor runs, so it + // cannot already be an ancestor of its parent: the parent chain is acyclic by construction. + parent?._children.Add(this); + } + + /// + /// Gets the group name. + /// + public string Name { get; } + + /// + /// Gets or sets the Fluent UI icon shown for the group in the dashboard. When , + /// + public string? Icon { get; set; } + + /// + /// Gets the parent group, or for a root group. + /// + public ResourceKitGroup? Parent { get; } + + /// + /// Gets or sets a value indicating whether the group, and so every resource kit in it, is enabled. + /// + public bool IsEnabled { get; set; } = true; + + /// + /// Gets or sets the execution mode in which the group, and so every resource kit in it, participates. + /// + public ResourceKitMode Mode { get; set; } = ResourceKitMode.All; + + /// + /// Gets or sets a value indicating whether the group is shown as a node in the Aspire dashboard. + /// + /// + /// When , the group still gates its resources, and they are parented to the nearest + /// ancestor group that is shown instead. + /// + public bool ShowInDashboard { get; set; } = true; + + /// + /// Gets or sets the absolute URLs shown on the group's dashboard node, for example a runbook link. + /// + public IReadOnlyList Urls { get; set; } = []; + + /// + /// Gets the groups whose is this group. + /// + /// Read-only: a child is added only by constructing it with this group as its parent. + public IReadOnlyList> Children { get; } + + /// + /// Gets the resource kits added directly to this group. + /// + public IReadOnlyList> Resources { get; } + + /// + /// Gets the resources that are not resource kits but were placed in this group with + /// WithResourceGroup(group). + /// + public IReadOnlyList IncludedResources { get; } + + // The public entry point is IResourceBuilder.WithResourceGroup, which carries the documentation. + internal void Include(IResource resource) + { + ArgumentNullException.ThrowIfNull(resource); + + // A group node is only ever parented to another group node; including one could parent it to itself. + if (resource is ResourceKitGroupResource) + throw new ArgumentException("A group's dashboard node cannot be included in a group.", nameof(resource)); + + if (_includesClosed) + { + throw new InvalidOperationException( + $"Resources cannot be included in the '{Name}' group after the host kit has configured its resources." + ); + } + + if (!_included.Contains(resource, ReferenceEqualityComparer.Instance)) + _included.Add(resource); + } + + /// + /// Gets the resource kits in this group and in every descendant group, each once. + /// + public IEnumerable> AllResources + { + get + { + // Iterative, and each group is visited once, so the walk cannot recurse or loop even if the tree + // were ever malformed. Order is depth-first: a group's own resources, then each child in turn. + HashSet> seenResources = [with(ReferenceEqualityComparer.Instance)]; + HashSet> seenGroups = [with(ReferenceEqualityComparer.Instance)]; + Stack> pending = new([this]); + + while (pending.Count > 0) + { + var group = pending.Pop(); + if (!seenGroups.Add(group)) + continue; + + foreach (var resource in group._resources) + { + if (seenResources.Add(resource)) + yield return resource; + } + + for (var i = group._children.Count - 1; i >= 0; i--) + pending.Push(group._children[i]); + } + } + } + + /// + /// Gets the group's dashboard resource, once the host kit has finished configuring its resources. + /// + /// + /// + /// until has run, so it is not + /// available inside ConfigureResource. It also stays outside run mode, when + /// is , when the group is disabled, or when none of its + /// resources were built. + /// + /// + /// The resource is named when no other resource has that name, and otherwise + /// <Name>-group (then -group-2, and so on), because Aspire resource names are unique. + /// + /// + public IResourceBuilder? DashboardResource { get; internal set; } + + /// + /// Determines whether the group, and every ancestor of it, is enabled for the current run. + /// + /// The distributed application builder. + /// when the group's resources may be built. + public bool IsEnabledFor(IDistributedApplicationBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + for (var group = this; group is not null; group = group.Parent) + { + if (!group.IsEnabled || !group.Mode.IsEnabledFor(builder)) + return false; + } + + return true; + } + + // Fixed at construction: a parent's depth is already known, and the parent can never change. + internal int Depth { get; } + + internal void CloseIncludes() => _includesClosed = true; + + // Whether this group or a descendant has anything to show: a built kit, or an included resource. + internal bool HasDashboardContent() + { + if (AllResources.Any(static r => r.IsEnabled)) + return true; + + HashSet> seen = [with(ReferenceEqualityComparer.Instance)]; + Stack> pending = new([this]); + while (pending.Count > 0) + { + var group = pending.Pop(); + if (!seen.Add(group)) + continue; + + if (group._included.Count > 0) + return true; + + foreach (var child in group._children) + pending.Push(child); + } + + return false; + } + + internal void AddMember(IResourceKit resource) + { + if (!_resources.Contains(resource)) + _resources.Add(resource); + } + + internal IResourceBuilder? NearestDashboardResource() + { + for (var group = this; group is not null; group = group.Parent) + { + if (group.DashboardResource is not null) + return group.DashboardResource; + } + + return null; + } + + /// + public override string ToString() + { + // Iterative, like every walk up the parent chain, so a deep hierarchy cannot exhaust the stack. + List names = []; + for (var group = this; group is not null; group = group.Parent) + names.Add(group.Name); + + names.Reverse(); + return string.Join('/', names); + } +} diff --git a/src/src/ResourceKit/ResourceKitGroupResource.cs b/src/src/ResourceKit/ResourceKitGroupResource.cs new file mode 100644 index 0000000..0c1947a --- /dev/null +++ b/src/src/ResourceKit/ResourceKitGroupResource.cs @@ -0,0 +1,24 @@ +using Aspire.Hosting.ApplicationModel; + +namespace Purview.Aspire.ResourceKit; + +/// +/// The dashboard node that represents a in run mode. +/// +/// +/// The resource has no lifecycle of its own: it is added in run mode only, is excluded from the publishing +/// manifest, and exists so the group's resources can be parented to it in the Aspire dashboard. +/// +/// The resource name, which is the group's . +public sealed class ResourceKitGroupResource(string name) : Resource(name) +{ + /// + /// The resource type shown for a group in the dashboard. + /// + public const string ResourceTypeName = "Group"; + + /// + /// The Fluent UI icon shown for a group in the dashboard. + /// + public const string IconName = "Folder"; +} diff --git a/src/src/ResourceKit/ResourceKitUrl.cs b/src/src/ResourceKit/ResourceKitUrl.cs new file mode 100644 index 0000000..9e7fa7f --- /dev/null +++ b/src/src/ResourceKit/ResourceKitUrl.cs @@ -0,0 +1,163 @@ +using Aspire.Hosting.ApplicationModel; + +namespace Purview.Aspire.ResourceKit; + +/// +/// Describes a named URL shown for a resource, or a resource kit group, in the Aspire dashboard. +/// +/// +/// +/// A URL with a adds a link. An absolute URL is shown as-is; a relative URL is resolved +/// against the it names, or - when no endpoint is named - against the resource's +/// https endpoint, then its http endpoint, then its first endpoint. +/// +/// +/// A URL without a customises the URL Aspire already shows for , +/// changing its and . +/// +/// +/// The type is mutable so generated options can bind it from configuration, for example +/// ExampleHostKit:API:Urls:Health:IsEnabled=false. +/// +/// +public sealed class ResourceKitUrl +{ + /// + /// Gets or sets the absolute or relative URL to add, or to customise the URL of + /// . + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Design", + "CA1056:URI-like properties should not be strings", + Justification = "A relative path such as '/health' is not a Uri, and the value binds from configuration; it mirrors ResourceUrlAnnotation.Url." + )] + public string? Url { get; set; } + + /// + /// Gets or sets the text the dashboard shows for the URL. + /// + public string? DisplayText { get; set; } + + /// + /// Gets or sets the name of the endpoint a relative URL is resolved against, or whose URL is customised. + /// + public string? Endpoint { get; set; } + + /// + /// Gets or sets where the dashboard shows the URL. + /// + public UrlDisplayLocation DisplayLocation { get; set; } = UrlDisplayLocation.SummaryAndDetails; + + /// + /// Gets or sets a value indicating whether the URL is applied. + /// + public bool IsEnabled { get; set; } = true; + + /// + /// Gets a value indicating whether is absolute, and therefore needs no endpoint. + /// + public bool IsAbsolute => TryGetAbsoluteUri(Url, out _); + + /// + /// Applies to the URLs Aspire has computed for a resource. + /// + /// The URLs to apply. Disabled URLs are skipped. + /// The resource's URLs, as exposed by . + /// The URLs that could not be applied because no matching endpoint URL exists. + public static IReadOnlyList Apply( + IEnumerable urls, + IList resourceUrls + ) + { + ArgumentNullException.ThrowIfNull(urls); + ArgumentNullException.ThrowIfNull(resourceUrls); + + // Resolve against the endpoint URLs as they were before this call, so one added URL is never the + // base of another. + ResourceUrlAnnotation[] endpointUrls = [.. resourceUrls.Where(static u => u.Endpoint is not null)]; + List unresolved = []; + + foreach (var url in urls) + { + if (!url.IsEnabled) + continue; + + if (url.Url is null) + { + var matched = false; + foreach (var endpointUrl in endpointUrls.Where(u => IsEndpoint(u, url.Endpoint))) + { + matched = true; + if (url.DisplayText is not null) + endpointUrl.DisplayText = url.DisplayText; + + endpointUrl.DisplayLocation = url.DisplayLocation; + } + + if (!matched) + unresolved.Add(url); + + continue; + } + + if (TryGetAbsoluteUri(url.Url, out var absolute)) + { + resourceUrls.Add( + new() + { + Url = absolute.ToString(), + DisplayText = url.DisplayText, + DisplayLocation = url.DisplayLocation, + } + ); + continue; + } + + var baseUrl = SelectBaseUrl(endpointUrls, url.Endpoint); + if (baseUrl is null || !Uri.TryCreate(baseUrl.Url, UriKind.Absolute, out var baseUri)) + { + unresolved.Add(url); + continue; + } + + resourceUrls.Add( + new() + { + Url = new Uri(baseUri, url.Url).ToString(), + DisplayText = url.DisplayText, + Endpoint = baseUrl.Endpoint, + DisplayLocation = url.DisplayLocation, + } + ); + } + + return unresolved; + } + + static ResourceUrlAnnotation? SelectBaseUrl(ResourceUrlAnnotation[] endpointUrls, string? endpoint) + { + if (endpoint is not null) + return endpointUrls.FirstOrDefault(u => IsEndpoint(u, endpoint)); + + // Prefer the https endpoint, then http, then any. + return endpointUrls.FirstOrDefault(static u => IsEndpoint(u, "https")) + ?? endpointUrls.FirstOrDefault(static u => IsEndpoint(u, "http")) + ?? endpointUrls.FirstOrDefault(); + } + + static bool IsEndpoint(ResourceUrlAnnotation url, string? endpoint) => + endpoint is not null && string.Equals(url.Endpoint?.EndpointName, endpoint, StringComparison.OrdinalIgnoreCase); + + // A path such as "/health" parses as an absolute file URI on Unix, so only accept a URI with a host. + static bool TryGetAbsoluteUri(string? url, out Uri uri) + { + if (url is not null && Uri.TryCreate(url, UriKind.Absolute, out var parsed) && !parsed.IsFile) + { + uri = parsed; + return true; + } + + uri = default!; + return false; + } +} diff --git a/src/src/ResourceKit/Sdk/README.md b/src/src/ResourceKit/Sdk/README.md index 4f4d6a9..dac5f4e 100644 --- a/src/src/ResourceKit/Sdk/README.md +++ b/src/src/ResourceKit/Sdk/README.md @@ -83,6 +83,28 @@ partial class APIResourceKit : ResourceKitBase Do not mix both attribute styles on the same class. +### Resource groups and dashboard URLs + +`[ResourceGroup]` marks a partial class as a named, nestable group (`[ResourceGroup]` or +`Parent = typeof(TParent)`), and +`[InGroup]` adds a resource kit to it. A group can be switched off or given a `Mode` from +`:Groups:`, which applies to every kit in it. In run mode each group appears as a +dashboard node with its kits nested underneath. + +`[ResourceUrl(name, url)]` adds a named dashboard URL: absolute, or relative to an endpoint. +`[EndpointUrl(endpoint)]` renames an endpoint's own URL. Each URL becomes a `ResourceKitUrl` option under +`::Urls:`. + +```csharp +[ResourceGroup("data")] +sealed partial class DataGroup; + +[ResourceDefinition("api")] +[InGroup] +[ResourceUrl("Health", "/health", DisplayText = "Health")] +[EndpointUrl("https", DisplayText = "API")] +partial class APIResourceKit { /* ... */ } +``` ## Minimal package usage ```csharp @@ -250,8 +272,18 @@ variables. | SG0018 | Warning | Project resource kit does not add the declared project via `AddProject()` (execution-only) | | SG0019 | Warning | Project resource kit explicit base does not use `ProjectResource` (execution-only) | | SG0020 | Error | `OptionsHelper.Assign` action sets more than one property path | - -SG0017–SG0019 are execution-only warnings: they indicate the resource will fail at runtime but never +| SG0021 | Error | `[InGroup]` type or group `Parent` is not a `[ResourceGroup]` class | +| SG0022 | Error | Resource group parent cycle | +| SG0023 | Error | Duplicate resource group name | +| SG0024 | Warning | More than one primary group on a resource kit (execution-only) | +| SG0025 | Error | Resource group declares a base type | +| SG0026 | Error | Duplicate URL name on a resource kit or group | +| SG0027 | Error | Endpoint URL on a resource or group without endpoints | +| SG0028 | Warning | Relative URL on a resource or group without endpoints (execution-only) | +| SG0029 | Error | Resource group declares its parent more than once | +| SG0030 | Error | Resource group is generic | +| SG0031 | Error | Resource group is also a resource kit or a group member | +SG0017–SG0019, SG0024 and SG0028 are execution-only warnings: they indicate the resource will fail at runtime but never block generation, so a resource kit with incomplete wiring (for example a project not yet added via `AddProject()`) is still generated and the host kit output is still emitted. diff --git a/src/src/SourceGeneration/AnalyzerReleases.Unshipped.md b/src/src/SourceGeneration/AnalyzerReleases.Unshipped.md index e9b1a80..d763b7e 100644 --- a/src/src/SourceGeneration/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGeneration/AnalyzerReleases.Unshipped.md @@ -2,3 +2,14 @@ Rule ID | Category | Severity | Notes --------|----------|----------|------ +SG0021 | Purview.Aspire.ResourceKit.SourceGenerator | Error | Type is not a resource group +SG0022 | Purview.Aspire.ResourceKit.SourceGenerator | Error | Resource group parent cycle +SG0023 | Purview.Aspire.ResourceKit.SourceGenerator | Error | Duplicate resource group name +SG0024 | Purview.Aspire.ResourceKit.SourceGenerator | Warning | More than one primary group +SG0025 | Purview.Aspire.ResourceKit.SourceGenerator | Error | Resource group cannot have explicit base +SG0026 | Purview.Aspire.ResourceKit.SourceGenerator | Error | Duplicate URL name +SG0027 | Purview.Aspire.ResourceKit.SourceGenerator | Error | Endpoint URL on a resource without endpoints +SG0028 | Purview.Aspire.ResourceKit.SourceGenerator | Warning | Relative URL on a resource without endpoints +SG0029 | Purview.Aspire.ResourceKit.SourceGenerator | Error | Resource group declares its parent more than once +SG0030 | Purview.Aspire.ResourceKit.SourceGenerator | Error | Resource group cannot be generic +SG0031 | Purview.Aspire.ResourceKit.SourceGenerator | Error | Resource group cannot be a resource kit or a group member diff --git a/src/src/SourceGeneration/Helpers/CodeGenEmiiter.Attributes.cs b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.Attributes.cs index bf9e495..7c1d591 100644 --- a/src/src/SourceGeneration/Helpers/CodeGenEmiiter.Attributes.cs +++ b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.Attributes.cs @@ -362,6 +362,319 @@ static SourceText ResourceKitDefinitionAttribute() return writer; } + static SourceText ResourceGroupAttributes() + { + var writer = CreateWriter(); + + writer + .XmlSummary( + "Marks a class as a named group of resource kits. Kits join it with InGroupAttribute{TGroup}.", + "The generator derives the class from ResourceKitGroup{THostKit} and binds its options." + ) + .AttributeClass( + new TypeDeclarationOptions(TypeLibrary.Purview.Aspire.ResourceKit.ResourceGroupAttribute) + { + IsSealed = false, + }, + AttributeTargets.Class, + static attributeBody => + { + attributeBody + .XmlSummary("Initializes a new instance of the ResourceGroupAttribute class.") + .Constructor( + new(TypeLibrary.Purview.Aspire.ResourceKit.ResourceGroupAttribute), + static ctor => ctor.Comment("Empty") + ); + + attributeBody + .XmlSummary("Initializes a new instance of the ResourceGroupAttribute class.") + .XmlParam( + "name", + "The group name, which also names its dashboard node (suffixed with -group if the name is taken)." + ) + .Constructor( + new(TypeLibrary.Purview.Aspire.ResourceKit.ResourceGroupAttribute) + { + Parameters = [new("name", TypeLibrary.System.String)], + }, + static ctor => ctor.Assignment("Name", "name") + ); + + attributeBody + .XmlSummary( + "Gets or sets the group name, which also names its dashboard node (suffixed with -group if the name is taken).", + "Defaults to the class name without a 'ResourceGroup' or 'Group' suffix." + ) + .Property( + new( + "Name", + TypeLibrary.System.String.MakeNullable(attributeBody), + TypeDeclarationAccessibility.Public + ) + { + IsInitOnly = true, + } + ); + + attributeBody + .XmlSummary("Gets or sets the parent group, another class marked with ResourceGroupAttribute.") + .Property( + new( + "Parent", + TypeLibrary.System.Type.MakeNullable(attributeBody), + TypeDeclarationAccessibility.Public + ) + { + IsInitOnly = true, + } + ); + + attributeBody + .XmlSummary("Gets or sets a value indicating whether the group is shown as a dashboard node.") + .Property( + new("ShowInDashboard", TypeLibrary.System.Boolean, TypeDeclarationAccessibility.Public) + { + Initializer = "true", + IsInitOnly = true, + } + ); + + AddModeProperty(attributeBody); + } + ); + + writer + .XmlSummary( + "Marks a class as a named group of resource kits, nested under the resource group TParent.", + "Equivalent to ResourceGroupAttribute with Parent = typeof(TParent); do not set Parent as well." + ) + .AttributeClass( + new TypeDeclarationOptions(TypeLibrary.Purview.Aspire.ResourceKit.GenericResourceGroupAttribute) + { + BaseType = TypeLibrary.Purview.Aspire.ResourceKit.ResourceGroupAttribute, + GenericTypes = [new("TParent") { Constraints = ["class"] }], + }, + AttributeTargets.Class, + static attributeBody => + { + attributeBody + .XmlSummary("Initializes a new instance of the ResourceGroupAttribute class.") + .Constructor( + new(TypeLibrary.Purview.Aspire.ResourceKit.ResourceGroupAttribute), + static ctor => ctor.Comment("Empty") + ); + + attributeBody + .XmlSummary("Initializes a new instance of the ResourceGroupAttribute class.") + .XmlParam( + "name", + "The group name, which also names its dashboard node (suffixed with -group if the name is taken)." + ) + .Constructor( + new(TypeLibrary.Purview.Aspire.ResourceKit.ResourceGroupAttribute) + { + Parameters = [new("name", TypeLibrary.System.String)], + Initializer = "base(name)", + }, + static ctor => ctor.Comment("Empty") + ); + } + ); + + writer + .XmlSummary("Adds a resource kit to a resource group.") + .AttributeClass( + new TypeDeclarationOptions(TypeLibrary.Purview.Aspire.ResourceKit.InGroupAttribute) + { + IsSealed = false, + IsAbstract = true, + }, + AttributeTargets.Class, + static attributeBody => + { + attributeBody + .XmlSummary("Initializes a new instance of the InGroupAttribute class.") + .Constructor( + new(TypeLibrary.Purview.Aspire.ResourceKit.InGroupAttribute) + { + Accessibility = TypeDeclarationAccessibility.Protected, + }, + static ctor => ctor.Comment("Empty") + ); + + attributeBody + .XmlSummary( + "Gets or sets a value indicating whether this is the kit's primary group, which decides where the kit is shown in the dashboard.", + "Defaults to the first group declared." + ) + .Property( + new("Primary", TypeLibrary.System.Boolean, TypeDeclarationAccessibility.Public) + { + IsInitOnly = true, + } + ); + }, + allowMultiple: true + ); + + writer + .XmlSummary("Adds a resource kit to the resource group TGroup.") + .AttributeClass( + new TypeDeclarationOptions(TypeLibrary.Purview.Aspire.ResourceKit.GenericInGroupAttribute) + { + BaseType = TypeLibrary.Purview.Aspire.ResourceKit.InGroupAttribute, + GenericTypes = [new("TGroup") { Constraints = ["class"] }], + }, + AttributeTargets.Class, + static attributeBody => + attributeBody + .XmlSummary("Initializes a new instance of the InGroupAttribute class.") + .Constructor( + new(TypeLibrary.Purview.Aspire.ResourceKit.InGroupAttribute), + static ctor => ctor.Comment("Empty") + ), + allowMultiple: true + ); + + return writer; + } + + static SourceText ResourceUrlAttributes() + { + var writer = CreateWriter(); + + writer + .XmlSummary( + "Declares a named URL the dashboard shows for a resource kit or resource group.", + "A relative URL is resolved against an endpoint of the resource; an absolute URL is shown as-is." + ) + .AttributeClass( + new TypeDeclarationOptions(TypeLibrary.Purview.Aspire.ResourceKit.ResourceUrlAttribute), + AttributeTargets.Class, + static attributeBody => + { + attributeBody + .XmlSummary("Initializes a new instance of the ResourceUrlAttribute class.") + .XmlParam("name", "The URL name, which is also its generated options property.") + .XmlParam("url", "The absolute URL, or a path relative to the resource's endpoint.") + .Constructor( + new(TypeLibrary.Purview.Aspire.ResourceKit.ResourceUrlAttribute) + { + Parameters = + [ + new("name", TypeLibrary.System.String), + new("url", TypeLibrary.System.String), + ], + }, + static ctor => ctor.Assignment("Name", "name").Assignment("Url", "url") + ); + + attributeBody + .XmlSummary("Gets the URL name, which is also its generated options property.") + .Property( + new("Name", TypeLibrary.System.String, TypeDeclarationAccessibility.Public) + { + HasSetter = false, + } + ); + + attributeBody + .XmlSummary("Gets the absolute URL, or a path relative to the resource's endpoint.") + .Property( + new("Url", TypeLibrary.System.String, TypeDeclarationAccessibility.Public) + { + HasSetter = false, + } + ); + + AddDisplayTextProperty(attributeBody); + + attributeBody + .XmlSummary( + "Gets or sets the endpoint a relative URL is resolved against.", + "Defaults to the https endpoint, then the http endpoint, then the first endpoint." + ) + .Property( + new( + "Endpoint", + TypeLibrary.System.String.MakeNullable(attributeBody), + TypeDeclarationAccessibility.Public + ) + { + IsInitOnly = true, + } + ); + + AddDisplayLocationProperty(attributeBody); + }, + allowMultiple: true + ); + + writer + .XmlSummary("Customises the URL the dashboard shows for one of a resource kit's endpoints.") + .AttributeClass( + new TypeDeclarationOptions(TypeLibrary.Purview.Aspire.ResourceKit.EndpointUrlAttribute), + AttributeTargets.Class, + static attributeBody => + { + attributeBody + .XmlSummary("Initializes a new instance of the EndpointUrlAttribute class.") + .XmlParam("endpoint", "The endpoint name; it also names the generated options property.") + .Constructor( + new(TypeLibrary.Purview.Aspire.ResourceKit.EndpointUrlAttribute) + { + Parameters = [new("endpoint", TypeLibrary.System.String)], + }, + static ctor => ctor.Assignment("Endpoint", "endpoint") + ); + + attributeBody + .XmlSummary("Gets the endpoint name.") + .Property( + new("Endpoint", TypeLibrary.System.String, TypeDeclarationAccessibility.Public) + { + HasSetter = false, + } + ); + + AddDisplayTextProperty(attributeBody); + AddDisplayLocationProperty(attributeBody); + }, + allowMultiple: true + ); + + return writer; + } + + static void AddDisplayTextProperty(CodeWriter attributeBody) => + attributeBody + .XmlSummary("Gets or sets the text the dashboard shows for the URL.") + .Property( + new( + "DisplayText", + TypeLibrary.System.String.MakeNullable(attributeBody), + TypeDeclarationAccessibility.Public + ) + { + IsInitOnly = true, + } + ); + + static void AddDisplayLocationProperty(CodeWriter attributeBody) => + attributeBody + .XmlSummary("Gets or sets where the dashboard shows the URL.") + .Property( + new( + "DisplayLocation", + TypeLibrary.Aspire.Hosting.ApplicationModel.UrlDisplayLocation, + TypeDeclarationAccessibility.Public + ) + { + Initializer = $"{TypeLibrary.Aspire.Hosting.ApplicationModel.UrlDisplayLocation}.SummaryAndDetails", + IsInitOnly = true, + } + ); + static CodeWriter CreateWriter() { CodeWriter writer = new(GenerationSettings.Create()); diff --git a/src/src/SourceGeneration/Helpers/CodeGenEmiiter.HostKit.cs b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.HostKit.cs index 2c3f804..05b408e 100644 --- a/src/src/SourceGeneration/Helpers/CodeGenEmiiter.HostKit.cs +++ b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.HostKit.cs @@ -70,6 +70,10 @@ static void EmitHostKit(OutputContext context, CancellationToken cancellationTok context.HostKit.ShouldGenerateOptions ? "Options.Mode" : ModeExpression(context.HostKit.Mode) ); + // Write the typed resource group accessor + if (context.HasResourceGroups) + GenerateGroupsProperty(context); + // Write the Resource Kit properties if (context.Model.HasResourceKits) { @@ -91,6 +95,9 @@ static void EmitHostKit(OutputContext context, CancellationToken cancellationTok // Write the partial lifecycle hooks. GenerateLifecyclePartialMethods(context, cancellationToken); + if (context.HasResourceGroups) + GenerateGroupsClass(context, cancellationToken); + if (context.Model.HostKit.Value.ShouldGenerateOptions) { // Generate the options class. @@ -152,6 +159,32 @@ static void EmitResourceKitBase(OutputContext context, CancellationToken cancell } } + static void GenerateGroupsProperty(OutputContext context) + { + var groupsType = context.HostKit.HostKitType.Nested(GroupsClassName); + + context + .Writer.XmlSummary("Gets the resource groups composed by this host kit.") + .XmlException( + TypeLibrary.System.InvalidOperationException, + "Thrown if the groups have not been initialized. Call Build first." + ); + + context.Writer.Property( + new("Groups", groupsType, TypeDeclarationAccessibility.Public) + { + HasGetter = true, + HasSetter = true, + SetterAccessibility = TypeDeclarationAccessibility.Private, + }, + writeGetterBody => + writeGetterBody.Return( + $"field ?? throw new {TypeLibrary.System.InvalidOperationException}({"The resource groups have not been initialized. Call Build first.".StringLiteral()})" + ), + writeSetterBody => writeSetterBody.Assignment("field", "value") + ); + } + static void GenerateResourceKitProperties(OutputContext context, CancellationToken cancellationToken) { context.Debug( @@ -235,6 +268,17 @@ static void GenerateBuildMethod(OutputContext context, CancellationToken cancell .MethodCall("OnPreBuild", ["builder"]) .NewLine(); + if (context.HasResourceGroups) + { + context + .Writer.Comment("Creating the resource groups, parents before their children.") + .Assignment( + "Groups", + new ObjectCreationOptions(context.HostKit.ShouldGenerateOptions ? ["Options"] : []) + ) + .NewLine(); + } + foreach ( var resourceKit in context .ResourceKits.AsImmutableArray() @@ -274,7 +318,17 @@ var resourceKit in context ) { cancellationToken.ThrowIfCancellationRequested(); - context.Writer.MethodCall("AddResource", resourceKit.PropertyName); + + // The primary group is first; it decides where the kit is shown in the dashboard. + string[] arguments = + [ + resourceKit.PropertyName, + .. resourceKit + .Groups.AsImmutableArray() + .Where(g => context.ResourceGroups.AsImmutableArray().Any(m => m.GroupType == g)) + .Select(g => $"Groups.{PropertyNameOf(context, g)}"), + ]; + context.Writer.MethodCall("AddResource", arguments); } } @@ -402,6 +456,27 @@ static void GenerateHostKitOptionsClass(OutputContext context, CancellationToken } ); + if (context.HasResourceGroups) + { + context + .Writer.NewLine() + .XmlSummary("Gets or sets options for the resource groups, one property per group.") + .Property( + new( + "Groups", + context.HostKit.OptionsType.Nested(GroupsOptionsClassName), + TypeDeclarationAccessibility.Public + ) + { + HasGetter = true, + IsInitOnly = true, + Initializer = "new()", + } + ); + + GenerateGroupsOptionsClass(context, cancellationToken); + } + if (context.HasResourceKits) { foreach ( diff --git a/src/src/SourceGeneration/Helpers/CodeGenEmiiter.ResourceGroup.cs b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.ResourceGroup.cs new file mode 100644 index 0000000..159df8b --- /dev/null +++ b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.ResourceGroup.cs @@ -0,0 +1,239 @@ +using Purview.Aspire.ResourceKit.SourceGeneration.Models; + +namespace Purview.Aspire.ResourceKit.SourceGeneration.Helpers; + +partial class CodeGenEmiiter +{ + const string GroupsClassName = "ResourceKitGroups"; + const string GroupsOptionsClassName = "ResourceKitGroupsOptions"; + + static void EmitResourceGroups(OutputContext context, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!context.HasResourceGroups) + return; + + context.Debug($"Emitting resource groups for host kit: {context.HostKit.HostKitType}"); + + var groupBase = TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitGroup.MakeGeneric( + context.HostKit.HostKitType + ); + + foreach (var group in context.ResourceGroups) + { + cancellationToken.ThrowIfCancellationRequested(); + + context.Debug($"Processing resource group: {group.GroupType.Name}", 1); + + using var groupNs = context.Writer.BlockNamespaceScope(group.GroupType); + using ( + context + .Writer.XmlSummary($"Represents the '{group.Name}' resource group.") + .ClassScope(new(group.GroupType, group.Accessibility) { IsPartial = true, BaseType = groupBase }) + ) + { + context + .Writer.XmlSummary("Initializes a new instance of the resource group.") + .Constructor( + new(group.GroupType, TypeDeclarationAccessibility.Public) + { + Parameters = context.HostKit.ShouldGenerateOptions + ? + [ + new("options", group.OptionsType), + new("parent", groupBase.MakeNullable(context.Writer)), + ] + : [new("parent", groupBase.MakeNullable(context.Writer))], + Initializer = $"base({group.Name.StringLiteral()}, parent)", + }, + body => + { + if (context.HostKit.ShouldGenerateOptions) + { + body.Assignment( + "Options", + $"options ?? throw new {TypeLibrary.System.ArgumentNullException}(nameof(options))" + ) + .Assignment("IsEnabled", "options.IsEnabled") + .Assignment("Mode", "options.Mode") + .Assignment("ShowInDashboard", "options.ShowInDashboard"); + } + else + { + body.Assignment("Mode", ModeExpression(group.Mode)) + .Assignment("ShowInDashboard", group.ShowInDashboard ? "true" : "false"); + } + + if (!group.Urls.IsEmpty) + body.Assignment( + "Urls", + UrlsExpression(group.Urls, context.HostKit.ShouldGenerateOptions) + ); + } + ); + + if (context.HostKit.ShouldGenerateOptions) + { + context + .Writer.XmlSummary("Gets the resource group options.") + .Property("Options", group.OptionsType, TypeDeclarationAccessibility.Public); + + GenerateResourceGroupOptionsClass(context, group, cancellationToken); + } + } + } + } + + static void GenerateResourceGroupOptionsClass( + OutputContext context, + ResourceGroupModel group, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + context.Debug($"Generating Resource Group Options class: {group.OptionsType.Name}", 2); + + context.Writer.XmlSummary( + $"Typed settings for .", + $"Can be accessed by resolving ." + ); + + using ( + context.Writer.ClassScope( + new(group.OptionsType, TypeDeclarationAccessibility.Public) { IsSealed = true, IsPartial = true } + ) + ) + { + context + .Writer.XmlSummary("Gets or sets whether the group, and so every resource in it, is enabled.") + .Property( + new("IsEnabled", TypeLibrary.System.Boolean, TypeDeclarationAccessibility.Public) + { + HasSetter = true, + Initializer = "true", + } + ); + + context + .Writer.NewLine() + .XmlSummary( + "Gets or sets the execution mode in which the group, and so every resource in it, participates." + ) + .Property( + new( + "Mode", + TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitMode, + TypeDeclarationAccessibility.Public + ) + { + HasSetter = true, + Initializer = ModeExpression(group.Mode), + } + ); + + context + .Writer.NewLine() + .XmlSummary("Gets or sets whether the group is shown as a node in the dashboard.") + .Property( + new("ShowInDashboard", TypeLibrary.System.Boolean, TypeDeclarationAccessibility.Public) + { + HasSetter = true, + Initializer = group.ShowInDashboard ? "true" : "false", + } + ); + + if (!group.Urls.IsEmpty) + { + context + .Writer.NewLine() + .XmlSummary("Gets or sets the dashboard URLs declared for the group.") + .Property( + new("Urls", group.OptionsType.Nested("UrlsOptions"), TypeDeclarationAccessibility.Public) + { + HasSetter = true, + Initializer = "new()", + } + ); + + GenerateUrlsOptionsClass(context.Writer, group.Urls); + } + } + } + + // The host kit's typed group accessor: one property per group, created parents first so a child can be + // handed its parent. + static void GenerateGroupsClass(OutputContext context, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + context.Writer.NewLine().XmlSummary("Provides typed access to the host kit's resource groups."); + + using (context.Writer.ClassScope(new(GroupsClassName, TypeDeclarationAccessibility.Public) { IsSealed = true })) + { + context + .Writer.XmlSummary("Initializes the resource groups, parents before their children.") + .Constructor( + new(context.HostKit.HostKitType.Nested(GroupsClassName), TypeDeclarationAccessibility.Internal) + { + Parameters = context.HostKit.ShouldGenerateOptions + ? [new("options", context.HostKit.OptionsType)] + : [], + }, + body => + { + foreach (var group in context.ResourceGroups) + { + var parent = group.HasParent ? PropertyNameOf(context, group.ParentType) : "null"; + var arguments = context.HostKit.ShouldGenerateOptions + ? new[] { $"options.Groups.{group.PropertyName}", parent } + : [parent]; + + body.Assignment(group.PropertyName, new ObjectCreationOptions([.. arguments])); + } + } + ); + + foreach (var group in context.ResourceGroups) + { + cancellationToken.ThrowIfCancellationRequested(); + + context + .Writer.XmlSummary($"Gets the resource group.") + .Property(group.PropertyName, group.GroupType, TypeDeclarationAccessibility.Public); + } + } + } + + static void GenerateGroupsOptionsClass(OutputContext context, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + context.Writer.NewLine().XmlSummary("Typed settings for the host kit's resource groups."); + + using ( + context.Writer.ClassScope( + new(GroupsOptionsClassName, TypeDeclarationAccessibility.Public) { IsSealed = true, IsPartial = true } + ) + ) + { + foreach (var group in context.ResourceGroups) + { + context + .Writer.XmlSummary($"Gets or sets options for {CodeWriter.XmlSee(group.GroupType)}.") + .Property( + new(group.PropertyName, group.OptionsType, TypeDeclarationAccessibility.Public) + { + HasGetter = true, + IsInitOnly = true, + Initializer = "new()", + } + ); + } + } + } + + static string PropertyNameOf(OutputContext context, TypeIdentity groupType) => + context.ResourceGroups.AsImmutableArray().First(g => g.GroupType == groupType).PropertyName; +} diff --git a/src/src/SourceGeneration/Helpers/CodeGenEmiiter.ResourceKit.cs b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.ResourceKit.cs index 675d9a7..c45caf5 100644 --- a/src/src/SourceGeneration/Helpers/CodeGenEmiiter.ResourceKit.cs +++ b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.ResourceKit.cs @@ -98,6 +98,30 @@ static void EmitResourceKits(OutputContext context, CancellationToken cancellati context.Writer, context.HostKit.ShouldGenerateOptions ? "Options.Mode" : ModeExpression(resourceKit.Mode) ); + + // Surface the declared dashboard URLs, as bound from options when the host kit has them. + if (!resourceKit.Urls.IsEmpty) + { + context + .Writer.NewLine() + .XmlInheritDoc() + .Property( + new( + "Urls", + TypeLibrary.System.Collections.Generic.IEnumerable.MakeGeneric( + TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitUrl + ), + TypeDeclarationAccessibility.Protected + ) + { + IsOverride = true, + ExpressionBody = UrlsExpression( + resourceKit.Urls, + context.HostKit.ShouldGenerateOptions + ), + } + ); + } } } @@ -167,6 +191,22 @@ CancellationToken cancellationToken Initializer = ModeExpression(resourceKit.Mode), } ); + + if (!resourceKit.Urls.IsEmpty) + { + context + .Writer.NewLine() + .XmlSummary("Gets or sets the dashboard URLs declared for the resource.") + .Property( + new("Urls", resourceKit.OptionsType.Nested("UrlsOptions"), TypeDeclarationAccessibility.Public) + { + HasSetter = true, + Initializer = "new()", + } + ); + + GenerateUrlsOptionsClass(context.Writer, resourceKit.Urls); + } } } } diff --git a/src/src/SourceGeneration/Helpers/CodeGenEmiiter.cs b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.cs index c6e9894..3851e4a 100644 --- a/src/src/SourceGeneration/Helpers/CodeGenEmiiter.cs +++ b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.cs @@ -9,8 +9,70 @@ static partial class CodeGenEmiiter { yield return ($"{nameof(HostKitAttribute)}.g.cs", HostKitAttribute()); yield return ("ResourceDefinitionAttribute.g.cs", ResourceKitDefinitionAttribute()); + yield return ("ResourceGroupAttribute.g.cs", ResourceGroupAttributes()); + yield return ("ResourceUrlAttribute.g.cs", ResourceUrlAttributes()); } + // Attribute-supplied URL display locations arrive as a fully-qualified enum member name, like modes. + static string DisplayLocationExpression(string? location) + { + if (location is null || location.Trim().Length == 0) + return $"{TypeLibrary.Aspire.Hosting.ApplicationModel.UrlDisplayLocation}.SummaryAndDetails"; + + return location.StartsWith("global::", StringComparison.Ordinal) ? location : $"global::{location}"; + } + + // A new ResourceKitUrl populated from a declared URL, for an options initializer or an Urls override. + static string UrlCreationExpression(UrlModel url) + { + List members = []; + if (url.Url is not null) + members.Add($"Url = {url.Url.StringLiteral()}"); + if (url.DisplayText is not null) + members.Add($"DisplayText = {url.DisplayText.StringLiteral()}"); + if (url.Endpoint is not null) + members.Add($"Endpoint = {url.Endpoint.StringLiteral()}"); + + members.Add($"DisplayLocation = {DisplayLocationExpression(url.DisplayLocation)}"); + + return $"new {TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitUrl}() {{ {string.Join(", ", members)} }}"; + } + + // Writes the nested options class holding one ResourceKitUrl per declared URL, named after the URL. + static void GenerateUrlsOptionsClass(CodeWriter writer, EquatableArray urls) + { + writer.NewLine().XmlSummary("Typed settings for the declared dashboard URLs, one property per URL name."); + + using ( + writer.ClassScope( + new("UrlsOptions", TypeDeclarationAccessibility.Public) { IsSealed = true, IsPartial = true } + ) + ) + { + foreach (var url in urls) + { + writer + .XmlSummary($"Gets or sets the '{url.PropertyName}' URL.") + .Property( + new( + url.PropertyName, + TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitUrl, + TypeDeclarationAccessibility.Public + ) + { + HasSetter = true, + Initializer = UrlCreationExpression(url), + } + ); + } + } + } + + static string UrlsExpression(EquatableArray urls, bool fromOptions) => + fromOptions + ? $"[{string.Join(", ", urls.AsImmutableArray().Select(static u => $"Options.Urls.{u.PropertyName}"))}]" + : $"[{string.Join(", ", urls.AsImmutableArray().Select(UrlCreationExpression))}]"; + public static CodeWriter Emit(OutputContext outputContext, CancellationToken cancellationToken) { var writer = outputContext.Writer; @@ -26,6 +88,7 @@ public static CodeWriter Emit(OutputContext outputContext, CancellationToken can EmitHostKit(outputContext, cancellationToken); EmitResourceKitBase(outputContext, cancellationToken); EmitResourceKits(outputContext, cancellationToken); + EmitResourceGroups(outputContext, cancellationToken); EmitExtensionClass(outputContext, cancellationToken); return writer; diff --git a/src/src/SourceGeneration/Helpers/DiagnosticLibrary.cs b/src/src/SourceGeneration/Helpers/DiagnosticLibrary.cs index f1842bb..3becfab 100644 --- a/src/src/SourceGeneration/Helpers/DiagnosticLibrary.cs +++ b/src/src/SourceGeneration/Helpers/DiagnosticLibrary.cs @@ -194,4 +194,113 @@ static class DiagnosticLibrary isEnabledByDefault: true, description: "Each OptionsHelper.Assign/Override action must set exactly one property path; an action that assigns more than one property throws at runtime when the arguments are built." ); + + public static readonly DiagnosticDescriptor GroupTypeNotResourceGroup = new( + id: "SG0021", + title: "Type is not a resource group", + messageFormat: "'{1}' uses '{0}' as a resource group, but '{0}' is not a [ResourceGroup] class declared in this compilation", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "The type argument of [InGroup], and the Parent of a [ResourceGroup], must be a class marked [ResourceGroup] in the same compilation, because the generator emits the group alongside the host kit." + ); + + public static readonly DiagnosticDescriptor ResourceGroupCycle = new( + id: "SG0022", + title: "Resource group parent cycle", + messageFormat: "The resource group '{0}' is its own ancestor; remove a Parent from the cycle", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor DuplicateResourceGroupName = new( + id: "SG0023", + title: "Duplicate resource group name", + messageFormat: "The resource group name '{0}' is used by more than one group; group names and property names must be unique", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "A group's name identifies it in the dashboard, where names are compared case-insensitively, so two groups with the same name could not be told apart; its property name is a member of the generated Groups accessor.", + customTags: [WellKnownDiagnosticTags.CompilationEnd] + ); + + public static readonly DiagnosticDescriptor MultiplePrimaryGroups = new( + id: "SG0024", + title: "More than one primary group", + messageFormat: "'{0}' marks more than one group as Primary; '{1}' is used as the primary group", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "A resource is shown under one parent in the dashboard, so only one of its groups can be primary." + ); + + public static readonly DiagnosticDescriptor ResourceGroupCannotHaveExplicitBase = new( + id: "SG0025", + title: "Resource group cannot have explicit base", + messageFormat: "The resource group '{0}' must not declare a base type; the generator derives it from ResourceKitGroup", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true + ); + + public static readonly DiagnosticDescriptor DuplicateUrlName = new( + id: "SG0026", + title: "Duplicate URL name", + messageFormat: "'{0}' declares more than one URL named '{1}'; URL names must be unique per resource kit or group", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "A URL's name is its generated options property. An [EndpointUrl] is named after its endpoint." + ); + + public static readonly DiagnosticDescriptor EndpointUrlRequiresEndpoints = new( + id: "SG0027", + title: "Endpoint URL on a resource without endpoints", + messageFormat: "'{0}' declares a URL for endpoint '{1}', but '{2}' does not have endpoints", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "[EndpointUrl], and [ResourceUrl] with an Endpoint, need a resource type that implements IResourceWithEndpoints. A resource group has no endpoints." + ); + + public static readonly DiagnosticDescriptor RelativeUrlWithoutEndpoints = new( + id: "SG0028", + title: "Relative URL on a resource without endpoints", + messageFormat: "'{0}' declares the relative URL '{1}', but '{2}' does not have endpoints to resolve it against", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Execution-only concern: the URL is skipped at runtime. Use an absolute URL, or declare it on a resource with endpoints." + ); + + public static readonly DiagnosticDescriptor MultipleResourceGroupParents = new( + id: "SG0029", + title: "Resource group declares its parent more than once", + messageFormat: "The resource group '{0}' declares its parent more than once; use either ResourceGroup or Parent = typeof(TParent), on a single [ResourceGroup] attribute", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "A group has exactly one parent, so ResourceGroup and ResourceGroup must not both be applied, and ResourceGroup must not also set Parent." + ); + + public static readonly DiagnosticDescriptor ResourceGroupCannotBeGeneric = new( + id: "SG0030", + title: "Resource group cannot be generic", + messageFormat: "The resource group '{0}' must not be generic or nested in a generic type", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "A group is a single named node, created once per host kit. A generic group has no single instance, and a group parented to a constructed form of itself could expand without end." + ); + + public static readonly DiagnosticDescriptor ResourceGroupCannotBeResourceKit = new( + id: "SG0031", + title: "Resource group cannot be a resource kit or a group member", + messageFormat: "The resource group '{0}' cannot also carry [ResourceDefinition] or [InGroup]; nest a group with ResourceGroup or Parent instead", + category: Category, + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "A class is either a resource kit, which joins groups with [InGroup], or a resource group, which nests under a parent group. One class cannot be both, so a group can never contain itself as a member." + ); } diff --git a/src/src/SourceGeneration/Helpers/GeneratorTrackingNames.cs b/src/src/SourceGeneration/Helpers/GeneratorTrackingNames.cs index e34b000..29c88a4 100644 --- a/src/src/SourceGeneration/Helpers/GeneratorTrackingNames.cs +++ b/src/src/SourceGeneration/Helpers/GeneratorTrackingNames.cs @@ -5,9 +5,13 @@ static class GeneratorTrackingNames public const string HostKitTargets = nameof(HostKitTargets); public const string ResourceDefinitionTargets = nameof(ResourceDefinitionTargets); public const string GenericResourceDefinitionTargets = nameof(GenericResourceDefinitionTargets); + public const string ResourceGroupTargets = nameof(ResourceGroupTargets); + public const string GenericResourceGroupTargets = nameof(GenericResourceGroupTargets); public const string CombineResourceKits = nameof(CombineResourceKits); + public const string CombineResourceGroups = nameof(CombineResourceGroups); public const string CollectHostKits = nameof(CollectHostKits); public const string CollectResourceKits = nameof(CollectResourceKits); + public const string CollectResourceGroups = nameof(CollectResourceGroups); public const string CollectGenericResourceKits = nameof(CollectGenericResourceKits); } diff --git a/src/src/SourceGeneration/Helpers/ResourceGroupOrdering.cs b/src/src/SourceGeneration/Helpers/ResourceGroupOrdering.cs new file mode 100644 index 0000000..212bd96 --- /dev/null +++ b/src/src/SourceGeneration/Helpers/ResourceGroupOrdering.cs @@ -0,0 +1,44 @@ +using System.Collections.Immutable; +using Purview.Aspire.ResourceKit.SourceGeneration.Models; + +namespace Purview.Aspire.ResourceKit.SourceGeneration.Helpers; + +static class ResourceGroupOrdering +{ + /// + /// Orders groups so every parent precedes its children, as the generated Groups accessor hands each + /// group its already-created parent. + /// + /// + /// A group whose parent is itself, is in a cycle, or is missing can never become ready, so it is left out + /// rather than looped on. Each pass either emits at least one group or stops, so the method makes at most + /// one pass per group. In practice those groups never get here: SG0021 and SG0022 block generation. + /// + public static EquatableArray ParentsFirst( + ImmutableArray groups, + CancellationToken cancellationToken + ) + { + List ordered = []; + HashSet emitted = []; + var remaining = groups.ToList(); + + while (remaining.Count > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + + var ready = remaining.Where(g => !g.HasParent || emitted.Contains(g.ParentType)).ToArray(); + if (ready.Length == 0) + break; + + foreach (var group in ready) + { + ordered.Add(group); + emitted.Add(group.GroupType); + remaining.Remove(group); + } + } + + return EquatableArray.Create([.. ordered]); + } +} diff --git a/src/src/SourceGeneration/Helpers/ResourceKitRules.cs b/src/src/SourceGeneration/Helpers/ResourceKitRules.cs index 41bcf48..ffe8d62 100644 --- a/src/src/SourceGeneration/Helpers/ResourceKitRules.cs +++ b/src/src/SourceGeneration/Helpers/ResourceKitRules.cs @@ -39,7 +39,18 @@ static class ResourceKitRules DiagnosticLibrary.NoAspireResourceFound.Id, DiagnosticLibrary.ResourcePropertyNeverSet.Id, DiagnosticLibrary.ProjectDefinitionMismatch.Id, - DiagnosticLibrary.ProjectResourceKitBaseMismatch.Id + DiagnosticLibrary.ProjectResourceKitBaseMismatch.Id, + DiagnosticLibrary.GroupTypeNotResourceGroup.Id, + DiagnosticLibrary.ResourceGroupCycle.Id, + DiagnosticLibrary.DuplicateResourceGroupName.Id, + DiagnosticLibrary.MultiplePrimaryGroups.Id, + DiagnosticLibrary.ResourceGroupCannotHaveExplicitBase.Id, + DiagnosticLibrary.DuplicateUrlName.Id, + DiagnosticLibrary.EndpointUrlRequiresEndpoints.Id, + DiagnosticLibrary.RelativeUrlWithoutEndpoints.Id, + DiagnosticLibrary.MultipleResourceGroupParents.Id, + DiagnosticLibrary.ResourceGroupCannotBeGeneric.Id, + DiagnosticLibrary.ResourceGroupCannotBeResourceKit.Id ); /// @@ -72,7 +83,9 @@ public ReportableDiagnostic ToDiagnosticInfo() => StringComparer.Ordinal, DiagnosticLibrary.ResourcePropertyNeverSet.Id, DiagnosticLibrary.ProjectDefinitionMismatch.Id, - DiagnosticLibrary.ProjectResourceKitBaseMismatch.Id + DiagnosticLibrary.ProjectResourceKitBaseMismatch.Id, + DiagnosticLibrary.MultiplePrimaryGroups.Id, + DiagnosticLibrary.RelativeUrlWithoutEndpoints.Id ); public static bool IsAnalyzerOwned(DiagnosticDescriptor descriptor) => AnalyzerOwnedRuleIds.Contains(descriptor.Id); @@ -278,10 +291,498 @@ CancellationToken cancellationToken } EvaluateResourceBuilderProperties(symbol, compilation, diagnostics, cancellationToken); + EvaluateGroupMemberships(symbol, diagnostics, cancellationToken); + EvaluateUrls( + symbol, + ResourceHasEndpoints(matchedAttribute, symbol), + isValidResourceType ? DisplayName(matchedAttribute, symbol) : null, + diagnostics, + cancellationToken + ); return diagnostics.ToImmutable(); } + public static ImmutableArray EvaluateResourceGroup( + INamedTypeSymbol symbol, + CancellationToken cancellationToken + ) + { + var diagnostics = ImmutableArray.CreateBuilder(); + var declaration = GetClassDeclaration(symbol, cancellationToken); + if (declaration is null) + return diagnostics.ToImmutable(); + + if (!TypeHelpers.IsPartial(declaration)) + { + diagnostics.Add( + new( + DiagnosticLibrary.ClassMustBePartial, + IsBlocking: true, + declaration.Identifier.GetLocation(), + [symbol.Name] + ) + ); + } + + if (TypeHelpers.HasNonEmptyConstructors(declaration, symbol.Name)) + { + diagnostics.Add( + new( + DiagnosticLibrary.NonEmptyConstructorsNotSupported, + IsBlocking: true, + declaration.Identifier.GetLocation(), + [symbol.Name] + ) + ); + } + + // As for resource kits, judge the base from the source: the generated base already exists here. + if (TypeHelpers.HasExplicitBaseType(declaration)) + { + diagnostics.Add( + new( + DiagnosticLibrary.ResourceGroupCannotHaveExplicitBase, + IsBlocking: true, + declaration.Identifier.GetLocation(), + [symbol.Name] + ) + ); + } + + var propertyName = GroupPropertyName(symbol); + if (!TypeHelpers.IsValidIdentifier(propertyName)) + { + diagnostics.Add( + new(DiagnosticLibrary.InvalidPropertyName, IsBlocking: true, GetLocation(symbol), [propertyName]) + ); + } + + if (IsGenericOrInGenericType(symbol)) + { + diagnostics.Add( + new( + DiagnosticLibrary.ResourceGroupCannotBeGeneric, + IsBlocking: true, + declaration.Identifier.GetLocation(), + [symbol.Name] + ) + ); + } + + // A group is never a member of anything, itself included: groups nest through their parent only. + if ( + TypeHelpers.HasAttribute(symbol, TypeLibrary.Purview.Aspire.ResourceKit.ResourceDefinitionAttribute) + || TypeHelpers.HasAttribute( + symbol, + TypeLibrary.Purview.Aspire.ResourceKit.GenericResourceDefinitionAttribute + ) + || InGroupAttributeData.AllAttributeData(symbol).Any() + ) + { + diagnostics.Add( + new( + DiagnosticLibrary.ResourceGroupCannotBeResourceKit, + IsBlocking: true, + declaration.Identifier.GetLocation(), + [symbol.Name] + ) + ); + } + + var groupAttributeCount = symbol.GetAttributes().Count(IsResourceGroupAttribute); + var parentDeclarations = GetGroupParentDeclarations(symbol); + if (groupAttributeCount > 1 || parentDeclarations.Length > 1) + { + diagnostics.Add( + new( + DiagnosticLibrary.MultipleResourceGroupParents, + IsBlocking: true, + GetLocation(symbol), + [symbol.Name] + ) + ); + } + + var parent = parentDeclarations.FirstOrDefault(); + if (parent is not null) + { + if (!IsResourceGroup(parent)) + { + diagnostics.Add( + new( + DiagnosticLibrary.GroupTypeNotResourceGroup, + IsBlocking: true, + GetLocation(symbol), + [parent.ToDisplayString(), symbol.Name] + ) + ); + } + else if (IsInParentCycle(symbol)) + { + diagnostics.Add( + new(DiagnosticLibrary.ResourceGroupCycle, IsBlocking: true, GetLocation(symbol), [symbol.Name]) + ); + } + } + + EvaluateUrls( + symbol, + hasEndpoints: false, + TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitGroupResource.Name, + diagnostics, + cancellationToken + ); + + return diagnostics.ToImmutable(); + } + + /// + /// Reads the groups a resource kit belongs to, primary group first, each once. + /// + public static ImmutableArray GetGroups(INamedTypeSymbol symbol) + { + var memberships = InGroupAttributeData + .AllAttributeData(symbol) + .Select(static m => + ( + m.Instance.Primary, + Group: m.Attribute.AttributeClass is { TypeArguments.Length: 1 } attributeClass + ? attributeClass.TypeArguments[0] as INamedTypeSymbol + : null + ) + ) + .Where(static m => m.Group is not null) + .ToArray(); + + // A stable sort: the first Primary group moves to the front, everything else keeps declaration order. + return + [ + .. memberships + .OrderBy(static m => m.Primary ? 0 : 1) + .Select(static m => m.Group!) + .Distinct(SymbolEqualityComparer.Default), + ]; + } + + /// + /// Reads the parent of a resource group: the Parent named argument, which is a and + /// so cannot be held by the attribute-data model, or the type argument of ResourceGroup<TParent>. + /// + /// + /// The first declaration wins; more than one is SG0029, which blocks generation. + /// + public static INamedTypeSymbol? GetGroupParent(INamedTypeSymbol symbol) => + GetGroupParentDeclarations(symbol).FirstOrDefault(); + + /// + /// Reads every parent a resource group declares, in declaration order. + /// + public static ImmutableArray GetGroupParentDeclarations(INamedTypeSymbol symbol) + { + var parents = ImmutableArray.CreateBuilder(); + foreach (var attribute in symbol.GetAttributes().Where(IsResourceGroupAttribute)) + { + if ( + attribute.AttributeClass is { IsGenericType: true, TypeArguments.Length: 1 } attributeClass + && attributeClass.TypeArguments[0] is INamedTypeSymbol typeArgument + ) + { + parents.Add(typeArgument); + } + + foreach (var argument in attribute.NamedArguments) + { + if (argument.Key == "Parent" && argument.Value.Value is INamedTypeSymbol parent) + parents.Add(parent); + } + } + + return parents.ToImmutable(); + } + + /// + /// Determines whether an attribute application is [ResourceGroup] or [ResourceGroup<TParent>]. + /// + public static bool IsResourceGroupAttribute(AttributeData attribute) => + attribute.AttributeClass is { } attributeClass + && ( + TypeLibrary.Purview.Aspire.ResourceKit.ResourceGroupAttribute.Matches(attributeClass) + || TypeLibrary.Purview.Aspire.ResourceKit.GenericResourceGroupAttribute.Matches( + attributeClass.OriginalDefinition + ) + ); + + public static bool HasResourceGroupAttribute(INamedTypeSymbol symbol) => + symbol.GetAttributes().Any(IsResourceGroupAttribute); + + public static bool IsResourceGroup(INamedTypeSymbol symbol) => + symbol.DeclaringSyntaxReferences.Length > 0 && HasResourceGroupAttribute(symbol); + + static bool IsGenericOrInGenericType(INamedTypeSymbol symbol) + { + for (var type = symbol; type is not null; type = type.ContainingType) + { + if (type.IsGenericType) + return true; + } + + return false; + } + + public static string GroupPropertyName(INamedTypeSymbol symbol) => + symbol.Name.TrimSuffix(TypeLibraryGenerator.GroupTrimSuffixes) ?? symbol.Name; + + public static string GroupName(INamedTypeSymbol symbol) => + ResourceGroupAttributeData.FromAttributeData(symbol.GetAttributes()).Name ?? GroupPropertyName(symbol); + + /// + /// Determines whether a group's parent chain leads back to the group itself. + /// + /// + /// The walk always terminates. Each step is normalised to its , + /// so a constructed generic such as G<G<T>> cannot produce an unbounded supply of new types, + /// and a step that reaches an already-visited type stops. There are finitely many declared types, so the + /// walk takes at most one step per declared group. + /// + internal static bool IsInParentCycle(INamedTypeSymbol symbol) + { + var start = symbol.OriginalDefinition; + HashSet seen = new(SymbolEqualityComparer.Default) { start }; + for ( + var parent = GetGroupParent(start)?.OriginalDefinition; + parent is not null; + parent = GetGroupParent(parent)?.OriginalDefinition + ) + { + if (SymbolEqualityComparer.Default.Equals(parent, start)) + return true; + + // A cycle further up the chain is reported on the groups that form it. + if (!seen.Add(parent)) + return false; + } + + return false; + } + + static void EvaluateGroupMemberships( + INamedTypeSymbol symbol, + ImmutableArray.Builder diagnostics, + CancellationToken cancellationToken + ) + { + var memberships = InGroupAttributeData.AllAttributeData(symbol).ToArray(); + foreach (var (_, attribute) in memberships) + { + cancellationToken.ThrowIfCancellationRequested(); + + if ( + attribute.AttributeClass is { TypeArguments.Length: 1 } attributeClass + && attributeClass.TypeArguments[0] is INamedTypeSymbol group + && !IsResourceGroup(group) + ) + { + diagnostics.Add( + new( + DiagnosticLibrary.GroupTypeNotResourceGroup, + IsBlocking: true, + GetLocation(attribute, cancellationToken) ?? GetLocation(symbol), + [group.ToDisplayString(), symbol.Name] + ) + ); + } + } + + var primaries = memberships.Where(static m => m.Instance.Primary).ToArray(); + if (primaries.Length > 1) + { + diagnostics.Add( + new( + DiagnosticLibrary.MultiplePrimaryGroups, + IsBlocking: false, + GetLocation(primaries[1].Attribute, cancellationToken) ?? GetLocation(symbol), + [symbol.Name, primaries[0].Instance.GroupType.Name] + ) + ); + } + } + + /// + /// Reads the [ResourceUrl] and [EndpointUrl] applications on a resource kit or group, in + /// declaration order. + /// + public static ImmutableArray<(UrlModel Model, AttributeData Attribute)> GetUrls(INamedTypeSymbol symbol) + { + var urls = ImmutableArray.CreateBuilder<(UrlModel, AttributeData)>(); + foreach (var attribute in symbol.GetAttributes()) + { + if (attribute.AttributeClass is null) + continue; + + if (TypeLibrary.Purview.Aspire.ResourceKit.ResourceUrlAttribute.Matches(attribute.AttributeClass)) + { + var data = ResourceUrlAttributeData.FromAttributeData(attribute); + urls.Add( + ( + new(data.Name ?? string.Empty, data.Url, data.DisplayText, data.Endpoint, data.DisplayLocation), + attribute + ) + ); + } + else if (TypeLibrary.Purview.Aspire.ResourceKit.EndpointUrlAttribute.Matches(attribute.AttributeClass)) + { + var data = EndpointUrlAttributeData.FromAttributeData(attribute); + urls.Add( + ( + new( + EndpointPropertyName(data.Endpoint ?? string.Empty), + Url: null, + data.DisplayText, + data.Endpoint, + data.DisplayLocation + ), + attribute + ) + ); + } + } + + return urls.ToImmutable(); + } + + // Endpoint names are often lower case and hyphenated ("otlp-grpc"); the options member is "OtlpGrpc". + static string EndpointPropertyName(string endpoint) + { + System.Text.StringBuilder builder = new(endpoint.Length); + var upperNext = true; + foreach (var c in endpoint) + { + if (!char.IsLetterOrDigit(c)) + { + upperNext = true; + continue; + } + + builder.Append(upperNext ? char.ToUpperInvariant(c) : c); + upperNext = false; + } + + return builder.ToString(); + } + + static void EvaluateUrls( + INamedTypeSymbol symbol, + bool? hasEndpoints, + string? resourceTypeName, + ImmutableArray.Builder diagnostics, + CancellationToken cancellationToken + ) + { + HashSet seen = new(StringComparer.Ordinal); + foreach (var (url, attribute) in GetUrls(symbol)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var location = GetLocation(attribute, cancellationToken) ?? GetLocation(symbol); + if (!TypeHelpers.IsValidIdentifier(url.PropertyName)) + { + diagnostics.Add( + new(DiagnosticLibrary.InvalidPropertyName, IsBlocking: true, location, [url.PropertyName]) + ); + } + else if (!seen.Add(url.PropertyName)) + { + diagnostics.Add( + new(DiagnosticLibrary.DuplicateUrlName, IsBlocking: true, location, [symbol.Name, url.PropertyName]) + ); + } + + // Only report against a resource type we could resolve: an unresolved one already reports SG0016. + if (hasEndpoints != false || resourceTypeName is null) + continue; + + if (url.Endpoint is not null) + { + diagnostics.Add( + new( + DiagnosticLibrary.EndpointUrlRequiresEndpoints, + IsBlocking: true, + location, + [symbol.Name, url.Endpoint, resourceTypeName] + ) + ); + } + else if (url.Url is not null && !IsAbsoluteUrl(url.Url)) + { + diagnostics.Add( + new( + DiagnosticLibrary.RelativeUrlWithoutEndpoints, + IsBlocking: false, + location, + [symbol.Name, url.Url, resourceTypeName] + ) + ); + } + } + } + + // Mirrors ResourceKitUrl.IsAbsolute: "/health" parses as a file URI on Unix, so require a non-file URI. + static bool IsAbsoluteUrl(string url) => Uri.TryCreate(url, UriKind.Absolute, out var uri) && !uri.IsFile; + + /// + /// Determines whether the resource a kit builds has endpoints, or when the resource + /// type cannot be resolved. + /// + static bool? ResourceHasEndpoints( + (ResourceDefinitionAttributeData Instance, AttributeData Attribute) matchedAttribute, + INamedTypeSymbol kitSymbol + ) + { + var resourceType = ResolveResourceTypeSymbol(matchedAttribute, kitSymbol); + if (resourceType is null) + return null; + + if (TypeHelpers.Implements(resourceType, TypeLibrary.Aspire.Hosting.IProjectMetadata)) + return true; + + return TypeHelpers.Implements(resourceType, TypeLibrary.Aspire.Hosting.ApplicationModel.IResourceWithEndpoints); + } + + static string? DisplayName( + (ResourceDefinitionAttributeData Instance, AttributeData Attribute) matchedAttribute, + INamedTypeSymbol kitSymbol + ) => ResolveResourceTypeSymbol(matchedAttribute, kitSymbol)?.Name; + + static INamedTypeSymbol? ResolveResourceTypeSymbol( + (ResourceDefinitionAttributeData Instance, AttributeData Attribute) matchedAttribute, + INamedTypeSymbol kitSymbol + ) + { + if ( + matchedAttribute.Attribute?.AttributeClass is + { IsGenericType: true, TypeArguments.Length: > 0 } attributeClass + ) + return attributeClass.TypeArguments[0] as INamedTypeSymbol; + + if (kitSymbol.BaseType is null) + return null; + + foreach (var argument in kitSymbol.BaseType.TypeArguments) + { + if ( + argument is INamedTypeSymbol named + && TypeHelpers.Implements(named, TypeLibrary.Aspire.Hosting.ApplicationModel.IResource) + ) + return named; + } + + return null; + } + + static Location? GetLocation(AttributeData attribute, CancellationToken cancellationToken) => + attribute.ApplicationSyntaxReference?.GetSyntax(cancellationToken).GetLocation(); + /// /// Evaluates SG0017 for every IResourceBuilder<T> property that is never assigned in /// BuildResource or ConfigureResource. diff --git a/src/src/SourceGeneration/Helpers/SourceGenLibrary.cs b/src/src/SourceGeneration/Helpers/SourceGenLibrary.cs index 5b90500..933e70b 100644 --- a/src/src/SourceGeneration/Helpers/SourceGenLibrary.cs +++ b/src/src/SourceGeneration/Helpers/SourceGenLibrary.cs @@ -40,6 +40,31 @@ public static GeneratorPipelines GetGeneratorValueProviders(IncrementalGenerator var resourceDefinitions = GetResourceDefinitionPipeline(context); var genericResourceDefinitions = GetGenerationResourceDefinitionPipeline(context); var allResourceKits = GetCombineResourceDefinitionsPipeline(resourceDefinitions, genericResourceDefinitions); + var resourceGroups = GetResourceGroupPipeline( + context, + TypeLibrary.Purview.Aspire.ResourceKit.ResourceGroupAttribute, + GeneratorTrackingNames.ResourceGroupTargets + ) + .CollectWith( + GetResourceGroupPipeline( + context, + TypeLibrary.Purview.Aspire.ResourceKit.GenericResourceGroupAttribute, + GeneratorTrackingNames.GenericResourceGroupTargets + ), + static (groups, genericGroups, _) => + EquatableArray>.Create([ + .. groups + .Concat(genericGroups) + .Where(static g => !g.IsEmpty) + // A class carrying both attributes arrives from both pipelines; SG0029 blocks it, so + // keep one model per type and let the diagnostic speak. + .GroupBy(static g => g.Value.GroupType.MetadataFullName, StringComparer.Ordinal) + .Select(static g => g.First()) + // Order by type so the aggregate is deterministic whatever order the groups arrive in. + .OrderBy(static g => g.Value.GroupType.MetadataFullName, StringComparer.Ordinal), + ]), + GeneratorTrackingNames.CombineResourceGroups + ); var provider = generationContext .CollectWith( @@ -51,6 +76,11 @@ public static GeneratorPipelines GetGeneratorValueProviders(IncrementalGenerator allResourceKits, CombineResourceDefinitionsPipeline(), GeneratorTrackingNames.CollectResourceKits + ) + .CombineWith( + resourceGroups, + static (model, groups, _) => model with { ResourceGroups = groups }, + GeneratorTrackingNames.CollectResourceGroups ); return new(provider, generationContext); @@ -124,6 +154,19 @@ IncrementalGeneratorInitializationContext context trackingName: GeneratorTrackingNames.ResourceDefinitionTargets ); + static IncrementalValuesProvider> GetResourceGroupPipeline( + IncrementalGeneratorInitializationContext context, + TypeIdentity attribute, + string trackingName + ) => + IncrementalPipeline.ForAttributeWithMetadataName( + context, + attribute, + transform: static (ctx, ct) => GetResourceGroupModel(ctx, ct), + predicate: static (s, _) => s is ClassDeclarationSyntax, + trackingName: trackingName + ); + static IncrementalValuesProvider> GetHostKitPipeline( IncrementalGeneratorInitializationContext context ) => @@ -204,9 +247,50 @@ CancellationToken cancellationToken PropertyName: propertyName, ResourceName: resourceName ?? "", HasExplicitBaseType: hasExplicitBaseType, - Mode: matchedAttribute.Instance.Mode + Mode: matchedAttribute.Instance.Mode, + Groups: EquatableArray.Create([ + .. ResourceKitRules.GetGroups(symbol).Select(static g => new TypeIdentity(g)), + ]), + Urls: GetUrlModels(symbol) + ), + diagnostics + ); + } + + static GeneratorResult GetResourceGroupModel( + GeneratorAttributeSyntaxContext context, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + + var symbol = (INamedTypeSymbol)context.TargetSymbol; + var data = ResourceGroupAttributeData.FromAttributeData(context.Attributes); + var parent = ResourceKitRules.GetGroupParent(symbol); + + TypeIdentity groupType = new(symbol); + + var diagnostics = ResourceKitRules + .EvaluateResourceGroup(symbol, cancellationToken) + .Select(static rule => rule.ToDiagnosticInfo()) + .ToImmutableArray(); + + return GeneratorResult.Create( + new( + GroupType: groupType, + OptionsType: groupType.Nested(symbol.Name + TypeLibraryGenerator.OptionsBaseClassSuffix), + ParentType: parent is null ? TypeIdentity.Empty : new(parent), + Accessibility: symbol.DeclaredAccessibility.ToTypeDeclarationAccessibility(), + Name: ResourceKitRules.GroupName(symbol), + PropertyName: ResourceKitRules.GroupPropertyName(symbol), + ShowInDashboard: data.ShowInDashboard, + Mode: data.Mode, + Urls: GetUrlModels(symbol) ), diagnostics ); } + + static EquatableArray GetUrlModels(INamedTypeSymbol symbol) => + EquatableArray.Create([.. ResourceKitRules.GetUrls(symbol).Select(static u => u.Model)]); } diff --git a/src/src/SourceGeneration/Helpers/TypeLibraryGenerator.cs b/src/src/SourceGeneration/Helpers/TypeLibraryGenerator.cs index 1543d50..ea8065a 100644 --- a/src/src/SourceGeneration/Helpers/TypeLibraryGenerator.cs +++ b/src/src/SourceGeneration/Helpers/TypeLibraryGenerator.cs @@ -6,6 +6,8 @@ static partial class TypeLibraryGenerator // Generated type information... public static readonly string[] TrimSuffixes = ["ResourceKit", "Resource", "Kit"]; + public static readonly string[] GroupTrimSuffixes = ["ResourceGroup", "Group"]; + public const string OptionsBaseClassSuffix = "Options"; public const string PurviewAspireResourceKitNamespace = "Purview.Aspire.ResourceKit"; @@ -19,6 +21,24 @@ static partial class TypeLibraryGenerator [TypeRef(nameof(ResourceDefinitionAttribute), PurviewAspireResourceKitNamespace, 1, true)] static readonly TypeIdentity GenericResourceDefinitionAttribute = default; + [TypeRef(PurviewAspireResourceKitNamespace, IncludeInGetTypes = true)] + static readonly TypeIdentity ResourceGroupAttribute = default; + + [TypeRef(nameof(ResourceGroupAttribute), PurviewAspireResourceKitNamespace, 1, true)] + static readonly TypeIdentity GenericResourceGroupAttribute = default; + + [TypeRef(PurviewAspireResourceKitNamespace, IncludeInGetTypes = true)] + static readonly TypeIdentity InGroupAttribute = default; + + [TypeRef(nameof(InGroupAttribute), PurviewAspireResourceKitNamespace, 1, true)] + static readonly TypeIdentity GenericInGroupAttribute = default; + + [TypeRef(PurviewAspireResourceKitNamespace, IncludeInGetTypes = true)] + static readonly TypeIdentity ResourceUrlAttribute = default; + + [TypeRef(PurviewAspireResourceKitNamespace, IncludeInGetTypes = true)] + static readonly TypeIdentity EndpointUrlAttribute = default; + // Library types [TypeRef(PurviewAspireResourceKitNamespace, IncludeInGetTypes = true)] static readonly TypeIdentity IHostKit = default; @@ -35,6 +55,15 @@ static partial class TypeLibraryGenerator [TypeRef(PurviewAspireResourceKitNamespace, IncludeInGetTypes = true)] static readonly TypeIdentity ResourceKitMode = default; + [TypeRef(PurviewAspireResourceKitNamespace, 1, true)] + static readonly TypeIdentity ResourceKitGroup = default; + + [TypeRef(PurviewAspireResourceKitNamespace, IncludeInGetTypes = true)] + static readonly TypeIdentity ResourceKitGroupResource = default; + + [TypeRef(PurviewAspireResourceKitNamespace, IncludeInGetTypes = true)] + static readonly TypeIdentity ResourceKitUrl = default; + // Other required types [TypeRef("Microsoft.Extensions.Configuration")] static readonly TypeIdentity ConfigurationBinder = default; @@ -53,6 +82,15 @@ static partial class TypeLibraryGenerator [TypeRef("Aspire.Hosting.ApplicationModel")] static readonly TypeIdentity ProjectResource = default; + [TypeRef("Aspire.Hosting.ApplicationModel")] + static readonly TypeIdentity IResourceWithEndpoints = default; + + [TypeRef("Aspire.Hosting.ApplicationModel")] + static readonly TypeIdentity UrlDisplayLocation = default; + + [TypeRef("System.Collections.Generic", 1)] + static readonly TypeIdentity IEnumerable = default; + [TypeRef("Aspire.Hosting")] static readonly TypeIdentity IProjectMetadata = default; diff --git a/src/src/SourceGeneration/HostKitGenerator.cs b/src/src/SourceGeneration/HostKitGenerator.cs index 363f6c7..2656fc0 100644 --- a/src/src/SourceGeneration/HostKitGenerator.cs +++ b/src/src/SourceGeneration/HostKitGenerator.cs @@ -49,8 +49,18 @@ .. m )) .ToImmutableArray(); + var validResourceGroups = ResourceGroupOrdering.ParentsFirst( + [ + .. generationModel + .ResourceGroups.AsImmutableArray() + .Where(static g => g.ShouldProcess) + .Select(static g => g.Value), + ], + sourceProductionContext.CancellationToken + ); + var writer = CodeGenEmiiter.Emit( - new(generationModel, validResourceKits, generationContext), + new(generationModel, validResourceKits, validResourceGroups, generationContext), sourceProductionContext.CancellationToken ); var hintName = HintNameHelper.ForHost(generationModel.HostKit.Value.HostKitType.MetadataFullName); diff --git a/src/src/SourceGeneration/Models/AttributeDataModels.cs b/src/src/SourceGeneration/Models/AttributeDataModels.cs index 673023a..3d147e8 100644 --- a/src/src/SourceGeneration/Models/AttributeDataModels.cs +++ b/src/src/SourceGeneration/Models/AttributeDataModels.cs @@ -33,3 +33,46 @@ readonly partial record struct ResourceDefinitionAttributeData( string? Mode, [GenericTypeArgument] TypeIdentity AspireResourceType ); + +// Parent is a System.Type, which a model cannot hold (ADM0010), and ResourceGroupAttribute carries the +// parent as a type argument; ResourceKitRules.GetGroupParentDeclarations reads both. +[Generate("Purview.Aspire.ResourceKit.ResourceGroupAttribute", MatchByInheritance = true)] +readonly partial record struct ResourceGroupAttributeData( + // For the property + [Property] + // For the ctor + [Argument("name")] + string? Name, +#pragma warning disable format + [Property(DefaultValue = true)] bool ShowInDashboard, + [Property(IsEnum = true, DefaultValue = "Purview.Aspire.ResourceKit.ResourceKitMode.All")] string? Mode +#pragma warning restore format +); + +[Generate("Purview.Aspire.ResourceKit.InGroupAttribute", MatchByInheritance = true)] +readonly partial record struct InGroupAttributeData( + [Property] bool Primary, + [GenericTypeArgument] TypeIdentity GroupType +); + +[Generate("Purview.Aspire.ResourceKit.ResourceUrlAttribute")] +readonly partial record struct ResourceUrlAttributeData( +#pragma warning disable format + [Argument("name")] string? Name, + [Argument("url")] string? Url, + [Property] string? DisplayText, + [Property] string? Endpoint, + [Property(IsEnum = true, DefaultValue = "Aspire.Hosting.ApplicationModel.UrlDisplayLocation.SummaryAndDetails")] + string? DisplayLocation +#pragma warning restore format +); + +[Generate("Purview.Aspire.ResourceKit.EndpointUrlAttribute")] +readonly partial record struct EndpointUrlAttributeData( +#pragma warning disable format + [Argument("endpoint")] string? Endpoint, + [Property] string? DisplayText, + [Property(IsEnum = true, DefaultValue = "Aspire.Hosting.ApplicationModel.UrlDisplayLocation.SummaryAndDetails")] + string? DisplayLocation +#pragma warning restore format +); diff --git a/src/src/SourceGeneration/Models/KitGenerationModels.cs b/src/src/SourceGeneration/Models/KitGenerationModels.cs index 085c9ba..45f8d96 100644 --- a/src/src/SourceGeneration/Models/KitGenerationModels.cs +++ b/src/src/SourceGeneration/Models/KitGenerationModels.cs @@ -28,6 +28,9 @@ EquatableArray Diagnostics public EquatableArray ResourceKits { get; init; } = EquatableArray.Empty; + public EquatableArray> ResourceGroups { get; init; } = + EquatableArray>.Empty; + public (bool IsFatal, EquatableArray Diagnostics) GetAllDiagnostics() { var allDiagnostics = Diagnostics @@ -40,6 +43,7 @@ EquatableArray Diagnostics .AsImmutableArray() .SelectMany(static r => r.Items.AsImmutableArray().SelectMany(static d => d.Diagnostics)) ) + .Concat(ResourceGroups.AsImmutableArray().SelectMany(static g => g.Diagnostics)) ) .ToImmutableArray(); @@ -47,7 +51,8 @@ EquatableArray Diagnostics HostKits.AsImmutableArray().Any(static h => !h.ShouldProcess) || ResourceKits .AsImmutableArray() - .Any(static r => r.Items.AsImmutableArray().Any(static d => !d.ShouldProcess)); + .Any(static r => r.Items.AsImmutableArray().Any(static d => !d.ShouldProcess)) + || ResourceGroups.AsImmutableArray().Any(static g => !g.ShouldProcess); return (isFatal, EquatableArray.Create([.. allDiagnostics])); } @@ -56,6 +61,8 @@ EquatableArray Diagnostics sealed record OutputContext( KitGenerationModel Model, EquatableArray ResourceKits, + // Ordered parents before their children. + EquatableArray ResourceGroups, GenerationContext Context ) : ISourceGenLogger { @@ -65,6 +72,8 @@ GenerationContext Context public bool HasResourceKits => ResourceKitCount > 0; + public bool HasResourceGroups => !ResourceGroups.IsEmpty; + public CodeWriter Writer { get; } = Context.CreateCodeWriter(); public void Log(SourceGenLogLevel level, int indentation, string message, params object[] args) => @@ -91,5 +100,33 @@ readonly record struct ResourceKitModel( string PropertyName, string ResourceName, bool HasExplicitBaseType, - string? Mode + string? Mode, + // The groups the kit belongs to, primary group first. + EquatableArray Groups, + EquatableArray Urls +); + +readonly record struct ResourceGroupModel( + TypeIdentity GroupType, + TypeIdentity OptionsType, + // TypeIdentity.Empty for a root group. + TypeIdentity ParentType, + TypeDeclarationAccessibility? Accessibility, + string Name, + string PropertyName, + bool ShowInDashboard, + string? Mode, + EquatableArray Urls +) +{ + public bool HasParent => ParentType != TypeIdentity.Empty; +} + +// A [ResourceUrl] (Url set) or an [EndpointUrl] (Url null). PropertyName is the generated options member. +readonly record struct UrlModel( + string PropertyName, + string? Url, + string? DisplayText, + string? Endpoint, + string? DisplayLocation ); diff --git a/src/src/SourceGeneration/ResourceKitDiagnosticAnalyzer.cs b/src/src/SourceGeneration/ResourceKitDiagnosticAnalyzer.cs index bfb3436..68058d3 100644 --- a/src/src/SourceGeneration/ResourceKitDiagnosticAnalyzer.cs +++ b/src/src/SourceGeneration/ResourceKitDiagnosticAnalyzer.cs @@ -32,6 +32,17 @@ public sealed class ResourceKitDiagnosticAnalyzer : DiagnosticAnalyzer DiagnosticLibrary.ResourcePropertyNeverSet, DiagnosticLibrary.ProjectDefinitionMismatch, DiagnosticLibrary.ProjectResourceKitBaseMismatch, + DiagnosticLibrary.GroupTypeNotResourceGroup, + DiagnosticLibrary.ResourceGroupCycle, + DiagnosticLibrary.DuplicateResourceGroupName, + DiagnosticLibrary.MultiplePrimaryGroups, + DiagnosticLibrary.ResourceGroupCannotHaveExplicitBase, + DiagnosticLibrary.DuplicateUrlName, + DiagnosticLibrary.EndpointUrlRequiresEndpoints, + DiagnosticLibrary.RelativeUrlWithoutEndpoints, + DiagnosticLibrary.MultipleResourceGroupParents, + DiagnosticLibrary.ResourceGroupCannotBeGeneric, + DiagnosticLibrary.ResourceGroupCannotBeResourceKit, ]; public override void Initialize(AnalysisContext context) @@ -46,6 +57,7 @@ public override void Initialize(AnalysisContext context) { ConcurrentBag hostKits = []; ConcurrentBag resourceKits = []; + ConcurrentBag resourceGroups = []; compilationStartContext.RegisterSymbolAction( symbolContext => @@ -87,13 +99,24 @@ public override void Initialize(AnalysisContext context) ) ); } + + if (ResourceKitRules.HasResourceGroupAttribute(type)) + { + resourceGroups.Add(type); + ReportRules( + symbolContext, + ResourceKitRules.EvaluateResourceGroup(type, symbolContext.CancellationToken) + ); + } }, SymbolKind.NamedType ); compilationStartContext.RegisterCompilationEndAction(compilationEndContext => - ReportCompilationRules(compilationEndContext, hostKits, resourceKits) - ); + { + ReportCompilationRules(compilationEndContext, hostKits, resourceKits); + ReportGroupCompilationRules(compilationEndContext, resourceGroups); + }); }); } @@ -166,6 +189,40 @@ ConcurrentBag resourceKits } } + // A group's name identifies it in the dashboard, which compares names case-insensitively; its property + // name is a member of the generated Groups accessor, which C# compares ordinally. + static void ReportGroupCompilationRules( + CompilationAnalysisContext context, + ConcurrentBag resourceGroups + ) + { + var groups = resourceGroups.ToArray(); + + var duplicates = groups + .GroupBy(ResourceKitRules.GroupName, StringComparer.OrdinalIgnoreCase) + .Where(static g => g.Count() > 1) + .Concat( + groups + .GroupBy(ResourceKitRules.GroupPropertyName, StringComparer.Ordinal) + .Where(static g => g.Count() > 1) + ); + + HashSet reported = new(SymbolEqualityComparer.Default); + foreach (var duplicate in duplicates) + { + foreach (var group in duplicate.Where(reported.Add)) + { + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticLibrary.DuplicateResourceGroupName, + GetLocation(group) ?? Location.None, + duplicate.Key + ) + ); + } + } + } + static Location? GetLocation(ISymbol symbol) => symbol.Locations.FirstOrDefault(static location => location.IsInSource); } diff --git a/src/tests/ResourceKit.IntegrationTests/ExampleAppHostIntegrationTests.cs b/src/tests/ResourceKit.IntegrationTests/ExampleAppHostIntegrationTests.cs index b02e50b..52c374a 100644 --- a/src/tests/ResourceKit.IntegrationTests/ExampleAppHostIntegrationTests.cs +++ b/src/tests/ResourceKit.IntegrationTests/ExampleAppHostIntegrationTests.cs @@ -1,3 +1,7 @@ +using System.Collections.Immutable; +using Aspire.Hosting.ApplicationModel; +using Microsoft.Extensions.DependencyInjection; +using Purview.Aspire.ResourceKit.Example; using Purview.Aspire.ResourceKit.Fixtures; using TUnit.Aspire; @@ -18,4 +22,60 @@ public async Task AppHost_WhenServicesStarted_APIIsHealthy(CancellationToken can // Assert // APIIsHealthyAsync asserts the API resource's /health endpoint answered 200 OK. } + + [Test] + public async Task AppHost_WhenServicesStarted_ParentsGroupedResourcesToTheirGroupNode( + CancellationToken cancellationToken + ) + { + // Arrange + // The example declares PlatformGroup > DataGroup > PostgresKit, so both group nodes exist and are nested. + await fixture.APIIsHealthyAsync(cancellationToken); + + // Act + var data = SnapshotOf(Platform.ResourceGroups.Data); + var postgres = SnapshotOf(Platform.ResourceKits.Postgres); + + // Assert + await Assert.That(data).IsNotNull(); + await Assert.That(ParentOf(data!.Relationships)).IsEqualTo(Platform.ResourceGroups.Platform); + await Assert.That(ParentOf(postgres!.Relationships)).IsEqualTo(Platform.ResourceGroups.Data); + } + + [Test] + public async Task AppHost_WhenServicesStarted_ShowsNamedUrls(CancellationToken cancellationToken) + { + // Arrange + // ExampleAPIKit declares [ResourceUrl("Health", "/health")]; PlatformGroup declares an absolute docs URL. + await fixture.APIIsHealthyAsync(cancellationToken); + + // Act + var api = SnapshotOf(Platform.ResourceKits.API); + var platform = SnapshotOf(Platform.ResourceGroups.Platform); + + // Assert + await Assert + .That( + api!.Urls.Count(static u => + u.DisplayProperties.DisplayName == "Health" && u.Url.EndsWith("/health", StringComparison.Ordinal) + ) + ) + .IsEqualTo(1); + await Assert + .That(api.Urls.Any(static u => u.DisplayProperties.DisplayName == "API")) + .IsTrue() + .Because("[EndpointUrl(\"https\", DisplayText = \"API\")] renames the https endpoint URL."); + await Assert.That(platform!.Urls.Any(static u => u.Url == Platform.ResourceGroups.DocsUrl)).IsTrue(); + } + + // The dashboard's view of a resource: the snapshot the resource notification service publishes. + CustomResourceSnapshot? SnapshotOf(string resourceName) => + fixture + .App.Services.GetRequiredService() + .TryGetCurrentState(resourceName, out var resourceEvent) + ? resourceEvent.Snapshot + : null; + + static string? ParentOf(ImmutableArray relationships) => + relationships.FirstOrDefault(static r => r.Type == "Parent")?.ResourceName; } diff --git a/src/tests/ResourceKit.UnitTests/ResourceKitGroupTests.cs b/src/tests/ResourceKit.UnitTests/ResourceKitGroupTests.cs new file mode 100644 index 0000000..e2bbd54 --- /dev/null +++ b/src/tests/ResourceKit.UnitTests/ResourceKitGroupTests.cs @@ -0,0 +1,806 @@ +using System.Diagnostics.CodeAnalysis; +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; + +namespace Purview.Aspire.ResourceKit; + +/// +/// Covers and how uses groups to +/// gate resources and to nest them in the dashboard. +/// +public sealed class ResourceKitGroupTests +{ + [Test] + public async Task Constructor_GivenParent_AddsGroupToParentsChildren() + { + // Arrange + ResourceKitGroup platform = new("platform"); + + // Act + ResourceKitGroup data = new("data", platform); + + // Assert + await Assert.That(platform.Children).Contains(data); + await Assert.That(data.Parent).IsSameReferenceAs(platform); + await Assert.That(data.ToString()).IsEqualTo("platform/data"); + } + + [Test] + public async Task IsEnabledFor_GivenDisabledAncestor_ReturnsFalse() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + ResourceKitGroup platform = new("platform") { IsEnabled = false }; + ResourceKitGroup data = new("data", platform); + + // Act + var isEnabled = data.IsEnabledFor(builder); + + // Assert + await Assert.That(isEnabled).IsFalse(); + } + + [Test] + public async Task IsEnabledFor_GivenAncestorModeExcludedByOperation_ReturnsFalse() + { + // Arrange + var builder = TestApplicationBuilderFactory.Create(DistributedApplicationOperation.Run); + ResourceKitGroup platform = new("platform") { Mode = ResourceKitMode.Publish }; + ResourceKitGroup data = new("data", platform); + + // Act + var isEnabled = data.IsEnabledFor(builder); + + // Assert + await Assert.That(isEnabled).IsFalse(); + } + + [Test] + public async Task IsEnabledFor_GivenEnabledChain_ReturnsTrue() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + ResourceKitGroup platform = new("platform"); + ResourceKitGroup data = new("data", platform); + + // Act + var isEnabled = data.IsEnabledFor(builder); + + // Assert + await Assert.That(isEnabled).IsTrue(); + } + + [Test] + public async Task AllResources_GivenNestedGroups_ReturnsEachResourceOnce() + { + // Arrange + TestHostKit hostKit = new(); + ResourceKitGroup platform = new("platform"); + ResourceKitGroup data = new("data", platform); + TestResourceKit postgres = new(hostKit, "postgres"); + TestResourceKit redis = new(hostKit, "redis"); + hostKit.AddResource(postgres, data, platform); + hostKit.AddResource(redis, data); + + // Act + var resources = platform.AllResources.ToArray(); + + // Assert + await Assert.That(resources.Length).IsEqualTo(2); + await Assert.That(resources).Contains(postgres); + await Assert.That(resources).Contains(redis); + } + + [Test] + public async Task GetGroups_GivenSeveralGroups_ReturnsPrimaryGroupFirst() + { + // Arrange + TestHostKit hostKit = new(); + ResourceKitGroup data = new("data"); + ResourceKitGroup backup = new("backup"); + TestResourceKit postgres = new(hostKit, "postgres"); + hostKit.AddResource(postgres, data, backup); + + // Act + var groups = hostKit.GetGroups(postgres); + + // Assert + await Assert.That(groups.Count).IsEqualTo(2); + await Assert.That(groups[0]).IsSameReferenceAs(data); + } + + [Test] + public async Task Build_GivenResourceInDisabledGroup_DoesNotBuildIt() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + TestHostKit hostKit = new(); + ResourceKitGroup data = new("data") { IsEnabled = false }; + TestResourceKit postgres = new(hostKit, "postgres"); + hostKit.AddResource(postgres, data); + + // Act + hostKit.Build(builder); + hostKit.Configure(); + + // Assert + await Assert.That(postgres.IsEnabled).IsFalse(); + await Assert.That(postgres.BuildCalled).IsFalse(); + await Assert.That(postgres.ConfigureCalled).IsFalse(); + } + + [Test] + public async Task Build_GivenResourceInOneDisabledGroupOfSeveral_DoesNotBuildIt() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + TestHostKit hostKit = new(); + ResourceKitGroup data = new("data"); + ResourceKitGroup backup = new("backup") { IsEnabled = false }; + TestResourceKit postgres = new(hostKit, "postgres"); + hostKit.AddResource(postgres, data, backup); + + // Act + hostKit.Build(builder); + hostKit.Configure(); + + // Assert + await Assert.That(postgres.IsEnabled).IsFalse(); + await Assert.That(postgres.BuildCalled).IsFalse(); + } + + [Test] + public async Task Build_GivenResourceInGroupWithDisabledAncestor_DoesNotBuildIt() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + TestHostKit hostKit = new(); + ResourceKitGroup platform = new("platform") { IsEnabled = false }; + ResourceKitGroup data = new("data", platform); + TestResourceKit postgres = new(hostKit, "postgres"); + hostKit.AddResource(postgres, data); + + // Act + hostKit.Build(builder); + hostKit.Configure(); + + // Assert + await Assert.That(postgres.BuildCalled).IsFalse(); + } + + [Test] + public async Task Configure_GivenEnabledGroupInRunMode_AddsDashboardNodeAndParentsMembers() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + TestHostKit hostKit = new(); + ResourceKitGroup data = new("data"); + TestResourceKit postgres = new(hostKit, "postgres"); + hostKit.AddResource(postgres, data); + + // Act + hostKit.Build(builder); + hostKit.Configure(); + + // Assert + await Assert.That(data.DashboardResource).IsNotNull(); + await Assert.That(builder.Resources.OfType().Single().Name).IsEqualTo("data"); + await Assert.That(ParentOf(postgres.Resource)).IsSameReferenceAs(data.DashboardResource!.Resource); + } + + [Test] + public async Task Configure_GivenNestedGroups_ParentsChildNodeToParentNode() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + TestHostKit hostKit = new(); + ResourceKitGroup platform = new("platform"); + ResourceKitGroup data = new("data", platform); + TestResourceKit postgres = new(hostKit, "postgres"); + hostKit.AddResource(postgres, data); + + // Act + hostKit.Build(builder); + hostKit.Configure(); + + // Assert + await Assert + .That(ParentOf(data.DashboardResource!.Resource)) + .IsSameReferenceAs(platform.DashboardResource!.Resource); + await Assert.That(ParentOf(postgres.Resource)).IsSameReferenceAs(data.DashboardResource.Resource); + } + + [Test] + public async Task Configure_GivenGroupHiddenFromDashboard_ParentsMembersToNearestShownAncestor() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + TestHostKit hostKit = new(); + ResourceKitGroup platform = new("platform"); + ResourceKitGroup data = new("data", platform) { ShowInDashboard = false }; + TestResourceKit postgres = new(hostKit, "postgres"); + hostKit.AddResource(postgres, data); + + // Act + hostKit.Build(builder); + hostKit.Configure(); + + // Assert + await Assert.That(data.DashboardResource).IsNull(); + await Assert.That(ParentOf(postgres.Resource)).IsSameReferenceAs(platform.DashboardResource!.Resource); + } + + [Test] + public async Task Configure_GivenGroupWithNoBuiltResources_DoesNotAddDashboardNode() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + TestHostKit hostKit = new(); + ResourceKitGroup data = new("data"); + TestResourceKit postgres = new(hostKit, "postgres") { IsEnabled = false }; + hostKit.AddResource(postgres, data); + + // Act + hostKit.Build(builder); + hostKit.Configure(); + + // Assert + await Assert.That(data.DashboardResource).IsNull(); + await Assert.That(builder.Resources.OfType()).IsEmpty(); + } + + [Test] + public async Task Configure_GivenPublishMode_DoesNotAddDashboardNode() + { + // Arrange + var builder = DistributedApplication.CreateBuilder([ + "--publisher", + "manifest", + "--output-path", + "manifest.json", + ]); + TestHostKit hostKit = new(); + ResourceKitGroup data = new("data"); + TestResourceKit postgres = new(hostKit, "postgres"); + hostKit.AddResource(postgres, data); + + // Act + hostKit.Build(builder); + hostKit.Configure(); + + // Assert + await Assert.That(builder.ExecutionContext.IsPublishMode).IsTrue(); + await Assert.That(postgres.BuildCalled).IsTrue(); + await Assert.That(data.DashboardResource).IsNull(); + } + + [Test] + public async Task Configure_GivenResourceWithOwnParent_KeepsItAndNestsTheTopOfTheChain() + { + // Arrange + // The AspireC4 shape: the kit's resource hangs off a row the integration created, which has no parent. + var builder = DistributedApplication.CreateBuilder(); + var owner = builder.AddParameter("owner", "value", secret: false); + TestHostKit hostKit = new(); + ResourceKitGroup data = new("data"); + TestResourceKit postgres = new(hostKit, "postgres", owner); + hostKit.AddResource(postgres, data); + + // Act + hostKit.Build(builder); + hostKit.Configure(); + + // Assert + await Assert.That(ParentOf(postgres.Resource)).IsSameReferenceAs(owner.Resource); + await Assert.That(ParentOf(owner.Resource)).IsSameReferenceAs(data.DashboardResource!.Resource); + } + + [Test] + public async Task Configure_GivenDeepOwnChain_NestsOnlyTheTopmostAncestor() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + var top = builder.AddParameter("top", "value", secret: false); + var middle = builder.AddParameter("middle", "value", secret: false).WithParentRelationship(top); + TestHostKit hostKit = new(); + ResourceKitGroup data = new("data"); + TestResourceKit postgres = new(hostKit, "postgres", middle); + hostKit.AddResource(postgres, data); + + // Act + hostKit.Build(builder); + hostKit.Configure(); + + // Assert + await Assert.That(ParentOf(postgres.Resource)).IsSameReferenceAs(middle.Resource); + await Assert.That(ParentOf(middle.Resource)).IsSameReferenceAs(top.Resource); + await Assert.That(ParentOf(top.Resource)).IsSameReferenceAs(data.DashboardResource!.Resource); + } + + [Test] + public async Task Configure_GivenKitParentedToAnotherKit_LeavesTheOtherKitInItsOwnGroup() + { + // Arrange + // The child kit is added first, so placement must not depend on the order kits are added in. + var builder = DistributedApplication.CreateBuilder(); + TestHostKit hostKit = new(); + ResourceKitGroup data = new("data"); + ResourceKitGroup tools = new("tools"); + TestResourceKit server = new(hostKit, "server"); + ParentedToKitResourceKit admin = new(hostKit, "admin", server); + hostKit.AddResource(admin, tools); + hostKit.AddResource(server, data); + + // Act + hostKit.Build(builder); + hostKit.Configure(); + + // Assert + await Assert.That(ParentOf(server.Resource)).IsSameReferenceAs(data.DashboardResource!.Resource); + await Assert.That(ParentOf(admin.Resource)).IsSameReferenceAs(server.Resource); + } + + [Test] + public async Task Configure_GivenChainEndingInAGroupNode_LeavesItThere() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + TestHostKit hostKit = new(); + ResourceKitGroup data = new("data"); + ResourceKitGroup tools = new("tools"); + TestResourceKit server = new(hostKit, "server"); + ParentedToKitResourceKit admin = new(hostKit, "admin", server); + hostKit.AddResource(server, data); + hostKit.AddResource(admin, tools); + + // Act + hostKit.Build(builder); + hostKit.Configure(); + + // Assert + // admin -> server -> data's node: the chain is already in a group, so tools' node gains nothing. + await Assert.That(ParentOf(admin.Resource)).IsSameReferenceAs(server.Resource); + await Assert.That(builder.Resources.Any(r => ParentOf(r) == tools.DashboardResource!.Resource)).IsFalse(); + } + + [Test] + [Timeout(60_000)] + public async Task Configure_GivenParentCycleBuiltOutsideTheHostKit_TerminatesAndLeavesItAlone( + CancellationToken cancellationToken + ) + { + // Arrange + // ResourceKit never creates a cycle, but an integration might; the walk must still stop. + var builder = DistributedApplication.CreateBuilder(); + var first = builder.AddParameter("first", "value", secret: false); + var second = builder.AddParameter("second", "value", secret: false).WithParentRelationship(first); + first.WithParentRelationship(second); + TestHostKit hostKit = new(); + ResourceKitGroup data = new("data"); + TestResourceKit postgres = new(hostKit, "postgres", first); + hostKit.AddResource(postgres, data); + + // Act + cancellationToken.ThrowIfCancellationRequested(); + hostKit.Build(builder); + hostKit.Configure(); + + // Assert + await Assert.That(ParentOf(postgres.Resource)).IsSameReferenceAs(first.Resource); + await Assert.That(ParentOf(first.Resource)).IsSameReferenceAs(second.Resource); + await Assert.That(ParentOf(second.Resource)).IsSameReferenceAs(first.Resource); + } + + [Test] + public async Task Configure_GivenGroupUrls_AnnotatesEnabledAbsoluteUrlsOnly() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + TestHostKit hostKit = new(); + ResourceKitGroup data = new("data") + { + Urls = + [ + new() { Url = "https://example.com/runbook", DisplayText = "Runbook" }, + new() { Url = "https://example.com/off", IsEnabled = false }, + new() { Url = "/health" }, + ], + }; + TestResourceKit postgres = new(hostKit, "postgres"); + hostKit.AddResource(postgres, data); + + // Act + hostKit.Build(builder); + hostKit.Configure(); + + // Assert + var urls = data.DashboardResource!.Resource.Annotations.OfType().ToArray(); + await Assert.That(urls.Length).IsEqualTo(1); + await Assert.That(urls[0].Url).IsEqualTo("https://example.com/runbook"); + await Assert.That(urls[0].DisplayText).IsEqualTo("Runbook"); + } + + [Test] + public async Task Configure_GivenGroupNamedLikeOneOfItsResources_SuffixesTheNodeName() + { + // Arrange + // The shape that failed in practice: a "minder" group holding a "minder" database. + var builder = DistributedApplication.CreateBuilder(); + TestHostKit hostKit = new(); + ResourceKitGroup minder = new("minder"); + TestResourceKit database = new(hostKit, "Minder"); + hostKit.AddResource(database, minder); + + // Act + hostKit.Build(builder); + hostKit.Configure(); + + // Assert + await Assert.That(minder.Name).IsEqualTo("minder"); + await Assert.That(minder.DashboardResource!.Resource.Name).IsEqualTo("minder-group"); + await Assert.That(ParentOf(database.Resource)).IsSameReferenceAs(minder.DashboardResource.Resource); + } + + [Test] + public async Task Configure_GivenSuffixedNameAlsoTaken_AppendsANumber() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + builder.AddParameter("MINDER-GROUP", "value", secret: false); + TestHostKit hostKit = new(); + ResourceKitGroup minder = new("minder"); + hostKit.AddResource(new TestResourceKit(hostKit, "minder"), minder); + + // Act + hostKit.Build(builder); + hostKit.Configure(); + + // Assert + await Assert.That(minder.DashboardResource!.Resource.Name).IsEqualTo("minder-group-2"); + } + + [Test] + public async Task Configure_GivenResourceAddedInConfigureResource_DoesNotCollideWithTheGroupNode() + { + // Arrange + // The node is added after every ConfigureResource, so a resource added there claims the name first. + var builder = DistributedApplication.CreateBuilder(); + TestHostKit hostKit = new(); + ResourceKitGroup data = new("data"); + hostKit.AddResource(new ConfigureAddsResourceKit(hostKit, "postgres", "data"), data); + + // Act + hostKit.Build(builder); + hostKit.Configure(); + + // Assert + await Assert.That(builder.Resources.Any(static r => r is ParameterResource { Name: "data" })).IsTrue(); + await Assert.That(data.DashboardResource!.Resource.Name).IsEqualTo("data-group"); + } + + [Test] + public async Task Build_WithoutConfigure_AddsNoDashboardNode() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + TestHostKit hostKit = new(); + ResourceKitGroup data = new("data"); + hostKit.AddResource(new TestResourceKit(hostKit, "postgres"), data); + + // Act + hostKit.Build(builder); + + // Assert + await Assert.That(data.DashboardResource).IsNull(); + await Assert.That(builder.Resources.OfType()).IsEmpty(); + } + + [Test] + public async Task Configure_GivenResourceIncludedByAKit_NestsItBesideTheKit() + { + // Arrange + // The DbGate shape: a kit adds an admin tool next to its own resource and includes it in its group. + var builder = DistributedApplication.CreateBuilder(); + TestHostKit hostKit = new(); + ResourceKitGroup persistence = new("persistence"); + IncludingResourceKit sql = new(hostKit, "sql", "dbgate", persistence); + hostKit.AddResource(sql, persistence); + + // Act + hostKit.Build(builder); + hostKit.Configure(); + + // Assert + var node = persistence.DashboardResource!.Resource; + await Assert.That(ParentOf(sql.Resource)).IsSameReferenceAs(node); + await Assert.That(ParentOf(sql.Included!.Resource)).IsSameReferenceAs(node); + await Assert.That(persistence.IncludedResources).Contains(sql.Included.Resource); + } + + [Test] + public async Task Configure_GivenGroupWithOnlyIncludedResources_AddsItsNode() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + var dbGate = builder.AddParameter("dbgate", "value", secret: false); + TestHostKit hostKit = new(); + ResourceKitGroup tools = new("tools"); + dbGate.WithResourceGroup(tools); + hostKit.AddGroup(tools); + + // Act + hostKit.Build(builder); + hostKit.Configure(); + + // Assert + await Assert.That(tools.DashboardResource).IsNotNull(); + await Assert.That(ParentOf(dbGate.Resource)).IsSameReferenceAs(tools.DashboardResource!.Resource); + } + + [Test] + public async Task Configure_GivenResourceIncludedInTwoGroups_KeepsItInTheFirst() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + var dbGate = builder.AddParameter("dbgate", "value", secret: false); + TestHostKit hostKit = new(); + ResourceKitGroup first = new("first"); + ResourceKitGroup second = new("second"); + dbGate.WithResourceGroup(first); + dbGate.WithResourceGroup(second); + hostKit.AddGroup(first); + hostKit.AddGroup(second); + + // Act + hostKit.Build(builder); + hostKit.Configure(); + + // Assert + var parents = dbGate + .Resource.Annotations.OfType() + .Where(static a => a.Type == "Parent"); + await Assert.That(parents.Count()).IsEqualTo(1); + await Assert.That(ParentOf(dbGate.Resource)).IsSameReferenceAs(first.DashboardResource!.Resource); + } + + [Test] + public async Task Configure_GivenIncludedResourceInHiddenGroup_NestsItUnderTheNearestShownAncestor() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + var dbGate = builder.AddParameter("dbgate", "value", secret: false); + TestHostKit hostKit = new(); + ResourceKitGroup platform = new("platform"); + ResourceKitGroup tools = new("tools", platform) { ShowInDashboard = false }; + dbGate.WithResourceGroup(tools); + hostKit.AddGroup(tools); + + // Act + hostKit.Build(builder); + hostKit.Configure(); + + // Assert + await Assert.That(tools.DashboardResource).IsNull(); + await Assert.That(ParentOf(dbGate.Resource)).IsSameReferenceAs(platform.DashboardResource!.Resource); + } + + [Test] + public async Task WithResourceGroup_GivenAGroupDashboardNode_ThrowsArgumentException() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + ResourceKitGroup data = new("data"); + var node = builder.AddResource(new ResourceKitGroupResource("other")); + + // Act + var exception = await Assert.That(() => node.WithResourceGroup(data)).ThrowsExactly(); + + // Assert + await Assert.That(exception).IsNotNull(); + } + + [Test] + public async Task WithResourceGroup_AfterConfigure_ThrowsInvalidOperationException() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + var dbGate = builder.AddParameter("dbgate", "value", secret: false); + TestHostKit hostKit = new(); + ResourceKitGroup data = new("data"); + hostKit.AddResource(new TestResourceKit(hostKit, "postgres"), data); + hostKit.Build(builder); + hostKit.Configure(); + + // Act + var exception = await Assert + .That(() => dbGate.WithResourceGroup(data)) + .ThrowsExactly(); + + // Assert + await Assert.That(exception).IsNotNull(); + } + + [Test] + public async Task Parent_GivenAGroup_CannotBeReassigned() + { + // Arrange + // A parent is set only by the constructor, before the instance can be anyone's ancestor, which is what + // makes the parent chain acyclic. A setter would let a group be made its own ancestor. + var parent = typeof(ResourceKitGroup).GetProperty(nameof(ResourceKitGroup<>.Parent)); + + // Act + var setter = parent!.SetMethod; + + // Assert + await Assert.That(setter).IsNull(); + } + + [Test] + public async Task Children_WhenCastToAMutableList_CannotBeChanged() + { + // Arrange + ResourceKitGroup platform = new("platform"); + ResourceKitGroup data = new("data", platform); + + // Act + var children = (ICollection>)platform.Children; + + // Assert + await Assert.That(platform.Children is List>).IsFalse(); + await Assert.That(children.IsReadOnly).IsTrue(); + await Assert.That(() => children.Add(platform)).ThrowsExactly(); + await Assert.That(platform.Children).IsEquivalentTo([data]); + } + + [Test] + public async Task Resources_WhenCastToAMutableList_CannotBeChanged() + { + // Arrange + TestHostKit hostKit = new(); + ResourceKitGroup data = new("data"); + hostKit.AddResource(new TestResourceKit(hostKit, "postgres"), data); + + // Act + var resources = (ICollection>)data.Resources; + + // Assert + await Assert.That(resources.IsReadOnly).IsTrue(); + } + + [Test] + public async Task GetGroups_WhenCastToAMutableList_CannotBeChanged() + { + // Arrange + TestHostKit hostKit = new(); + ResourceKitGroup data = new("data"); + TestResourceKit postgres = new(hostKit, "postgres"); + hostKit.AddResource(postgres, data); + + // Act + var groups = (ICollection>)hostKit.GetGroups(postgres); + + // Assert + await Assert.That(groups.IsReadOnly).IsTrue(); + } + + [Test] + [Timeout(60_000)] + public async Task Walks_GivenAVeryDeepHierarchy_CompleteWithoutExhaustingTheStack( + CancellationToken cancellationToken + ) + { + // Arrange + // Deep enough that any recursive walk would overflow the stack. + const int depth = 100_000; + var builder = DistributedApplication.CreateBuilder(); + TestHostKit hostKit = new(); + ResourceKitGroup root = new("g0"); + var leaf = root; + for (var i = 1; i < depth; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + leaf = new($"g{i}", leaf); + } + + TestResourceKit postgres = new(hostKit, "postgres"); + hostKit.AddResource(postgres, leaf); + + // Act + var allResources = root.AllResources.ToArray(); + var isEnabled = leaf.IsEnabledFor(builder); + var path = leaf.ToString(); + + // Assert + await Assert.That(allResources.Length).IsEqualTo(1); + await Assert.That(allResources[0]).IsSameReferenceAs(postgres); + await Assert.That(isEnabled).IsTrue(); + await Assert.That(path).StartsWith("g0/g1/"); + await Assert.That(path).EndsWith($"/g{depth - 1}"); + } + + [Test] + public async Task AddGroup_WhenSealed_ThrowsInvalidOperationException() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + TestHostKit hostKit = new(); + hostKit.Build(builder); + + // Act + var exception = await Assert + .That(() => hostKit.AddGroup(new("data"))) + .ThrowsExactly(); + + // Assert + await Assert.That(exception).IsNotNull(); + } + + static IResource? ParentOf(IResource resource) => + resource + .Annotations.OfType() + .SingleOrDefault(static a => a.Type == "Parent") + ?.Resource; + + sealed class TestHostKit : HostKitBase; + + // Adds a second, non-kit resource beside its own and includes it in a group, as SqlServerKit does with DbGate. + sealed class IncludingResourceKit( + TestHostKit hostKit, + string name, + string includedName, + ResourceKitGroup group + ) : ResourceKitBase(hostKit, name) + { + public IResourceBuilder? Included { get; private set; } + + protected override IResourceBuilder BuildResource( + [NotNull] IDistributedApplicationBuilder builder + ) + { + Included = builder.AddParameter(includedName, "value", secret: false).WithResourceGroup(group); + return builder.AddParameter(Name, "value", secret: false); + } + } + + // Parents its resource to another kit's resource, which only exists once that kit has been built. + sealed class ParentedToKitResourceKit(TestHostKit hostKit, string name, TestResourceKit parent) + : ResourceKitBase(hostKit, name) + { + protected override IResourceBuilder BuildResource( + [NotNull] IDistributedApplicationBuilder builder + ) => builder.AddParameter(Name, "value", secret: false); + + protected override void ConfigureResource() => ResourceBuilder.WithParentRelationship(parent.Resource); + } + + // Adds a second resource from ConfigureResource, as a kit that adds a database there does. + sealed class ConfigureAddsResourceKit(TestHostKit hostKit, string name, string configuredName) + : ResourceKitBase(hostKit, name) + { + protected override IResourceBuilder BuildResource( + [NotNull] IDistributedApplicationBuilder builder + ) => builder.AddParameter(Name, "value", secret: false); + + protected override void ConfigureResource() => + ApplicationBuilder.AddParameter(configuredName, "value", secret: false); + } + + sealed class TestResourceKit(TestHostKit hostKit, string name, IResourceBuilder? parent = null) + : ResourceKitBase(hostKit, name) + { + public bool BuildCalled { get; private set; } + + public bool ConfigureCalled { get; private set; } + + protected override IResourceBuilder BuildResource( + [NotNull] IDistributedApplicationBuilder builder + ) + { + BuildCalled = true; + var resource = builder.AddParameter(Name, "value", secret: false); + return parent is null ? resource : resource.WithParentRelationship(parent); + } + + protected override void ConfigureResource() => ConfigureCalled = true; + } +} diff --git a/src/tests/ResourceKit.UnitTests/ResourceKitUrlTests.cs b/src/tests/ResourceKit.UnitTests/ResourceKitUrlTests.cs new file mode 100644 index 0000000..f058460 --- /dev/null +++ b/src/tests/ResourceKit.UnitTests/ResourceKitUrlTests.cs @@ -0,0 +1,219 @@ +using System.Diagnostics.CodeAnalysis; +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; + +namespace Purview.Aspire.ResourceKit; + +public sealed class ResourceKitUrlTests +{ + [Test] + public async Task Apply_GivenRelativeUrlWithoutEndpoint_AddsOneUrlAgainstHttpsEndpoint() + { + // Arrange + var urls = CreateEndpointUrls(); + ResourceKitUrl url = new() { Url = "/health", DisplayText = "Health" }; + + // Act + var unresolved = ResourceKitUrl.Apply([url], urls); + + // Assert + await Assert.That(unresolved).IsEmpty(); + await Assert.That(urls.Count).IsEqualTo(3); + + var added = urls[^1]; + await Assert.That(added.Url).IsEqualTo("https://localhost:5001/health"); + await Assert.That(added.DisplayText).IsEqualTo("Health"); + await Assert.That(added.Endpoint?.EndpointName).IsEqualTo("https"); + } + + [Test] + public async Task Apply_GivenRelativeUrlWithNamedEndpoint_ResolvesAgainstThatEndpoint() + { + // Arrange + var urls = CreateEndpointUrls(); + ResourceKitUrl url = new() { Url = "/scalar", Endpoint = "http" }; + + // Act + ResourceKitUrl.Apply([url], urls); + + // Assert + await Assert.That(urls[^1].Url).IsEqualTo("http://localhost:5000/scalar"); + } + + [Test] + public async Task Apply_GivenRelativeUrl_DoesNotChangeExistingEndpointUrls() + { + // Arrange + var urls = CreateEndpointUrls(); + ResourceKitUrl url = new() { Url = "/health" }; + + // Act + ResourceKitUrl.Apply([url], urls); + + // Assert + await Assert.That(urls[0].Url).IsEqualTo("http://localhost:5000"); + await Assert.That(urls[1].Url).IsEqualTo("https://localhost:5001"); + } + + [Test] + public async Task Apply_GivenAbsoluteUrl_AddsItWithoutEndpoint() + { + // Arrange + List urls = []; + ResourceKitUrl url = new() + { + Url = "https://example.com/runbook", + DisplayText = "Runbook", + DisplayLocation = UrlDisplayLocation.DetailsOnly, + }; + + // Act + var unresolved = ResourceKitUrl.Apply([url], urls); + + // Assert + await Assert.That(unresolved).IsEmpty(); + await Assert.That(urls.Count).IsEqualTo(1); + await Assert.That(urls[0].Url).IsEqualTo("https://example.com/runbook"); + await Assert.That(urls[0].Endpoint).IsNull(); + await Assert.That(urls[0].DisplayLocation).IsEqualTo(UrlDisplayLocation.DetailsOnly); + } + + [Test] + public async Task Apply_GivenEndpointUrlWithoutUrl_CustomisesTheEndpointsUrl() + { + // Arrange + var urls = CreateEndpointUrls(); + ResourceKitUrl url = new() + { + Endpoint = "http", + DisplayText = "API (HTTP)", + DisplayLocation = UrlDisplayLocation.DetailsOnly, + }; + + // Act + ResourceKitUrl.Apply([url], urls); + + // Assert + await Assert.That(urls.Count).IsEqualTo(2); + await Assert.That(urls[0].DisplayText).IsEqualTo("API (HTTP)"); + await Assert.That(urls[0].DisplayLocation).IsEqualTo(UrlDisplayLocation.DetailsOnly); + await Assert.That(urls[1].DisplayText).IsNull(); + } + + [Test] + public async Task Apply_GivenDisabledUrl_SkipsIt() + { + // Arrange + var urls = CreateEndpointUrls(); + ResourceKitUrl url = new() { Url = "/health", IsEnabled = false }; + + // Act + var unresolved = ResourceKitUrl.Apply([url], urls); + + // Assert + await Assert.That(unresolved).IsEmpty(); + await Assert.That(urls.Count).IsEqualTo(2); + } + + [Test] + public async Task Apply_GivenRelativeUrlAndNoEndpoints_ReturnsItAsUnresolved() + { + // Arrange + List urls = []; + ResourceKitUrl url = new() { Url = "/health" }; + + // Act + var unresolved = ResourceKitUrl.Apply([url], urls); + + // Assert + await Assert.That(unresolved).Contains(url); + await Assert.That(urls).IsEmpty(); + } + + [Test] + public async Task Apply_GivenUnknownEndpoint_ReturnsItAsUnresolved() + { + // Arrange + var urls = CreateEndpointUrls(); + ResourceKitUrl url = new() { Endpoint = "grpc", DisplayText = "gRPC" }; + + // Act + var unresolved = ResourceKitUrl.Apply([url], urls); + + // Assert + await Assert.That(unresolved).Contains(url); + } + + [Test] + [Arguments("/health", false)] + [Arguments("health", false)] + [Arguments("https://example.com", true)] + public async Task IsAbsolute_GivenUrl_ReportsWhetherItNeedsAnEndpoint(string value, bool expected) + { + // Arrange + ResourceKitUrl url = new() { Url = value }; + + // Act + var isAbsolute = url.IsAbsolute; + + // Assert + await Assert.That(isAbsolute).IsEqualTo(expected); + } + + [Test] + public async Task Build_GivenKitWithUrls_RegistersUrlsCallback() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + TestHostKit hostKit = new(); + UrlResourceKit resource = new(hostKit, [new() { Url = "https://example.com", DisplayText = "Docs" }]); + + // Act + resource.Build(builder); + + // Assert + await Assert.That(resource.Resource.Annotations.OfType().Count()).IsEqualTo(1); + } + + [Test] + public async Task Build_GivenKitWithOnlyDisabledUrls_DoesNotRegisterUrlsCallback() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + TestHostKit hostKit = new(); + UrlResourceKit resource = new(hostKit, [new() { Url = "https://example.com", IsEnabled = false }]); + + // Act + resource.Build(builder); + + // Assert + await Assert.That(resource.Resource.Annotations.OfType()).IsEmpty(); + } + + static List CreateEndpointUrls() + { + var builder = DistributedApplication.CreateBuilder(); + var container = builder + .AddContainer("api", "image") + .WithHttpEndpoint(port: 5000, name: "http") + .WithHttpsEndpoint(port: 5001, name: "https"); + + return + [ + new() { Url = "http://localhost:5000", Endpoint = container.GetEndpoint("http") }, + new() { Url = "https://localhost:5001", Endpoint = container.GetEndpoint("https") }, + ]; + } + + sealed class TestHostKit : HostKitBase; + + sealed class UrlResourceKit(TestHostKit hostKit, IEnumerable urls) + : ResourceKitBase(hostKit, "api") + { + protected override IEnumerable Urls => urls; + + protected override IResourceBuilder BuildResource( + [NotNull] IDistributedApplicationBuilder builder + ) => builder.AddContainer(Name, "image"); + } +} diff --git a/src/tests/SharedTestingInfra/ResourceKitSourceGeneratorTestBase.cs b/src/tests/SharedTestingInfra/ResourceKitSourceGeneratorTestBase.cs index cd73541..144f4bf 100644 --- a/src/tests/SharedTestingInfra/ResourceKitSourceGeneratorTestBase.cs +++ b/src/tests/SharedTestingInfra/ResourceKitSourceGeneratorTestBase.cs @@ -6,8 +6,9 @@ public abstract class ResourceKitSourceGeneratorTestBase : TUnitSourceGeneratorTestBase where TGenerator : class, IIncrementalGenerator, new() { - // Generated attribute files: EmbeddedAttribute, HostKitAttribute, ResourceDefinitionAttribute. - public static readonly int ExpectedGeneratedFileCount = 3; + // Generated attribute files: EmbeddedAttribute, HostKitAttribute, ResourceDefinitionAttribute, + // ResourceGroupAttribute, ResourceUrlAttribute. + public static readonly int ExpectedGeneratedFileCount = 5; public static readonly int ExpectedFileCountPlusGen = ExpectedGeneratedFileCount + 1; } diff --git a/src/tests/SharedTestingInfra/ResourceKitSourceGeneratorTestOptions.cs b/src/tests/SharedTestingInfra/ResourceKitSourceGeneratorTestOptions.cs index 6b29974..3883708 100644 --- a/src/tests/SharedTestingInfra/ResourceKitSourceGeneratorTestOptions.cs +++ b/src/tests/SharedTestingInfra/ResourceKitSourceGeneratorTestOptions.cs @@ -37,6 +37,8 @@ public ResourceKitSourceGeneratorTestOptions() "EmbeddedAttribute", TypeLibrary.Purview.Aspire.ResourceKit.HostKitAttribute.Name, TypeLibrary.Purview.Aspire.ResourceKit.ResourceDefinitionAttribute.Name, + TypeLibrary.Purview.Aspire.ResourceKit.ResourceGroupAttribute.Name, + TypeLibrary.Purview.Aspire.ResourceKit.ResourceUrlAttribute.Name, ]; AnalyzerTypes = [typeof(SourceGeneration.ResourceKitDiagnosticAnalyzer)]; } diff --git a/src/tests/SourceGeneration.IntegrationTests/GeneratorCachingTests.cs b/src/tests/SourceGeneration.IntegrationTests/GeneratorCachingTests.cs index f2c6b62..127d627 100644 --- a/src/tests/SourceGeneration.IntegrationTests/GeneratorCachingTests.cs +++ b/src/tests/SourceGeneration.IntegrationTests/GeneratorCachingTests.cs @@ -43,6 +43,40 @@ CancellationToken cancellationToken { // Arrange var sources = TestHelper.GenerateSources().ToArray(); + + // Act & Assert + await AssertUnrelatedChangeReusesCachedOutputAsync(sources, cancellationToken); + } + + [Test] + public async Task Generate_GivenGroupsAndUrlsAndUnrelatedSourceChange_HostKitOutputIsReusedFromCache( + CancellationToken cancellationToken + ) + { + // Arrange + string[] sources = + [ + .. TestHelper.GenerateSources(), + """ + namespace Testing.Groups; + + // TestHelper's host kit is public, and a group must be at least as accessible as its host kit. + [ResourceGroup] + [ResourceUrl("Runbook", "https://example.com/runbook")] + public partial class PlatformGroup; + + [ResourceGroup(Parent = typeof(PlatformGroup))] + public partial class DataGroup; + """, + ]; + + // Act & Assert + await AssertUnrelatedChangeReusesCachedOutputAsync(sources, cancellationToken); + } + + async Task AssertUnrelatedChangeReusesCachedOutputAsync(string[] sources, CancellationToken cancellationToken) + { + // Arrange var harnessCompilation = (await GenerateAsync(sources, cancellationToken)).CompilationResult.Compilation; var parseOptions = (CSharpParseOptions)harnessCompilation.SyntaxTrees.First().Options; diff --git a/src/tests/SourceGeneration.IntegrationTests/ResourceGroupGenerationTests.cs b/src/tests/SourceGeneration.IntegrationTests/ResourceGroupGenerationTests.cs new file mode 100644 index 0000000..0f0c2df --- /dev/null +++ b/src/tests/SourceGeneration.IntegrationTests/ResourceGroupGenerationTests.cs @@ -0,0 +1,768 @@ +using Microsoft.CodeAnalysis; +using Purview.Aspire.ResourceKit.SourceGeneration.Helpers; + +namespace Purview.Aspire.ResourceKit.SourceGeneration; + +/// +/// Verifies what emits for [ResourceGroup] and [InGroup<TGroup>], +/// and the group diagnostics reports (SG0021-SG0025). +/// +public sealed class ResourceGroupGenerationTests : ResourceKitSourceGeneratorTestBase +{ + const string Kit = """ + protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => + throw new global::System.NotImplementedException(); + """; + + [Test] + public async Task Generate_GivenKitInSeveralGroups_RegistersItWithPrimaryGroupFirst( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = $$""" + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceGroup] + partial class DataGroup; + + [ResourceGroup] + partial class BackupGroup; + + [ResourceDefinition] + [InGroup] + [InGroup(Primary = true)] + sealed partial class PostgresKit + { + {{Kit}} + } + """; + + // Act + using var result = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasNoErrorDiagnostics(); + await Assert.That(result.GetSource()).Contains("AddResource(Postgres, Groups.Backup, Groups.Data);"); + } + + [Test] + public async Task Generate_GivenChildDeclaredBeforeParent_CreatesParentFirst(CancellationToken cancellationToken) + { + // Arrange + const string source = $$""" + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceGroup(Parent = typeof(PlatformGroup))] + partial class DataGroup; + + [ResourceGroup] + partial class PlatformGroup; + + [ResourceDefinition] + [InGroup] + sealed partial class PostgresKit + { + {{Kit}} + } + """; + + // Act + using var result = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasNoErrorDiagnostics(); + + var generated = result.GetSource(); + var platform = generated.IndexOf("Platform = new(options.Groups.Platform, null);", StringComparison.Ordinal); + var data = generated.IndexOf("Data = new(options.Groups.Data, Platform);", StringComparison.Ordinal); + await Assert.That(platform).IsGreaterThan(-1); + await Assert.That(data).IsGreaterThan(platform); + } + + [Test] + public async Task Generate_GivenGroup_DerivesItFromResourceKitGroupAndBindsOptions( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceGroup("storage", ShowInDashboard = false)] + partial class DataGroup; + """; + + // Act + using var result = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasNoErrorDiagnostics(); + + var generated = result.GetSource(); + await Assert + .That(generated) + .Contains( + "partial class DataGroup : global::Purview.Aspire.ResourceKit.ResourceKitGroup" + ); + await Assert.That(generated).Contains("base(\"storage\", parent)"); + await Assert.That(generated).Contains("public bool ShowInDashboard { get; set; } = false;"); + await Assert.That(generated).Contains("ResourceKitGroupsOptions Groups { get; init; } = new();"); + } + + [Test] + public async Task Generate_GivenGenerateOptionsDisabled_SetsGroupDefaultsFromAttribute( + CancellationToken cancellationToken + ) + { + // Arrange + var source = $$""" + namespace Testing; + + [HostKit(GenerateOptions = false)] + partial class TestingHostKit; + + [ResourceGroup(ShowInDashboard = false, Mode = {{TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitMode}}.Publish)] + partial class DataGroup; + """; + + // Act + using var result = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasNoErrorDiagnostics(); + + var generated = result.GetSource(); + await Assert + .That(generated) + .Contains($"Mode = {TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitMode}.Publish;"); + await Assert.That(generated).Contains("ShowInDashboard = false;"); + await Assert.That(generated).Contains("Data = new(null);"); + } + + [Test] + public async Task Generate_GivenInGroupOfNonGroupType_ReportsGroupTypeNotResourceGroup( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = $$""" + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + sealed class NotAGroup; + + [ResourceDefinition] + [InGroup] + sealed partial class PostgresKit + { + {{Kit}} + } + """; + + // Act + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.GroupTypeNotResourceGroup); + } + + [Test] + public async Task Generate_GivenParentOfNonGroupType_ReportsGroupTypeNotResourceGroup( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + sealed class NotAGroup; + + [ResourceGroup(Parent = typeof(NotAGroup))] + partial class DataGroup; + """; + + // Act + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.GroupTypeNotResourceGroup); + } + + [Test] + public async Task Generate_GivenParentCycle_ReportsResourceGroupCycle(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceGroup(Parent = typeof(BGroup))] + partial class AGroup; + + [ResourceGroup(Parent = typeof(AGroup))] + partial class BGroup; + """; + + // Act + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ResourceGroupCycle); + } + + [Test] + public async Task Generate_GivenTwoGroupsWithTheSameName_ReportsDuplicateResourceGroupName( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceGroup("data")] + partial class DataGroup; + + [ResourceGroup("Data")] + partial class StorageGroup; + """; + + // Act + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.DuplicateResourceGroupName); + } + + [Test] + public async Task Generate_GivenTwoPrimaryGroups_ReportsMultiplePrimaryGroupsAndStillGenerates( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = $$""" + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceGroup] + partial class DataGroup; + + [ResourceGroup] + partial class BackupGroup; + + [ResourceDefinition] + [InGroup(Primary = true)] + [InGroup(Primary = true)] + sealed partial class PostgresKit + { + {{Kit}} + } + """; + + // Act + using var result = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.MultiplePrimaryGroups); + await Assert.That(result).HasNoErrorDiagnostics(); + await Assert.That(result.GetSource()).Contains("AddResource(Postgres, Groups.Data, Groups.Backup);"); + } + + [Test] + public async Task Generate_GivenGroupWithExplicitBase_ReportsResourceGroupCannotHaveExplicitBase( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + class SomeBase; + + [ResourceGroup] + partial class DataGroup : SomeBase; + """; + + // Act + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ResourceGroupCannotHaveExplicitBase); + } + + [Test] + public async Task Generate_GivenGroupThatIsNotPartial_ReportsClassMustBePartial(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceGroup] + sealed class DataGroup; + """; + + // Act + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ClassMustBePartial); + } + + [Test] + public async Task Generate_GivenGenericResourceGroup_NestsTheGroupUnderItsTypeArgument( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = $$""" + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceGroup] + partial class PlatformGroup; + + [ResourceGroup("storage")] + partial class DataGroup; + + [ResourceDefinition] + [InGroup] + sealed partial class PostgresKit + { + {{Kit}} + } + """; + + // Act + using var result = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasNoErrorDiagnostics(); + + var generated = result.GetSource(); + await Assert.That(generated).Contains("Data = new(options.Groups.Data, Platform);"); + await Assert.That(generated).Contains("base(\"storage\", parent)"); + } + + [Test] + [Timeout(CycleTimeoutMilliseconds)] + public async Task Generate_GivenGroupThatIsItsOwnParentThroughParent_ReportsResourceGroupCycle( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceGroup(Parent = typeof(AGroup))] + partial class AGroup; + """; + + // Act + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); + + // Assert + await Assert.That(Reporting(result, DiagnosticLibrary.ResourceGroupCycle)).IsEquivalentTo(["AGroup"]); + } + + [Test] + [Timeout(CycleTimeoutMilliseconds)] + public async Task Generate_GivenGroupThatIsItsOwnParentThroughTypeArgument_ReportsResourceGroupCycle( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceGroup] + partial class AGroup; + """; + + // Act + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); + + // Assert + await Assert.That(Reporting(result, DiagnosticLibrary.ResourceGroupCycle)).IsEquivalentTo(["AGroup"]); + } + + [Test] + [Timeout(CycleTimeoutMilliseconds)] + public async Task Generate_GivenThreeGroupCycle_ReportsResourceGroupCycleOnEveryMember( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceGroup] + partial class AGroup; + + [ResourceGroup(Parent = typeof(CGroup))] + partial class BGroup; + + [ResourceGroup] + partial class CGroup; + """; + + // Act + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); + + // Assert + await Assert + .That(Reporting(result, DiagnosticLibrary.ResourceGroupCycle)) + .IsEquivalentTo(["AGroup", "BGroup", "CGroup"]); + } + + [Test] + [Timeout(CycleTimeoutMilliseconds)] + public async Task Generate_GivenGroupLeadingIntoCycleItIsNotPartOf_ReportsOnlyTheCycleMembers( + CancellationToken cancellationToken + ) + { + // Arrange + // AGroup -> BGroup -> CGroup -> BGroup: the walk from AGroup must stop when it revisits BGroup. + const string source = """ + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceGroup] + partial class AGroup; + + [ResourceGroup] + partial class BGroup; + + [ResourceGroup] + partial class CGroup; + """; + + // Act + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); + + // Assert + await Assert.That(Reporting(result, DiagnosticLibrary.ResourceGroupCycle)).IsEquivalentTo(["BGroup", "CGroup"]); + await Assert.That(result.DriverResult.GeneratedTrees.Length).IsEqualTo(ExpectedGeneratedFileCount); + } + + [Test] + [Timeout(CycleTimeoutMilliseconds)] + public async Task Generate_GivenGroupParentedToConstructedFormOfItself_ReportsResourceGroupCannotBeGeneric( + CancellationToken cancellationToken + ) + { + // Arrange + // Without normalising to the original definition, G -> G> -> ... never revisits a type. + const string source = """ + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceGroup>>] + partial class G; + """; + + // Act + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ResourceGroupCannotBeGeneric); + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ResourceGroupCycle); + } + + [Test] + public async Task Generate_GivenParentAsTypeArgumentAndProperty_ReportsMultipleResourceGroupParents( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceGroup] + partial class PlatformGroup; + + [ResourceGroup] + partial class SharedGroup; + + [ResourceGroup(Parent = typeof(SharedGroup))] + partial class DataGroup; + """; + + // Act + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); + + // Assert + await Assert + .That(Reporting(result, DiagnosticLibrary.MultipleResourceGroupParents)) + .IsEquivalentTo(["DataGroup"]); + } + + [Test] + public async Task Generate_GivenBothGroupAttributes_ReportsMultipleResourceGroupParents( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceGroup] + partial class PlatformGroup; + + [ResourceGroup] + [ResourceGroup] + partial class DataGroup; + """; + + // Act + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); + + // Assert + await Assert + .That(Reporting(result, DiagnosticLibrary.MultipleResourceGroupParents)) + .IsEquivalentTo(["DataGroup"]); + } + + [Test] + public async Task Generate_GivenGenericGroupClass_ReportsResourceGroupCannotBeGeneric( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceGroup] + partial class DataGroup; + """; + + // Act + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ResourceGroupCannotBeGeneric); + } + + [Test] + public async Task Generate_GivenGroupInItself_ReportsResourceGroupCannotBeResourceKit( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceGroup] + [InGroup] + partial class DataGroup; + """; + + // Act + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); + + // Assert + await Assert + .That(Reporting(result, DiagnosticLibrary.ResourceGroupCannotBeResourceKit)) + .IsEquivalentTo(["DataGroup"]); + } + + [Test] + public async Task Generate_GivenClassThatIsBothGroupAndKit_ReportsResourceGroupCannotBeResourceKit( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = $$""" + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceGroup] + [ResourceDefinition] + sealed partial class PostgresKit + { + {{Kit}} + } + """; + + // Act + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ResourceGroupCannotBeResourceKit); + } + + [Test] + public async Task Generate_GivenKitInItself_ReportsGroupTypeNotResourceGroup(CancellationToken cancellationToken) + { + // Arrange + const string source = $$""" + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceDefinition] + [InGroup] + sealed partial class PostgresKit + { + {{Kit}} + } + """; + + // Act + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.GroupTypeNotResourceGroup); + } + + // A cycle that is not guarded hangs rather than fails; the timeout turns a hang into a failure. + const int CycleTimeoutMilliseconds = 60_000; + + // The source text each diagnostic points at, which for a group rule is the group's identifier. + static string[] Reporting(DriverRunResult result, DiagnosticDescriptor descriptor) => + [ + .. (result.AnalyzerResult?.Diagnostics ?? []) + .Where(d => d.Id == descriptor.Id && d.Location.SourceTree is not null) + .Select(static d => d.Location.SourceTree!.GetText().ToString(d.Location.SourceSpan)) + .Order(StringComparer.Ordinal), + ]; + + protected override ResourceKitSourceGeneratorTestOptions OnBeforeRun( + IEnumerable sources, + ResourceKitSourceGeneratorTestOptions options, + CancellationToken cancellationToken + ) + { + return base.OnBeforeRun( + sources, + options + .WithAdditionalAssemblyTypes(typeof(DefaultAspireResource)) + .WithAdditionalNamespaces(TestingTypeLibrary.Purview.Aspire.ResourceKit.DefaultAspireResource), + cancellationToken + ); + } +} diff --git a/src/tests/SourceGeneration.IntegrationTests/ResourceUrlGenerationTests.cs b/src/tests/SourceGeneration.IntegrationTests/ResourceUrlGenerationTests.cs new file mode 100644 index 0000000..002ed58 --- /dev/null +++ b/src/tests/SourceGeneration.IntegrationTests/ResourceUrlGenerationTests.cs @@ -0,0 +1,326 @@ +using Purview.Aspire.ResourceKit.SourceGeneration.Helpers; + +namespace Purview.Aspire.ResourceKit.SourceGeneration; + +/// +/// Verifies what emits for [ResourceUrl] and [EndpointUrl], and the +/// URL diagnostics reports (SG0026-SG0028). +/// +public sealed class ResourceUrlGenerationTests : ResourceKitSourceGeneratorTestBase +{ + const string ContainerKit = """ + protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => + builder.AddContainer(Name, "image"); + """; + + const string ResourceKit = """ + protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => + throw new global::System.NotImplementedException(); + """; + + [Test] + public async Task Generate_GivenResourceUrl_EmitsUrlOptionAndUrlsOverride(CancellationToken cancellationToken) + { + // Arrange + const string source = $$""" + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceDefinition] + [ResourceUrl("Health", "/health", DisplayText = "Health", Endpoint = "http")] + sealed partial class ApiKit + { + {{ContainerKit}} + } + """; + + // Act + using var result = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasNoErrorDiagnostics(); + + var generated = result.GetSource(); + await Assert + .That(generated) + .Contains( + $"public {TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitUrl} Health {{ get; set; }} = new {TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitUrl}() {{ Url = \"/health\", DisplayText = \"Health\", Endpoint = \"http\", DisplayLocation = {TypeLibrary.Aspire.Hosting.ApplicationModel.UrlDisplayLocation}.SummaryAndDetails }};" + ); + await Assert.That(generated).Contains("Urls => [Options.Urls.Health];"); + } + + [Test] + public async Task Generate_GivenEndpointUrl_NamesOptionAfterEndpointInPascalCase( + CancellationToken cancellationToken + ) + { + // Arrange + var source = $$""" + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceDefinition] + [EndpointUrl("otlp-grpc", DisplayText = "OTLP", DisplayLocation = {{TypeLibrary.Aspire.Hosting.ApplicationModel.UrlDisplayLocation}}.DetailsOnly)] + sealed partial class CollectorKit + { + {{ContainerKit}} + } + """; + + // Act + using var result = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasNoErrorDiagnostics(); + + var generated = result.GetSource(); + await Assert.That(generated).Contains("ResourceKitUrl OtlpGrpc { get; set; }"); + await Assert + .That(generated) + .Contains( + $"DisplayText = \"OTLP\", Endpoint = \"otlp-grpc\", DisplayLocation = {TypeLibrary.Aspire.Hosting.ApplicationModel.UrlDisplayLocation}.DetailsOnly" + ); + } + + [Test] + public async Task Generate_GivenGenerateOptionsDisabled_EmitsUrlsInline(CancellationToken cancellationToken) + { + // Arrange + const string source = $$""" + namespace Testing; + + [HostKit(GenerateOptions = false)] + partial class TestingHostKit; + + [ResourceDefinition] + [ResourceUrl("Docs", "https://example.com/docs")] + sealed partial class ApiKit + { + {{ContainerKit}} + } + """; + + // Act + using var result = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasNoErrorDiagnostics(); + + var generated = result.GetSource(); + await Assert.That(generated).DoesNotContain("UrlsOptions"); + await Assert + .That(generated) + .Contains( + $"Urls => [new {TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitUrl}() {{ Url = \"https://example.com/docs\"" + ); + } + + [Test] + public async Task Generate_GivenKitWithoutUrls_DoesNotOverrideUrls(CancellationToken cancellationToken) + { + // Arrange + const string source = $$""" + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceDefinition] + sealed partial class ApiKit + { + {{ContainerKit}} + } + """; + + // Act + using var result = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasNoErrorDiagnostics(); + await Assert.That(result.GetSource()).DoesNotContain("Urls =>"); + } + + [Test] + public async Task Generate_GivenGroupUrl_AssignsGroupUrlsFromOptions(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceGroup] + [ResourceUrl("Runbook", "https://example.com/runbook")] + partial class DataGroup; + """; + + // Act + using var result = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasNoErrorDiagnostics(); + await Assert.That(result.GetSource()).Contains("Urls = [Options.Urls.Runbook];"); + } + + [Test] + public async Task Generate_GivenTwoUrlsWithTheSameName_ReportsDuplicateUrlName(CancellationToken cancellationToken) + { + // Arrange + const string source = $$""" + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceDefinition] + [ResourceUrl("Health", "/health")] + [ResourceUrl("Health", "/alive")] + sealed partial class ApiKit + { + {{ContainerKit}} + } + """; + + // Act + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.DuplicateUrlName); + } + + [Test] + public async Task Generate_GivenEndpointUrlOnResourceWithoutEndpoints_ReportsEndpointUrlRequiresEndpoints( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = $$""" + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceDefinition] + [EndpointUrl("http")] + sealed partial class ParameterKit + { + {{ResourceKit}} + } + """; + + // Act + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.EndpointUrlRequiresEndpoints); + } + + [Test] + public async Task Generate_GivenEndpointUrlOnGroup_ReportsEndpointUrlRequiresEndpoints( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceGroup] + [EndpointUrl("http")] + partial class DataGroup; + """; + + // Act + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.EndpointUrlRequiresEndpoints); + } + + [Test] + public async Task Generate_GivenRelativeUrlOnGroup_ReportsRelativeUrlWithoutEndpointsAndStillGenerates( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = """ + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceGroup] + [ResourceUrl("Health", "/health")] + partial class DataGroup; + """; + + // Act + using var result = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.RelativeUrlWithoutEndpoints); + await Assert.That(result).HasNoErrorDiagnostics(); + } + + [Test] + public async Task Generate_GivenAbsoluteUrlOnResourceWithoutEndpoints_DoesNotReportRelativeUrlWithoutEndpoints( + CancellationToken cancellationToken + ) + { + // Arrange + const string source = $$""" + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceDefinition] + [ResourceUrl("Docs", "https://example.com/docs")] + sealed partial class ParameterKit + { + {{ResourceKit}} + } + """; + + // Act + using var result = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.RelativeUrlWithoutEndpoints); + await Assert.That(result).HasNoErrorDiagnostics(); + } + + protected override ResourceKitSourceGeneratorTestOptions OnBeforeRun( + IEnumerable sources, + ResourceKitSourceGeneratorTestOptions options, + CancellationToken cancellationToken + ) + { + return base.OnBeforeRun( + sources, + options + .WithAdditionalAssemblyTypes(typeof(DefaultAspireResource)) + .WithAdditionalNamespaces(TestingTypeLibrary.Purview.Aspire.ResourceKit.DefaultAspireResource), + cancellationToken + ); + } +} diff --git a/src/tests/SourceGeneration.UnitTests/Helpers/ResourceGroupOrderingTests.cs b/src/tests/SourceGeneration.UnitTests/Helpers/ResourceGroupOrderingTests.cs new file mode 100644 index 0000000..fb5a408 --- /dev/null +++ b/src/tests/SourceGeneration.UnitTests/Helpers/ResourceGroupOrderingTests.cs @@ -0,0 +1,90 @@ +using Purview.Aspire.ResourceKit.SourceGeneration.Models; + +namespace Purview.Aspire.ResourceKit.SourceGeneration.Helpers; + +/// +/// Covers directly: SG0021/SG0022 block generation before a cycle or a missing +/// parent can reach it, so the generator never exercises its loop guard. +/// +public sealed class ResourceGroupOrderingTests +{ + // A guard that failed would spin rather than fail; the timeout turns that into a failure. + const int TimeoutMilliseconds = 30_000; + + [Test] + public async Task ParentsFirst_GivenChildBeforeParent_OrdersParentFirst(CancellationToken cancellationToken) + { + // Arrange + var data = Group("DataGroup", parent: "PlatformGroup"); + var platform = Group("PlatformGroup"); + + // Act + var ordered = ResourceGroupOrdering.ParentsFirst([data, platform], cancellationToken); + + // Assert + await Assert.That(Names(ordered)).IsEqualTo("PlatformGroup,DataGroup"); + } + + [Test] + [Timeout(TimeoutMilliseconds)] + public async Task ParentsFirst_GivenGroupThatIsItsOwnParent_LeavesItOut(CancellationToken cancellationToken) + { + // Arrange + var self = Group("AGroup", parent: "AGroup"); + var root = Group("RootGroup"); + + // Act + var ordered = ResourceGroupOrdering.ParentsFirst([self, root], cancellationToken); + + // Assert + await Assert.That(Names(ordered)).IsEqualTo("RootGroup"); + } + + [Test] + [Timeout(TimeoutMilliseconds)] + public async Task ParentsFirst_GivenCycle_LeavesTheCycleOutAndKeepsTheRest(CancellationToken cancellationToken) + { + // Arrange + var a = Group("AGroup", parent: "BGroup"); + var b = Group("BGroup", parent: "CGroup"); + var c = Group("CGroup", parent: "AGroup"); + var root = Group("RootGroup"); + var child = Group("ChildGroup", parent: "RootGroup"); + + // Act + var ordered = ResourceGroupOrdering.ParentsFirst([a, b, c, child, root], cancellationToken); + + // Assert + await Assert.That(Names(ordered)).IsEqualTo("RootGroup,ChildGroup"); + } + + [Test] + [Timeout(TimeoutMilliseconds)] + public async Task ParentsFirst_GivenMissingParent_LeavesTheOrphanOut(CancellationToken cancellationToken) + { + // Arrange + var orphan = Group("DataGroup", parent: "MissingGroup"); + + // Act + var ordered = ResourceGroupOrdering.ParentsFirst([orphan], cancellationToken); + + // Assert + await Assert.That(Names(ordered)).IsEmpty(); + } + + static string Names(EquatableArray groups) => + string.Join(",", groups.AsImmutableArray().Select(static g => g.Name)); + + static ResourceGroupModel Group(string name, string? parent = null) => + new( + GroupType: new(name, "Testing", 0), + OptionsType: TypeIdentity.Empty, + ParentType: parent is null ? TypeIdentity.Empty : new(parent, "Testing", 0), + Accessibility: null, + Name: name, + PropertyName: name, + ShowInDashboard: true, + Mode: null, + Urls: EquatableArray.Empty + ); +} From 73599304fd8bdc373d48b377c48f718bd94f0c37 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 11 Nov 2026 10:34:03 +0000 Subject: [PATCH 02/12] feat: net11 alignment --- .config/dotnet-tools.json | 4 +- .editorconfig | 2 +- AGENTS.md | 12 +- CHANGELOG.md | 37 +++-- Directory.Packages.props | 22 ++- Justfile | 7 +- README.md | 5 +- docs/wiki/Getting-Started.md | 3 +- docs/wiki/OptionsHelper.md | 9 + docs/wiki/Release-Flow.md | 2 +- docs/wiki/Release-Notes.md | 11 +- docs/wiki/Toolchain.md | 91 ++++++++++ docs/wiki/Versioning.md | 14 +- global.json | 10 +- package.json | 4 +- scripts/verify-consumers.proj | 33 +++- skills-lock.json | 58 +++---- skills/purview-resourcekit-authoring/SKILL.md | 6 +- skills/purview-resourcekit-testing/SKILL.md | 2 +- src/Directory.Build.props | 42 ++++- src/Versions.props | 19 ++- .../ResourceKit/OptionsEnvironmentHelper.cs | 27 ++- src/src/ResourceKit/OptionsHelper.cs | 29 +++- src/src/ResourceKit/Sdk/README.md | 5 +- .../Helpers/CodeGenEmiiter.Attributes.cs | 15 +- .../Helpers/CodeGenEmiiter.ExtensionMethod.cs | 59 +++---- .../Helpers/ResourceKitRules.cs | 4 +- .../Helpers/SourceGenLibrary.cs | 4 +- src/src/SourceGeneration/HostKitGenerator.cs | 58 +++---- .../ResourceKitDiagnosticAnalyzer.cs | 3 +- .../ExampleAppHostTests.cs | 22 +-- .../PackageToolchainTests.cs | 157 ++++++++++++++++++ .../Repository.UnitTests/RepositoryPaths.cs | 29 ++++ .../OptionsHelperTests.cs | 30 ++++ 34 files changed, 635 insertions(+), 200 deletions(-) create mode 100644 docs/wiki/Toolchain.md create mode 100644 src/tests/Repository.UnitTests/PackageToolchainTests.cs create mode 100644 src/tests/Repository.UnitTests/RepositoryPaths.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 076bd1b..bc9bc27 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,7 +3,7 @@ "isRoot": true, "tools": { "csharpier": { - "version": "1.3.0", + "version": "1.4.0", "commands": [ "csharpier" ], @@ -17,7 +17,7 @@ "rollForward": false }, "purview.build": { - "version": "0.8.1", + "version": "0.9.0", "commands": [ "purview-build" ], diff --git a/.editorconfig b/.editorconfig index f8b3050..a9b39cb 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,4 @@ -# Purview.BuildSdk 1.3.0 - managed file, do not edit +# Purview.BuildSdk 2.0.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/AGENTS.md b/AGENTS.md index f20b8e9..b27c68c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,9 +14,15 @@ At a high level: This is a .NET repository centered on Aspire and source generation. -- **.NET 10 is the floor and the only leg**: `src/Versions.props` declares `DefaultTargetFramework`, - every project inherits it, and the package ships a single `lib/net10.0` asset. Do not add a - multi-targeting leg without giving it its own lowest-compatible `Microsoft.Extensions.*` floor. +- **.NET 10 is the floor; .NET 11 is the newest leg**: `src/Versions.props` declares + `DefaultTargetFramework` (`net10.0`) and `LatestTargetFramework` (`net11.0`). + `Purview.BuildSdk` 2.x resolves the build from its `Current` set (`net10.0;net11.0`), and the package + ships `lib/net10.0` and `lib/net11.0`. The .NET 10 leg declares the lowest-compatible + `Microsoft.Extensions.*` floor; do not raise it to the 11.x band just because the net11.0 leg exists. +- **.NET 11 opt-ins come from the SDK**: BuildSdk 2.x gives a `net11.0` target Runtime Async + (`runtime-async=on`) and `MemorySafetyRules=2` by default. Do not set either by hand, and do not + expect a `net10.0` target to compile C# 15 union/closed declarations: the runtime types those + features require are not in the .NET 10 framework. - The two Roslyn components (`SourceGeneration`, `SourceGeneration.CodeFixes`) target `netstandard2.0` by SDK rule — a generator runs inside the compiler and IDE host, not on the consumer's framework. - Uses the **Purview DotNet Project SDK** (`Purview.BuildSdk`) for project defaults and conventions. diff --git a/CHANGELOG.md b/CHANGELOG.md index bb50cb9..da4f2b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,19 +14,36 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `[ResourceDefinition]` (named property or constructor argument), override it from configuration (`:Mode`, `::Mode`), and `Build` gates on it before `IsEnabled` and `IsResourceEnabled(builder)`, so publish-only and local-development-only resources need no code. +- **.NET 11 runtime leg** — the package now ships `lib/net11.0` alongside `lib/net10.0`. A .NET 11 + AppHost resolves the net11.0 asset; .NET 10 consumers keep the floor. `verify-consumers` builds a + multi-targeted consumer and asserts both assets and the generated output on both frameworks. +- **`OptionsHelper.PathFor`** — a non-boxing selector overload. The existing + `object?`-returning overload forces a boxing conversion into the expression tree for value-type + members; the generic overload keeps the member in its natural type. `Ignore` now resolves selectors + to paths once, at call time, rather than re-walking the expression at `Build()`. +- **Package validation** — `pack` compares the package with the published `1.1.0` baseline and fails + on a removed or changed public API, the way a test fails on a behaviour change. ### Changed -- **Breaking: .NET 10 is now the minimum.** The runtime targets `net10.0` only — the `net8.0` and - `net9.0` legs are removed, so a consuming AppHost must be on .NET 10. This also drops the - `Microsoft.Extensions.*` 10.x floor those legs previously forced onto `net8.0` consumers, which was - the whole point: a package should not vendor a framework's abstractions to consumers on an older - framework. Raising a framework floor requires a **major** version. +- **Breaking: .NET 10 is now the minimum.** The `net8.0` and `net9.0` legs are removed, so a consuming + AppHost must be on .NET 10 or .NET 11; the package ships `lib/net10.0` (the floor) and `lib/net11.0` + (the newest leg). Both legs declare the `Microsoft.Extensions.*` 10.x servicing band — the net11.0 + leg deliberately does not raise a .NET 10 consumer's floor. +- **.NET 11 GA toolchain** — SDK `11.0.100`, `Purview.BuildSdk` 2.0.0, `Aspire.AppHost.Sdk` 13.6.5, + `Purview.SourceGeneratorFramework` 2.0.0, `Purview.Telemetry.SourceGenerator` 5.3.0, TUnit 1.83.0, + `Microsoft.Extensions.*` at the 10.0.15 / 10.11.0 servicing band, and SourceLink 11.0.100. The + net11.0 leg gets Runtime Async and `MemorySafetyRules=2` from the SDK; `Aspire.Hosting` 13.6.5 + also takes the security fix the previous pin lacked. The test suite runs on both frameworks, with + `--timeout`, `--maximum-failed-tests` and per-module report directories. - **Repository guardrails** — `.config/architecture-policy.json` plus - `src/tests/Repository.UnitTests/ArchitectureTests.cs` now enforce the dependency direction between the - runtime, the Roslyn components and the samples, and `just verify-consumers` packs the package and builds - a real consumer against it (one that installs it by `PackageReference` and is generated into, which no - test in this repository previously proved). + `src/tests/Repository.UnitTests/ArchitectureTests.cs` enforce the dependency direction between the + runtime, the Roslyn components and the samples; `just verify-consumers` packs the package and builds + a real consumer against it (one that installs it by `PackageReference` and is generated into, which + no test in this repository previously proved); and `PackageToolchainTests` fails the build if a + `net8.0`/`net9.0` declaration reappears or a `Microsoft.Extensions.*` reference drifts into the + 11.x band. The decision record for the feature adoption and the union/`closed` assessment is + [`docs/wiki/Toolchain.md`](docs/wiki/Toolchain.md). - **Agent skill delivery documented correctly** — `README.md` no longer claims the package installs skills into a consuming repository on build. That mirror was removed in `Purview.BuildSdk` 1.0.6; the skills CLI is the only installer. @@ -41,7 +58,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **The repository builds without the Aspire CLI installed.** `Example.AppHost` and `Example.ManualAppHost` set `AspireUseCliBundle=true`, which makes a *build* require a resolvable CLI - bundle: with none present, `Aspire.Hosting.AppHost.targets` runs `dnx aspire.cli@13.6.4 setup` once per + bundle: with none present, `Aspire.Hosting.AppHost.targets` runs `dnx aspire.cli@ setup` once per AppHost, so one solution build restores and extracts the same CLI twice, and the loser dies on the locked NuGet cache file with exit code 1. MSBuild reports that only as ASPIRE009 ("Automatic Aspire CLI bundle setup failed with exit code 1"), which failed the release pipeline on sources the PR pipeline had diff --git a/Directory.Packages.props b/Directory.Packages.props index 44ad6a1..6e39f25 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,13 +2,19 @@ true 5.9.0 - 1.76.0 - 1.3.0 - 5.2.0 - 10.0.13 + 1.83.0 + 2.0.0 + 5.3.0 + + 10.0.15 10.11.0 1.20.1 - 13.6.4 + 13.6.5 @@ -34,7 +40,7 @@ Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="$(DotNetRuntimeVersion)" /> - + @@ -52,8 +58,8 @@ - - + + diff --git a/Justfile b/Justfile index 7f9053d..2c54828 100644 --- a/Justfile +++ b/Justfile @@ -74,10 +74,15 @@ clean *args: dotnet clean {{ solution }} -c {{ build_configuration }} {{ args }} # Run tests with the specified configuration, defaulting to "Debug" +# +# --timeout, --maximum-failed-tests and --results-directory-layout are .NET 11 SDK run-level +# options: a hung Aspire fixture or a cascading failure stops the whole run instead of waiting for +# every test application, and each module/TFM gets its own results directory so reports cannot +# overwrite one another. See docs/wiki/Toolchain.md. [group('Build and Test')] test filter=default_test_filter *args: echo "Running tests for {{ BLUE }}{{ solution }}{{ NORMAL }} with configuration {{ YELLOW }}{{ build_configuration }}{{ NORMAL }} and filter {{ GREEN }}{{ filter }}{{ NORMAL }}" - dotnet test {{ solution }} -c {{ build_configuration }} --treenode-filter "{{ filter }}" -- {{ args }} + dotnet test {{ solution }} -c {{ build_configuration }} --treenode-filter "{{ filter }}" --timeout 30m --maximum-failed-tests 25 --results-directory-layout per-module --nologo -- {{ args }} # Run unit tests only [group('Build and Test')] diff --git a/README.md b/README.md index 2cd6520..4da39e3 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ If your AppHost is getting bigger, this helps you keep resource setup maintainable and discoverable by moving composition into focused resource classes and generating the plumbing for you. > [!IMPORTANT] -> Requires **.NET 10**. The package targets `net10.0` only — there is no `net8.0` or `net9.0` asset. +> Requires **.NET 10**. The package ships `net10.0` and `net11.0` assets — there is no `net8.0` or +> `net9.0` asset. A .NET 10 AppHost resolves the `net10.0` asset; a .NET 11 AppHost resolves the +> `net11.0` asset. > See [Versioning and compatibility](docs/wiki/Versioning.md#supported-target-frameworks). > > For what changed, see the [changelog](CHANGELOG.md) and [release notes](docs/wiki/Release-Notes.md). @@ -102,6 +104,7 @@ From your `[HostKit]` and `[ResourceDefinition]` declarations, ResourceKit gener - [Examples: generated vs manual](docs/wiki/Examples.md) - [Release notes](docs/wiki/Release-Notes.md) - [Versioning and compatibility](docs/wiki/Versioning.md) +- [.NET 11 toolchain and feature adoption](docs/wiki/Toolchain.md) - [Changelog](CHANGELOG.md) - NuGet package deep dive (API and generator behavior): [`src/src/ResourceKit/Sdk/README.md`](src/src/ResourceKit/Sdk/README.md) - Workspace notes for contributors: [`src/README.md`](src/README.md) diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index f727d33..5499dca 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -10,7 +10,8 @@ an Aspire AppHost project and a service project ready to compose. ## Install the package > [!IMPORTANT] -> `Purview.Aspire.ResourceKit` targets **.NET 10** only, so the AppHost project must be on `net10.0`. +> `Purview.Aspire.ResourceKit` targets **.NET 10** and **.NET 11**, so the AppHost project must be on +> `net10.0` or `net11.0`. Anything older is unsupported. > See [Versioning and compatibility](Versioning.md#supported-target-frameworks). Install the `Purview.Aspire.ResourceKit` package in your AppHost project. diff --git a/docs/wiki/OptionsHelper.md b/docs/wiki/OptionsHelper.md index 75c632f..7c1bbb5 100644 --- a/docs/wiki/OptionsHelper.md +++ b/docs/wiki/OptionsHelper.md @@ -98,6 +98,15 @@ var path = OptionsHelper.PathFor(f => f.API.Name // "API.Name" ``` +When the selected member is a value type, use the two-type-parameter overload. The `object?`-returning +overload boxes the value into the expression tree; the generic overload keeps the member in its +natural type: + +```csharp +var path = OptionsHelper.PathFor(f => f.API.IsEnabled); +// "API.IsEnabled" +``` + ## SectionNameFor Look up the resolved section name for any options type directly: diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index 885805e..56190ee 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -134,7 +134,7 @@ federated credential before the shared pipeline runs; the shared workflow itself | `Build:TestFilter` | `/*/*/*/*[Category=Unit]` | TUnit tree-node filter (unit-only) | | `PackValidation:RequireSymbolPackage` | `true` | Every `.nupkg` needs a matching `.snupkg` | | `PackValidation:RequireSymbolFiles` | `true` | Every `.snupkg` must contain PDBs | -| `PackValidation:RequiredContent` | Expected package contents | Asserts the package ships its expected output — the `lib/net10.0` runtime assembly + XML doc, the generator **and** code-fix assemblies under `analyzers/dotnet/cs`, `buildTransitive/Purview.Aspire.ResourceKit.props`, `README.md`, and `purview-logo-light.png`. It also fails on **undeclared** package content, so a new asset has to be added here in the same change that introduces it | +| `PackValidation:RequiredContent` | Expected package contents | Asserts the package ships its expected output — the `lib/net10.0` **and** `lib/net11.0` runtime assemblies + XML docs, the generator **and** code-fix assemblies under `analyzers/dotnet/cs`, `buildTransitive/Purview.Aspire.ResourceKit.props`, `README.md`, and `purview-logo-light.png`. It also fails on **undeclared** package content, so a new asset has to be added here in the same change that introduces it | | `Release:Mode` | `None` | Publishing is enabled only by the release workflow | Configuration precedence is command line, environment variables, `purview-build.json`, then the tool's diff --git a/docs/wiki/Release-Notes.md b/docs/wiki/Release-Notes.md index 170dd18..d49da48 100644 --- a/docs/wiki/Release-Notes.md +++ b/docs/wiki/Release-Notes.md @@ -8,8 +8,15 @@ the [GitHub releases page](https://github.com/purview-dev/aspire-resourcekit/rel ## Next (unreleased) - **Breaking: .NET 10 is the minimum.** The `net8.0` and `net9.0` targets are gone; a consuming AppHost - must be on `net10.0`, and the package ships a single `lib/net10.0` asset. See - [Supported target frameworks](Versioning.md#supported-target-frameworks). + must be on `net10.0` or `net11.0`, and the package ships `lib/net10.0` (the floor) and + `lib/net11.0` (the newest leg). See + [Supported target frameworks](Versioning.md#supported-target-frameworks) and + [.NET 11 toolchain and feature adoption](Toolchain.md). +- **.NET 11 leg and tooling** — the repository builds with the .NET 11 GA SDK, `Purview.BuildSdk` 2.x + and TUnit 1.83. The net11.0 leg uses the SDK's Runtime Async and memory-safety opt-ins; the net10.0 + leg keeps the 10.0.15 `Microsoft.Extensions.*` floor. `OptionsHelper.PathFor` adds + a non-boxing selector overload, and pack validation now compares against the published 1.1.0 + baseline. ## 1.0.0 diff --git a/docs/wiki/Toolchain.md b/docs/wiki/Toolchain.md new file mode 100644 index 0000000..5f32477 --- /dev/null +++ b/docs/wiki/Toolchain.md @@ -0,0 +1,91 @@ +# .NET 11 toolchain and feature adoption + +This page records what the repository takes from the .NET 11 SDK and the .NET 11 runtime, and what it +deliberately does not. The short version: **.NET 11 is a shipped leg, not a new floor**, and every +consumer-facing dependency is still declared at the .NET 10 servicing band. + +## Toolchain + +| Piece | Version | Why | +| --- | --- | --- | +| .NET SDK | `11.0.100` (GA) | C# 15, .NET 11 runtime/tooling; `rollForward: latestFeature` keeps a feature band, not a major | +| `Purview.BuildSdk` | `2.0.0` | Curated TFM sets, C# 15 defaults, Runtime Async and memory-safety opt-ins for `net11.0+` | +| `Aspire.AppHost.Sdk` | `13.6.5` | Includes the `Aspire.Hosting` security fix that `13.6.4` lacked | +| `Purview.SourceGeneratorFramework` | `2.0.0` | Generator/runtime split; analyzer stays on `netstandard2.0` | +| `TUnit` | `1.83.0` | Has `net10.0` and `net11.0` assets, so each test leg runs a first-class build | +| `Microsoft.SourceLink.GitHub` | `11.0.100` | Build-time only; matches the SDK band | + +`Purview.BuildSdk` 2.x resolves the build from its `Current` set, `net10.0;net11.0`. The runtime +package therefore ships `lib/net10.0` and `lib/net11.0`. The two Roslyn components remain +`netstandard2.0`, because they load in the compiler/IDE host rather than in a consumer process. + +## What is enabled + +| Area | Feature | Where it applies | +| --- | --- | --- | +| Language | C# 15 (`LangVersion=latest`) | Both legs. Syntax that needs .NET 11 runtime types (unions, `closed`) is available only to the `net11.0` leg | +| Runtime | Runtime Async (`runtime-async=on`) | `net11.0` targets, supplied by BuildSdk 2.x. Production code is synchronous, so this is exercised by the net11 test leg, not by a changed library contract. The sample AppHosts stay on the net10.0 floor because `Aspire.AppHost.Sdk` cannot multi-target | +| Runtime | `MemorySafetyRules=2` | `net11.0` targets, supplied by BuildSdk 2.x. The library does not use `unsafe`, so this is a safety net rather than a code change | +| Build | Multithreaded MSBuild (`-mt`) and the MSBuild server | Repository-wide via the SDK's bootstrapped response file; no project setting | +| Analysis | `AnalysisLevel=latest`, `AnalysisMode=All`, `EnforceCodeStyleInBuild` | Both legs, via the SDK | +| Supply chain | `NuGetAudit=true`, `NuGetAuditMode=all` | Both legs, via the SDK | +| Testing | Per-TFM test legs | `dotnet test` runs the same suite on `net10.0` and `net11.0` | +| Testing | `--timeout`, `--maximum-failed-tests`, `--results-directory-layout per-module` | `Justfile` test recipes; a hung fixture or a cascading failure stops the run, and each module/TFM keeps its own report directory | +| Packaging | Package validation against the published `1.1.0` baseline | `src/Directory.Build.props`; a removed or changed public API fails `pack` | +| Packaging | Two runtime assets, one analyzer asset | `lib/net10.0` + `lib/net11.0`; the generator under `analyzers/dotnet/cs` serves both | +| Debug | Portable PDBs, SourceLink, embedded untracked sources, `.snupkg` | Supplied by `Purview.BuildSdk`; unchanged by this upgrade | + +Runtime Async, `MemorySafetyRules` and C# 15's runtime-dependent features are **not** enabled by hand. +`Purview.BuildSdk` 2.x owns those switches for `net11.0+`, so a project that opts into another SDK band +gets the band's defaults rather than a stale copy in this repository. + +## Deliberately not adopted + +| Feature | Why not | +| --- | --- | +| C# 15 pointer relaxations / `unsafe` expressions | The repository has no `unsafe` code and keeps `AllowUnsafeBlocks` at its compiler default; enabling a preview memory-safety model would add review surface without a workload | +| `DOTNET_EnableCrashReport=1` | Useful for a self-hosted service that crashes natively, but test runs would leave crash artifacts on every failing leg; portable PDBs and SourceLink already cover managed diagnostics | +| File-based apps, `#:include`, `dotnet run -e` | Repository workflows are solution/project based; these are developer convenience features with no CI or package contract | +| Container publishing, multi-arch builds, `SOURCE_DATE_EPOCH` | The repository publishes a NuGet package, not a container image | +| Mobile test targets / device selection | Not applicable | +| `PublishReadyToRun` / Native AOT | The package is a library and an analyzer; the consumer owns deployment, and R2R/AOT would change the shipped asset contract | +| Union types / `closed` hierarchies | Assessed below; no suitable API in this library | + +## Dependency floor + +Every `Microsoft.Extensions.*` reference stays in the `10.x` band (`10.0.15` for the runtime band, +`10.11.0` for the extensions libraries), even for the `net11.0` asset. This is the lowest common +servicing band: a .NET 10 consumer inherits it, and a .NET 11 consumer is perfectly able to resolve the +`net10.0` assets of those packages. Raising them to `11.0.0` because the repository builds with the +.NET 11 SDK would force an unnecessary upgrade on every .NET 10 consumer. + +`PackageToolchainTests` fails the build if a `Microsoft.Extensions.*` version drifts out of the floor +band, or if `net8.0`/`net9.0` is reintroduced anywhere in the repository's target-framework +declarations. + +## Union types and `closed` hierarchies: re-assessed + +Both were evaluated for the .NET 11 leg and neither is used in the shipped library. + +- **Union types are not required.** The only closed set of alternative values in the runtime is the + set of scalar option values that arrive through `PropertyInfo.GetValue`, and reflection has already + boxed those values before any union could receive them. A custom union would add a tag and a field + per case without removing that allocation, so it would be slower and larger than the current + conversion. If a future `net11.0`-only API does need a union, it should implement the non-boxing + access pattern (`HasValue` plus a `TryGetValue` overload per case) rather than leaning on the boxed + `Value` property. +- **`closed` is not appropriate.** `HostKitBase` and `ResourceKitBase` + are public extension points: a consumer's generated kits derive from them. Marking either `closed` + would prevent exactly the inheritance the product exists to support, and would be a breaking change. + Types that are not extension points are already `sealed`; `sealed` is stronger than `closed` and is + the correct tool there. +- **The non-boxing win that was taken** is `OptionsHelper.PathFor(...)`. The existing + `object?`-returning selector forces the compiler to box a value-type member into the expression tree. + The generic overload keeps the value in its natural type, and `Ignore` now resolves selectors to + paths once at call time rather than re-walking expressions at `Build()`. + +## See also + +- [Versioning and compatibility](Versioning.md#supported-target-frameworks) +- [Release flow](Release-Flow.md) +- [Testing with ResourceKit](Testing-with-ResourceKit.md) diff --git a/docs/wiki/Versioning.md b/docs/wiki/Versioning.md index 443b2bc..e061f60 100644 --- a/docs/wiki/Versioning.md +++ b/docs/wiki/Versioning.md @@ -18,13 +18,16 @@ carry no stability guarantee. `1.0.0` is the first stable release. ## Supported target frameworks -The runtime targets **`.NET 10`** and nothing lower, so a consumer needs a `net10.0` AppHost. The -package ships a single `lib/net10.0` asset; there is no `net8.0` or `net9.0` leg to fall back to. +The runtime targets **`.NET 10`** and **`.NET 11`** and nothing lower, so a consumer needs a +`net10.0` or `net11.0` AppHost. The package ships `lib/net10.0` (the floor) and `lib/net11.0` (the +newest leg); there is no `net8.0` or `net9.0` leg to fall back to. Raising the floor is a MAJOR version, because a consumer on an older framework cannot take the -package at all. The reverse — adding a framework — is additive as long as each leg declares its own -lowest-compatible `Microsoft.Extensions.*` floor, since the floor a published package declares becomes -the floor every consumer inherits. +package at all. Adding a framework is additive, but the new leg does **not** get to raise the +dependency floor: both legs declare the lowest-compatible `Microsoft.Extensions.*` 10.x band, because +the floor a published package declares becomes the floor every consumer inherits. The net11.0 leg +consumes the net10.0 assets of those packages deliberately. `PackageToolchainTests` fails the build +if a `Microsoft.Extensions.*` reference drifts into the 11.x band. The Roslyn components (`SourceGeneration`, `SourceGeneration.CodeFixes`) target `netstandard2.0` regardless: a generator runs inside the compiler and IDE host, where the consumer's target framework @@ -60,5 +63,6 @@ release-time procedure. ## See also +- [.NET 11 toolchain and feature adoption](Toolchain.md) - [Release notes](Release-Notes.md) - [Release flow](Release-Flow.md) diff --git a/global.json b/global.json index 4d9bb41..a2c5e99 100644 --- a/global.json +++ b/global.json @@ -1,12 +1,12 @@ { "sdk": { - "version": "11.0.100-rc.3.26522.148", - "rollForward": "latestMajor", - "allowPrerelease": true + "version": "11.0.100", + "rollForward": "latestFeature", + "allowPrerelease": false }, "msbuild-sdks": { - "Purview.BuildSdk": "1.3.0", - "Aspire.AppHost.Sdk": "13.6.4" + "Purview.BuildSdk": "2.0.0", + "Aspire.AppHost.Sdk": "13.6.5" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/package.json b/package.json index 7a9fbb9..45d90de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aspire-resourcekit", - "version": "1.1.0", + "version": "2.0.0", "description": "Source-generator-powered framework for structuring .NET Aspire AppHost resource composition as strongly typed, test-friendly classes.", "keywords": [], "homepage": "https://purview.dev/projects/aspire-resourcekit/", @@ -16,4 +16,4 @@ "type": "git", "url": "git+https://github.com/purview-dev/aspire-resourcekit.git" } -} +} \ No newline at end of file diff --git a/scripts/verify-consumers.proj b/scripts/verify-consumers.proj index 7b3575c..e673db7 100644 --- a/scripts/verify-consumers.proj +++ b/scripts/verify-consumers.proj @@ -64,13 +64,19 @@ - + + + + <_PackagedFile Include="$(UnzipDir)/**/*" /> @@ -80,6 +86,7 @@ <_ShippedCodeFixes Include="$(UnzipDir)/$(MergedCodeFixes)" /> <_ShippedRuntime Include="$(UnzipDir)/lib/**/$(PackageId).dll" /> <_ShippedFloorRuntime Include="$(UnzipDir)/lib/$(MinimumTargetFramework)/$(PackageId).dll" /> + <_ShippedLatestRuntime Include="$(UnzipDir)/lib/$(LatestTargetFramework)/$(PackageId).dll" /> <_ShippedBuildTransitive Include="$(UnzipDir)/buildTransitive/$(PackageId).props" /> <_ShippedReadme Include="$(UnzipDir)/README.md" /> <_ShippedLogo Include="$(UnzipDir)/purview-logo-light.png" /> @@ -112,8 +119,12 @@ Text="The package ships no lib/$(MinimumTargetFramework)/$(PackageId).dll, so it does not support the framework floor declared in src/Versions.props." /> + @@ -155,7 +170,7 @@ @@ -174,6 +189,8 @@ <_ConsumerGenerated Include="$(ConsumerDir)/obj/**/*.g.cs" /> <_ConsumerHostKit Include="$(ConsumerDir)/obj/**/*ConsumerHostKit*.g.cs" /> + <_ConsumerGeneratedNet10 Include="$(ConsumerDir)/obj/**/net10.0/**/*.g.cs" /> + <_ConsumerGeneratedNet11 Include="$(ConsumerDir)/obj/**/net11.0/**/*.g.cs" /> + + - $(DefaultTargetFramework) + Purview.Aspire.ResourceKit https://github.com/purview-dev/aspire-resourcekit + + + + $(DefaultTargetFramework) + + + + + true + + 1.1.0 + + https://purview.dev/ $(PurviewHomepage)projects/aspire-resourcekit/ diff --git a/src/Versions.props b/src/Versions.props index fba8f18..5894a9a 100644 --- a/src/Versions.props +++ b/src/Versions.props @@ -1,14 +1,21 @@ net10.0 + + net11.0 diff --git a/src/src/ResourceKit/OptionsEnvironmentHelper.cs b/src/src/ResourceKit/OptionsEnvironmentHelper.cs index 350abd0..c44d56a 100644 --- a/src/src/ResourceKit/OptionsEnvironmentHelper.cs +++ b/src/src/ResourceKit/OptionsEnvironmentHelper.cs @@ -37,7 +37,7 @@ internal static IReadOnlyDictionary BuildEnvironmentVariables>? overrides = null, - IEnumerable>>? ignores = null + IEnumerable? ignorePaths = null ) { ArgumentNullException.ThrowIfNull(options); @@ -60,13 +60,12 @@ internal static IReadOnlyDictionary BuildEnvironmentVariables(TOptions options, string? secti : IOptionsEnvironmentBuilder { readonly List> _overrides = []; - readonly List>> _ignores = []; + readonly List _ignorePaths = []; public IOptionsEnvironmentBuilder Override(params Action[] assignments) { @@ -204,12 +203,22 @@ public IOptionsEnvironmentBuilder Ignore(params Expression Build() { - return OptionsEnvironmentHelper.BuildEnvironmentVariables(options, sectionNameOverride, _overrides, _ignores); + return OptionsEnvironmentHelper.BuildEnvironmentVariables( + options, + sectionNameOverride, + _overrides, + _ignorePaths + ); } } diff --git a/src/src/ResourceKit/OptionsHelper.cs b/src/src/ResourceKit/OptionsHelper.cs index 6cd07bb..25f7155 100644 --- a/src/src/ResourceKit/OptionsHelper.cs +++ b/src/src/ResourceKit/OptionsHelper.cs @@ -85,6 +85,33 @@ public static string PathFor(Expression> selec return GetMemberPath(selector); } + /// + /// Gets the dot-separated member path for a property selector on the specified options type without + /// boxing the selected value. + /// + /// The root options type. + /// The selected member type. + /// A member selector expression. + /// The dot-separated member path (for example, Level1.Level2.Name). + /// + /// + /// The overload returns + /// , so a value-type member is boxed by the compiler on every call. Prefer this + /// overload when the member type is known: it keeps the value in its natural type all the way + /// through expression analysis. + /// + /// + /// This is the same non-boxing shape a C# 15 union exposes through TryGetValue - resolve the + /// value once, then consume it as its natural type rather than through Value. + /// + /// + public static string PathFor(Expression> selector) + { + ArgumentNullException.ThrowIfNull(selector); + + return GetMemberPath(selector); + } + /// /// Gets the configuration section name for the specified options type. /// @@ -176,7 +203,7 @@ static TOptions CreateRootOptionsInstance() : (TOptions)instance; } - static string GetMemberPath(Expression> selector) + static string GetMemberPath(Expression> selector) { ArgumentNullException.ThrowIfNull(selector); diff --git a/src/src/ResourceKit/Sdk/README.md b/src/src/ResourceKit/Sdk/README.md index 4f4d6a9..9d03123 100644 --- a/src/src/ResourceKit/Sdk/README.md +++ b/src/src/ResourceKit/Sdk/README.md @@ -5,8 +5,9 @@ This package contains the runtime abstractions and source-generator contracts fo Use this README when integrating the **`Purview.Aspire.ResourceKit` NuGet package** into your host project. > [!IMPORTANT] -> Requires **.NET 10**. The package ships a single `lib/net10.0` asset — there is no `net8.0` or -> `net9.0` target to fall back to. +> Requires **.NET 10**. The package ships `lib/net10.0` and `lib/net11.0` assets — there is no +> `net8.0` or `net9.0` target to fall back to. A .NET 10 AppHost resolves the net10.0 asset; a +> .NET 11 AppHost resolves the net11.0 asset. ## Package goals diff --git a/src/src/SourceGeneration/Helpers/CodeGenEmiiter.Attributes.cs b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.Attributes.cs index bf9e495..ee9615e 100644 --- a/src/src/SourceGeneration/Helpers/CodeGenEmiiter.Attributes.cs +++ b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.Attributes.cs @@ -17,9 +17,8 @@ static SourceText HostKitAttribute() { attributeBody .XmlSummary("Initializes a new instance of the HostKitAttribute class.") - .Constructor( - new(TypeLibrary.Purview.Aspire.ResourceKit.HostKitAttribute), - static ctor => ctor.Comment("Empty") + .Constructor(new(TypeLibrary.Purview.Aspire.ResourceKit.HostKitAttribute), static ctor => + ctor.Comment("Empty") ); attributeBody @@ -158,9 +157,8 @@ static SourceText ResourceKitDefinitionAttribute() { attributeBody .XmlSummary("Initializes a new instance of the ResourceDefinitionAttribute class.") - .Constructor( - new(TypeLibrary.Purview.Aspire.ResourceKit.ResourceDefinitionAttribute), - static ctor => ctor.Comment("Empty") + .Constructor(new(TypeLibrary.Purview.Aspire.ResourceKit.ResourceDefinitionAttribute), static ctor => + ctor.Comment("Empty") ); attributeBody .XmlSummary("Initializes a new instance of the ResourceDefinitionAttribute class.") @@ -277,9 +275,8 @@ static SourceText ResourceKitDefinitionAttribute() { attributeBody .XmlSummary("Initializes a new instance of the ResourceDefinitionAttribute class.") - .Constructor( - new(TypeLibrary.Purview.Aspire.ResourceKit.ResourceDefinitionAttribute), - static ctor => ctor.Comment("Empty") + .Constructor(new(TypeLibrary.Purview.Aspire.ResourceKit.ResourceDefinitionAttribute), static ctor => + ctor.Comment("Empty") ); attributeBody .XmlSummary("Initializes a new instance of the ResourceDefinitionAttribute class.") diff --git a/src/src/SourceGeneration/Helpers/CodeGenEmiiter.ExtensionMethod.cs b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.ExtensionMethod.cs index 030ac38..45cb008 100644 --- a/src/src/SourceGeneration/Helpers/CodeGenEmiiter.ExtensionMethod.cs +++ b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.ExtensionMethod.cs @@ -129,20 +129,17 @@ static void BuildExtensionMethod(CodeWriter writer, OutputContext context) writer .Comment("Bind the host kit options from configuration, or create a new instance if not found.") - .Assignment( - "var", - "optionsBuilder", - assignment => - assignment.MethodCallChain( - "builder.Services.AddOptions", - [], - chain => - chain.Method( - "BindConfiguration", - arguments: [$"{context.HostKit.OptionsType}.SectionName"] - ), - genericArguments: [context.HostKit.OptionsType] - ) + .Assignment("var", "optionsBuilder", assignment => + assignment.MethodCallChain( + "builder.Services.AddOptions", + [], + chain => + chain.Method( + "BindConfiguration", + arguments: [$"{context.HostKit.OptionsType}.SectionName"] + ), + genericArguments: [context.HostKit.OptionsType] + ) ); writer @@ -151,31 +148,25 @@ static void BuildExtensionMethod(CodeWriter writer, OutputContext context) .MethodCallOn("optionsBuilder", "ValidateOnStart") .NewLine(); - writer.Assignment( - "var", - "hostKitOptions", - assignment => - assignment.MethodCallChain( - "builder.Configuration.GetSection", - [$"{context.HostKit.OptionsType}.SectionName"], - configure: chain => - chain.Method("Get", genericArguments: [context.HostKit.OptionsType]).Postfix("?? new()") - ) + writer.Assignment("var", "hostKitOptions", assignment => + assignment.MethodCallChain( + "builder.Configuration.GetSection", + [$"{context.HostKit.OptionsType}.SectionName"], + configure: chain => + chain.Method("Get", genericArguments: [context.HostKit.OptionsType]).Postfix("?? new()") + ) ); } writer .Comment("Create an instance of the generated host kit and configure it.") - .Assignment( - context.HostKit.HostKitType, - "hostKit", - assignment => - assignment.New( - context.HostKit.HostKitType, - context.HostKit.ShouldGenerateOptions - ? ["onBuilt", "onConfigured", "hostKitOptions"] - : ["onBuilt", "onConfigured"] - ) + .Assignment(context.HostKit.HostKitType, "hostKit", assignment => + assignment.New( + context.HostKit.HostKitType, + context.HostKit.ShouldGenerateOptions + ? ["onBuilt", "onConfigured", "hostKitOptions"] + : ["onBuilt", "onConfigured"] + ) ); writer diff --git a/src/src/SourceGeneration/Helpers/ResourceKitRules.cs b/src/src/SourceGeneration/Helpers/ResourceKitRules.cs index 41bcf48..94e9dea 100644 --- a/src/src/SourceGeneration/Helpers/ResourceKitRules.cs +++ b/src/src/SourceGeneration/Helpers/ResourceKitRules.cs @@ -167,8 +167,8 @@ CancellationToken cancellationToken hasExplicitBaseType && TypeHelpers.IsDerivedFromExpectedBase(symbol, TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitBase); var isGenericResourceDefinition = matchedAttribute.Attribute.AttributeClass!.IsGenericType; - var propertyName = - matchedAttribute.Instance.PropertyName ?? symbol.Name.TrimSuffix(TypeLibraryGenerator.TrimSuffixes)!; + var propertyName = matchedAttribute.Instance.PropertyName + ?? symbol.Name.TrimSuffix(TypeLibraryGenerator.TrimSuffixes)!; var (_, isValidResourceType, declaredProject) = ResolveResourceType(matchedAttribute, symbol); if (allAttributes.Length > 1) diff --git a/src/src/SourceGeneration/Helpers/SourceGenLibrary.cs b/src/src/SourceGeneration/Helpers/SourceGenLibrary.cs index 5b90500..1fbfc87 100644 --- a/src/src/SourceGeneration/Helpers/SourceGenLibrary.cs +++ b/src/src/SourceGeneration/Helpers/SourceGenLibrary.cs @@ -183,8 +183,8 @@ CancellationToken cancellationToken var resourceName = matchedAttribute.Instance.Name ?? symbol.Name.TrimSuffix(TypeLibraryGenerator.TrimSuffixes); var hasExplicitBaseType = TypeHelpers.HasExplicitBaseType(symbol); var isGenericResourceDefinition = matchedAttribute.Attribute.AttributeClass!.IsGenericType; - var propertyName = - matchedAttribute.Instance.PropertyName ?? symbol.Name.TrimSuffix(TypeLibraryGenerator.TrimSuffixes)!; + var propertyName = matchedAttribute.Instance.PropertyName + ?? symbol.Name.TrimSuffix(TypeLibraryGenerator.TrimSuffixes)!; var (aspireResourceType, _, _) = ResourceKitRules.ResolveResourceType(matchedAttribute, symbol); TypeIdentity resourceKitType = new(symbol); diff --git a/src/src/SourceGeneration/HostKitGenerator.cs b/src/src/SourceGeneration/HostKitGenerator.cs index 363f6c7..d1d0a10 100644 --- a/src/src/SourceGeneration/HostKitGenerator.cs +++ b/src/src/SourceGeneration/HostKitGenerator.cs @@ -22,41 +22,35 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var outputProvider = pipelines.Outputs.Combine(pipelines.Context); - context.RegisterSourceOutput( - outputProvider, - static (sourceProductionContext, combined) => - { - var (generationModel, generationContext) = combined; - if (generationContext.Settings.IsSourceGeneratorDisabled) - return; + context.RegisterSourceOutput(outputProvider, static (sourceProductionContext, combined) => + { + var (generationModel, generationContext) = combined; + if (generationContext.Settings.IsSourceGeneratorDisabled) + return; - // If there are any fatal diagnostics, report them and skip generation - if (ReportDiagnostics(sourceProductionContext, generationModel)) - return; - if (!generationModel.HasHostKit) - return; + // If there are any fatal diagnostics, report them and skip generation + if (ReportDiagnostics(sourceProductionContext, generationModel)) + return; + if (!generationModel.HasHostKit) + return; - var validResourceKits = generationModel - .ResourceKits.AsImmutableArray() - .Select(static m => new ResourceKitModelGroup( - m.Namespace, - EquatableArray.Create([ - .. m - .Items.AsImmutableArray() - .Where(static m => m.ShouldProcess) - .Select(static m => m.Value), - ]) - )) - .ToImmutableArray(); + var validResourceKits = generationModel + .ResourceKits.AsImmutableArray() + .Select(static m => new ResourceKitModelGroup( + m.Namespace, + EquatableArray.Create([ + .. m.Items.AsImmutableArray().Where(static m => m.ShouldProcess).Select(static m => m.Value), + ]) + )) + .ToImmutableArray(); - var writer = CodeGenEmiiter.Emit( - new(generationModel, validResourceKits, generationContext), - sourceProductionContext.CancellationToken - ); - var hintName = HintNameHelper.ForHost(generationModel.HostKit.Value.HostKitType.MetadataFullName); - sourceProductionContext.AddSource(hintName, writer); - } - ); + var writer = CodeGenEmiiter.Emit( + new(generationModel, validResourceKits, generationContext), + sourceProductionContext.CancellationToken + ); + var hintName = HintNameHelper.ForHost(generationModel.HostKit.Value.HostKitType.MetadataFullName); + sourceProductionContext.AddSource(hintName, writer); + }); } static bool ReportDiagnostics(SourceProductionContext sourceProductionContext, KitGenerationModel outputContext) diff --git a/src/src/SourceGeneration/ResourceKitDiagnosticAnalyzer.cs b/src/src/SourceGeneration/ResourceKitDiagnosticAnalyzer.cs index bfb3436..e3c9629 100644 --- a/src/src/SourceGeneration/ResourceKitDiagnosticAnalyzer.cs +++ b/src/src/SourceGeneration/ResourceKitDiagnosticAnalyzer.cs @@ -144,8 +144,7 @@ ConcurrentBag resourceKits if (matchedAttribute.Attribute is null) continue; - var propertyName = - matchedAttribute.Instance.PropertyName + var propertyName = matchedAttribute.Instance.PropertyName ?? resourceKit.Name.TrimSuffix(TypeLibraryGenerator.TrimSuffixes); if (string.IsNullOrWhiteSpace(propertyName)) continue; diff --git a/src/tests/Repository.UnitTests/ExampleAppHostTests.cs b/src/tests/Repository.UnitTests/ExampleAppHostTests.cs index fe6d129..b188241 100644 --- a/src/tests/Repository.UnitTests/ExampleAppHostTests.cs +++ b/src/tests/Repository.UnitTests/ExampleAppHostTests.cs @@ -23,7 +23,7 @@ namespace Purview.Aspire.ResourceKit.Repository; /// public sealed class ExampleAppHostTests { - static readonly string RepositoryRoot = FindRepositoryRoot(); + static readonly string RepositoryRoot = RepositoryPaths.Root; [Test] [Arguments("src/src/Example.AppHost/Example.AppHost.csproj")] @@ -74,24 +74,4 @@ await Assert .IsTrue() .Because($"the marker walk resolved '{RepositoryRoot}', which does not look like this repository"); } - - static string FindRepositoryRoot() - { - // The same '.git/' marker walk Purview.BuildSdk uses to find the repository root. - for ( - var directory = new DirectoryInfo(AppContext.BaseDirectory); - directory is not null; - directory = directory.Parent - ) - { - if (Directory.Exists(Path.Combine(directory.FullName, ".git"))) - { - return directory.FullName; - } - } - - throw new InvalidOperationException( - $"No '.git' directory was found above '{AppContext.BaseDirectory}', so the repository root is unresolvable." - ); - } } diff --git a/src/tests/Repository.UnitTests/PackageToolchainTests.cs b/src/tests/Repository.UnitTests/PackageToolchainTests.cs new file mode 100644 index 0000000..a1c8331 --- /dev/null +++ b/src/tests/Repository.UnitTests/PackageToolchainTests.cs @@ -0,0 +1,157 @@ +using System.Xml.Linq; + +namespace Purview.Aspire.ResourceKit.Repository; + +/// +/// Holds the repository to the two package-floor rules that are easy to break silently: no +/// out-of-support target framework may be reintroduced, and a Microsoft.Extensions.* package +/// must stay on the servicing band of the lowest supported framework. +/// +/// +/// A higher-band Microsoft.Extensions.* reference is not a compile error in this repository - +/// it compiles because the .NET 11 SDK and runtime are installed - but it raises the dependency floor +/// a .NET 10 consumer inherits from the published package. The failure belongs here, not in a +/// consumer's restore. +/// +public sealed class PackageToolchainTests +{ + static readonly string[] TargetFrameworkDeclarationNames = + [ + "DefaultTargetFramework", + "TargetFramework", + "TargetFrameworks", + ]; + + [Test] + public async Task ShippedTargetFrameworks_GivenRepository_DeclareTheFloorAndNewestLeg( + CancellationToken cancellationToken + ) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + var versions = XDocument.Load(Path.Combine(RepositoryPaths.Root, "src", "Versions.props")); + var declarations = versions + .Descendants() + .Where(element => element.Name.LocalName is "DefaultTargetFramework" or "LatestTargetFramework") + .ToDictionary(element => element.Name.LocalName, element => element.Value.Trim(), StringComparer.Ordinal); + + // Act + var floor = declarations["DefaultTargetFramework"]; + var latest = declarations["LatestTargetFramework"]; + + // Assert + await Assert.That(floor).IsEqualTo("net10.0"); + await Assert + .That(latest) + .IsEqualTo("net11.0") + .Because( + "Purview.BuildSdk 2.x publishes net10.0 and net11.0; a change to the shipped set must be a " + + "deliberate edit to Versions.props and the package-shape assertion in verify-consumers." + ); + } + + [Test] + public async Task TargetFrameworkDeclarations_GivenRepository_DoNotReferenceOutOfSupportFrameworks( + CancellationToken cancellationToken + ) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + var sourceRoot = Path.Combine(RepositoryPaths.Root, "src"); + var documents = Directory + .EnumerateFiles(sourceRoot, "*.*proj", SearchOption.AllDirectories) + .Concat(Directory.EnumerateFiles(sourceRoot, "*.props", SearchOption.AllDirectories)) + .Where(static path => !IsBuildOutput(path)); + + // Act + var offenders = documents + .SelectMany(path => + XDocument + .Load(path) + .Descendants() + .Where(element => + TargetFrameworkDeclarationNames.Contains(element.Name.LocalName, StringComparer.Ordinal) + ) + .SelectMany(element => + element + .Value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(static framework => framework is "net8.0" or "net9.0") + .Select(framework => + $"{Path.GetRelativePath(RepositoryPaths.Root, path)}: {element.Name.LocalName}={framework}" + ) + ) + ) + .ToArray(); + + // Assert + await Assert + .That(offenders) + .IsEmpty() + .Because( + ".NET 8 and .NET 9 are out of support. The shipped set is net10.0 (the floor) and net11.0 " + + "(the newest leg); reintroducing net8.0 or net9.0 silently commits the package to a " + + "runtime nobody is servicing." + ); + } + + [Test] + public async Task MicrosoftExtensionsPackages_GivenRepository_StayOnTheFloorServicingBand( + CancellationToken cancellationToken + ) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + var packages = XDocument.Load(Path.Combine(RepositoryPaths.Root, "Directory.Packages.props")); + var properties = packages + .Descendants() + .Where(element => element.Parent?.Name.LocalName == "PropertyGroup") + .GroupBy(element => element.Name.LocalName, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Last().Value.Trim(), StringComparer.Ordinal); + var floorVersion = properties["DotNetRuntimeVersion"]; + var floorMajor = floorVersion.Split('.')[0]; + + // Act + var offenders = packages + .Descendants() + .Where(element => element.Name.LocalName == "PackageVersion") + .Select(element => new + { + Include = element.Attribute("Include")?.Value, + Version = Resolve(element.Attribute("Version")?.Value, properties), + }) + .Where(package => package.Include?.StartsWith("Microsoft.Extensions.", StringComparison.Ordinal) == true) + .Where(package => + package.Version is null || !package.Version.StartsWith($"{floorMajor}.", StringComparison.Ordinal) + ) + .Select(package => $"{package.Include} {package.Version}") + .ToArray(); + + // Assert + await Assert + .That(offenders) + .IsEmpty() + .Because( + $"The .NET 10 floor must not inherit a Microsoft.Extensions.* reference from the .NET 11 " + + $"band, even though the package also ships a net11.0 asset. Every such package must " + + $"resolve to {floorMajor}.x (the lowest common servicing band); otherwise a .NET 10 " + + "consumer is forced to download and use packages built for a framework it does not " + + "target. See docs/wiki/Toolchain.md." + ); + } + + static string? Resolve(string? value, Dictionary properties) + { + if (value is null || value.Length < 3 || !value.StartsWith("$(", StringComparison.Ordinal) || value[^1] != ')') + return value; + + var propertyName = value[2..^1]; + return properties.TryGetValue(propertyName, out var resolved) ? resolved : value; + } + + static bool IsBuildOutput(string path) + { + var segments = path.Split(Path.DirectorySeparatorChar); + return segments.Contains("bin", StringComparer.OrdinalIgnoreCase) + || segments.Contains("obj", StringComparer.OrdinalIgnoreCase); + } +} diff --git a/src/tests/Repository.UnitTests/RepositoryPaths.cs b/src/tests/Repository.UnitTests/RepositoryPaths.cs new file mode 100644 index 0000000..ff1740b --- /dev/null +++ b/src/tests/Repository.UnitTests/RepositoryPaths.cs @@ -0,0 +1,29 @@ +namespace Purview.Aspire.ResourceKit.Repository; + +/// +/// Resolves the repository root once for the repository-level guardrail tests. +/// +static class RepositoryPaths +{ + public static string Root { get; } = FindRepositoryRoot(); + + static string FindRepositoryRoot() + { + // The same '.git/' marker walk Purview.BuildSdk uses to find the repository root. + for ( + var directory = new DirectoryInfo(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent + ) + { + if (Directory.Exists(Path.Combine(directory.FullName, ".git"))) + { + return directory.FullName; + } + } + + throw new InvalidOperationException( + $"No '.git' directory was found above '{AppContext.BaseDirectory}', so the repository root is unresolvable." + ); + } +} diff --git a/src/tests/ResourceKit.UnitTests/OptionsHelperTests.cs b/src/tests/ResourceKit.UnitTests/OptionsHelperTests.cs index 9475cb0..2ee0725 100644 --- a/src/tests/ResourceKit.UnitTests/OptionsHelperTests.cs +++ b/src/tests/ResourceKit.UnitTests/OptionsHelperTests.cs @@ -346,6 +346,19 @@ public async Task PathFor_GivenDeepSelector_ReturnsDotSeparatedPath() await Assert.That(path).IsEqualTo("Level1.Level2.Level3.Level4.Name"); } + [Test] + public async Task PathFor_GivenValueTypeSelector_ReturnsMemberPathWithoutBoxing() + { + // Arrange + // The two-type-parameter overload is the non-boxing shape: TValue stays int rather than + // becoming object?, so the compiler does not insert a boxing conversion into the expression. + // Act + var path = OptionsHelper.PathFor(static f => f.Count); + + // Assert + await Assert.That(path).IsEqualTo("Count"); + } + [Test] public async Task PathFor_GivenInvalidSelector_Throws() { @@ -469,6 +482,23 @@ public async Task Environment_GivenOverridesAndIgnores_AppliesOverridesAndRemove await Assert.That(envVars).DoesNotContainKey("HostKit__API__Name"); } + [Test] + public async Task Environment_GivenValueTypeIgnore_RemovesValue() + { + // Arrange + HostKitOptions options = new() + { + Redis = new RedisOptions { Name = "redis-a", IsEnabled = true }, + }; + + // Act + var envVars = OptionsHelper.Environment(options).Ignore(static o => o.Redis.IsEnabled).Build(); + + // Assert + await Assert.That(envVars).DoesNotContainKey("HostKit__Redis__IsEnabled"); + await Assert.That(envVars["HostKit__Redis__Name"]).IsEqualTo("redis-a"); + } + [Test] public async Task Environment_GivenOptionsWithSectionNameConst_UsesSectionNameAsKeyPrefix() { From 72495a60ff8f6de7073afc9d4f231392f88e3fb6 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 11 Nov 2026 14:57:15 +0000 Subject: [PATCH 03/12] chore: bumped nuget versions --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 6e39f25..cf9cb90 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,7 +4,7 @@ 5.9.0 1.83.0 2.0.0 - 5.3.0 + 6.0.0 C{"Every client .NET,
same solution, lockstep?"} + C -- yes --> K["Contracts
OpenAPI only as an output"] + C -- no --> B{"Any .NET client
at all?"} + B -- no --> O["OpenAPI
generated from the server's types"] + B -- yes --> D{"The only other clients are .NET
in another repository,
and they accept a package?"} + D -- yes --> P["Contracts, shipped as a package"] + D -- no --> H["Hybrid
Contracts is the source, OpenAPI generated from it"] +``` + +Steps 3 and 4 do not overlap: a .NET client in the solution alongside a partner or non-.NET client is +step 3, because the .NET client still has value objects and rules to lose. + +## Rules that follow + +- **Never regenerate a C# client from your own API's OpenAPI document when that client is in the same + solution.** It throws away value objects and rules to buy decoupling the solution does not have. +- **Never hand-maintain a parallel set of models** on either side. Two definitions of one wire shape + disagree eventually, and nothing fails when they start to. +- **The server always validates.** Sharing validators with a client is for user experience and + consistency. It is never a substitute for validating on the server, because the client is untrusted. +- **Moving from Contracts to Hybrid must be additive.** Because Contracts is already the source, adding + a non-.NET client means turning on generation of the OpenAPI document and a contract test. Nothing in + Contracts changes. Design for that, but do not build it until the client exists. + +## Contracts project constraints + +- **References only the domain library (value objects) and the runtime Purview packages** — + `Purview.ValueObjects`, `Purview.ZodSharp`, `Purview.Results`. Never the server, persistence, HTTP + clients or hosting, and never an ASP.NET Core bridge such as `Purview.ZodSharp.AspNetCore`; those + belong to the API host. A Contracts project that references the server is a server that every client + now ships. +- **Browser-safe when a WebAssembly client references it.** No file, network or process I/O, safe to + trim, and no reflection-dependent behaviour. The same applies transitively to the domain library, + because the client downloads it too. Make the build enforce it, not a reviewer — three mechanisms, + each catching what the others cannot: + - **Your repository's architecture policy** forbids `Microsoft.AspNetCore` packages + (`forbiddenPackagePrefixes`) and the `Microsoft.AspNetCore.App` framework reference + (`forbiddenFrameworkReferences`) in `Contracts` and `Core`. The playbook's own policy forbids the + packages in `Core` only; the rest arrives with the Build SDK's planned Blazor support, so add it + yourself until then. See `purview-architecture-testing`. + - **`true`** on both projects turns on the trim and AOT analyzers, + so reflection a trimmed client would break on is a warning at build time. `Purview.ValueObjects` + and `Purview.ZodSharp` already set it. + - **``** makes the platform-compatibility analyzer (`CA1416`) + flag an API that is unsupported in the browser, such as starting a process. + + The policy rules fail a test run. The two analyzers produce warnings, and the SDK does not turn on + `TreatWarningsAsErrors` for an ordinary project — set it on `Contracts` and `Core`, or the "clean + build has no warnings" rule is the only thing standing behind them. No analyzer proves the absence of + every kind of I/O; what they miss is a review obligation, and the client's own tests running in the + browser are the last check. +- **Deserialisation mode is a deliberate choice per direction.** The server reads requests through + `Create` (strict), because the client is untrusted. Record what the client uses for responses, and + why — strict catches a server that has drifted, and `Hydrate` trusts a server the client cannot + verify. + +## Version skew + +| Shipping | Strategy | Failure it prevents | What to do | +| -- | -- | -- | -- | +| Lockstep | Contracts, internal apps | A silent failure that looks like a bug, when an old cached client talks to a new server | The server stamps its build on responses; the client prompts the user to reload when the stamps differ | +| Independent | Hybrid, OpenAPI | A deployed client breaking without warning | Additive changes only; a breaking change gets a new route version; a contract test compares the generated document against the last published one and fails on a breaking difference | + +## Backend-for-Frontend + +A BFF is a host, not a layer, and it is held to the same justification test as anything else. +Microsoft's [Blazor Web App with OIDC and the BFF pattern](https://learn.microsoft.com/aspnet/core/blazor/security/blazor-web-app-with-oidc?pivots=with-bff-pattern) +is the authority on the mechanics; what follows is the rules and the failures behind them. + +### What earns it + +- **Keeping tokens out of the browser.** The BFF signs the user in (OpenID Connect), holds the access + and refresh tokens, and gives the browser only an authentication cookie. Script in the page — a + compromised dependency, an XSS hole — cannot read a token that never reached it. This prevents tokens + being stolen from browser storage. +- **It forwards with the user's access token, never its own identity.** The API validates that token + exactly as it would any other, so authorisation and the audit trail stay per-user. A BFF calling with + a client-credentials token turns every action into the BFF's and moves authorisation into the BFF. + +### In a Blazor solution, the BFF is the host + +The project that serves the WebAssembly client is the natural BFF: it already runs on the server, at +the client's origin. Do not add a separate BFF in front of it. + +- **It references the client project** (`Bff → WebApp`) because it serves the client's assets. The + client never references it; they talk over HTTP with the cookie. +- **Same origin is the point.** The client calls the BFF's own routes, the BFF forwards them to the API + (with YARP's `MapForwarder`, for example), and there is no CORS configuration and no third-party + cookie. +- **The client shares Contracts with the API directly.** The WebAssembly client references Contracts, + sends Contracts types, and runs the generated validators. The BFF in the middle passes the bytes + through untouched. + +### Rules that come with a cookie + +Moving the credential into a cookie trades token theft for a different attack, and the BFF has to +close it: + +- **Every forwarded call requires an anti-forgery signal.** The browser attaches the cookie to any + request to the BFF's origin, including one a hostile page triggers. Require a custom header the + client always sends, which a cross-site form cannot set, and reject calls without it — Duende.BFF's + `X-CSRF: 1` is the established form. `SameSite` narrows the attack; it does not replace the check. + This prevents cross-site request forgery. +- **The cookie is `HttpOnly`, `Secure` and `SameSite=Strict` or `Lax`.** `HttpOnly` is what keeps + script from reading it; without it the BFF protects nothing. +- **Data Protection keys are persisted and shared** when the BFF runs on more than one instance or + survives a restart. The cookie is encrypted with them, so per-instance keys sign users out at random. +- **Refresh happens on the server.** The BFF refreshes the access token before it expires and signs + the user out when refresh fails. The client never sees either token. +- **A large session moves to a server-side store.** Tokens kept inside the cookie make it large enough + to be split across several cookies, and the session cannot be revoked server-side. Use a ticket store + when either matters. + +### What it may reference + +- **A BFF that only forwards does not reference Contracts** and never reads payloads. Reading a payload + it does not own makes it a second consumer that has to change in lockstep for no benefit. +- **Prerendering is the common exception.** If the BFF prerenders client components that load data, + it must run those data calls on the server, and it references Contracts to do so. That is a real + reason; turning prerendering off for those components is the alternative, and the choice should be + written down. +- **A BFF that combines several API calls into one response owns its own contract** for those + responses, chosen with this same procedure. That is a separate justification. The forwarding-only + test — "a service layer that forwards" is not earned — still applies to everything else it does. +- **One BFF per client kind, not one per screen.** Web and mobile may each get one when their sign-in + or response needs differ. + +**None of this is enforced yet.** The architecture policy has no BFF responsibility; Blazor and BFF +support is planned for a future Build SDK release. Until then every rule in this section is a review +obligation, and a repository that adds a BFF project records it in its own policy — see +`project-responsibilities.md`. Even once it is in the policy, "Contracts only when it prerenders or +combines" stays a review obligation, because a dependency matrix cannot express a condition. + +## Worked examples + +| Clients today | Strategy | +| -- | -- | +| Blazor WebAssembly app, same solution, lockstep, signed in through a BFF | Contracts, referenced by the API and the WebAssembly client. The BFF is the host that serves the client, holds the tokens and forwards with the user's token; it references Contracts only if it prerenders data. OpenAPI generated for documentation. | +| Blazor WebAssembly app plus a React Native mobile app on its own release cadence | Hybrid. Mobile generates from the document and takes Zod rules from the exported schema. Additive-only versioning. A mobile BFF if sign-in differs. | +| Partner integrations only | OpenAPI, generated from the server's types. No Contracts project. | + +## See also + +- [Contracts and Data Exchange](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/data/Contracts-And-Data-Exchange.md) + — `Create` and `Hydrate`, and the schema as the source of truth for a wire shape +- [zodsharp](https://github.com/purview-dev/zodsharp) — authoritative on `[ZodSchema]` and JSON Schema + export diff --git a/.agents/skills/purview-architecture/references/project-context-discovery.md b/.agents/skills/purview-architecture/references/project-context-discovery.md new file mode 100644 index 0000000..d40569a --- /dev/null +++ b/.agents/skills/purview-architecture/references/project-context-discovery.md @@ -0,0 +1,115 @@ +# Project context discovery + +The procedure for establishing enough context to make an architectural recommendation, and — just as +importantly — for knowing when to stop asking. + +## The principle + +Every question you ask a user is a question they have usually already answered in the repository. +Reading is free and silent; asking is slow and, when the answer was already in a project file, it +reads as not having looked. So the order is fixed: + +1. Read the repository. +2. Read the conversation so far. +3. Ask only about what remains *and* changes your answer. + +A dimension whose answer would not change your recommendation is not worth a question. If a project +is a stateless CLI, the database GUID strategy is not an unknown — it is irrelevant, and raising it +is noise. + +## Dimension 1 — complexity + +Assess from requirements, never from line or project count. + +| Signal | Suggests | +| -- | -- | +| One functional area, one consumer, no integrations | Small | +| A handful of features, one or two integrations, a single team | Medium | +| Several bounded areas, multiple services or teams, operational obligations (SLOs, auditability, money) | Large | + +Where to look: `README.md`, the wiki, issue or ticket references, the solution file's project list, +`PackageReference` entries naming external systems, the number of distinct hosts. + +Traps: + +- **A big repository is not a complex one.** A monorepo of ten tiny tools is ten small projects. +- **A small repository can be complex.** Few files, hard invariants. +- **Generated code inflates everything.** Discount it. + +## Dimension 2 — project type + +Read it; do not ask it. + +| Evidence | Type | +| -- | -- | +| `Microsoft.NET.Sdk.Web` | Web application or HTTP API — distinguish by whether views/pages or endpoints dominate | +| `OutputType` of `Exe` plus an argument parser | CLI | +| `IsPackable`, no entry point | Reusable library | +| `IHostedService` / `BackgroundService`, `Worker` SDK | Background worker | +| An AppHost project, `Aspire.Hosting` | Distributed application | +| WPF/WinForms/Avalonia/MAUI SDK or `UseWPF` | Desktop application | + +A reusable library carries the strongest constraint on the rest of your advice: it must not configure +global telemetry providers, must not read configuration from the ambient environment, and must not +decide a persistence technology for its consumer. + +## Dimension 3 — persistence + +| Question | Read it from | +| -- | -- | +| Is persistence required at all? | Any data-access `PackageReference`, a `DbContext`, a connection string, a repository type, an Aspire resource | +| Which technology? | The EF Core provider package (`Microsoft.EntityFrameworkCore.SqlServer`, `Npgsql.EntityFrameworkCore.PostgreSQL`), the client package, or the `Purview.Containers` resource in the AppHost | +| Is more than one implementation required? | Two providers referenced, a provider abstraction already present, or an explicitly stated requirement | +| EF Core or something else? | Presence of `Microsoft.EntityFrameworkCore` versus Dapper, a raw client, or a document store SDK | + +"No persistence" is a complete and common answer. When it is the answer, every storage-shaped +recommendation — repositories, migrations, GUID strategies, connection resilience — is out of scope. + +## Dimension 4 — telemetry + +| Question | Read it from | +| -- | -- | +| Is telemetry configured? | `ILogger` injection, `ActivitySource`/`Meter` construction, `AddOpenTelemetry`, a `ServiceDefaults` project | +| Is Purview Telemetry available? | A `Purview.Telemetry.SourceGenerator` `PackageReference`, or interfaces attributed `[Logger]`, `[ActivitySource]`, `[Meter]` | +| Which signals are relevant? | Logs always; traces once a call crosses a process or a dependency; metrics once someone would alert on a rate or a duration | +| Is there an exporter? | An OTLP endpoint in configuration, a console exporter, an Aspire dashboard, an APM package | + +Existing instrumentation is a constraint, not a starting point to replace. Preserve it, extend it, +and never register the same source twice. + +## Conditional dimension — identifier generation + +Resolve **only** when persistence exists and GUIDs are, or are about to be, primary keys. + +- Are GUIDs primary keys, or just surface identifiers? +- Is the value generated in the application or by the database? +- Which provider and version? (This changes the answer — see `purview-persistence`.) +- Does index locality matter at the expected write volume? +- Must the identifier exist before the entity is persisted (outbox, correlation, client-side graph)? + +The last question usually decides it: if the application needs the value before `SaveChanges`, the +database cannot be the generator. + +## Asking well + +When you must ask, ask few and ask concretely. Batch the unknowns into one short set, offer the +likely answers, and state what you will do if the user does not care. + +Good: + +> Two things I can't tell from the repository: is this service expected to own its own database, or +> read from the existing one? And should failures be returned to the caller or thrown? I'll assume +> its own database and `Purview.Results` for expected failures unless you say otherwise. + +Bad: + +> What is the project complexity? What is the project type? Is persistence required? Which database? +> Is telemetry configured? Do you use GUID primary keys? + +The second asks six questions, five of which the repository answers, and one of which is irrelevant. + +## Recording the answer + +When a dimension is resolved by conversation rather than by the repository, that is a decision the +next agent will not be able to read. Write it down — in `AGENTS.md`, a wiki page, or an ADR — so the +question is asked once and never again. diff --git a/.agents/skills/purview-architecture/references/project-responsibilities.md b/.agents/skills/purview-architecture/references/project-responsibilities.md new file mode 100644 index 0000000..dc4e67c --- /dev/null +++ b/.agents/skills/purview-architecture/references/project-responsibilities.md @@ -0,0 +1,119 @@ +# Project responsibilities, naming and dependency direction + +The catalogue in brief. The authoritative definition is +[`.config/architecture-policy.json`](https://github.com/purview-dev/engineering-playbook/blob/main/.config/architecture-policy.json), +and the full page — with diagrams — is +[Project Responsibilities](https://github.com/purview-dev/engineering-playbook/blob/main/docs/wiki/foundations/Project-Responsibilities.md). +Do not restate either here or in another skill; link to them. + +## The catalogue + +`A → B` means A depends on B **at compile time**. + +| Responsibility | Also known as | Owns | May depend on | +| -- | -- | -- | -- | +| `Core` | `Domain` | Entities, value objects, invariants, domain events and errors | Shared | +| `Abstractions` | — | Cross-boundary capability contracts | Core, Shared | +| `Application` | `UseCases` | Use cases, workflows, orchestration | Core, Abstractions, Contracts, Shared | +| `Contracts` | — | Externally exposed request/response types; browser-safe when a WebAssembly client references it | Core, Shared | +| `Persistence` | — | DbContext, mappings, migration *definitions* | Core, Abstractions, Application, Shared | +| `Providers` | `Integrations` | Integration with a *named external platform* | Core, Abstractions, Application, Shared | +| `Adapters` | — | An *application-defined capability* via one technology | Core, Abstractions, Application, Shared | +| `Composition` | — | DI registration and implementation selection | Everything above | +| `API` | — | HTTP host | Composition, Contracts, ServiceDefaults, … | +| `Worker` | — | Background-processing host | Composition, ServiceDefaults, … | +| `Migrator` | — | *Executes* migrations and controlled seeding | Persistence, Composition, ServiceDefaults | +| `AppHost` | — | Aspire orchestration | The hosts it orchestrates | +| `ServiceDefaults` | — | Telemetry, health, resilience, discovery defaults | Shared | +| `WebApp` | — | Frontend, including a Blazor WebAssembly client that references Contracts | Contracts, ServiceDefaults, Shared | +| `Shared` | — | Small domain-independent primitives | *nothing* | + +**None of these is mandatory.** A small application with `Core` and one host has a complete and +correct architecture. The catalogue names responsibilities so you can tell when splitting one out +buys something — it is not a list of projects to create. + +### Backend-for-Frontend: guidance, not yet policy + +A BFF is a host responsibility that the policy does **not** define yet; Blazor and BFF support is +planned for a future Build SDK release. Until then it is guidance, and every rule below is a review +obligation: + +| Owns | May depend on | Split it out when | Leave it combined when | +| -- | -- | -- | -- | +| Sign-in and token custody for a browser or mobile client; forwards with the user's token. In Blazor, the host that serves the WebAssembly client | The WebAssembly client project, ServiceDefaults, Shared — and Contracts only when it prerenders data or combines responses | Tokens must be kept out of the browser, or several API calls must become one response | It would only forward calls that the client could make with a token it already holds safely | + +A repository that adds a BFF project today needs its own responsibility entry or a waiver in its +policy, or the architecture tests reject the project as unclassified. See `contract-strategy.md`. + +## When to split + +| Responsibility | Split it out when | Leave it combined when | +| -- | -- | -- | +| `Core` | A domain model exists | The app is a thin utility | +| `Abstractions` | Two implementations, or the consumer must not see the implementation | One implementation — keep the interface beside it | +| `Application` | Workflows are reused by more than one host | It would only forward CRUD | +| `Contracts` | The contract strategy is Contracts or Hybrid — see `contract-strategy.md` | The API host is the only consumer, which is the OpenAPI strategy || `Persistence` | Persistence exists at all | A single-project utility writing a file | +| `Providers` | Two platforms with different SDKs or lifecycles | One platform | +| `Adapters` | One capability, several technologies | One capability, one technology | +| `Composition` | Two hosts compose the same implementations | One host — compose in the host | +| `Migrator` | Schema changes are a deliberate deployment step | Development-only startup migration | +| `Shared` | A primitive is used by two or more responsibilities | Almost always | + +## Naming + +```text +[.] +``` + +| Situation | Name | +| -- | -- | +| One implementation | `Persistence` — do **not** qualify it just because it is SQL Server | +| Several, same dependencies and lifecycle | `Persistence` — one project, several classes | +| Several, genuinely different | `Persistence.SqlServer`, `Persistence.PostgreSql` | +| Several, with shared code | Add `Persistence` alongside the qualified projects | +| Several, nothing shared | Qualified projects only — no empty parent | + +Discouraged as project names, because each names a category rather than a responsibility: +`Infrastructure`, `Services`, `Managers`, `Models`, `Helpers`, `Common`, `Utils`, `Utilities`. + +`Infrastructure` is the common case. It is a useful *category* — covering Persistence, Providers and +Adapters — and a poor *project*, because every technical class qualifies for it. + +**Do not rename an established project** merely to match canonical terminology. A rename costs more +than the consistency is worth, and `Domain`/`UseCases`/`Integrations` are recognised aliases. + +## Providers versus Adapters + +Not a vendor distinction: + +- **Provider** — the requirement names the platform: "sync issues from GitHub". Swapping it changes + the product. +- **Adapter** — the requirement names the capability: "send an email". Swapping the technology + changes nothing the user sees. + +Azure Blob behind `IFileStore` is an Adapter. The Azure DevOps work-item API in a product that +integrates Azure DevOps is a Provider. + +## Dependency rules that catch people out + +1. **Composition is referenced by nothing** except a host. It selects implementations, so it depends + on them; the reverse would make the choice circular. +2. **Application never references a concrete infrastructure project.** It depends on an abstraction + and receives an implementation it cannot name. +3. **Sibling implementations stay independent.** If `Providers.GitHub` needs something from + `Providers.AzureDevOps`, that shared thing belongs in the unqualified project or an abstraction. +4. **Persistence owns migration definitions; Migrator executes them.** Production API startup does + not mutate a schema. +5. **Runtime is not compile time.** DI registration, an HTTP call and Aspire orchestration are not + assembly references. A Worker and an API can share Application without referencing each other. + +## Recommending this to a repository that predates it + +Do not propose a mass rename. Suggest, in order: + +1. Fix dependency *directions* that are actually wrong — those are bugs. +2. Split a project only where a named problem justifies it. +3. Record a waiver for an established name rather than renaming it, so the rule stays enforceable + without churn. + +A waiver excuses the name only; the project still classifies, so every dependency rule still applies. diff --git a/.agents/skills/purview-architecture/references/proportional-architecture.md b/.agents/skills/purview-architecture/references/proportional-architecture.md new file mode 100644 index 0000000..3ed7f55 --- /dev/null +++ b/.agents/skills/purview-architecture/references/proportional-architecture.md @@ -0,0 +1,92 @@ +# Proportional architecture + +Structure is a cost paid now against a failure expected later. Proportional architecture is the +practice of only paying when the failure is named, present, and more expensive than the structure. + +## The justification test + +Before adding a project, an interface, a layer or a pattern, answer in one sentence: + +> **What failure does this prevent in *this* codebase, and has it happened or is it certain to?** + +If the sentence needs the words "in case", "later", "when we scale", or "it's good practice", the +structure is not yet justified. Write the simpler thing. The simpler thing is also the thing that is +cheapest to change when the failure finally does arrive. + +This is not an argument against structure. It is an argument against *unfalsifiable* structure. + +## By scale + +### Small — a utility, CLI, prototype, simple API, limited-scope application + +- One project. A second only when there is a test project, or when a genuinely reusable library + falls out. +- Call the database, the API or the file system directly from the code that needs it. +- Validate at the edge, once. +- Logging from the first commit; traces and metrics when there is something to correlate. + +Do not add: a repository interface over `DbContext`, a service layer that forwards, a mapping layer +between two near-identical shapes, an interface with one implementation and no test double need +(`Purview.Containers` and TUnit.Mocks remove most of that need), a `Domain`/`Application`/ +`Infrastructure` split. + +### Medium — several functional areas, some integrations, moderate domain complexity + +- Introduce a boundary where you can name what it separates. Usually that is domain rules from the + host, so a domain project and an application host. +- An abstraction over an external dependency earns its place when the dependency is genuinely + swappable, or when testing against it is otherwise painful. "Swappable in principle" is not + swappable. +- Dependency instrumentation, trace propagation across the integrations, metrics on the operations + someone would be paged about. + +Do not add: CQRS unless reads and writes have actually diverged in shape, lifetime or scaling; +event sourcing unless the history *is* the product; a second infrastructure implementation that has +no second consumer. + +### Large — complex domain, multiple services or teams, substantial operational requirements + +- Modularity along ownership lines, because the real constraint is people, not code. +- Independent infrastructure implementations where they are genuinely independent. +- Boundaries made enforceable rather than documented — a dependency-direction rule that a wrong + `ProjectReference` fails the build. See the playbook's Architecture-As-Tests page. +- End-to-end tracing, standardised resource attributes, SLIs, retention and sampling policy. + +Even here, the justification test holds per decision. "Large" licenses the *option* to add +structure, not the obligation to add all of it on day one. + +## Patterns and the conditions that earn them + +| Pattern | Earned when | Not earned by | +| -- | -- | -- | +| Repository over EF Core | You must swap the store, or the `DbContext` API genuinely leaks into domain decisions | Testability — EF Core's in-memory and container-backed testing already covers it | +| CQRS | Read and write models have diverged in shape or scaling needs | A desire for symmetry with a reference architecture | +| Event sourcing | The sequence of changes is the product, or audit is a hard requirement | "We might want history" | +| Mediator / in-process bus | Many handlers, genuinely decoupled registration | Three call sites that could be method calls | +| Mapping layer | Two shapes that must diverge and are owned separately | Two shapes that are identical today | +| Extra DI indirection | A real second implementation, or a composition root that must vary | Inversion for its own sake | +| Generic infrastructure framework | Three or more concrete uses exist now | One use and an expectation of more | + +## Evolving incrementally + +The honest sequence is: write the direct implementation; let the second caller arrive; extract the +boundary the second caller revealed. Each step is small, reversible and informed. + +The dishonest sequence is: predict the second caller; build the boundary; never get the second +caller; maintain the boundary forever. The cost is invisible because it is spread across every +future change. + +When you do extract, extract along the seam the code showed you — not the seam the diagram +predicted. + +## What this does not license + +Proportional is not an excuse for: + +- Skipping tests, or skipping telemetry. +- Leaving validation duplicated across entry points. +- Letting domain rules live in the UI or in the database. +- Two sources of truth for the same fact. + +Those are not structure bought in advance; they are defects. Simplicity means fewer moving parts, +not fewer guarantees. diff --git a/.agents/skills/purview-buildsdk-adoption/SKILL.md b/.agents/skills/purview-buildsdk-adoption/SKILL.md new file mode 100644 index 0000000..9f25048 --- /dev/null +++ b/.agents/skills/purview-buildsdk-adoption/SKILL.md @@ -0,0 +1,178 @@ +--- +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, target framework 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, required settings and versions, target frameworks, 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". + +### Required settings and versions + +Four values decide whether the SDK behaves as documented. Set the first three **before** the import +where noted: + +| What | Where | Notes | +| -- | -- | -- | +| `NamespacePrefix` | `Directory.Build.props`, above the import | Required — the build fails without it unless `DisableNamespacePrefixCheck=true`. | +| SDK version | `global.json` → `msbuild-sdks.Purview.BuildSdk` | The pinned `Purview.BuildSdk` version, e.g. `2.0.1`. The SDK bootstraps `global.json` when it is missing and writes the version it was packed as (`PurviewBuildSdkVersionForGlobalJson`). | +| Product version | repo-root `package.json` → `version` | Drives `Version` and `PackageVersion`. `UsePackageJsonVersion` / `RootPackageJson` are pre-import. | +| Package versions | `Directory.Packages.props` | CPM is on; a `PackageVersion` is a minimum, not an exact pin. | + +```json +// global.json +{ + "test": { "runner": "Microsoft.Testing.Platform" }, + "msbuild-sdks": { "Purview.BuildSdk": "2.0.1" } +} +``` + +## 2. Pre-import ordering is the first thing to check + +**A property that must affect `Sdk.props` evaluation has to be set before the import.** Setting it +in a `.csproj`, or below the import, is too late and silently does nothing — no error, no warning. +This is the single most common adoption failure. + +Set before the import: + +- `NamespacePrefix` +- `UsePackageJsonVersion`, `RootPackageJson` +- `EnableAssemblyNameGeneration` +- `AutoIncludeUsings` +- `PurviewSharedTestingOutputType`, `PurviewTestContextNoWarn` +- any repo-wide testing-framework selection you want every project to inherit + +Everything else is read in `Sdk.targets` and can be set in a project file. + +When a property appears to have no effect, confirm what MSBuild actually resolved before changing +anything else: + +```bash +dotnet msbuild -getProperty:NamespacePrefix -getProperty:RootNamespace +``` + +## 3. Version detection + +Versions come from `package.json`. Resolution order: + +1. An explicit `RootPackageJson`. +2. CI workspace variables. +3. The `.git` root. +4. A nearby `package.json`. + +On success both `Version` and `PackageVersion` are set from the `version` field. + +If the version looks wrong, do not guess at the discovery — make it fail loudly instead: + +```xml +Strict +``` + +`Strict` fails the build when discovery or resolution cannot succeed, rather than silently skipping +it and leaving the default `1.0.0`. Set `VersionDetectionLogEnabled=true` to see what it found. + +## 4. Naming and placement + +Most of the SDK's value is inferred from project names and locations, so an adoption that keeps a +bespoke structure gets correspondingly little. Confirm the repository follows the conventions rather +than introducing new structure to work around them — see `purview-buildsdk-project-layout`, and +`purview-buildsdk-repository-review` for auditing an existing layout. + +The highest-value single check: every test project is named `.Tests`. **The dot is +required.** A project named plainly `UnitTests` is not detected as a test project at all, and +silently gets no runner, no coverage, no `InternalsVisibleTo` and no category. + +## 5. Agent skills are a separate, explicit step + +Nothing the SDK does during a restore or a build touches agent skills. Installing, updating and +cleaning them up are separate targets a person runs by name — `InstallPurviewSkills`, +`UpdatePurviewSkills`, `CheckPurviewSkills`, `FixPurviewSkills` — and the ones that change the +repository refuse to run inside a build (`PRSKL0009`). Do not hook them into one. The skills CLI does +the installing, through `bun x` by default; with `PurviewSkillsAgents` unset it auto-detects agents. + +If the repository uses `just`, add recipes for them — see `purview-buildsdk-skills-justfile`. For what each +target does and the `PRSKL*` diagnostics, see `purview-buildsdk-skill-installation`. + +## 6. Target frameworks + +A project that declares neither `TargetFramework` nor `TargetFrameworks` gets `net10.0;net11.0`. +Confirm what a project resolves before changing it: + +```bash +dotnet msbuild -getProperty:TargetFramework -getProperty:TargetFrameworks +``` + +To set it: + +- **Declare it explicitly** — `TargetFramework` (one) or `TargetFrameworks` (many). It must be declared + **unconditionally**: the SDK reads it from the project XML before the body is evaluated, so a + `Condition` on it is not seen (`PurviewConditionedProjectDeclaration`). +- **Select a curated set** — `Supported` in + `Directory.Build.props` or the `.csproj` (`Current`, `Latest`, `Supported`, `Broad`, `All`), so a + lifecycle change is one SDK bump rather than an edit in every project. +- **Redefine a set** — set `PurviewTargetFrameworks` before the import to pin the repository's + meaning of a set. + +A Roslyn component targets `netstandard2.0` and ignores the sets. Full detail in +`purview-buildsdk-configuration` and [Configuration Reference](https://github.com/purview-dev/build-sdk/blob/main/docs/wiki/Configuration-Reference.md#target-framework-sets). + +## 7. SourceLink provider + +The SDK adds a SourceLink provider package (`SourceLinkPackageName`, default +`Microsoft.SourceLink.GitHub`) to every non-test project, and `PublishRepositoryUrl=true` writes the +repository URL and commit into the packed `.nuspec`. Match the repository host **before the SDK import**: + +| Host | Package | +| -- | -- | +| GitHub / GitHub Enterprise | `Microsoft.SourceLink.GitHub` *(default)* | +| Azure DevOps Services / Azure Repos | `Microsoft.SourceLink.AzureRepos.Git` | +| Azure DevOps Server / TFS (on-premises) | `Microsoft.SourceLink.AzureDevOpsServer.Git`, plus a `SourceLinkAzureDevOpsServerGitHost` item | +| GitLab | `Microsoft.SourceLink.GitLab` | +| Bitbucket | `Microsoft.SourceLink.Bitbucket.Git` | + +CPM is on, so add a `PackageVersion` for the chosen provider to `Directory.Packages.props` — the shipped +template already declares GitHub, Azure Repos and GitLab. `DisableSourceLink=true` opts out entirely. +Detail, including the on-premises host item, is in `purview-buildsdk-configuration`. + +## Constraints + +- Prefer minimal, targeted property changes over broad `Directory.Build.props` rewrites. +- Do not disable `PurviewAutoSdkPack` unless the consumer explicitly asks to opt out. +- Do not restate an SDK default in a project file. Hand-configuration that merely repeats a default + is pure maintenance cost, and it hides the fact that the default later changed. +- Re-run the build — or the repository's canonical build command — after each change, and confirm + the fix before moving to the next one. diff --git a/.agents/skills/purview-buildsdk-configuration/SKILL.md b/.agents/skills/purview-buildsdk-configuration/SKILL.md new file mode 100644 index 0000000..a83cb4c --- /dev/null +++ b/.agents/skills/purview-buildsdk-configuration/SKILL.md @@ -0,0 +1,207 @@ +--- +name: purview-buildsdk-configuration +description: Configure Purview.BuildSdk from Directory.Build.props or a .csproj. Use when setting NamespacePrefix, setting the target framework(s) or selecting a curated framework set, choosing a SourceLink provider for the repository host (GitHub, Azure DevOps, GitLab, Bitbucket), choosing a testing or mocking framework, turning telemetry or SourceLink off, controlling version detection from package.json, pinning the SDK version in global.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 + +A target framework is a special case: the SDK reads `TargetFramework`/`TargetFrameworks` and +`PurviewTargetFrameworkSet` from the **project XML**, so they can live in the project or in +`Directory.Build.props`, but must be declared **unconditionally** either way — see +[Setting the target framework(s)](#setting-the-target-frameworks). + +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. + +## Required settings and versions + +Four things must be right, and three of them are read during `Sdk.props` evaluation — set those in +`Directory.Build.props` **above** the ``: + +| Setting | Where | Notes | +| -- | -- | -- | +| `NamespacePrefix` | `Directory.Build.props`, pre-import | Required. The build fails without it (`ValidateRootNamespacePrefixTarget`) unless `DisableNamespacePrefixCheck=true`. | +| SDK version | `global.json` → `msbuild-sdks.Purview.BuildSdk` | The pinned `Purview.BuildSdk` version. The SDK bootstraps `global.json` (with this value) when it is missing; `PurviewBuildSdkVersionForGlobalJson` decides what is written. | +| Product version | repo-root `package.json` → `version` | Drives both `Version` and `PackageVersion`. `UsePackageJsonVersion` (`true`/`false`/`Strict`) and `RootPackageJson` are pre-import; `Strict` fails the build when discovery cannot succeed instead of silently staying on `0.0.1`. | +| Package versions | `Directory.Packages.props` | CPM is on (`ManagePackageVersionsCentrally=true`). Each `PackageVersion` is a minimum, not an exact pin. | + +## Setting the target framework(s) + +A project that declares neither `TargetFramework` nor `TargetFrameworks` gets the SDK's default set, +`net10.0;net11.0` (`Current`). Pick one of three routes, narrowest first: + +1. **Explicit declaration** — `net10.0` for one target, + `net10.0;net11.0` for many. Declare it **unconditionally**: the + SDK reads it from the project XML before the body is evaluated, so a `Condition` on it is not seen + (and is reported as `PurviewConditionedProjectDeclaration`). A project declaring `TargetFrameworks` + keeps `TargetFramework` empty. +2. **A curated set** — `Supported` in + `Directory.Build.props` (repository-wide) or the `.csproj`. Sets: `Current` (default), + `Latest` (`net11.0`), `Supported` (`net10.0;net11.0`), `Broad` (`net8.0;net9.0;net10.0`), `All` + (`net8.0;net9.0;net10.0;net11.0`). An unknown name fails with `PurviewInvalidTargetFrameworkSet` + rather than silently falling back. +3. **Redefine a set** — set `PurviewTargetFrameworks` (for example `PurviewTargetFrameworksSupported`) + **before the import** to pin the repository's meaning of a set across an SDK bump. + +A Roslyn component (`IsRoslynComponent=true`) targets `netstandard2.0` and ignores the sets; selecting +one is reported as `PurviewTargetFrameworkSetIgnored`. The full table is in +[references/properties.md](references/properties.md#target-framework-and-language). + +## 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;net11.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 | +| `MemorySafetyRules` | `0` | C# 15 memory-safety tier on `net11.0`+: `0` off (default), `1` preview syntax, `2` updated rules (`updated-memory-safety-rules`, opt-in — an experimental compiler feature) | +| `PurviewRuntimeAsync` | `false` | Opts a `net11.0`+ target into `runtime-async=on`; `UseRuntimeAsync=true` also opts in | +| `IsClientSideProject` | *(computed)* | Whether the project's assemblies can reach a browser; overrides the `ServerShared` classification | +| `PurviewAutoServerSharedReference` | `true` | Adds a sibling `ServerShared` reference to a non-client-side project | +| `PurviewMultiThreadedBuild` | `true` | Bootstraps a `Directory.Build.rsp` enabling MSBuild `-mt` | + +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 a large set of properties to Roslyn analyzers and source generators through +`CompilerVisibleProperty`, readable as `build_property.`: + +```csharp +context.AnalyzerConfigOptions.GlobalOptions.TryGetValue("build_property.IsBffProject", out var isBff); +``` + +- **Consuming them** — read the exported property instead of re-deriving SDK behaviour; the export is + the SDK's answer and stays correct when the rules change. The SDK's own analyzers use this + (`build_property.IsBffProject` gates `PDS0008`/`PDS0009`). +- **Exposing a new one** — an analyzer can only see a property the SDK lists as a + `CompilerVisibleProperty` item (with a ``) in `Sdk.props`. Add it there; there is no + other mechanism, and an MSBuild property is invisible to the compiler until it is listed. + +The exported set is listed in +[references/properties.md](references/properties.md#compiler-visible-properties). + +## 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 three repository-wide files from the package: `.editorconfig`, `global.json` and +`Directory.Build.rsp`. + +- `PurviewRepoBootstrapMode` — `IfMissing` (default), `Always`, `WarnOnDrift`, `Never` +- `DisableAutoCopySdkFiles` — the master off switch +- `PurviewRepoBootstrapCopyRetries` / `...RetryDelayMilliseconds` / `...FailureAsError` +- `BootstrapMultiThreadedBuildRspToRepoRoot` / `PurviewMultiThreadedBuild` — the response file that + enables MSBuild `-mt` (and `-nr:false`, which avoids a `RoslynCodeTaskFactory` cache left by a + non-`-mt` build) + +Every project in a solution writes those same destinations, so the write is race-tolerant by +design: an existing file is never overwritten unless asked, each write is staged into a temporary +file and renamed into place, retries are quiet, and only a failure that survives every retry is +reported. + +**The SDK writes nothing into an agent directory.** It used to mirror a package's `.agents/**` into +the repository; that is gone, along with `EnableAgentFolderInPackage`, `AgentPackDestinationFolder` +and the `PurviewAgentFolder*` properties. Skills have exactly one installer, the skills CLI — see +`purview-buildsdk-skill-installation`. + +The skill targets read their settings when they run, so these can go in `Directory.Build.props` or +on the command line, and a repository-wide choice belongs in `Directory.Build.props`: + +| Property | Default | Controls | +| -- | -- | -- | +| `PurviewSkillsRunner` | `bun` (`bun x skills`) | Which package runner launches the skills CLI | +| `PurviewSkillsAgents` | empty — the CLI auto-detects | Agents to install to; when set, the only agent directories `CheckPurviewSkills` allows | +| `PurviewSkillsDryRun` | `false` | Install and update print commands instead of running them | + +On a command line, `dotnet msbuild -p:` splits values on `,` and `;` — write +`-p:PurviewSkillsAgents=claude-code%2Ccodex`. For `just` recipes, see `purview-buildsdk-skills-justfile`. + +## SourceLink + +Every non-test project gets a SourceLink provider package — `SourceLinkPackageName`, default +`Microsoft.SourceLink.GitHub`, added with `PrivateAssets="all"` so a debugger can step into a consumed +package's source and the `.nuspec` records the repository URL and commit. A repository on another host +selects the matching package **before the SDK import**: + +| Host | Provider package | +| -- | -- | +| GitHub / GitHub Enterprise | `Microsoft.SourceLink.GitHub` *(default)* | +| Azure DevOps Services / Azure Repos | `Microsoft.SourceLink.AzureRepos.Git` | +| Azure DevOps Server / TFS (on-premises) | `Microsoft.SourceLink.AzureDevOpsServer.Git` (+ `SourceLinkAzureDevOpsServerGitHost`) | +| GitLab | `Microsoft.SourceLink.GitLab` | +| Bitbucket | `Microsoft.SourceLink.Bitbucket.Git` | + +Because CPM is on, the chosen provider also needs a `PackageVersion` in `Directory.Packages.props`. Use +`DisableSourceLink=true` (pre-import) to opt out. The host matrix, the on-premises Azure DevOps Server +host item, the CPM requirement and the self-hosted case are in +[references/sourcelink.md](references/sourcelink.md). + +## Guidance when changing SDK configuration + +1. Preserve existing defaults unless the task explicitly changes product behaviour. +2. Keep `README.md`, the property declaration, its validation and the shipped skills aligned — a + property documented in one place and absent from another is the failure mode this repository + keeps hitting. +3. A new property that affects import-time behaviour must say so in its documentation. +4. Add the property to the `CompilerVisibleProperty` list if an analyzer would benefit from it. diff --git a/.agents/skills/purview-buildsdk-configuration/references/properties.md b/.agents/skills/purview-buildsdk-configuration/references/properties.md new file mode 100644 index 0000000..01b49f8 --- /dev/null +++ b/.agents/skills/purview-buildsdk-configuration/references/properties.md @@ -0,0 +1,215 @@ +# 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;net11.0` | Applied when neither `TargetFramework` nor `TargetFrameworks` is set. Declare it (or `TargetFrameworks`) **unconditionally** — the SDK reads it from the project XML, so a `Condition` on it is not seen. A project that explicitly declares `IsRoslynComponent=true` defaults to `netstandard2.0` | +| `PurviewTargetFrameworkSet` | `Current` | Named set resolved when the project declares no TFM: `Current`, `Latest`, `Supported`, `Broad` or `All`. An unknown name fails with `PurviewInvalidTargetFrameworkSet`. A single-entry set resolves to `TargetFramework` rather than paying for an outer build. Declare unconditionally | +| `PurviewTargetFrameworksCurrent` | `net10.0;net11.0` | Redefines the `Current` set (the default). Set before the SDK import | +| `PurviewTargetFrameworksLatest` | `net11.0` | Redefines the `Latest` set | +| `PurviewTargetFrameworksSupported` | `net10.0;net11.0` | Redefines the `Supported` set | +| `PurviewTargetFrameworksBroad` | `net8.0;net9.0;net10.0` | Redefines the `Broad` set | +| `PurviewTargetFrameworksAll` | `net8.0;net9.0;net10.0;net11.0` | Redefines the `All` set | +| `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 | + +Overrides are layered, narrowest first: an explicit `TargetFramework`/`TargetFrameworks` in the project +always wins, then a `PurviewTargetFrameworkSet` in the project, then a set the repository selected, then +a `PurviewTargetFrameworks` redefinition, then the SDK default. Roslyn components are not covered — +they target `netstandard2.0`; selecting a set on one is reported as `PurviewTargetFrameworkSetIgnored`. + +## 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 | + +## .NET 11 features and ServerShared + +| Property | Default | Behaviour | +| -- | -- | -- | +| `MemorySafetyRules` | `0` | C# 15 memory-safety tier on `net11.0`+ targets: `0` off (default), `1` preview pointer syntax (sets `LangVersion=preview` when none was chosen), `2` `updated-memory-safety-rules`. Tier `2` is opt-in — the updated rules are an experimental compiler feature. Unknown values fail with `PRCFG0001`. Never touches `AllowUnsafeBlocks` | +| `PurviewRuntimeAsync` | `false` | Set to `true` for `runtime-async=on` on `net11.0`+ targets. `UseRuntimeAsync=true` also opts in; an explicit `false` under either name keeps it off | +| `PurviewMultiThreadedBuild` | `true` | Bootstraps a managed `Directory.Build.rsp` with `-mt`/`-nr:false`; `false` writes `-mt:false` | +| `BootstrapMultiThreadedBuildRspToRepoRoot` | `true` | Whether that bootstrapped response file is written at all | +| `IsClientSideProject` | *(computed)* | `true` for a WebAssembly client, `PurviewBrowserSafe` library, Razor class library, or `*.Client`/`*.Browser`/`*.Wasm`/`*.WebAssembly`/`*.Components` name. Explicit value wins | +| `PurviewAutoServerSharedReference` | `true` | Adds `../ServerShared*/ServerShared*.csproj` to a non-client-side project | +| `PurviewServerSharedReferenceCheckAsError` | `false` | Makes `PRSHR0001`/`PRSHR0002` errors | +| `DisablePurviewServerSharedReferenceCheck` | `false` | Turns `PRSHR0001`/`PRSHR0002` off | + +## 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` | The provider package, added with `PrivateAssets=all` to every non-test project. Set before the SDK import to match the host — `Microsoft.SourceLink.AzureRepos.Git` (Azure DevOps Services), `Microsoft.SourceLink.AzureDevOpsServer.Git` (on-premises ADO Server / TFS), `Microsoft.SourceLink.GitLab`, `Microsoft.SourceLink.Bitbucket.Git` | +| `DisableSourceLink` | `false` | `true` (before the import) stops the SDK adding the package at all. An empty `SourceLinkPackageName` does **not** opt out — the GitHub default fills it | +| `EnableSourceLink` | `true` (set by the provider) | The provider package's own switch; `false` keeps the package but skips the source mapping | + +Provider selection is read during `Sdk.props`, so it must be pre-import. See +[sourcelink.md](sourcelink.md) for the host matrix, Azure DevOps specifics, the CPM `PackageVersion` +requirement and self-hosted hosts. + +## 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` | +| `BootstrapMultiThreadedBuildRspToRepoRoot` | `true` | Creates a managed `Directory.Build.rsp` enabling `-mt` | +| `RepositoryDirectoryBuildRspFilePath` | *(derived)* | Overrides the destination `Directory.Build.rsp` | +| `PurviewMultiThreadedBuild` | `true` | `false` rewrites a managed rsp to `-mt:false` | +| `PurviewBuildSdkVersionForGlobalJson` | restored package version, else the version it was packed as | | +| `PurviewRepoBootstrapCopyRetries` | `3` | | +| `PurviewRepoBootstrapCopyRetryDelayMilliseconds` | `500` | | +| `PurviewRepoBootstrapCopyFailureAsError` | `true` | `false` downgrades a persistent write failure to a warning | +| `PurviewSuppressCopyRetryWarnings` | `true` | Demotes the built-in copy task's `MSB3026` retry notice to a message | +| `PurviewRepositoryRootMarkers` | `.git/;.github/;.gitattributes;LICENSE.md;AGENTS.md;.agents/;CLAUDE.md;.claude/;package.json` | Ordered markers identifying a repository root, most authoritative first | + +There is no agent-folder mirror. `EnableAgentFolderInPackage`, `AgentPackDestinationFolder`, +`PurviewAgentFolderSourcePath`, `PurviewAgentFolderCopy*`, `PurviewAgentSyncManifestPath` and +`DisableMirroredFileTrackingCheck` were removed: skills are installed by the skills CLI, and +nothing else was left for the mirror to deliver. + +## Skills + +Covered in full by `purview-buildsdk-skill-installation`: `PurviewSkillsRunner`, +`PurviewSkillsRunnerCandidates`, `PurviewSkillsCommand`, `PurviewSkillsPackage`, +`PurviewSkillsAgents`, `PurviewSkillsSource`, `PurviewSkillsInstallRoot`, `PurviewSkillsRepoRoot`, +`PurviewSkillsDryRun`, and the `PurviewSkillsAgentDirectory` item group. + +## Compiler-visible properties + +The SDK lists these as `CompilerVisibleProperty` items, so Roslyn analyzers and source generators read +them as `build_property.`: + +```csharp +context.AnalyzerConfigOptions.GlobalOptions.TryGetValue("build_property.IsBffProject", out var isBff); +``` + +Read the exported property rather than re-deriving SDK behaviour — it is the SDK's own answer and stays +correct when the rules change. To make a new property visible, add a `CompilerVisibleProperty` item +(with a ``) in `Sdk.props`; nothing else exposes an MSBuild property to the compiler. + +Exported: + +- `UsePackageJsonVersion`, `RootPackageJson`, `RepoRoot`, `Version`, `PackageVersion` +- `NamespacePrefix`, `DisableNamespacePrefixCheck` +- `TestingFramework`, `SubstituteFramework`, `TestDataFramework` +- `ExcludePurviewTelemetry`, `ExcludeMSTelemetryExtension` +- `EnableAssemblyNameGeneration`, `DisableAutoInternalsVisibleTo`, `DisableGenerateAssemblyInfoClass`, + `DisableGenerateAssemblyIdentifierClass`, `AssemblyIdentifierClassName` +- `IsCSharpProject`, `IsTestProject`, `IsSharedTestingProject`, `IsSharedProject`, + `IsServerSharedProject`, `IsClientSideProject` +- `PurviewAutoServerSharedReference`, `DisablePurviewServerSharedReferenceCheck`, + `PurviewServerSharedReferenceCheckAsError` +- `MemorySafetyRules`, `PurviewRuntimeAsync`, `PurviewMultiThreadedBuild` +- `TestingType`, `TargetProjectName` +- `IsContainerProject`, `IsSdkProject`, `SdkProjectName`, `IsWebProject`, `IsWebSdkProject`, + `IsWorkerSdkProject`, `IsAspireHostProject`, `IsCLIProject` +- `IsBlazorWebAssemblyProject`, `IsRazorClassLibraryProject`, `IsBlazorHostProject`, `IsBffProject` +- `PurviewBrowserSafe`, `PurviewBrowserSafeWarningsAsErrors`, `DisablePurviewBrowserSafeReferenceCheck`, + `PurviewBrowserSafeReferenceCheckAsError`, `DisablePurviewBlazorWebAssemblyDefaults`, + `PurviewAutoBlazorClientReference`, `PurviewBffExposesApi` +- `EditorConfigFilePath`, `RepositoryEditorConfigFilePath`, `BootstrapEditorConfigToRepoRoot` +- `RepositoryGlobalJsonFilePath`, `BootstrapGlobalJsonToRepoRoot`, `DisableAutoCopySdkFiles` +- `PurviewRepoBootstrapMode`, `PurviewRepoBootstrapCopyRetries`, + `PurviewRepoBootstrapCopyRetryDelayMilliseconds`, `PurviewRepoBootstrapCopyFailureAsError` +- `PurviewSuppressCopyRetryWarnings` +- `PurviewSkillsSourceFolder`, + `PurviewSkillsAgents`, `PurviewSkillsInstallRoot`, `PurviewRepositoryRootMarkers`, + `PurviewSkillsRunner`, `PurviewSkillsDryRun` +- `PurviewBuildSdkVersionForGlobalJson`, `CurrentYear`, `AutoGeneratedAssemblyInfoFile`, + `AutoGeneratedAssemblyIdentifierFile` diff --git a/.agents/skills/purview-buildsdk-configuration/references/sourcelink.md b/.agents/skills/purview-buildsdk-configuration/references/sourcelink.md new file mode 100644 index 0000000..062d958 --- /dev/null +++ b/.agents/skills/purview-buildsdk-configuration/references/sourcelink.md @@ -0,0 +1,79 @@ +# SourceLink + +Every non-test project gets a SourceLink **provider** package, added with `PrivateAssets="all"`, so a +debugger can step into a consumed package's source and the packed `.nuspec` records the repository URL +and the exact commit (`PublishRepositoryUrl=true`). Test and shared-testing projects are excluded, +because their symbols are never published. + +## Property reference + +| Property | Default | Behaviour | +| -- | -- | -- | +| `SourceLinkPackageName` | `Microsoft.SourceLink.GitHub` | The provider package. Set before the SDK import | +| `DisableSourceLink` | `false` | `true` (before the import) stops the package being added at all | +| `EnableSourceLink` | `true` (set by the provider) | The provider package's own switch; `false` keeps the package but skips the source mapping | + +All three are read during `Sdk.props`, so they must be set **before the SDK import** — in +`Directory.Build.props`, above the ``. A value in the `.csproj` body is too late. An **empty** +`SourceLinkPackageName` does not disable SourceLink: the SDK fills it with the GitHub default. + +## Choosing a provider for the host + +| Host | `SourceLinkPackageName` | Extra configuration | +| -- | -- | -- | +| GitHub / GitHub Enterprise | `Microsoft.SourceLink.GitHub` *(default)* | none | +| Azure DevOps Services / Azure Repos (`dev.azure.com`, `*.visualstudio.com`) | `Microsoft.SourceLink.AzureRepos.Git` | none | +| Azure DevOps Server / TFS (on-premises) | `Microsoft.SourceLink.AzureDevOpsServer.Git` | a `SourceLinkAzureDevOpsServerGitHost` item | +| GitLab (12.0+) | `Microsoft.SourceLink.GitLab` | older GitLab: `SourceLinkGitLabHost` + `Version` | +| Bitbucket Cloud | `Microsoft.SourceLink.Bitbucket.Git` | Bitbucket Server / DC < 4.7: `SourceLinkBitbucketGitHost` + `Version` | +| anything else / self-hosted | *(none built in)* | a third-party or hand-rolled provider, or opt out | + +```xml + + + Microsoft.SourceLink.AzureRepos.Git + +``` + +### Azure DevOps + +- **Azure DevOps Services** — `Microsoft.SourceLink.AzureRepos.Git`, and nothing else. The provider + reads the `origin` remote and derives the content URLs itself. +- **Azure DevOps Server / TFS (on-premises)** — `Microsoft.SourceLink.AzureDevOpsServer.Git` plus the + host, because the virtual directory cannot be inferred from the repository URL: + + ```xml + + + + ``` + + `Include` is the domain, optionally with a port (`tfs.example.com:8080`). A path + (`tfs.example.com/tfs`) fails the provider's own validation ("not a valid domain name"), so the + virtual directory is a separate attribute. +- Azure Pipelines sets `TF_BUILD`, so `ContinuousIntegrationBuild` is on there with no configuration. + +## Central Package Management + +CPM is on, so the chosen provider needs a `PackageVersion` in `Directory.Packages.props`. The shipped +`templates/Directory.Packages.props` declares `Microsoft.SourceLink.GitHub`, +`Microsoft.SourceLink.AzureRepos.Git` and `Microsoft.SourceLink.GitLab` at `$(SourceLinkVersion)`. A +provider that is selected but not declared fails the restore with `NU1010`. + +## Continuous integration + +`ContinuousIntegrationBuild` is set to `true` only when `CI`, `GITHUB_ACTIONS` or `TF_BUILD` is present +(`TF_BUILD` is Azure Pipelines). A local build is deliberately **not** treated as CI, because +SourceLink's CI-mode dirty-repository checks would otherwise fail a local `TreatWarningsAsErrors` build. + +## Opting out, and self-hosted hosts + +`DisableSourceLink=true` (before the import) stops the SDK adding the package. For a host with no +official provider, bring a source-control package (`Microsoft.Build.Tasks.Git`) and a provider package +in yourself and set `DisableSourceLink=true`, so the GitHub default is not added alongside them. + +## Why it matters beyond debugging + +`PublishRepositoryUrl=true` writes `` into the `.nuspec`. Purview's +agent-skill tooling reads that to pin a package to the commit it was built from, and reports a package +with no git identity as `PRSKL0002`. diff --git a/.agents/skills/purview-buildsdk-project-detection/SKILL.md b/.agents/skills/purview-buildsdk-project-detection/SKILL.md new file mode 100644 index 0000000..9ebc421 --- /dev/null +++ b/.agents/skills/purview-buildsdk-project-detection/SKILL.md @@ -0,0 +1,178 @@ +--- +name: purview-buildsdk-project-detection +description: Explain why Purview.BuildSdk classified a project as test, shared, shared-testing, ServerShared, client-side, CLI, web, worker, Aspire host, container, Roslyn component, Blazor WebAssembly client, Razor class library, Blazor host or BFF, or why it picked the target framework(s) it did, 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`, `IsServerSharedProject`, `IsClientSideProject`, +`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, shared-testing and ServerShared projects + +Recognised by **exact name**, never by substring. The three lists are in +[references/detection.md](references/detection.md). Derived: `IsSharedProject`, +`IsSharedTestingProject`, `IsServerSharedProject`. + +`ServerShared` is separate from `Shared` for a reason: a `Shared` project is referenced by almost +everything, including code that ends up in a browser, so server-only helpers get their own name and +are referenced automatically only by a project that cannot be downloaded. `IsClientSideProject` is the +classification that decides it. + +### 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). + +### Target frameworks + +A project that declares neither `TargetFramework` nor `TargetFrameworks` gets the SDK's default set, +`net10.0;net11.0` (`Current`). A `PurviewTargetFrameworkSet` (`Current`, `Latest`, `Supported`, `Broad`, +`All`) resolves the same way; an unknown name fails with `PurviewInvalidTargetFrameworkSet`. A Roslyn +component keeps `netstandard2.0` and ignores the sets (`PurviewTargetFrameworkSetIgnored`). + +The declaration is read from the **project XML** before the body is evaluated, so a `Condition` on a +`TargetFramework`, `TargetFrameworks` or `PurviewTargetFrameworkSet` is not seen and is reported as +`PurviewConditionedProjectDeclaration`. See `purview-buildsdk-configuration` for how to set them. + +## 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**: a SourceLink provider package (`SourceLinkPackageName`, default + `Microsoft.SourceLink.GitHub`; `DisableSourceLink=true` opts out), 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. The rules +read the SDK's compiler-visible properties (`build_property.*`) rather than re-deriving project state — +`PDS0008`/`PDS0009`, for example, are gated on `build_property.IsBffProject`. The exported set is in +`purview-buildsdk-configuration`. + +| Rule | What it enforces | +| -- | -- | +| `PDS0002` | Extensions classes live under `Extensions//` | +| `PDS0003` | Explicit types with target-typed `new()` | +| `PDS0004` | Correct acronym capitalisation | +| `PDS0008` | A BFF's authentication cookie is not set `HttpOnly = false` | +| `PDS0009` | A BFF's authentication cookie is not set `SameSite = None` | + +`PDS0004` follows .NET naming guidance: well-known framework spellings (`Sql`, `Guid`, `Uuid`, +`Url`, `Dns`, `Tcp`, `Http`, `Xml`, `Db`, …) are exempt — `Db` among them, so `DbContext`, +`DbConnection` and `DbSet` are never flagged. A small set is still uppercased: `Api`, `Ai`, `Ui`, +`Io`, `Os`, `Cpu`, `Gpu`, `Cli`, `Gui`, `Ram`, `Ssh`. Members mandated by a contract — interface +implementations and base-class overrides — are never renamed. + +Customise through `dotnet_analyzer_configuration.pds0004.allowed_words` (exempt segments), +`.acronym_map` (segment renames, e.g. `Sql:SQL`, or `Db:DB` to re-enable it) and +`.allowed_identifiers` (brand names, exact or word-boundary prefix, first match wins — `CosmosDb` +covers `CosmosDbServer`). All three merge with and override the shipped defaults. + +The shipped `.editorconfig` marks generated content (`Migrations/`, `*.g.cs`, `Generated/`, +`*.Designer.cs`, `obj/`, `bin/`) as `generated_code = true`, and suppresses namespace-conflict +diagnostics under `Extensions/`, so no `#pragma` suppressions are needed there. + +Two Visual Studio refactorings ship with them, both described in +[references/detection.md](references/detection.md). + +## When changing a detection rule + +1. Keep the `.csproj` filename equal to its directory name. +2. Update `README.md` and the shipped skills in the same change — a rule true in code and false in + prose is this repository's recurring failure. +3. Add or update the integration test in `src/tests/BuildSdk.IntegrationTests/` that pins it. diff --git a/.agents/skills/purview-buildsdk-project-detection/references/blazor.md b/.agents/skills/purview-buildsdk-project-detection/references/blazor.md new file mode 100644 index 0000000..c65d153 --- /dev/null +++ b/.agents/skills/purview-buildsdk-project-detection/references/blazor.md @@ -0,0 +1,105 @@ +# Blazor WebAssembly, browser-safe libraries and BFF hosts + +Every behaviour below applies only to the project type it names, and each one names the failure it +prevents. Nothing here changes a project that is not a WebAssembly client, a Blazor host, a BFF or a +`PurviewBrowserSafe` library. + +## Blazor WebAssembly client (`IsBlazorWebAssemblyProject`) + +| Applied | Failure it prevents | Opt out | +| -- | -- | -- | +| `IsPackable=false` | packing an app's `wwwroot` as a package nobody can consume | `IsPackable=true` in the project | +| `IsContainerProject=false`, so no `PublishAot`, `InvariantGlobalization` or Docker defaults | `PublishAot` on a client is a publish that fails, and the Blazor SDK owns globalisation | `DisablePurviewBlazorWebAssemblyDefaults=true` before the SDK import | +| no automatic `../Shared*/Shared*.csproj` reference | a server-side library joining the browser download because of a folder name | `DisablePurviewBlazorWebAssemblyDefaults=true` before the SDK import | +| the browser-safe reference check (`PRBLZ0001`) | a library joining the download without anyone deciding it was safe there | `DisablePurviewBrowserSafeReferenceCheck=true` | + +Globalisation, trimming mode and the Blazor runtime settings are deliberately left alone: the Blazor +SDK owns them, and a second default would compete with it. + +### The automatic packages + +The SDK adds `Purview.Telemetry.SourceGenerator` and `Microsoft.Extensions.Telemetry.Abstractions` +to every non-test project, a client included. Both were checked rather than assumed: + +- the generator is analyzer-only (`PrivateAssets=all`), so nothing of it is downloaded; +- its generated `ActivitySource`, `ILogger` and `Meter` code, and `Telemetry.Abstractions` itself, + publish with no trim warnings and run on the WebAssembly runtime + (`BlazorWebAppTests.AutomaticTelemetryPackages_RunInsideTheWebAssemblyRuntime` boots it); +- the SDK adds no exporter. An OTLP exporter cannot work from a browser, which is exactly why the SDK + never adds one - that is the application's choice. + +Nothing is therefore excluded from a client by default. `ExcludePurviewTelemetry` and +`ExcludeMSTelemetryExtension` still remove them. + +## Blazor host (`IsBlazorHostProject`) + +A web SDK project named `` beside a WebAssembly client named `.Client` is the Blazor Web +App pairing. The host gets an automatic `ProjectReference` to the client - probed the same way a test +project's target is, and skipped when the project already references it - so the client's framework +files are served. Opt out with `PurviewAutoBlazorClientReference=false`. + +`IsBlazorHostProject` says nothing about `IsBffProject`. In a Blazor Web App the host is the BFF only +if it keeps the tokens. + +## BFF host (`IsBffProject`) + +- **No packages are added.** YARP, OpenID Connect and any BFF library are the application's choice, + and Duende.BFF carries a commercial licence. +- The `Microsoft.AspNetCore.OpenApi.Generated` interceptor namespace every other web project gets is + removed, because a BFF that only forwards has no API document to generate. A BFF referencing + `Microsoft.AspNetCore.OpenApi` keeps it; `PurviewBffExposesApi` decides explicitly. +- `PDS0008` reports `HttpOnly = false` on the `CookieAuthenticationOptions` cookie (script could read + the session). `PDS0009` reports `SameSite = None` on it (cross-site requests would carry it). Only a + constant assigned to that cookie is reported - never the antiforgery cookie, which script may + legitimately read, nor the OpenID Connect correlation and nonce cookies, which need + `SameSite=None`. + +Deliberately **not** checked, because a build cannot check them precisely: + +- **a persisted Data Protection key ring.** The call that persists it (`PersistKeysTo*`) is routinely + made in a shared ServiceDefaults project, which the BFF's compilation cannot see, so the rule would + fire on correctly configured BFFs - and a noisy rule gets suppressed wholesale. +- **anti-forgery on forwarded routes.** It stays a review obligation. + +## Browser-safe library (`PurviewBrowserSafe=true`) + +Applies to class libraries only, and only what the project has not set: + +- `IsAotCompatible=true` (on a TFM that supports it), which turns on `IsTrimmable` and the trim, + single-file and AOT analyzers; +- ``, so `CA1416` reports an API the browser does not support; +- `WarningsAsErrors` for every trim (`IL2001`-`IL2128`), single-file (`IL3000`-`IL3005`) and AOT + (`IL3050`-`IL3059`) diagnostic and `CA1416`, listed in `PurviewBrowserSafeWarningCodes`. + `TreatWarningsAsErrors` is not touched. + +`CA1416` is `suggestion` in the shipped `.editorconfig`. The compiler's `/warnaserror+:CA1416` +outranks an `.editorconfig` severity, so the error still fires without the repository's setting +changing. The consequence of `PurviewBrowserSafeWarningsAsErrors=false` is that the trim and AOT +diagnostics stay warnings while `CA1416` falls back to the repository's configured severity. + +What it cannot catch: file and network I/O. `File` works against the browser's in-memory file system +and `HttpClient` works through `fetch`, so neither is annotated as unsupported, and no analyzer reports +them. Reviewing for them stays a human obligation. + +### The reference check (`PRBLZ0001`) + +A WebAssembly client asks each `ProjectReference` - through an MSBuild call on the referenced project, +never by guessing from its name - for its `PurviewBrowserSafe` value, and warns for each one that does +not set it. A browser-safe library runs the same check over its own references, so the guarantee +covers the whole download, not only the first level. + +- `PurviewBrowserSafe="true"` metadata on the `ProjectReference` records the decision for a project + that does not use this SDK. +- References with `ReferenceOutputAssembly="false"` or an `OutputItemType` are not downloaded and are + not checked. Package references are not checked either. +- `PurviewBrowserSafeReferenceCheckAsError=true` makes it an error. + +### Purview packages a Contracts library builds on + +Checked against a `PurviewBrowserSafe` library, then run on the WebAssembly runtime: + +| Package | Result | +| -- | -- | +| `Purview.Results` 1.0.4 | clean | +| `Purview.ZodSharp` 2.4.2 | clean, including `[ZodSchema]` | +| `Purview.ValueObjects` 1.2.3 | the runtime assembly is AOT-compatible, but the JSON converter the generator emits for a scalar calls the reflection-based `JsonSerializer.Serialize`/`Deserialize(..., options)` overloads, which is `IL2026` and `IL3050` - errors in a browser-safe library. Set `GenerateJsonConverter = false` on the scalar until the generator writes the value directly. | diff --git a/.agents/skills/purview-buildsdk-project-detection/references/detection.md b/.agents/skills/purview-buildsdk-project-detection/references/detection.md new file mode 100644 index 0000000..51243c9 --- /dev/null +++ b/.agents/skills/purview-buildsdk-project-detection/references/detection.md @@ -0,0 +1,98 @@ +# Detection detail + +## Shared project names (exact) + +`Shared`, `SharedFramework`, `SharedInfrastructure`, `SharedInfra`, `SharedUtilities`, +`SharedUtils`, `SharedLibrary`, `SharedLib`, `SharedHelpers` + +## ServerShared project names (exact) + +`ServerShared`, `ServerSharedFramework`, `ServerSharedInfrastructure`, `ServerSharedInfra`, +`ServerSharedUtilities`, `ServerSharedUtils`, `ServerSharedLibrary`, `ServerSharedLib`, +`ServerSharedHelpers` + +A `ServerShared` project is referenced automatically by every non-client-side project, and never by a +WebAssembly client, a `PurviewBrowserSafe` library, a Razor class library, a project named with a +client-side segment (`*.Client`, `*.ClientShared`, `*.Browser`, `*.Wasm`, `*.WebAssembly`, +`*.Components`, or exactly `Components`/`ClientShared`), or a `Shared`/`SharedTesting` project. +`IsClientSideProject` overrides the classification. `PRSHR0001` reports a client-side reference and +`PRSHR0002` a `Shared` reference; `PurviewBrowserSafe="true"` on the reference records an accepted +decision. + +## 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`/`ServerShared` reference, the browser-safe reference check - see [blazor.md](blazor.md) | +| ServerShared project | referenced automatically by every non-client-side project; never by a WebAssembly client, browser-safe library, Razor class library, client-side name, or `Shared`/`SharedTesting` project | +| Blazor host | an automatic reference to its `.Client` - see [blazor.md](blazor.md) | +| BFF | no packages, no OpenAPI interceptor unless it exposes an API, `PDS0008`/`PDS0009` - see [blazor.md](blazor.md) | +| `PurviewBrowserSafe` library | `IsAotCompatible`, `SupportedPlatform browser`, trim/AOT/`CA1416` as errors - see [blazor.md](blazor.md) | + +## The Visual Studio refactorings + +Two ship with the code-fix assembly, both serving the `PDS0002` convention: + +- **Split extensions class into one class per receiver type** — offered on a static extensions class + targeting several receiver types. It produces one `Extensions` class per receiver + (a generic `this TBuilder where TBuilder : IHostApplicationBuilder` receiver becomes + `HostApplicationBuilderExtensions`) and places each under `Extensions//`. +- **Move extensions class to conventional location** — offered on a misplaced single-receiver + extensions class. It re-paths the file to `Extensions//`, fixes the namespace + to the receiver's, and updates `using` directives in other referencing documents so the move + compiles. diff --git a/.agents/skills/purview-buildsdk-project-detection/references/test-types.md b/.agents/skills/purview-buildsdk-project-detection/references/test-types.md new file mode 100644 index 0000000..dace44f --- /dev/null +++ b/.agents/skills/purview-buildsdk-project-detection/references/test-types.md @@ -0,0 +1,35 @@ +# The shipped `TestType` group + +These 53 names drive **`InternalsVisibleTo` generation**, not test detection. A non-test project +grants friend access to `{AssemblyName}.{TestType}Tests` for each name here. + +Detection is the pattern `^.*\.([\w|_]\w*)Tests?$`, so a suffix outside this group is still detected, +still runs and still gets its category — it simply cannot reach the target project's `internal` +members. Prefer a name from this group unless the test type genuinely needs its own word. + +```text +Unit Integration E2E EndToEnd Acceptance +Functional Performance Load Smoke Stress +Regression Security Chaos Scenario System +Threat BlackBox WhiteBox GrayBox GreyBox +Accessibility Usability Visual Interactive Localization +Globalization Environment Configuration Deployment Architecture +Contract Component Sanity Property Fuzz +Mutation Snapshot Approval Benchmark Capacity +Concurrency Endurance Soak Spike Failover +Recovery Resilience Compatibility Interoperability Migration +Upgrade Penetration Compliance +``` + +## The five to reach for first + +`Unit`, `Integration`, `E2E`, `Functional`, `Contract`. Everything else is available, but a reader +has to ask what it means — use one only when the test type itself carries operational weight. + +## What a suffix buys you + +| | detected as a test project | gets a category | gets a runner and coverage | sees `internal` members | +| -- | -- | -- | -- | -- | +| `Api.UnitTests` | yes | `Unit` | yes | yes | +| `Api.GoldenTests` | yes | `Golden` | yes | **no** | +| `UnitTests` *(no dot)* | **no** | no | **no** | no | diff --git a/.agents/skills/purview-buildsdk-project-layout/SKILL.md b/.agents/skills/purview-buildsdk-project-layout/SKILL.md new file mode 100644 index 0000000..5550808 --- /dev/null +++ b/.agents/skills/purview-buildsdk-project-layout/SKILL.md @@ -0,0 +1,170 @@ +--- +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, choosing the target framework(s) for a project, 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`. + +## Target frameworks + +A new project should inherit the SDK's default set (`net10.0;net11.0`) rather than pin its own TFMs, so +a lifecycle change is one SDK bump rather than an edit in every project. When a project genuinely needs +a different set, select one with `PurviewTargetFrameworkSet` (`Current`, `Latest`, `Supported`, `Broad`, +`All`) or declare `TargetFramework`/`TargetFrameworks` **unconditionally** — a `Condition` on a TFM is +not seen, because the SDK reads it from the project XML. A Roslyn component targets `netstandard2.0`. +See `purview-buildsdk-configuration`. + +## Automatic project references to preserve + +Layout is what makes these work. Move a project off the convention and you own the reference. + +Test projects probe for their target, in order: + +- `../$(TargetProjectName)/$(TargetProjectName).csproj` +- `../../$(TargetProjectName)/$(TargetProjectName).csproj` +- `../src/$(TargetProjectName)/$(TargetProjectName).csproj` +- `../../src/$(TargetProjectName)/$(TargetProjectName).csproj` + +Non-test projects pick up sibling shared projects via `../Shared*/Shared*.csproj`; test projects +pick up `../SharedTesting*/SharedTesting*.csproj`. A Blazor WebAssembly client is the exception: it +does not pick up `Shared*`, because everything it references is downloaded to the browser. + +## Blazor Web App: host and client + +```text +src/src/ + Shop/Shop.csproj Microsoft.NET.Sdk.Web - the host + Shop.Client/Shop.Client.csproj Microsoft.NET.Sdk.BlazorWebAssembly - the client + Shop.ClientShared/Shop.ClientShared.csproj a PurviewBrowserSafe library both use + Shop.Bff/Shop.Bff.csproj a BFF, when tokens are kept server-side in a separate host +src/tests/ + Shop.Client.UnitTests/ TargetProjectName = Shop.Client +``` + +- A web host named `` beside a WebAssembly client named `.Client` is the pairing the SDK + recognises: the host gets `IsBlazorHostProject=true` and a reference to the client automatically + (skipped when declared; `PurviewAutoBlazorClientReference=false` opts out). +- Identities with `NamespacePrefix=Acme`: `Shop.Client` is `Acme.Shop.Client`; `Shop.Bff` is + `Acme.Shop.Bff`; `ClientShared` is a stripped segment, so `Shop.ClientShared` has namespace + `Acme.Shop` and assembly `Acme.Shop.ClientShared`. +- Every library the client references must set `true`, or the + build reports `PRBLZ0001`. Keep server-only code (file, process, database access) out of anything + the client references. +- Name a BFF host `*Bff` (case-sensitive) for `IsBffProject=true`. Being the client's host does not + make a project a BFF. + +## Creating a new project + +1. Match the SDK and style of sibling projects. +2. Use Central Package Management if the repository has it. +3. Add only the dependencies the project's scope needs — the SDK supplies the testing stack, + SourceLink and telemetry by default. +4. Add the project to the solution. +5. Keep target frameworks, nullability, analyzers and warning settings consistent with neighbours. + +## Moving or splitting + +1. Create the destination using the established layout. +2. Move the files. +3. Update namespaces, usings and references for the destination. +4. Verify the destination still produces the intended `TestingType`, `TargetProjectName` and + `RootNamespace` — ask MSBuild rather than assuming: + `dotnet msbuild -getProperty:RootNamespace -getProperty:TargetProjectName`. +5. Remove dependencies the source no longer needs. +6. Update solution membership and project references. +7. Build, then run the affected tests. + +## Guardrails + +- Prefer minimal, targeted diffs; avoid cross-cutting renames unrelated to the move. +- Keep test intent unchanged while relocating. +- When the structure is ambiguous, infer from the nearest sibling projects and say which assumption + you made. +- When in doubt, preserve compatibility with the SDK's automatic naming, namespace and + project-reference behaviour. diff --git a/.agents/skills/purview-buildsdk-repository-review/SKILL.md b/.agents/skills/purview-buildsdk-repository-review/SKILL.md new file mode 100644 index 0000000..6fee41c --- /dev/null +++ b/.agents/skills/purview-buildsdk-repository-review/SKILL.md @@ -0,0 +1,134 @@ +--- +name: purview-buildsdk-repository-review +description: Review a repository that uses Purview.BuildSdk for naming, placement, test-structure and agent-skill-management alignment, and report what the SDK can and cannot do automatically as a result. Use when asked to audit, rationalise or health-check a repo against the SDK's conventions, when onboarding an existing codebase onto Purview.BuildSdk, or when deciding whether a layout quirk is worth changing. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Reviewing a repository against the SDK's conventions + +This is an audit, not a refactor. The deliverable is a prioritised list of findings and the cost of +each, so somebody else can decide what to change. + +For the conventions themselves use `purview-buildsdk-project-layout`; for why one project was +classified as it was, `purview-buildsdk-project-detection`. + +## The governing judgement + +**Deviation is only a finding when it costs the repository something.** The SDK infers behaviour +from naming and placement, so a deviation is interesting precisely to the extent that it turns an +automatic behaviour off. A layout that differs from the canonical one but still gets every automatic +behaviour is not debt, and reporting it as such wastes the reader's attention. + +Sort every finding into one of three buckets, and say which: + +| Bucket | Meaning | +| -- | -- | +| **Acceptable variance** | Differs from the canonical layout, costs nothing. Preserve it. | +| **Structural debt** | Blocks an automatic behaviour the repository would benefit from. | +| **Incremental opportunity** | Low-risk tidying worth doing when the area is touched anyway. | + +## 1. Read before concluding + +Inspect, in this order, before forming any opinion: + +- `Directory.Build.props` and `Directory.Build.targets` — where the SDK is imported, and what is set + before the import +- `global.json` (the pinned SDK version) and repo `package.json` (the product version) +- 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. +- Target frameworks: a project should inherit the SDK's default set (`net10.0;net11.0`) or select a + curated `PurviewTargetFrameworkSet` (`Current`, `Latest`, `Supported`, `Broad`, `All`), not hard-code + a TFM list. A hard-coded list is an incremental opportunity; a `Condition`-guarded + `TargetFramework`/`TargetFrameworks` is structural debt, because the SDK reads the declaration from + the project XML and cannot evaluate the condition (`PurviewConditionedProjectDeclaration`). + +## 3. Verify with MSBuild, do not infer from folder names + +A review that reasons from directory names will be wrong about at least one project. Ask: + +```bash +dotnet msbuild -getProperty:RootNamespace -getProperty:AssemblyName \ + -getProperty:IsTestProject -getProperty:TestingType -getProperty:TargetProjectName +``` + +Quote the computed values in the findings. "`Api.Tests` resolves `TestingType=` empty, so it has no +runner" is actionable; "`Api.Tests` looks like it might not be detected" is not. + +## 4. Review test readability + +- Subject-based suites: `{SubjectName}Tests`, with + `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}` methods. +- Non-subject-based suites — build, generator, workflow, package, full-system — named clearly for + their broader role. A forced `{Subject}Tests` on one of these is worse, not better. +- TUnit categories and display names used where a suite is large enough to need navigating. + +See `purview-buildsdk-testing`. + +## 5. Check the test dependencies are earned + +The SDK already supplies `TUnit`, `TUnit.Mocks`, `Bogus` and the Microsoft.Testing.Platform wiring +to every detected test project. Anything beyond that was a deliberate choice, and deliberate choices +go stale: + +- `TUnit.Aspire` and `Purview.Containers` are **not** universal defaults. Confirm each reference is + genuinely needed by that project rather than copied from a sibling. +- Where a project references `Testcontainers` directly, check whether a `Purview.Containers` typed + module now covers it — the module set grows, and a direct reference that was once necessary often + is not any more. +- A package the SDK already adds, restated in a project file, is maintenance cost with no effect. + +## 6. Check how agent skills are managed + +Only relevant when the repository has a `skills-lock.json`, an agent skills directory, or a package +reference that ships skills: + +- **Anything that runs `InstallPurviewSkills`, `UpdatePurviewSkills` or `FixPurviewSkills` + implicitly** — an `AfterTargets`/`BeforeTargets` hook, a `*DependsOn` property, a git hook, a + `just` recipe dependency — is structural debt: the SDK fails it with `PRSKL0009`, and skill changes + should only happen when somebody asks. +- **Hand-written skill scripts** (`update-agent-skills.ts`, `ensure-universal-skills.ts`, + `package.json` `skills:*` entries) are an incremental opportunity: the SDK targets replace them, and + pin package skills to their package's commit where the scripts install latest. See + `purview-buildsdk-skills-justfile`. +- **Drift**: run `dotnet msbuild -t:RecommendPurviewSkills` and quote any `PRSKL0010`/`PRSKL0011`. +- **Layout**: if `PurviewSkillsAgents` is set, run `-t:CheckPurviewSkills` and quote any `PRSKL0015`. + If it is not set, every agent directory is legitimate — that is acceptable variance, not a finding. + +## 7. Explain consequences, not just mismatches + +For each finding, name the specific automatic behaviour that is lost: `RootNamespace`, +`AssemblyName`, `PackageId`, `TargetProjectName`, the automatic target-project reference, the +sibling `Shared*` reference, `InternalsVisibleTo` generation, the test runner and coverage wiring. + +"Project is not under `src/`" is not a finding. "Project is not under `src/`, so the test project's +four probe paths all miss and the `ProjectReference` has to be maintained by hand" is. + +## Reporting the result + +1. One sentence on whether the repository broadly fits the SDK's conventions. +2. The findings, ordered by impact, each with its bucket and the behaviour it costs. +3. The low-risk rationalisation steps, as concrete renames or moves. +4. An explicit split of what is **already automatic** versus what this repository has configured by + hand — that list is usually the most useful part of the review, because hand-configuration that + merely restates a default is pure maintenance cost. + +Do not propose a cross-cutting rename as a first step. Name the one change with the best +cost-to-benefit ratio and say what it unlocks. diff --git a/.agents/skills/purview-buildsdk-settings-docs-sync/SKILL.md b/.agents/skills/purview-buildsdk-settings-docs-sync/SKILL.md new file mode 100644 index 0000000..0933fb6 --- /dev/null +++ b/.agents/skills/purview-buildsdk-settings-docs-sync/SKILL.md @@ -0,0 +1,188 @@ +--- +name: purview-buildsdk-settings-docs-sync +description: Change or document a Purview.BuildSdk setting without leaving part of the surface behind. Use when flipping a .NET 11 compiler feature default (Runtime Async, memory-safety rules), adding or renaming a property, or making sure every skill and doc explains how to set required settings and versions, the target framework(s), and the compiler-visible (build_property.*) settings. Repository-local guidance for purview-dev/build-sdk. +license: MIT +metadata: + scope: repository-local + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Keeping settings, docs and skills in step + +> **Repository-local.** This skill lives in `.agents/skills/`, which is gitignored, not packed and not +> published. It is deliberately **not** linked from `AGENTS.md`: `RepositorySkillsSelfTests` fails any +> AGENTS.md link that points into `.agents/skills/`, because a fresh clone does not have this folder. +> The published skills are authored under `skills/` and are a different thing entirely. + +## The rule this exists for + +A property is written down in several places, and only some of them are checked by a test. The failure +mode this repository keeps hitting is a change that lands in one place and stops there — the property +declaration, its validation, `README.md`, the wiki page and the shipped skills drift apart, and the +only evidence is a consumer following a doc that still describes last release's default. + +So: **treat a settings change as a sweep, not an edit.** Work the surfaces below, then prove it with +the validation commands at the end. + +## When to use this + +- Flipping a .NET 11 compiler feature default (`MemorySafetyRules`, `PurviewRuntimeAsync`). +- Adding, renaming, re-defaulting or removing a user-facing property. +- Making required settings, versions, target frameworks or compiler-visible settings clearer in the + docs and the shipped skills. +- Auditing whether a property is documented everywhere before you change it. + +For auditing a *consumer* repository, use `purview-buildsdk-repository-review`. For what a project +detection rule does, use `purview-buildsdk-project-detection`. + +## The surfaces — one property, many homes + +Find the row and make sure every file agrees. + +| Surface | File | What changes | +| -- | -- | -- | +| Runtime default (all projects) | `src/src/BuildSdk/Sdk/Props/Defaults.props` | The default when unset, plus the explanatory comment. Sets the value in `Sdk.props`, which is also what makes it compiler-visible. | +| Per-TFM default | `src/src/BuildSdk/Sdk/Targets/Modern.targets` | The `net11.0`+ feature selection. Must agree with `Defaults.props`. | +| Compiler-visible description | `src/src/BuildSdk/Sdk/Sdk.props` | The `CompilerVisibleProperty` ``, which is what an analyzer author reads. | +| Pinning test | `src/tests/BuildSdk.IntegrationTests/Dotnet11FeaturesTests.cs` | The test that fails when the default changes silently. | +| Changelog | `CHANGELOG.md` | A `[Unreleased]` entry: `### Changed` for behaviour, `### Documentation` for docs-only. | +| README | `README.md` | The feature table row, the property table(s), the quick start, and the `.NET 11 features` section + tiers paragraph. | +| Wiki | `docs/wiki/Configuration-Reference.md`, `Getting-Started.md`, `Version-Detection.md`, `Project-Type-Detection.md` | The property tables, the required-settings and target-framework sections, and the compiler-visible section. | +| Shipped skills | `skills/purview-buildsdk-*/SKILL.md` and `references/*.md` | The property tables and the sections below. | +| AGENTS.md | `AGENTS.md` | The "authored set, and what each one answers" table, when a skill's scope grows. | + +Two traps worth naming: + +- **The default lives in two places.** `Defaults.props` defaults the property for every project (which + is what exposes it to the compiler); `Modern.targets` re-defaults it per-TFM. Change one and the + other silently disagrees. +- **`Sdk.props` vs `Sdk.targets` decides *where* a value may be set.** A property read during + `Sdk.props` evaluation must be set before the import; everything else can be set in the project. Say + which it is, in the docs, next to the property. + +A **feature** bigger than a single property gets its own guide as well. SourceLink is the worked +example, because the decision is per repository host: add a wiki page `docs/wiki/.md` and +register it in `_Sidebar.md` **and** `Home.md`, add a `## ` section to `README.md`, and — when +it is configuration — a `references/.md` under +`skills/purview-buildsdk-configuration/` that the `SKILL.md` links. Fix any wrong package or property +name you find while documenting: the SourceLink pages used to name `Microsoft.SourceLink.AzureDevOps.Git`, +which does not exist. + +## Worked example — flip a .NET 11 compiler feature default + +1. `Defaults.props` — change the default and the comment. Say **why** (an experimental compiler feature + is opt-in), not just what. +2. `Modern.targets` — change the same default in the per-TFM block and its header comment. +3. `Sdk.props` — the `CompilerVisibleProperty` `` ("0 off (default), …"). +4. `Dotnet11FeaturesTests.cs` — make the default test assert the switch is **absent**, rename it to say + so, and add an explicit opt-in test. De-couple any other test that asserted the switch was on by + default (for example, the project-features-survive test). +5. `README.md` — the `.NET 11 opt-ins` row, the `.NET 11 features` table row and the tiers paragraph, + and the property table row. +6. `docs/wiki/Configuration-Reference.md` — the General row **and** the `.NET 11 features` table. +7. Skills — the `MemorySafetyRules`/`PurviewRuntimeAsync` rows in + `skills/purview-buildsdk-configuration/SKILL.md` and `references/properties.md`. +8. `CHANGELOG.md` — a `[Unreleased]` `### Changed` entry that names the old default, the new one, how to + opt in, and what is unchanged. + +Keep `AllowUnsafeBlocks` (and anything else) at its compiler default: a feature flip must not quietly +change a second setting. + +## Required settings and versions — where it must be said + +Four values, stated as a table wherever a reader lands first: + +| Setting | Home | +| -- | -- | +| `NamespacePrefix` (required, pre-import) | `Directory.Build.props`, above the `` | +| SDK version | `global.json` → `msbuild-sdks.Purview.BuildSdk` (`PurviewBuildSdkVersionForGlobalJson` decides what the bootstrap writes) | +| Product version | repo-root `package.json` → `version` (`UsePackageJsonVersion`, `RootPackageJson`) | +| Package versions | `Directory.Packages.props` (CPM; a `PackageVersion` is a minimum) | + +Keep it identical in all four homes: `README.md` quick start, `docs/wiki/Configuration-Reference.md`, +`docs/wiki/Getting-Started.md`, and the `purview-buildsdk-adoption` and +`purview-buildsdk-configuration` skills. + +## Target framework(s) — where it must be said + +Every skill and doc that talks about configuring a project must explain how to set the target +framework(s), consistently: + +- the default set is `net10.0;net11.0` (`Current`); +- an explicit `TargetFramework`/`TargetFrameworks` must be declared **unconditionally** (the SDK reads + it from the project XML, so a `Condition` is not seen — `PurviewConditionedProjectDeclaration`); +- a curated `PurviewTargetFrameworkSet` (`Current`, `Latest`, `Supported`, `Broad`, `All`) or a + redefined `PurviewTargetFrameworks` (pre-import, in `Directory.Build.props`); +- a Roslyn component keeps `netstandard2.0` and ignores the sets (`PurviewTargetFrameworkSetIgnored`). + +The canonical home is `skills/purview-buildsdk-configuration/SKILL.md` and its +`references/properties.md`; the other skills point at it rather than restating the whole thing. + +## Compiler-visible settings — where it must be said + +The `CompilerVisibleProperty` → `build_property.*` mechanism, plus a one-line +`AnalyzerConfigOptionsProvider.GlobalOptions.TryGetValue("build_property.X", …)` example, belongs in: + +- `skills/purview-buildsdk-configuration/SKILL.md` and `references/properties.md`, +- `skills/purview-buildsdk-project-detection/SKILL.md` (the analyzers section), +- `README.md`, `docs/wiki/Configuration-Reference.md` and `docs/wiki/Project-Type-Detection.md`. + +Say both halves: **consume** the exported property instead of re-deriving SDK behaviour, and **add a +new one** by listing it as a `CompilerVisibleProperty` item (with a ``) in `Sdk.props` — +nothing else exposes an MSBuild property to the compiler. + +## Skills sweep + +Each published skill that touches project configuration gets the change; a skill that is only about +skills tooling does not. + +| Skill | What to update | +| -- | -- | +| `purview-buildsdk-configuration` | required settings, target framework(s), compiler-visible properties, the property table, `references/properties.md` | +| `purview-buildsdk-adoption` | the required-settings table and `global.json` example, the target-framework step | +| `purview-buildsdk-project-layout` | the target-framework section | +| `purview-buildsdk-project-detection` | the target-framework subsection, the `build_property.*` note | +| `purview-buildsdk-repository-review` | the read list (`global.json`, `package.json`), the target-framework finding | +| `purview-buildsdk-testing` | the test-project target-framework paragraph | +| `purview-buildsdk-skill-tests`, `-skill-installation`, `-skills-justfile` | **not** project configuration — leave them alone | + +Also update each skill's frontmatter `description` so the change makes the skill **fire** on the right +question ("setting the target framework(s)", "pinning the SDK version in global.json"). + +## Validate + +```bash +just build +just skills-validate # PRSKL0005, the pack-time check +just version-sync-check # the version pins must still match package.json +just lint-check # CSharpier over the repo + +# Targeted and fast (MTP: --treenode-filter, never --filter): +dotnet test --project src/BuildSdk.slnx --configuration Debug \ + --treenode-filter "/*/*/Dotnet11FeaturesTests/*" --ignore-exit-code 8 +dotnet test --project src/BuildSdk.slnx --configuration Debug \ + --treenode-filter "/*/*/(TargetFrameworkSetTests)|(CompilerVisiblePropertyTests)|(CoreDefaultsTests)/*" --ignore-exit-code 8 +dotnet test --project src/BuildSdk.slnx --configuration Debug \ + --treenode-filter "/*/*/(RepositorySkillTests)|(RepositorySkillsSelfTests)|(PurviewSkillValidationTests)/*" --ignore-exit-code 8 +``` + +State the commands you ran before declaring the change done. + +## Guardrails + +- **Do not publish repository-local advice.** Anything under `skills/` ships to every consumer through + `npx skills add purview-dev/build-sdk`; guidance about working *in this repository* stays here, in + `.agents/skills/`, or in `AGENTS.md`. +- **Do not add a link into `.agents/skills/` from `AGENTS.md`** — it is gitignored, so the link is + broken in a fresh clone and `RepositorySkillsSelfTests` fails the build. +- **Do not give a local skill an authored skill's name.** + `AuthoredSkillsAreNotInstalledIntoTheAgentDirectories` fails when a folder here collides with one + under `skills/`. +- **Preserve a default unless the task explicitly changes product behaviour.** A docs task is not + licence to re-default anything. +- **Do not restate a default in a consumer project file** — recommend deleting the repetition instead. +- **Never force-add `.agents/skills/`.** Everything the CLI writes is reproducible from + `skills-lock.json`; `InstalledSkillsAreNotTracked` fails if any of it is committed. +- Keep changes small, use Conventional Commits, and do not mix an unrelated refactor into the sweep. + diff --git a/.agents/skills/purview-buildsdk-skill-installation/SKILL.md b/.agents/skills/purview-buildsdk-skill-installation/SKILL.md new file mode 100644 index 0000000..5c2694d --- /dev/null +++ b/.agents/skills/purview-buildsdk-skill-installation/SKILL.md @@ -0,0 +1,248 @@ +--- +name: purview-buildsdk-skill-installation +description: Install, update, list, check and diagnose the agent skills a Purview.BuildSdk package ships. Use for the InstallPurviewSkills, UpdatePurviewSkills, CheckPurviewSkills, FixPurviewSkills, ListPurviewSkills, RecommendPurviewSkills and VerifyPurviewSkills MSBuild targets, for updating skills after a package upgrade, for choosing agents with PurviewSkillsAgents, for any PRSKL diagnostic, for choosing a package runner (bun x, npx, pnpm dlx, bunx, yarn dlx, deno), for skills-lock.json, or when a package ships skills but no agent can see them. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Installing the skills a package ships + +For *authoring* skills, use `purview-buildsdk-skill-tests`; for `just` recipes that run these +targets, `purview-buildsdk-skills-justfile`. + +## The one thing to understand first + +**The SDK writes nothing into an agent directory.** A package *ships* `.agents/skills//`, but +as metadata — it is what tells the SDK which skills the package offers and at which commit. Nothing +copies it out. + +Skills are installed by the skills CLI and by nothing else. That is a deliberate single-writer rule. +Earlier versions of this SDK mirrored a package's whole `.agents/**` folder into the consuming +repository, and the CLI's own universal install location is `.agents/skills/` — the same paths. The +mirror would silently overwrite a pinned install on the next build, while `PRSKL0011` compared a +lockfile ref against content the mirror had already replaced. The mirror was removed outright rather +than fenced off, because the CLI covers the whole job. + +Installing is therefore a separate, deliberately **explicit** step. It is never a side effect of a +restore, a build, a design-time build or a test run. + +```bash +dotnet msbuild -t:ListPurviewSkills # diagnostics only, installs nothing +dotnet msbuild -t:InstallPurviewSkills # delegates to the skills CLI +dotnet msbuild -t:UpdatePurviewSkills # re-add every skills-lock.json source: refresh, and add new skills +dotnet msbuild -t:CheckPurviewSkills # only the PurviewSkillsAgents directories exist +dotnet msbuild -t:FixPurviewSkills # remove the CLI's links from any other agent directory +``` + +They run **only when named**. None has `BeforeTargets` or `AfterTargets`, nothing in the SDK depends +on them, and `InstallPurviewSkills`, `UpdatePurviewSkills` and `FixPurviewSkills` refuse to run when +something else pulls them into a build, a restore or a design-time build (`PRSKL0009`). Do not hook +them into a build, a `*DependsOn` property or a git hook. If skills are changing on their own, +something has been wired up that should not be. + +Name **the solution**. Skills belong to the repository, so each target runs once however many +projects the solution holds: every project hands the work to the same elected leader, and MSBuild +runs it once. Package resolution reads every project in the solution, so a package only one project +references is still found. A single project works too, but sees only its own packages. For `just` +recipes that run these targets, see `purview-buildsdk-skills-justfile`. + +## How a skill is resolved, and why the version is always right + +There is deliberately **no custom manifest**. A package that ships skills already carries both +halves of the mapping, and `ResolvePurviewSkillSources` reads them: + +- `.agents/skills//` in the restored package says *which* skills it ships. +- The nuspec's ``, written by SourceLink for every package this + SDK builds, says which git repository and which **exact commit** produced them. + +So the resolved ref is the commit the *restored package version* was built from. An older +`PackageReference` gets that version's skills, not whatever is on the default branch. Compatibility +is exact by construction rather than by convention. + +## Updating + +```bash +# after bumping the PackageReference +dotnet restore +dotnet msbuild -t:UpdatePurviewSkills +``` + +`UpdatePurviewSkills` re-adds every unique source recorded in `skills-lock.json` with `skills add`: + +- a source a restored package ships is re-added at **that package's commit**, naming every skill + that package version ships — the same pinned `add` `PRSKL0011` recommends, so the skills match the + package version referenced; +- every other source is re-added at its latest with `--skill "*"`. + +That is the whole update. `skills add` reinstalls every skill it selects, so one re-add both +refreshes the installed skills and adds any the source has gained; `skills update` would only +re-fetch what the lockfile already lists, and is never run. `-p:PurviewSkillsDryRun=true` prints every +command and runs none. Each failed source is named (`PRSKL0008`). + +`InstallPurviewSkills` also works as an update for package skills alone: it re-adds each at its +package's commit. Do not reach for a bare `skills update` instead; see below. + +## Choosing agents + +`PurviewSkillsAgents` is **empty by default**, which passes no `--agent` and lets the CLI auto-detect +the agents on the machine. Set it to choose — in a project file `universal`, +or on a command line with the separator escaped or the value quoted, because `dotnet msbuild -p:` +itself splits on `,` and `;`: + +```bash +dotnet msbuild -t:UpdatePurviewSkills -p:PurviewSkillsAgents=claude-code%2Ccodex +``` + +Each agent becomes its own `--agent` flag; the CLI does not split a comma-separated value. + +When `PurviewSkillsAgents` is set, `CheckPurviewSkills` fails (`PRSKL0015`) for any agent skill +directory at the repository root that belongs to an agent it does not name. `.agents/skills` is always +allowed — it is the CLI's canonical store — and the authored `skills/` folder is never touched. +`FixPurviewSkills` removes links (symlinks, junctions) from those directories, never their targets, +and then any folder left empty; a real file is kept and reported, because it is not provably the +CLI's. The agent-to-directory map is the `PurviewSkillsAgentDirectory` item group, taken from the +CLI's own agent table; an agent it does not know is `PRSKL0016`, fixed by adding an item: + +```xml + +``` + +With `PurviewSkillsAgents` empty, both targets say so and do nothing: every agent directory is then a +legitimate destination. + +## Installing from an explicit source + +For a skill you want but have no `PackageReference` for: + +```bash +dotnet msbuild -t:InstallPurviewSkills -p:PurviewSkillsSource=purview-dev/build-sdk +``` + +The caller owns the ref here — append `#` to pin it. This path needs no restore, so +`PRSKL0001` does not apply. + +## Diagnostics + +| Code | Meaning | Fix | +| -- | -- | -- | +| `PRSKL0001` | `project.assets.json` is missing | Restore. Resolution reads the restore graph and deliberately does not trigger a restore of its own | +| `PRSKL0002` | The package's nuspec has no GitHub repository URL, or no commit | The package cannot be pinned. It is reported rather than installed from a guessed branch | +| `PRSKL0003` | No repository root resolved | Pass `-p:PurviewSkillsInstallRoot=` | +| `PRSKL0004` | No skills CLI and no package runner found | Install Bun or Node, or set `PurviewSkillsCommand` | +| `PRSKL0005` | An authored skill is malformed | See `purview-buildsdk-skill-tests` | +| `PRSKL0006` | Retired, with `PurviewSkillsUpdateMode` | Remove the property; update no longer runs `skills update` | +| `PRSKL0007` | `UpdatePurviewSkills` found no `skills-lock.json` | Install first with `-t:InstallPurviewSkills` | +| `PRSKL0008` | Re-adding a lockfile source failed | Read the CLI output above it | +| `PRSKL0009` | Install, update or fix was pulled into a build, a restore or a design-time build | Remove the hook; run the target on its own | +| `PRSKL0010` | A skill a referenced package ships is missing | Warning. `bun x skills add ...` | +| `PRSKL0011` | An installed skill is pinned to a different ref than the referenced package | Warning. Install the package's own commit | +| `PRSKL0012` | A skill cannot be verified | Message | +| `PRSKL0013` | An orphaned lockfile entry | Message | +| `PRSKL0014` | Recorded in the lockfile but never restored | Message | +| `PRSKL0015` | An agent skill directory exists for an agent `PurviewSkillsAgents` does not name | `-t:FixPurviewSkills`, then remove anything it kept by hand | +| `PRSKL0016` | `PurviewSkillsAgents` names an agent with no known skills directory | Warning. Add a `PurviewSkillsAgentDirectory` item | +| `PRSKL0017` | Projects in the solution reference one skill-shipping package at different versions | Warning. Align the versions; only one version's skills can be installed | + +## Nothing about skills runs during a build + +This used to be the opposite: a `RecommendPurviewSkillsOnBuild` hook ran on `AfterTargets="Build"`. +It was removed after measurement — on a 39-project solution it read and regex-scanned every project's +`project.assets.json` (≈500 KB, 256 libraries each), made thousands of failing `Directory.Exists` +calls, read the lockfile and walked the repository root, on every build, to print advice that a +repository managing its own skills had already acted on. All of its inputs are repository-scoped, so +the per-project cost bought nothing. + +The work is now paid only when somebody names a target: + +```bash +dotnet msbuild -t:RecommendPurviewSkills # report, never fail +dotnet msbuild -t:VerifyPurviewSkills # report, and fail on PRSKL0010/0011 +``` + +`EvaluatePurviewSkillState` joins the skills each restored package ships against the CLI's own +`skills-lock.json`. Both inputs are local files, so it stays offline and spawns no process. + +`VerifyPurviewSkills` is the gate to call from CI. It escalates only `PRSKL0010` (missing) and +`PRSKL0011` (drifted) to errors: both are factual, both are fixed by one command, and neither can fire +without a referenced package that really ships skills. `PRSKL0012` stays a warning because a package +published without repository metadata is not the consumer's to fix, and `PRSKL0013`/`PRSKL0014` stay +messages. Failing over something the caller cannot clear is how a gate gets switched off. + +Three things shape the output: + +- Missing and drifted are **warnings** (fixable in one command); unverifiable and orphaned are + **messages**. `VerifyPurviewSkills` turns missing and drifted into failures, and promotes + unverifiable to a warning. +- A correctly installed set produces no output at all. +- There is no deduplication stamp any more. `EnablePurviewSkillRecommendations`, + `PurviewSkillRecommendationsInCI` and `PurviewSkillRecommendationStampPath` were retired with the + hook: a stamp existed so a per-build hook could report one state once per repository, and a target + a person invokes should answer every time it is asked. + +Orphan detection is **owner-scoped on purpose**: a lockfile cannot record who asked for an entry, so +only an entry under an owner the project references — but from a repository it no longer references +— is reported. A skill from any other owner is the developer's own, and is never mentioned. + +### Drift never recommends a bare `skills update` + +Re-adding a source without a ref installs the *newest* published skill, which is only correct when +the newest skill happens to describe the referenced package version. `PRSKL0011` names the referenced +package's own commit in a pinned `skills add` instead — which is what `-t:InstallPurviewSkills` and +`-t:UpdatePurviewSkills` run for package skills. + +## Package runners + +The CLI is published to npm, and every runner spells "run this npm package" differently. +`PurviewSkillsRunner` selects one, defaulting to `bun` (`bun x skills`). + +| Runner | Invocation | +| -- | -- | +| `bun` *(default)* | `bun x skills@1` | +| `npx` | `npx -y skills@1` | +| `bunx` | `bunx skills@1` | +| `pnpm` | `pnpm dlx skills@1` | +| `yarn` | `yarn dlx skills@1` — Yarn 2+ only | +| `deno` | `deno run -A npm:skills@1` | + +An unknown runner falls through to ` $(PurviewSkillsPackage)`, so it still works. + +`DetectPurviewSkillsCli` probes `PATH` for `skills` first, then `$(PurviewSkillsRunner)`, then +`$(PurviewSkillsRunnerCandidates)`, honouring `PATHEXT` on Windows where these are `.cmd` shims. +`PATH` is read directly rather than by spawning a version check: deterministic, no process, and it +cannot hang on a cold download. **The runner actually found wins over the configured preference.** +`PurviewSkillsCommand` overrides the launcher entirely and skips detection. + +`ListPurviewSkills` reports runner availability but **never** errors on its absence — resolving and +reporting needs no Node tooling at all. + +## Always the repository root + +`skills-lock.json` and the agent directories belong to the repository, not to a project, so every +skill target runs from one resolved root: + +1. `PurviewSkillsInstallRoot` — an explicit override +2. `PurviewSkillsRepoRoot` — only when the **user** set it +3. the `PurviewRepositoryRootMarkers` walk +4. `PurviewSkillsRepoRoot` / `RepoRoot` as the SDK guessed them +5. CI workspace variables +6. SourceLink's git `SourceRoot` + +There is deliberately **no project-directory fallback**. That would scatter a lockfile and an agent +directory through the tree, one per project. An unresolved root is `PRSKL0003` on install, and +silence for recommendations — a build never fails over advice. + +The marker walk is described in [references/repository-root.md](references/repository-root.md); its +two-pass behaviour and its home-directory bound are both load-bearing. + +## Why the CLI rather than a reimplementation + +The CLI owns the agent-specific destinations and their discovery conventions, which differ per agent +and change independently of this SDK. It also owns `skills-lock.json`, so a repository can restore +the same set later with `bun x skills experimental_install`. + +The lockfile is read **directly** rather than by parsing `skills list --json`, because that +listing omits the `ref` — and the ref is the whole compatibility question. Reading a file also needs +no Node tooling, which is what keeps recommendations working on a machine that can build .NET but +has no Node. There is deliberately no competing lockfile. diff --git a/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md b/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md new file mode 100644 index 0000000..d556174 --- /dev/null +++ b/.agents/skills/purview-buildsdk-skill-installation/references/repository-root.md @@ -0,0 +1,57 @@ +# The repository-root marker walk + +`FindPurviewRepositoryRoot` is an inline MSBuild task driven by `PurviewRepositoryRootMarkers`: + +```text +.git/;.github/;.gitattributes;LICENSE.md;AGENTS.md;.agents/;CLAUDE.md;.claude/;package.json +``` + +A trailing slash marks a directory. A directory marker still matches a **file** of that name, +because `.git` is a directory in a clone but a file in a worktree or a submodule, and both identify +a repository root. + +## Three things that are load-bearing + +### It cannot be an MSBuild intrinsic + +`GetDirectoryNameOfFileAbove` matches **files only** and returns nothing for `.git/`, `.github/`, +`.agents/` or `.claude/` — four of the nine markers. Verified, not assumed. + +### Two passes + +- **Pass 1** — the first marker in the list is *definitive* and is searched all the way up. `.git/` + *is* the repository root, so a monorepo sub-package with its own `package.json` does not win. +- **Pass 2** — every other marker resolves nearest-ancestor-first, with list order breaking ties + inside a single directory. Searching them all to the top would let a stray `.claude/` in a + developer's home directory outrank the real root. + +### The home directory is never a candidate + +The home directory and everything above it are excluded. Without that bound, a project outside any +repository resolves to `~` and installs into it — and a `.claude/` or a `.github/` very commonly +sits there. A repository *below* the home directory is unaffected, because it is below it. + +## Why the marker walk outranks the evaluation-time guess + +`PurviewSkillsRepoRoot` is computed during evaluation from `RepoRoot` and an `AGENTS.md` walk-up. +An `AGENTS.md` can legitimately sit in a subdirectory, and resolving to it would put a lockfile and +an agent directory somewhere that is not the repository. + +`_PurviewSkillsRepoRootWasSpecified` is captured in `Sdk.props` *before* defaulting, so a value the +**user** set still outranks the walk while a value the SDK guessed does not. + +## A target's Condition is evaluated before its DependsOnTargets + +A property a dependency computes is still empty in the depending target's `Condition`. That is why +the unresolved-root guard sits on the `EvaluatePurviewSkillState` task rather than on +`RecommendPurviewSkills`, and why the explicit-only guard (`PRSKL0009`) is a dependency target with +its own `Error` rather than a `Condition` on `InstallPurviewSkills`, `UpdatePurviewSkills` or +`FixPurviewSkills`. + +## The managed port + +`Purview.Skills.Testing` ships `SkillRepositoryRoot`, a managed port of the same walk with the same +default markers, so a test that validates a repository's skills resolves the same root the build +installs them into. The two are kept in step by `SkillRepositoryRootTests`, including an assertion +on the default marker list itself — both are defaults nobody sets, so nothing else would notice them +drifting apart. diff --git a/.agents/skills/purview-buildsdk-skill-tests/SKILL.md b/.agents/skills/purview-buildsdk-skill-tests/SKILL.md new file mode 100644 index 0000000..4043ac4 --- /dev/null +++ b/.agents/skills/purview-buildsdk-skill-tests/SKILL.md @@ -0,0 +1,131 @@ +--- +name: purview-buildsdk-skill-tests +description: Author agent skills in a repository that uses Purview.BuildSdk, and validate them with Purview.Skills.Testing or the SDK's pack-time ValidatePurviewSkills target. Use when adding or renaming a skill, writing SKILL.md frontmatter, splitting content into references/ or scripts/, fixing a PRSKL0005 pack failure or a skill-name, skill-description or skill-bundled-resources test failure, or deciding where skills belong in a repository. +license: MIT +metadata: + package: Purview.Skills.Testing + repository: purview-dev/build-sdk +--- + +# Authoring and validating agent skills + +For installing skills a package ships, use `purview-buildsdk-skill-installation`. + +## Where skills live + +**At the repository root, in `skills//SKILL.md`.** Always. + +The skills CLI only discovers skills at a root `skills/` folder, and it reaches a repository by +The skills CLI only discovers skills at a root `skills/` folder, and it reaches a repository by +cloning it, so nothing else is visible to it. Nothing is packed into a NuGet package — a package +carries .NET artifacts, and skills are delivered by the CLI from git. + +```text +skills/ + my-skill/ + SKILL.md required + references/ loaded on demand + scripts/ executable, run on demand + assets/ templates and static resources +``` + +Link to a skill by its **authored** path, `./skills//SKILL.md`. A link into `.agents/skills/` +points at a gitignored file that does not exist in a fresh clone. + +## `SKILL.md` format + +```markdown +--- +name: my-skill +description: What it does, and when to use it. Include the words a task would contain. +license: MIT +metadata: + package: My.Package +--- + +# My skill + +Instructions. +``` + +| Field | Required | Constraint | +| -- | -- | -- | +| `name` | yes | 1–64 chars, lowercase `a-z0-9` and single hyphens, no leading, trailing or consecutive hyphen, **must equal the folder name** | +| `description` | yes | 1–1024 chars, non-empty | +| `license` | no | A licence name, or the name of a bundled licence file | +| `compatibility` | no | ≤500 chars, only when the skill has real environment requirements | +| `metadata` | no | A map of string to string. Anything not defined above belongs here | +| `allowed-tools` | no | Space-separated pre-approved tools. Experimental | + +**There are no other top-level fields.** `category`, `roles` and `tags` are common inventions and a +strict client rejects them; put them under `metadata`. + +The full specification is at . +[references/authoring.md](references/authoring.md) covers description writing, naming for a shared +namespace, and progressive disclosure. + +## Progressive disclosure + +An agent loads a skill in three stages: the `name` and `description` at startup, the whole +`SKILL.md` body once the skill activates, and `references/`, `scripts/` or `assets/` only when the +task reaches them. + +So: keep `SKILL.md` **under 500 lines**, and move detail into `references/`. The body is a cost paid +on every activation; a reference file is paid only when it is needed. + +Keep file references one level deep from `SKILL.md`, and relative to the skill root. A path that +climbs out of the skill folder is broken everywhere except the authoring repository, because only +the skill's own folder is installed. + +## Validating + +### In tests — `Purview.Skills.Testing` + +```csharp +[InheritsTests] +public sealed class SkillTests : PurviewSkillTests; +``` + +**`[InheritsTests]` is load-bearing.** TUnit does not walk base types during discovery, so without +it the class compiles, the run is green and nothing is validated. TUnit reports `TUnit0030`; treat +it as an error. + +| Rule id | What it checks | +| -- | -- | +| `skills-discovery` | The folder, when present, holds at least one skill | +| `skill-structure` | Every folder has a `SKILL.md` whose frontmatter parses | +| `skill-name` | Spec-legal, unique, and equal to the folder name | +| `skill-description` | Present, non-empty, within 1024 characters | +| `skill-optional-fields` | Only spec-defined fields, each within its limit | +| `skill-bundled-resources` | Every linked file exists, inside the skill folder | +| `skill-body-size` | `SKILL.md` stays under the recommended 500 lines | + +A repository with **no** `skills/` folder passes every rule — authoring skills is optional. An +**empty** folder fails `skills-discovery`, because every other rule is vacuous against an empty +catalogue. + +Overrides: `RepositoryRoot` (default: the SDK's repository-root marker walk), +`SkillsFolderName` (default `skills`), and `SkipRule(string)`. + +The evaluator takes a `SkillCatalogue`, so a rule can be exercised against a fixture folder rather +than a real repository — the same three-layer shape this repository uses throughout: a reader, an +evaluator over an in-memory model, and a thin test base class. + +### At pack time — `ValidatePurviewSkills` + +It runs `BeforeTargets="Pack"`, so a malformed skill fails in the repository that owns it rather +than reaching the CLI, which would silently skip it. + +```bash +dotnet msbuild -t:ValidatePurviewSkills +``` + +Failures are reported as `PRSKL0005`, one per problem. Every authored skill is checked. + +The two checks overlap deliberately: the pack-time one runs without a test host, the test one +covers more of the specification. + +## What is deliberately not checked + +Prose quality, and whether an agent actually obeys the skill. A build can only check that a skill is +well formed. Treat a green run as "installable", not "good". diff --git a/.agents/skills/purview-buildsdk-skill-tests/references/authoring.md b/.agents/skills/purview-buildsdk-skill-tests/references/authoring.md new file mode 100644 index 0000000..69d4ee4 --- /dev/null +++ b/.agents/skills/purview-buildsdk-skill-tests/references/authoring.md @@ -0,0 +1,79 @@ +# Authoring a skill that fires, and keeps firing + +## The description is the whole activation decision + +`name` and `description` are the only things loaded at startup. Everything else — the body, the +references, the scripts — is invisible until an agent has already decided to activate the skill on +the strength of those two fields. + +So write the description for a *matcher*, not for a reader: + +- Say **what it does** and **when to use it**, in that order. +- Include the words a real task would contain: the tool names, the file names, the error codes, the + property names, the phrases a person would type. +- Name the thing the skill is *not*, when a neighbouring skill could be confused with it. + +| | | +| -- | -- | +| Poor | `Helps with the build SDK.` | +| Better | `Configure Purview.BuildSdk from Directory.Build.props or a .csproj. Use when setting NamespacePrefix, choosing a testing framework, or working out whether a property must be set before the SDK import.` | + +The limit is 1024 characters. Most of that budget is better spent on trigger words than on prose. + +## Naming for a shared namespace + +Skills install into one flat directory per agent, shared with every other skill a developer has +installed from anywhere. A generic name is a collision waiting to happen, and the loser is whichever +one installed second. + +- Prefix with the vendor and the product: `purview-buildsdk-configuration`, not `sdk-configuration`. +- `sdk-*`, `project-*`, `test-*`, `build-*` are all too common to use unprefixed. +- The name must be 1–64 characters, lowercase `a-z0-9` with single hyphens, no leading, trailing or + consecutive hyphen — and it must equal the folder name, because the CLI installs by the + frontmatter name while a human reads the folder. + +Renaming a skill means renaming the folder and the frontmatter together, and re-running the install. + +## Progressive disclosure in practice + +| Stage | Loaded | Budget | +| -- | -- | -- | +| Metadata | `name`, `description` | ~100 tokens, always | +| Instructions | the whole `SKILL.md` body | under 5000 tokens recommended; keep the file under 500 lines | +| Resources | `references/`, `scripts/`, `assets/` | only when the task reaches them | + +What belongs where: + +- **`SKILL.md`** — the decisions, the rules, the things that are wrong by default, and a pointer to + everything else. If a reader needs it to make the *first* correct move, it goes here. +- **`references/`** — long tables, full option lists, grammars, enumerations. Keep each file + focused; an agent loads the whole file. +- **`scripts/`** — executable code. Self-contained, or documenting its dependencies clearly, with + helpful error messages. +- **`assets/`** — templates, schemas, images, lookup data. + +Keep references one level deep. Avoid a chain where one reference sends an agent to another. + +## Links + +Use relative paths from the skill root: + +```markdown +See [the reference](references/REFERENCE.md) for detail. +Run `scripts/extract.py`. +``` + +Only the skill's own folder is installed, so a link that climbs out of it (`../other-skill/...`) is +broken on every machine except the one that authored it. To point at another skill, name it in prose +and let the agent find it by name. + +## Keeping a skill true + +A skill that describes a product goes stale the moment the product changes, and nothing about a +stale skill looks broken — it just gives confidently wrong advice. + +- Change the skill in the **same commit** as the behaviour it documents. +- Prefer stating the property name, the diagnostic code or the target name over paraphrasing the + behaviour; those are greppable when the code changes. +- Where a number or a list is authoritative somewhere else — a default, an enumeration — say where + it comes from, so a reader can check it. diff --git a/.agents/skills/purview-buildsdk-skills-justfile/SKILL.md b/.agents/skills/purview-buildsdk-skills-justfile/SKILL.md new file mode 100644 index 0000000..2da36a2 --- /dev/null +++ b/.agents/skills/purview-buildsdk-skills-justfile/SKILL.md @@ -0,0 +1,177 @@ +--- +name: purview-buildsdk-skills-justfile +description: Add or update Justfile recipes that run Purview.BuildSdk's agent-skill targets - skills-update, skills-check, skills-fix, skills-install, skills-recommend and skills-verify. Use when a repository wants a just recipe to update, install, check or clean up its agent skills, when replacing hand-written skill scripts (update-agent-skills.ts, ensure-universal-skills.ts, package.json skills:* scripts) with the SDK targets, or when an existing skills recipe runs the wrong project, the wrong runner or the wrong agents. +license: MIT +metadata: + package: Purview.BuildSdk + repository: purview-dev/build-sdk +--- + +# Justfile recipes for the agent-skill targets + +The recipes are thin: each one names one SDK target. The behaviour lives in the targets, which are +covered by `purview-buildsdk-skill-installation`; this skill is about wiring them into a `Justfile` +correctly. + +## The rule that matters most + +**Every skill recipe is run by a person, on purpose.** `InstallPurviewSkills`, `UpdatePurviewSkills` +and `FixPurviewSkills` change the repository, and the SDK refuses to run them inside a build, a +restore or a design-time build (`PRSKL0009`). Keep the Justfile consistent with that: + +- never make a skills recipe a dependency of `build`, `test`, `default` or any other recipe; +- never call `skills-update`, `skills-install` or `skills-fix` from a git hook (`lefthook.yml`, + `.husky/`) or a CI job that commits; +- `skills-verify` is the one recipe CI may call: it is read-only and fails on missing or drifted skills. + +## Recipes + +Put them in their own file beside the `Justfile`, and import it from the `Justfile`: + +```just +# Justfile +import "Justfile.skills.just" +``` + +The only line that differs between repositories is `skills_project`. Work it out from the repository +as described in [Choosing `skills_project`](#choosing-skills_project) — do not ask for it, and do not +leave a placeholder: + +```just +skills_project := solution_file # or a path, when the Justfile has no such variable + +# Update agent skills: re-add every skills-lock.json source, refreshing installed skills and adding new ones +[group('Agent skills')] +skills-update *args: + dotnet msbuild "{{ skills_project }}" "-t:UpdatePurviewSkills;FixPurviewSkills" -v:n {{ args }} + +# Check that only the skill directories of the agents in PurviewSkillsAgents exist +[group('Agent skills')] +skills-check *args: + dotnet msbuild "{{ skills_project }}" -t:CheckPurviewSkills -v:n {{ args }} + +# Remove the skills CLI's links from agent directories PurviewSkillsAgents does not select +[group('Agent skills')] +skills-fix *args: + dotnet msbuild "{{ skills_project }}" -t:FixPurviewSkills -v:n {{ args }} + +# Install the skills the referenced packages ship (pass -p:PurviewSkillsSource=owner/repo for others) +[group('Agent skills')] +skills-install *args: + dotnet msbuild "{{ skills_project }}" -t:InstallPurviewSkills -v:n {{ args }} + +# Report missing, drifted or unverifiable skills without changing anything +[group('Agent skills')] +skills-recommend *args: + dotnet msbuild "{{ skills_project }}" -t:RecommendPurviewSkills -v:n {{ args }} + +# Fail when a referenced package's skills are missing or drifted - the CI gate +[group('Agent skills')] +skills-verify *args: + dotnet msbuild "{{ skills_project }}" -t:VerifyPurviewSkills -v:n {{ args }} +``` + +Only add the recipes the repository will use. `skills-update` and `skills-check` are the usual pair. + +### Choosing `skills_project` + +Take the first of these that applies: + +1. **The `Justfile` already names the solution in a variable** — `solution_file`, `solution`, `sln` + or similar, typically used by `build`, `test` and `restore`. Reference it: + `skills_project := solution_file`. An imported file shares the importing file's variables, so the + skill recipes then follow the solution if it is ever renamed or moved, with nothing to keep in step. +2. **An existing recipe runs `dotnet build`, `test` or `restore` on a literal solution path.** Use that + path. If several recipes repeat it, offer to lift it into a `solution_file` variable and reference + that, but do not restructure the `Justfile` unasked. +3. **No `Justfile`, or one that never names a solution.** Search for `*.slnx` and `*.sln`, ignoring + `bin/`, `obj/`, `node_modules/` and `artifacts/`. If there is exactly one, use its path relative to + the `Justfile`. With several, prefer the one the CI workflow or README builds; ask only if that + still leaves more than one. +4. **No solution at all.** Name a project that imports the SDK and references every package that + ships skills, and say in a comment that it sees only its own packages. + +An existing `Justfile.skills.just` pointed at a hand-picked project gets the same treatment: replace +the project with the solution, chosen as above. Remove any comment explaining why that project was +picked, because the reason no longer holds. + +### Details that break a recipe if missed + +- **Name the solution.** Every skill target runs once for the whole solution, however many projects + it holds: each project hands the work to the same elected leader, and MSBuild runs it once. Package + resolution reads every project in the solution, so a package only a test project references is + still pinned. There is no project to choose and nothing to keep in step as projects come and go. + A single project also works, but sees only that project's packages. (SDK releases before this + behaviour ran the target once per project; there, name one project that references every package + that ships skills.) +- **Restore first.** The targets read `project.assets.json` and never restore on their own + (`PRSKL0001`). A fresh clone needs `dotnet restore` (or `just restore`) before `skills-update`. +- **Quote a multi-target `-t:`.** `"-t:UpdatePurviewSkills;FixPurviewSkills"` — unquoted, `sh` treats + the `;` as the end of the command and runs `FixPurviewSkills` as a separate, failing command. +- **Keep `*args` last** so a caller can pass properties: `just skills-update -p:PurviewSkillsDryRun=true`. +- `FixPurviewSkills` after `UpdatePurviewSkills` does nothing unless `PurviewSkillsAgents` is set, so + it is safe in the recipe either way. + +## Runner and agents belong in MSBuild, not in the recipe + +Set repository-wide choices once in `Directory.Build.props`, not as flags repeated in each recipe, +so a plain `dotnet msbuild -t:...` behaves the same as the recipe: + +```xml + + + bun + + universal + +``` + +One-off overrides go on the command line. `dotnet msbuild -p:` splits values on `,` and `;`, so +escape the separator: `just skills-update -p:PurviewSkillsAgents=claude-code%2Ccodex`. + +Useful properties to pass through `*args`: + +| Property | Effect | +| -- | -- | +| `PurviewSkillsDryRun=true` | Print every CLI command; run none | +| `PurviewSkillsRunner=npx` | Launch the CLI through another runner (`bunx`, `pnpm`, `yarn`, `deno`) | +| `PurviewSkillsSource=owner/repo#ref` | `skills-install` from an explicit source with no package behind it | + +## Replacing hand-written skill scripts + +Repositories often carry their own scripts for this. The SDK targets replace them: + +| Hand-written | Replace with | +| -- | -- | +| `scripts/update-agent-skills.ts`, `"skills:update"` | `skills-update` (`UpdatePurviewSkills`) | +| `--update` / `--no-update` | Nothing: the re-add refreshes installed skills and adds new ones, so there is no separate `skills update` step to switch | +| `--dry-run` | `-p:PurviewSkillsDryRun=true` | +| `scripts/ensure-universal-skills.ts check`, `"skills:check"` | `skills-check` with `PurviewSkillsAgents=universal` | +| `scripts/ensure-universal-skills.ts fix`, `"skills:fix"` | `skills-fix` | +| a hard-coded `-a universal` | `PurviewSkillsAgents` — or leave it empty to auto-detect | + +Two behaviours differ, deliberately — mention them when migrating: + +- A source a referenced package ships is re-added **at that package's commit**, not at latest, so its + skills keep matching the package version. Other sources are re-added at latest, as before. +- Update is `skills add`, not `skills update`, so it also installs skills a source has **gained**: a + package source gets every skill its referenced version ships, and any other source is re-added with + `--skill "*"`. + +After switching, delete the scripts and their `package.json` entries, and remove `package.json` +itself if nothing else uses it. Run `just skills-update -p:PurviewSkillsDryRun=true` and compare the +printed commands with what the old script ran before deleting anything. + +## Updating an existing Justfile + +1. Read the whole `Justfile` first: match its group names, comment style, and any `set shell` + (a PowerShell shell still needs the `-t:` value quoted). +2. Keep recipe names the repository already uses; change only what they run. If `skills-check` + already means "report", rename it to `skills-recommend` rather than silently changing its meaning, + and say so. A recipe pointed at a hand-picked "small" project, to avoid running once per project, + can point at the solution instead. +3. Check nothing depends on a changing recipe: `just --list`, then search the Justfile, CI workflows + and hook configuration for the recipe name. +4. Verify: `just --list` shows the group, `just skills-update -p:PurviewSkillsDryRun=true` prints the + commands, and `just skills-check` passes or names a real problem. diff --git a/.agents/skills/purview-buildsdk-testing/SKILL.md b/.agents/skills/purview-buildsdk-testing/SKILL.md new file mode 100644 index 0000000..0ac749b --- /dev/null +++ b/.agents/skills/purview-buildsdk-testing/SKILL.md @@ -0,0 +1,126 @@ +--- +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 or matching its target framework to its subject, 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` + +A test project inherits the SDK's default target framework set (`net10.0;net11.0`). When it must match +its subject — a project still on one TFM, or a subject that multi-targets — set +`TargetFramework`/`TargetFrameworks` on the **test** project, declared unconditionally, or select a +`PurviewTargetFrameworkSet`. See `purview-buildsdk-configuration`. + +Add nothing else unless the project's purpose demands it. Two that stay explicit: + +- `TUnit.Aspire` — Aspire lifecycle and AppHost-backed integration tests +- `Purview.Containers` — container-backed integration tests. One API over Microsoft WSL Containers + (no Docker install needed on Windows) or Docker, with typed modules for SQL Server, PostgreSQL, + Redis, MySQL, RabbitMQ, Azurite and NATS. Experimental and prerelease, so pin the version. + Reference `Testcontainers` directly only where no typed module exists yet. + +## Authoring rules + +- Use TUnit (`TUnit.Core`, `TUnit.Assertions`). Do not introduce xUnit, NUnit, MSTest or + FluentAssertions unless explicitly asked. +- Every test method carries `[Test]`. +- **Every assertion is awaited**: `await Assert.That(actual).IsEqualTo(expected);`. An un-awaited + assertion silently passes. +- Arrange / Act / Assert, with the `// Arrange`, `// Act`, `// Assert` comments present. +- Test classes are `{SubjectName}Tests`, in the **same namespace** as the subject. +- Test methods are `{SubjectOrMemberUnderTest}_{Scenario}_{Expectation}`. Use `ctor` as the subject + for constructor tests. Methods, constructors, properties, operators, conversions and validation + hooks are all valid subjects. +- When any method called in the test body accepts a `CancellationToken`, take + `CancellationToken cancellationToken` as the **last parameter** and pass it through. + +```csharp +[Test] +public async Task ProcessAsync_GivenAnEmptyBatch_ReturnsZero(CancellationToken cancellationToken) +{ + // Arrange + var sut = new BatchProcessor(); + + // Act + var result = await sut.ProcessAsync([], cancellationToken); + + // Assert + await Assert.That(result).IsEqualTo(0); +} +``` + +For a suite that is not subject-based — a build, generator, workflow, package or full-system suite — +a broader, more truthful class name is better than a forced `{Subject}Tests`. Use TUnit display +names and categories to keep it navigable. + +When refactoring existing tests, bring them into compliance with all of the above while preserving +test intent. + +## Categories + +The detected test type becomes the baseline category automatically — `Api.IntegrationTests` yields +`Category=Integration`. Add further categories when they make a large suite easier to navigate; +they are what `--treenode-filter` property predicates select on. + +## Running tests + +```bash +dotnet test # the whole suite +dotnet test --treenode-filter "/*/*/LoginTests/*" # one class +dotnet test --treenode-filter "/*/*/*/*[Category=Unit]" +``` + +Prefer the repository's own task (`just test`, or equivalent) for a full run. + +The filter path is `////`, with `*` wildcards, `=`/`!=` property +predicates, `&`/`|` inside a single bracket group, and `**` only at the end. The full grammar, the +`--` separator rules across SDK versions, and the zero-tests checklist are in +[references/treenode-filter.md](references/treenode-filter.md). + +### "Zero tests ran" + +In order: + +1. Are you using `--treenode-filter` and not `--filter`? This is the cause most of the time. +2. Try `dotnet test --treenode-filter "..."`; on an older SDK use `dotnet test -- --treenode-filter "..."`. +3. Broaden to `/*/*/*/*`, then narrow one segment at a time. +4. Does the project actually contain `[Test]` methods? +5. Is the project **detected** as a test project? A name without the dot — `UnitTests` rather than + `Api.UnitTests` — gets no runner at all. Check with + `dotnet msbuild -getProperty:IsTestProject`. +6. Are all property predicates inside one bracket group, `[(A=1)&(B=2)]`? + +## Cross-platform requirement + +CI runs the full suite on Linux, so a test that only passes on Windows is a failing change. + +- Never hardcode a platform-specific path — no `C:\...`, no backslash-only separators, no + case-insensitive path assumptions. +- Build paths with `Path.Combine`, `Path.GetTempPath()`, `Path.DirectorySeparatorChar`, + `Path.GetFullPath()`. +- Feed production path-math code only native-format paths. Normalise a fixed Windows-style literal + to the current platform before passing it in. +- Do not rely on a case-insensitive filesystem, a `C:` drive, or trailing-separator behaviour. +- Keep `SyntaxTree` file paths and `build_property.*` values consistent and native when writing + analyzer tests. diff --git a/.agents/skills/purview-buildsdk-testing/references/treenode-filter.md b/.agents/skills/purview-buildsdk-testing/references/treenode-filter.md new file mode 100644 index 0000000..35d032b --- /dev/null +++ b/.agents/skills/purview-buildsdk-testing/references/treenode-filter.md @@ -0,0 +1,107 @@ +# `--treenode-filter` reference + +TUnit runs on Microsoft.Testing.Platform, not VSTest, so the VSTest `--filter` syntax does not +apply. Narrowing is done with `--treenode-filter`. + +## The path + +```text +//// +``` + +Each segment maps to one level of the tree: + +| Position | Matches | +| -- | -- | +| 1 | Assembly | +| 2 | Namespace | +| 3 | Class | +| 4 | Test name | + +```text +/*/*/LoginTests/* every test in LoginTests +/*/*/*/AcceptCookiesTest one test by name +/*/MyProject.Tests.Integration/*/* every test in a namespace +/*/*/*/* everything +``` + +## Operators + +### `*` — wildcard + +Matches a whole segment or part of one. + +```text +/*/*/LoginTests*/* +/*/*/MyProject.Tests.Api*/* +``` + +### `=` and `!=` — property predicates + +In the last segment, inside `[...]`. + +```text +/*/*/*/*[Category=Unit] +/*/*/*/*[Category!=Slow] +/*/*/*/*[Owner=*Team-Backend*] +``` + +### `&` and `|` — combining + +Inside a **single** bracket group, or a single parenthesised segment. + +```text +/*/*/*/*[(Category=Unit)&(Priority=High)] +/**[(Category=Unit)|(Priority=High)] +/*/*/(LoginTests)|(SignupTests)/* +``` + +### `**` — match all + +Any depth, but **only at the end** of the path. + +```text +/** +/MyAssembly/** +``` + +`/**/Path` is not valid. + +## Rules that catch people out + +- Only **one** property group `[...]` per path segment. `[Category=Smoke]|[Priority=High]` is not + valid; write `[(Category=Smoke)|(Priority=High)]`. +- `**` must be last. +- A segment left empty is not a wildcard — write `*`. + +## The `--` separator + +```bash +dotnet test --treenode-filter "/*/*/*/*[Category=Unit]" # preferred +dotnet test -- --treenode-filter "/*/*/*/*[Category=Unit]" # older SDKs +``` + +Newer .NET SDKs forward unrecognised arguments to the MTP runner directly. On an older SDK the `--` +separator is required, and omitting it produces an argument error rather than a filter. + +## Common selections + +| Intent | Filter | +| -- | -- | +| Everything | `/*/*/*/*` | +| All unit tests | `/*/*/*/*[Category=Unit]` | +| High-priority unit tests | `/*/*/*/*[(Category=Unit)&(Priority=High)]` | +| One class | `/*/*/MyFeatureTests/*` | +| A name pattern | `/*/*/*/MyScenario*` | +| Critical integration tests | `/*/MyProject.Tests.Integration/*/*[Priority=Critical]` | + +## Zero tests ran + +1. `--treenode-filter`, not `--filter`. +2. Try without the `--` separator first, then with it. +3. Broaden to `/*/*/*/*`, then narrow one segment at a time. +4. Confirm the project has `[Test]` methods. +5. Confirm the project is **detected** as a test project — + `dotnet msbuild -getProperty:IsTestProject`. A name without the dot, such as + `UnitTests` rather than `Api.UnitTests`, is not detected and gets no runner. +6. Confirm every property predicate sits in one bracket group. diff --git a/.agents/skills/purview-component-compatibility/SKILL.md b/.agents/skills/purview-component-compatibility/SKILL.md new file mode 100644 index 0000000..b017681 --- /dev/null +++ b/.agents/skills/purview-component-compatibility/SKILL.md @@ -0,0 +1,116 @@ +--- +name: purview-component-compatibility +description: >- + Use when developing, reviewing or releasing a published component - a NuGet package, or an npm + package - and choosing which dependency version to declare, when a consumer reports a version + conflict or an unexpected upgrade, or when a component's supported target frameworks change. Applies + the golden rule that a published component declares the LOWEST compatible version rather than the + latest, walks the Purview component hierarchy to find each floor, and verifies it by building + against it. Not for applications: they take the latest compatible version instead. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" + repository: purview-dev/engineering-playbook +--- + +# Purview component compatibility + +A published component makes a promise with every version it declares: *this is the least I require of +you*. Raise that number and every consumer is pulled up with you, whether or not they wanted it. + +**The golden rule: declare the lowest version that compiles, passes your tests, and is supported on +every target framework you claim.** The version you happen to have installed is never the answer, and +neither is the latest. + +## Which direction? + +| What you are publishing | Declare | Why | +| -- | -- | -- | +| an application, a test suite, a tool — it runs, and nobody installs it | the **latest** compatible version | you own the runtime, and a version you skip is a fix you did not take | +| a component — a NuGet or npm package somebody else installs | the **lowest** compatible version | dependency resolution takes the highest minimum in the graph, so your floor becomes theirs | + +This skill is about the second row. If the repository only ever runs, stop here and take the latest. + +## Why a floor matters + +NuGet and npm resolve a graph to the **highest** minimum among all constraints. If a package declares +`Testcontainers` 4.16 because 4.16 is what its author had installed, every consumer is forced onto 4.16, +including the one whose own constraint is 4.0 and whose runtime is pinned to it. The consumer cannot +refuse an upgrade the package never needed to ask for, and the symptom appears in *their* build, not +yours. + +## The workflow + +### 1. Name the component and its frameworks + +The floor is per target framework, not per package. Write down every `TargetFramework` the component +publishes; a multi-targeting component has a different floor on each, and the one that matters is the +oldest framework still supported, not the newest one you build against. + +### 2. Read the hierarchy, do not remember it + +The dependencies — and their minimums — are in the published `.nuspec`, not in anyone's memory: + +```bash +node scripts/dependencies.mjs Purview.Containers +node scripts/dependencies.mjs Purview.Architecture.Testing --tfm net11.0 +node scripts/dependencies.mjs Purview.Containers --walk +``` + +A bare version is a **minimum**; a bracketed one (`[1.2.3]`, `[1.0,2.0)`) is a range. The script prints +the distinction, because it is the whole rule: a minimum you raise is a minimum every consumer inherits. + +`references/purview-components.md` records which Purview package depends on which, and which target +frameworks each family supports. Where the two disagree the registry wins — that file is the map, the +`.nuspec` is the territory. + +### 3. Fix the floor, per framework + +For each dependency, find the oldest version that: + +- exposes every API the component calls (the compiler decides this, not the changelog); +- passes the component's own tests and its consumer-verification suite; +- is supported on every framework in that dependency group. + +Then declare that version. Not `latest`, not floating (`1.*`), and never the version that resolved on +your machine by accident. + +### 4. Prove the floor + +A floor nobody builds against is a guess. Add a CI leg that restores the component with every dependency +held at its declared floor and runs the suite — the ordinary leg keeps resolving the latest, which is +what a consumer will actually get. `references/lowest-compatible.md` has the mechanics. + +### 5. Keep the ceiling visible, and the floor written down + +A component's README carries a badge for the current release and a sentence naming the floor it +supports. Badges are generated from the registry, so they cannot go stale; a version written into prose +is wrong within one release. + +## Pitfalls that produce a floor nobody meant + +| Pitfall | What it looks like | The fix | +| -- | -- | -- | +| Declaring what is installed | `dotnet add package X` writes the latest; right for an application, wrong for a component | edit the version down, then prove it in step 4 | +| Transitive uplift | a dependency pulls in a newer transient package than your floor allows | override it deliberately — the persistence sample does this for a security-patched package — and comment why | +| One framework's floor | a `net10.0-windows` group needs a higher floor than `net10.0` | read each group; never average them | +| Prerelease in the graph | a family that ships prereleases depends on them at the same version | pin the family to one property so the floor moves together | +| Analyzer and source-generator packages | compile-time only, so they rarely constrain a consumer | still declare a real floor; they appear in the graph and in transitive audits | +| Floating versions | `1.*` resolves differently on every restore | banned: a floor is a number | + +## Checks before you answer + +- Is each declared version the lowest that passes, or the one that happened to be installed? +- Does every declared target framework have a floor that compiles on that framework? +- Was the floor proved by a build, or only by your machine? +- Does any page state a version number? It should be a badge or a link to the registry. +- If a floor was raised, is that a breaking change the version number admits to? + +## See also + +`references/lowest-compatible.md` for the CI mechanics and the override patterns, and +`references/purview-components.md` for the Purview hierarchy. The rule itself, and the application-side +half of it, is stated in the Engineering Playbook's Dependency Versions page; the +`purview-engineering-principles` skill summarises it as one of the eleven conventions. + diff --git a/.agents/skills/purview-component-compatibility/references/lowest-compatible.md b/.agents/skills/purview-component-compatibility/references/lowest-compatible.md new file mode 100644 index 0000000..ccdb3b6 --- /dev/null +++ b/.agents/skills/purview-component-compatibility/references/lowest-compatible.md @@ -0,0 +1,93 @@ +# Proving the floor + +The rule is "declare the lowest compatible version". This file is the mechanics: how to find the floor, +how to prove it in a build, and the patterns that keep it from silently rising. + +## What "compatible" means, precisely + +A version is a valid floor when all three hold: + +1. **It compiles against every API the component uses.** The compiler decides this, not a changelog. If + you cannot say which member forced the version up, you have not found the floor. +2. **The component's own tests pass against it**, including the consumer-verification suite, which + builds a throwaway consumer rather than the component's own test project. +3. **It is supported on every target framework in that dependency group.** A floor is per group, and a + multi-targeting component can have several. + +## Finding it + +```bash +dotnet list package --outdated # what you declared against what exists +dotnet list package --include-transitive # what actually resolved +dotnet nuget why # which path pulled a version in +``` + +Then work downward: lower the declared version, build, and read the compiler. The first version that +compiles *and* passes is the floor. Do not stop at the first failure and pick the version above it — +that is how a floor ends up one minor above what the code needs. + +## Making the floor a build leg + +Keep one property per family (so a family cannot be bumped halfway) and make each conditional on a floor +switch. Nothing here is NuGet-specific magic: it is MSBuild, and central package management already +gives you the single place to put it. + +```xml + + LATEST + FLOOR + +``` + +```bash +dotnet restore -p:DependencyFloor=true +dotnet build -p:DependencyFloor=true --no-restore +dotnet test -p:DependencyFloor=true --no-build +``` + +`LATEST` and `FLOOR` are literals in the file; the point is that they are two properties, not two files. +Run the ordinary leg at `LATEST` and a second, slower leg at `FLOOR`, and give the floor leg its own CI +job so a failure names the floor rather than the build. + +Where a component multi-targets, add the framework dimension and run the pair per framework: + +```bash +for tfm in net8.0 net9.0 net10.0; do + dotnet build -p:DependencyFloor=true -f "$tfm" +done +``` + +## Transitive pins + +A floor can be raised by something you never declared. Transitive pinning puts those versions where you +can see them: + +```xml + + true + +``` + +Then an override is a deliberate line with a reason beside it — the persistence sample in this +repository does exactly that for a security-patched package the SQL client drags in. An override without +a comment is indistinguishable from an accident, and the next person deletes it. + +## The ceiling, and the API surface + +- The floor leg proves the **bottom**; the ordinary leg proves the **top**. Both, or you know neither. +- An API-compatibility check against the previous release catches the other direction of the same + mistake: a floor can stay put while the public surface grows a member that only the newest dependency + provides. `Microsoft.DotNet.ApiCompat.Task` wires that into the build; `Microsoft.DotNet.ApiCompat.Tool` + runs it from a pipeline. +- A prerelease dependency is a floor with an expiry: it cannot be consumed by anyone who has not opted + into prereleases. Say so in the package's README rather than letting the error message explain it. + +## What not to do + +| Temptation | Why it is wrong | +| -- | -- | +| `dotnet add package X` and commit | it writes the latest; correct for an application, wrong for a component | +| floating versions (`1.*`) | resolves differently on every restore, so the floor is nobody's decision | +| "it restores on my machine" | that proves one graph, on one date, with one SDK | +| raising a floor in a patch release | the consumer's build changes without their version changing | +| one floor leg for all frameworks | the framework with the lowest floor is the one nobody tested | diff --git a/.agents/skills/purview-component-compatibility/references/purview-components.md b/.agents/skills/purview-component-compatibility/references/purview-components.md new file mode 100644 index 0000000..6e13b65 --- /dev/null +++ b/.agents/skills/purview-component-compatibility/references/purview-components.md @@ -0,0 +1,60 @@ +# The Purview component hierarchy + +Which Purview package depends on which, and which target frameworks each family supports, so a harvest +or a floor decision starts from the shape rather than from a memory of it. + +Every row was read from the registry with `scripts/dependencies.mjs`, which reads the published +`.nuspec`. **The registry is the authority; this file is the map.** Re-run the script before acting on +any row — a family that multi-targets changes its floors on every release. + +## The families + +| Family | Packages | Target frameworks | Depends on | +| -- | -- | -- | -- | +| Build SDK | `Purview.BuildSdk` | `netstandard2.0` (an MSBuild SDK) | nothing | +| Test-support rule sets | `Purview.Skills.Testing`, `Purview.Architecture.Testing`, `Purview.Architecture.Testing.Cli` | `net10.0`; `Architecture.Testing` also `net11.0` | `TUnit.Core`, `TUnit.Assertions`, `JsonSchema.Net` | +| Telemetry | `Purview.Telemetry.SourceGenerator` | `netstandard2.0` (an analyzer) | nothing | +| Containers | `Purview.Containers` + `.Core`, `.Docker`, `.Wsl`, `.MsSql`, `.PostgreSql` | `net10.0`; `.Wsl` adds `net10.0-windows10.0.19041` | its own family at the same version, `Microsoft.Extensions.Telemetry.Abstractions`, `Testcontainers` | +| Results | `Purview.Results`, `.AspNetCore`, `.ZodSharp`, `.ZodSharp.AspNetCore` | `net10.0`, `net11.0` | nothing; the adapters depend on the core and on ZodSharp | +| ZodSharp | `Purview.ZodSharp`, `.AspNetCore`, `.SystemTextJson`, `.NewtonsoftJson` | `net9.0`, `net10.0`, `net11.0` | `Microsoft.Extensions.Options`, `Microsoft.Extensions.DependencyInjection.Abstractions` | +| Value Objects | `Purview.ValueObjects` | `net8.0`, `net9.0`, `net10.0`, `net11.0` | nothing | +| Source Generator Framework | `Purview.SourceGeneratorFramework`, `.Testing`, `.Testing.TUnit` | `netstandard2.0` | Roslyn (`Microsoft.CodeAnalysis.CSharp`), `System.Collections.Immutable`, `System.Reflection.Metadata` | +| Aspire ResourceKit | `Purview.Aspire.ResourceKit` | `net8.0`, `net9.0`, `net10.0` | `Aspire.Hosting`, OpenTelemetry exporter and hosting, `Microsoft.Extensions.*` | +| Astroc4 | `@purview-dev/astroc4` (npm, not NuGet) | an Astro/Starlight plugin | `likec4`, `@astrojs/starlight`, `astro` as peers | + +## Reading the shape + +Three patterns are worth internalising, because each one changes what "the floor" means: + +- **A family ships at one version.** `Purview.Containers.Core` is required at the umbrella's own version, + and `Purview.Results.ZodSharp` requires `Purview.Results` and `Purview.ZodSharp` at their current ones. + Declare the family with one property, so its floor moves as a unit. +- **An analyzer has no consumer-side floor worth arguing about.** `Purview.BuildSdk`, + `Purview.Telemetry.SourceGenerator` and `Purview.SourceGeneratorFramework` target `netstandard2.0` and do + their work at compile time. They still appear in a transitive audit, so they still get a real version. +- **A multi-targeting component has a different floor per framework**, and ZodSharp shows it most + clearly: the `net11.0` group declares older `Microsoft.Extensions.*` minimums than the `net10.0` group, + because the lower framework carries the newer requirements. Reading one group and applying it to + another is how a floor is set wrong in the direction that breaks consumers. + +## What the script prints + +```text +Purview.Containers 1.0.0-prerelease.11 + net10.0: Purview.Containers.Core >= 1.0.0-prerelease.11 + net10.0: Purview.Containers.Docker >= 1.0.0-prerelease.11 + net10.0: Purview.Containers.Wsl >= 1.0.0-prerelease.11 + net10.0: Microsoft.Extensions.Telemetry.Abstractions >= 10.11.0 + net10.0: Testcontainers >= 4.16.0 + Purview.Containers.Core 1.0.0-prerelease.11 + net10.0: Microsoft.Extensions.Telemetry.Abstractions >= 10.11.0 +``` + +A bare version is a minimum; brackets would mean a range. The numbers above are what the registry said +when this page was written — the shape is the part that lasts, which is why the command is here and the +versions are not presented as targets. + +## Related components that are not Purview packages + +`AspireC4.Hosting` is published under its own name and versions on its own schedule, so it is not part of +a Purview family floor. Read it the same way, with the same script. diff --git a/.agents/skills/purview-component-compatibility/scripts/dependencies.mjs b/.agents/skills/purview-component-compatibility/scripts/dependencies.mjs new file mode 100644 index 0000000..3069ef6 --- /dev/null +++ b/.agents/skills/purview-component-compatibility/scripts/dependencies.mjs @@ -0,0 +1,239 @@ +#!/usr/bin/env node +/** + * Prints a package's dependency groups exactly as NuGet declares them, so the floor a component + * inherits can be read rather than guessed. + * + * The golden rule of package development is to declare the LOWEST compatible version, and that decision + * needs the real graph: which dependency, at what minimum, on which target framework. This script reads + * it from the registry - the `.nuspec` the published package carries - instead of from a table that + * would be stale by the next release. + * + * Zero dependencies: it uses the platform `fetch`, so it runs anywhere Node 18 or later does. + * + * node dependencies.mjs Purview.Containers + * node dependencies.mjs Purview.Architecture.Testing --tfm net11.0 + * node dependencies.mjs Purview.Containers --walk # recurse into Purview.* dependencies + * node dependencies.mjs Purview.Results --json + * + * An npm package is not a NuGet id; read it with `npm view versions --json`. + * + * Exit codes: 0 on success, 1 when the package, version or network is not available. + */ + +/** The NuGet flat-container root: the same host that serves the package's own `.nuspec`. */ +const REGISTRY = "https://api.nuget.org/v3-flatcontainer"; + +/** How deep `--walk` follows dependencies before it stops. */ +const MAX_DEPTH = 3; + +/** True when a version string carries a prerelease label. */ +function isPrerelease(version) { + return version.includes("-"); +} + +/** Every published version of a package, oldest first. */ +async function versionsOf(id) { + const response = await fetch(`${REGISTRY}/${id.toLowerCase()}/index.json`); + if (!response.ok) { + throw new Error(`${id} is not on nuget.org (HTTP ${response.status}).`); + } + + const body = await response.json(); + return body.versions; +} + +/** The version to describe: the one asked for, else the newest stable, else the newest anything. */ +function chooseVersion(available, requested) { + if (requested) { + const match = available.find((version) => version.toLowerCase() === requested.toLowerCase()); + if (!match) { + throw new Error(`'${requested}' is not a published version. Newest is ${available.at(-1)}.`); + } + + return match; + } + + const stable = available.filter((version) => !isPrerelease(version)); + return stable.at(-1) ?? available.at(-1); +} + +/** The package's `.nuspec`, which is what a consumer's restore actually reads. */ +async function readNuspec(id, version) { + const name = id.toLowerCase(); + const response = await fetch(`${REGISTRY}/${name}/${version.toLowerCase()}/${name}.nuspec`); + if (!response.ok) { + throw new Error(`No .nuspec for ${id} ${version} (HTTP ${response.status}).`); + } + + return response.text(); +} + +/** The value of one XML attribute on a tag, or null. */ +function attribute(tag, name) { + const match = new RegExp(`${name}\\s*=\\s*"([^"]*)"`).exec(tag); + return match ? match[1] : null; +} + +/** + * How a nuspec version string constrains a consumer: a bare version is a MINIMUM, and a bracketed one is + * a range. This is the distinction the whole rule turns on, because a minimum you raise is a minimum + * every consumer inherits. + */ +function classify(range) { + if (!range) { + return "any"; + } + + return range.startsWith("[") || range.startsWith("(") ? "range" : "minimum"; +} + +/** The dependency elements inside a fragment, normalized. */ +function dependenciesIn(fragment) { + const dependencies = []; + const pattern = /]*)\/?>/g; + let match; + + while ((match = pattern.exec(fragment)) !== null) { + const tag = match[1]; + const version = attribute(tag, "version"); + dependencies.push({ id: attribute(tag, "id"), version, constraint: classify(version) }); + } + + return dependencies; +} + +/** The dependency groups a nuspec declares, one per target framework. */ +function dependencyGroups(nuspec) { + const groups = []; + const pattern = /]*)>([\s\S]*?)<\/group>/g; + let match; + + while ((match = pattern.exec(nuspec)) !== null) { + groups.push({ + framework: attribute(match[1], "targetFramework") ?? "(any)", + dependencies: dependenciesIn(match[2]), + }); + } + + if (groups.length === 0) { + const flat = dependenciesIn(nuspec); + if (flat.length > 0) { + groups.push({ framework: "(any)", dependencies: flat }); + } + } + + return groups; +} + +/** A printable name for a target framework, spelled the way the ecosystem usually writes it. */ +function shortFramework(framework) { + return framework + .replace(/^\.NETStandard/i, "netstandard") + .replace(/^\.NETCoreApp/i, "net") + .replace(/^\.NETFramework/i, "netframework") + .toLowerCase(); +} + +/** Prints one package's groups, recursing into Purview components when asked. */ +async function describe(id, requestedVersion, options, depth, seen) { + const version = chooseVersion(await versionsOf(id), requestedVersion); + const groups = dependencyGroups(await readNuspec(id, version)); + const indent = " ".repeat(depth); + + console.log(`${indent}${id} ${version}`); + + for (const group of groups) { + const framework = shortFramework(group.framework); + if (options.tfm && framework !== options.tfm.toLowerCase()) { + continue; + } + + if (group.dependencies.length === 0) { + console.log(`${indent} ${framework}: no dependencies`); + continue; + } + + for (const dependency of group.dependencies) { + const relation = dependency.constraint === "minimum" ? ">=" : dependency.constraint; + console.log( + `${indent} ${framework}: ${dependency.id} ${relation} ${dependency.version ?? "any"}`, + ); + } + + if (options.walk && depth < MAX_DEPTH) { + for (const dependency of group.dependencies) { + const isPurview = /^purview\./i.test(dependency.id ?? ""); + if (!(options.all || isPurview) || seen.has(dependency.id.toLowerCase())) { + continue; + } + + seen.add(dependency.id.toLowerCase()); + await describe(dependency.id, null, options, depth + 1, seen); + } + } + } + + return { id, version, groups }; +} + +/** Parses the command line. An unknown argument is a mistake worth failing on. */ +function parseArguments(argv) { + const options = { packages: [], tfm: null, walk: false, all: false, json: false }; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + + if (argument === "--walk" || argument === "-w") { + options.walk = true; + } else if (argument === "--all") { + options.all = true; + } else if (argument === "--json") { + options.json = true; + } else if (argument === "--tfm") { + options.tfm = argv[(index += 1)]; + if (!options.tfm) { + throw new Error("--tfm needs a value, e.g. --tfm net10.0."); + } + } else if (argument.startsWith("-")) { + throw new Error(`Unknown argument '${argument}'.`); + } else { + options.packages.push(argument); + } + } + + if (options.packages.length === 0) { + throw new Error( + "Name at least one package, e.g. `node dependencies.mjs Purview.Containers`.\n" + + "A package may carry a version: `Purview.Containers@1.0.0-prerelease.11`.", + ); + } + + return options; +} + +/** The command. */ +async function main() { + const options = parseArguments(process.argv.slice(2)); + const results = []; + + for (const entry of options.packages) { + const separator = entry.indexOf("@", 1); + const id = separator === -1 ? entry : entry.slice(0, separator); + const version = separator === -1 ? null : entry.slice(separator + 1); + + results.push(await describe(id, version, options, 0, new Set([id.toLowerCase()]))); + + if (!options.walk && !options.json) { + console.log(""); + } + } + + if (options.json) { + console.log(JSON.stringify(results, null, "\t")); + } +} + +main().catch((error) => { + console.error(`error: ${error.message}`); + process.exitCode = 1; +}); diff --git a/.agents/skills/purview-engineering-principles/SKILL.md b/.agents/skills/purview-engineering-principles/SKILL.md new file mode 100644 index 0000000..24fe707 --- /dev/null +++ b/.agents/skills/purview-engineering-principles/SKILL.md @@ -0,0 +1,198 @@ +--- +name: purview-engineering-principles +description: >- + Use when setting up, reviewing or rationalising a .NET repository against the Purview engineering + conventions - convention over configuration, derived identity, architecture as tests, warnings as + errors, proving consumer-facing claims, documentation ownership, test data builders, agent + legibility, tiering verification by risk, the version policy for applications and for published + components, and the conditional persistence rules. Each rule names the failure it prevents and + whether a build or a reviewer enforces it. +metadata: + product: Purview engineering playbook + repository: purview-dev/engineering-playbook +--- + +# Purview engineering principles + +Eleven rules, each with the failure it prevents and the mechanism that makes it true. The distinction +that matters throughout: **a convention a build can check is checked by the build; a convention only +a reviewer can check is labelled a review obligation and never described as enforced.** A rule that +claims enforcement it does not have is worse than no rule, because people stop looking. + +## 1. Convention over configuration + +A repository declares as little as possible. One MSBuild SDK supplies the defaults, and a project +file is short because the SDK already knows what a project of that shape needs. + +Configuration copied between repositories diverges. When the rule lives in one package, fixing it +once fixes it everywhere; when it lives in eleven project files, it is fixed eleven times — or, more +often, once. + +*Enforced by:* `` in a single +`Directory.Build.props`. The SDK detects project type from the filename and the `Sdk` attribute. + +*At your scale:* a single-project repository still benefits. The import is two lines and removes the +target framework, language version, nullable context, analyzer set and package metadata. + +## 2. Identity is derived, never repeated + +`RootNamespace` comes from `$(NamespacePrefix).$(ProjectName)`, and `AssemblyName` follows the +resolved `RootNamespace`. A name appears once — in the folder and file name — and everything else is +computed. + +A hand-typed namespace drifts from the assembly it lives in, and the drift is invisible until +something reflects over types: an architecture test, a serializer, a DI scan. + +*Enforced by:* the SDK during `.props` evaluation, plus a build-time check +(`PurviewProjectFileNameMismatch`) when a `.csproj` filename does not match its parent directory. + +*At your scale:* always. It costs nothing and removes a class of bug. + +## 3. Architecture is a test, not a diagram + +The dependencies a layer may have are asserted by tests that read the compiled assemblies. A +`ProjectReference` pointing the wrong way fails the build. + +A diagram is a claim about the past. Dependency direction erodes one convenient reference at a time, +and every such reference looks reasonable in isolation. + +*Enforced by:* a test project that references every layer and inspects their metadata references. + +*At your scale:* small repositories can skip it. Adopt it the moment a **second layer** exists — +that is when the wrong direction first becomes possible. + +## 4. The build is the only authority + +Style is enforced by analyzers and `EnforceCodeStyleInBuild`, and the SDK's own style policy is +re-validated on every compile, so a repository cannot quietly downgrade a rule. + +*At your scale:* always. If a rule matters and cannot be checked, say so plainly. + +## 5. Warnings are not acceptable output + +A clean build has no warnings. One that will never be fixed is either fixed, suppressed *where it +occurs* with a stated justification, or turned off deliberately. + +Warnings are a budget. A build that emits twenty is a build where the twenty-first is invisible — and +the twenty-first is the one that mattered. + +*Enforced by:* severity, partly. The SDK's shipped `.editorconfig` sets many rules to `error`. The +rest is review, backed by the pipeline running `dotnet build` with the repository's configuration. + +*At your scale:* a small repository can tolerate `warning`; a large one cannot. Decide deliberately +rather than by accumulation. + +## 6. Every claim about a consumer is proved by a consumer + +When a package documents how it must be used — a target framework, an asset, a guard, a workaround — +something in the repository builds that documented consumer and asserts the documented outcome. + +Consumer-facing documentation is the part of a library least likely to be exercised by its own tests, +because **the library's own build is not a consumer**. It drifts silently and fails in somebody +else's repository. + +*Enforced by:* a verification script that packs, then builds throwaway consumer projects against the +produced packages, asserting the happy path *and* the documented guard errors. `purview-dev/containers` +is the worked example (`just verify-consumers`), deliberately excluded from the default unit filter +because it restores from nuget.org. + +*At your scale:* only once a package is consumed outside its own repository. Before that, the +repository's own tests already are the consumer. + +## 7. Documentation is a build artefact of the repository + +The wiki lives in the repository it describes, in `docs/wiki`, as plain Markdown. The repository owns no +site configuration at all: `_Sidebar.md` is the navigation, `Home.md` is the hub, and the site that +publishes them lives in one place outside the repository. + +Documentation that lives elsewhere is updated elsewhere. A page with no owner and no review path is +wrong within one release. + +*Enforced by:* review, plus the navigation file — a page absent from `_Sidebar.md` is not published. + +*At your scale:* always. Even a repository with one page should own that page. + +## 8. Everything a test needs is built by one builder + +Domain content in a test comes from `TestBuilder`. A test never constructs a domain object inline, +and it overrides **only** the value it is proving. + +Inline construction encodes a type's shape into every test that touches it, so one constructor change +becomes a repository-wide edit. It also hides which values the test actually depends on, because +every value looks deliberate. + +*Enforced by:* review. + +*At your scale:* adopt from the first test. Retrofitting is a mechanical edit across every test file, +and exactly the kind of edit that is postponed forever. + +## 9. An agent is a reader with no memory + +A repository states its purpose, its layout and its conventions in `AGENTS.md`. **Nothing important +is discoverable only by reading the code.** + +A coding agent has the repository and nothing else — no colleague to ask, no institutional memory, no +intuition about which of two plausible patterns is intended. It will confidently generalise from +whatever it finds first. That is also every new-joiner failure mode; the agent just makes the cost +visible sooner, because an agent does not ask. + +*Enforced by:* review, plus publishing skills so agent-facing guidance tracks the library version +rather than a repository's memory of it. + +*At your scale:* always. `AGENTS.md` is cheaper than the questions it prevents. + +## 10. A change is verified in proportion to its risk + +Checks are tiered, and a repository declares which tiers its pipeline runs. A pull request runs the +fast tier; the slow tier runs deliberately, locally or on a dedicated runner. + +A pipeline that runs everything on every pull request gets made optional. One that runs too little +gets ignored. Tiering is what keeps the gate credible. + +*Enforced by:* the pipeline's `TestFilter` and the test project's own **name** — the SDK categorises +a `*UnitTests` project as `Unit`, so the tier is a property of the project rather than of a +hand-written attribute. + +*At your scale:* one tier is fine until the slow tests exist. Then split by project name, not by +attribute. + +## 11. A version is a range, not a number + +An application takes the latest version its runtime supports. A published component declares the +**lowest** version that compiles, passes its tests and satisfies the contract it documents, on each +target framework it claims — because dependency resolution is a set of lower bounds, so the floor a +component declares becomes the floor every consumer inherits. + +A page states no release number. It shows a badge generated from the registry, or links to the +registry, so a version cannot be wrong within a release. + +*Enforced by:* a lowest-compatible CI leg that restores against the declared floor, and an +API-compatibility check per target framework. Nothing enforces the floor of a package that has no +consumer yet — that tier arrives with the first release. + +*At your scale:* with one application there is nothing to earn yet; take the latest and let +`dotnet list package --outdated` report the rest. The golden rule starts at the first published package. + +## Applying these in review + +Three questions catch most of it: + +1. **Is this configuration, or is it a default the SDK should own?** If two repositories would copy + it, it belongs in the SDK. +2. **Is this rule enforced, or merely stated?** If stated, say so where it is stated. +3. **Could an agent — or a new joiner — find this without being told?** If not, it goes in + `AGENTS.md` or the wiki. + +## The conditional persistence rules + +Three further rules apply only when a repository actually stores data: a value known before storage is +derived with UUIDv5, every other GUID key uses one strategy per store, and EF Core is the persistence +boundary. They are not part of the eleven because a stateless CLI or a pure library must not inherit them. +`purview-persistence` owns them and stays silent when persistence is not real. + +## Related skills + +`purview-architecture` for project-shape decisions, `purview-architecture-testing` for turning a +boundary into a failing build, `purview-telemetry` and `purview-persistence` for the per-concern +guidance, and `purview-component-compatibility` for deciding and verifying a published component's +lowest compatible dependency versions. diff --git a/.agents/skills/purview-org-harvest/SKILL.md b/.agents/skills/purview-org-harvest/SKILL.md new file mode 100644 index 0000000..2c05ea1 --- /dev/null +++ b/.agents/skills/purview-org-harvest/SKILL.md @@ -0,0 +1,197 @@ +--- +name: purview-org-harvest +description: >- + Use when a Purview repository's conventions may have fallen behind the organisation - + inventorying every public purview-dev repository at main, deciding which repository is + best-in-class for each concern before adopting anything, and recording the result in a dated + snapshot. Always reads the remote: a local clone is a cache, and it may be stale, renamed or + missing entirely. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" + repository: purview-dev/engineering-playbook +--- + +# Purview organisation harvest + +Seventeen public repositories, each with its own wiki, its own `skills/` folder and its own `AGENTS.md`. +They are not equally up to date, and the one you have cloned is not necessarily the one that has the +answer. Two failures follow, and this skill exists to prevent both. + +**Copying from a stale clone.** A clone is a cache with no expiry. The convention you read there may +be two months behind the one the owner now documents, and nothing about the local file says so. + +**Copying from the wrong owner.** "How do we ship?" is answered by `build`. "How do we keep a boundary +honest?" is answered by `architests`. Asking a library that does neither produces a confident answer +with no evidence, which is worse than no answer. + +So: establish membership, read at `main`, name one owner per concern, write down what you found. + +## 1. Scope + +Name two things before reading anything: + +| Input | Example | +| -- | -- | +| The repository being changed | `containers` | +| The concern in question | "how a test reaches a real database" | + +A harvest without a named concern becomes a tour, and a tour produces a list of differences nobody +acted on. The concern is what makes "best-in-class" a decision rather than a preference. + +## 2. Discover membership + +`gh repo list` is the membership authority. A catalogue, a README table, an `llms.txt` and a local +folder listing are all convenience indexes: they are maintained by hand, so they under-count, and the +omission is silent. + +```bash +gh auth status +gh repo list purview-dev --limit 200 \ + --json name,visibility,isArchived,pushedAt,defaultBranchRef +``` + +Drop `isArchived` and anything that is not `PUBLIC`. **If `gh auth status` fails, stop.** Degrading to +the local clone is the exact failure this skill prevents, and it degrades silently. + +The filter denies by default: a repository must be exactly `PUBLIC` and exactly not archived to be +recorded, so a visibility you have never seen fails closed. `gh repo list` also only lists what your +token can see, which is why the token's visibility and the file's contents are two separate questions: +the section below is about the second one. + +A local clone is admissible only when its `HEAD` equals the SHA the snapshot recorded: + +```bash +git -C ../containers rev-parse HEAD +``` + +That is a speed optimisation, never a substitute. + +## 3. Read it at `main` + +Every repository is read at `main`, and the commit is recorded. The snapshot is what makes the claim +checkable a month later, when nobody remembers which commit the analysis saw. + +```bash +node scripts/inventory.mjs # drift between the organisation and the snapshot +node scripts/inventory.mjs --write # regenerate .config/org-inventory.json +node scripts/inventory.mjs --repo containers +``` + +The script is deterministic and dependency-free: it shells out to `gh`, so it reuses the CLI's own +authentication, and it runs the same on Windows and on a Linux agent — it is Node, not a shell script, +so there is no PowerShell edition and no Bash edition to keep in step. It writes +`.config/org-inventory.json` in the repository you run it from — generated, never hand-edited. + +To inspect a decision without a network call, replay a recorded harvest with `--from` and `--details`. +That is the same script, not a variant, so what you review is what runs. + +## 4. Harvest in two tiers + +Seventeen repositories is not a reading list; it is a budget. Two tiers keep it affordable. + +| Tier | Call | Answers | +| -- | -- | -- | +| 1 — shape | `gh api repos/purview-dev//git/trees/main?recursive=1`, for all seventeen | Which folders exist, how many pages the wiki has, whether `skills/` is authored, whether `AGENTS.md` exists. Cheap enough for everything. | +| 2 — content | `gh api -H 'Accept: application/vnd.github.raw' repos/purview-dev//contents/`, **only for the owner of the concern** | What the convention actually says. | + +Tier 1 tells you where the evidence is. Tier 2 reads it, in the one repository that wrote it down. + +```bash +gh api repos/purview-dev/architests/git/trees/main?recursive=1 --jq '.tree[].path' +gh api -H 'Accept: application/vnd.github.raw' \ + repos/purview-dev/architests/contents/docs/wiki/Architecture-As-Tests.md +``` + +More invocations, and what each one is for: `references/harvest-commands.md`. + +## 5. Classify: one owner per concern + +Pick exactly one owner per concern, from `references/best-in-class.md`. Two signs the classification +is wrong: + +- **Two owners for one concern.** `build-sdk` and this playbook's `purview-engineering-principles` + both answer "review this repository". That overlap is a finding to resolve, not a tie to break by + preferring whichever you read first. +- **An owner that has never run the thing it documents.** Check the tree before trusting the page. + +## 6. Divergence register + +Write the differences down before changing anything, with an adopt-or-reject for each. A divergence +with no decision recorded is the same divergence next quarter. + +| Column | Answers | +| -- | -- | +| Concern | What is being compared | +| Owner | The repository the convention comes from, and the path it is stated at | +| Here | What this repository does now | +| Decision | Adopt, or reject with the reason | +| Mechanism | The build rule, test or review obligation that keeps it true | + +Rejecting is a legitimate outcome and is usually the right one at a smaller scale. What is not +legitimate is adopting a convention whose failure the repository cannot have. + +## 7. Adopt + +A convention arrives as a set of changes that land together, or it is a claim with no mechanism: + +```text +the page states the rule, why, how it is enforced, and what a smaller repository does instead +src/ demonstrates it, or says why it does not +the snapshot records the commit it was read at +the skill points at the owner, and never paraphrases it +``` + +**Link to the owner; do not restate it.** A paraphrase is a second source of truth, and the copy with +no owner is the one that loses. + +## 8. Verify + +```bash +just build # zero warnings +just test # architecture, skill and documentation rules +just lint-check +node scripts/inventory.mjs --print +``` + +If the harvest changed what the snapshot should record, regenerate it and commit the diff. The +snapshot being current is a review obligation rather than a build failure — a build that goes red on +a Tuesday because a clock passed is a build people disable. + +## Private repositories + +A private repository may inform a maintainer's judgement. It is never evidence, and it is never +written down. This is not a preference, and it is not a heuristic an agent gets to weigh against a +good argument. Five statements, in order of how often they are broken: + +1. A committed snapshot contains public, non-archived repositories only. The writer denies by default + and refuses to persist anything else, and a test asserts it. +2. A private repository may be read when `gh` is authenticated for it, and what it teaches may shape + a rule. +3. Nothing published names it: not the snapshot, not the page, not the skill, not a workflow, not a + config, not a commit message. Attribute the rule to "a Purview application repository", or to + nothing. +4. **If a rule cannot be justified without the private source, it is not published.** +5. **If you cannot say where a name came from, do not write the name.** A repository the snapshot + does not list is either private or gone, and both fail the same test. + +The fourth is the one an agent rationalises around, because the evidence is often excellent. A rule +whose only justification is out of reach of the people it is published to cannot be reviewed by them, +which makes it folklore with a citation they cannot follow. + +## Checks before you answer + +- Did you read membership from the organisation rather than from a catalogue or a folder? +- Is every claim about a repository true at the SHA the snapshot recorded, not at your clone? +- Did you name exactly one owner per concern, and resolve any overlap rather than duplicating it? +- Did you reject anything? A harvest with no rejections usually means nothing was understood. +- Does every change you propose have a mechanism, or is it labelled a review obligation? +- Is every repository you named public? + +## See also + +- `references/best-in-class.md` — which repository owns which concern, and where to read it +- `references/harvest-commands.md` — the `gh` invocations, and the trap in each one +- `purview-engineering-principles` — the conventions themselves, once you know which owner to read +- `purview-architecture` — the project-shape decisions a harvest most often touches diff --git a/.agents/skills/purview-org-harvest/references/best-in-class.md b/.agents/skills/purview-org-harvest/references/best-in-class.md new file mode 100644 index 0000000..9ab69cc --- /dev/null +++ b/.agents/skills/purview-org-harvest/references/best-in-class.md @@ -0,0 +1,77 @@ +# Best-in-class, by concern + +One owner per concern. Read the owner, not the nearest repository that happens to have a wiki. + +Every row was read from the organisation with `gh repo list purview-dev` and +`gh api repos//git/trees/main?recursive=1`. The page counts and skill counts are the shape at +the time of writing, not a promise: they are what the snapshot records, and the snapshot is +regenerated rather than trusted. + +## The matrix + +| Concern | Owner | Read it at | +| -- | -- | -- | +| Repository shape, MSBuild defaults, analyzers, skill authoring | `build-sdk` | `docs/wiki`, `templates/`, `skills/purview-buildsdk-*` | +| Pipeline, release, pack validation | `build` | `docs/wiki`, `skills/purview-build-*` | +| Architecture policy: dependency direction, naming rules | `architests` | `docs` (nine pages; `Policy-Reference.md` is the reference, `Extending.md` the how-to), `skills/purview-architests-*` | +| Local dependencies in tests | `containers` | `docs/wiki`, `skills/purview-containers-*` | +| TypeScript contracts generated from C# | `zodsharp` | `docs/wiki`, `skills/purview-zodsharp-*` | +| Logging, tracing, metrics | `telemetry-sourcegenerator` | `docs/wiki`, `skills/purview-telemetry-*` | +| Event streams and event storage | `event-sourcing` | `docs/wiki` — **no skills folder** | +| Operation outcomes without exceptions | `results` | `docs/wiki`, `skills/purview-results-*` | +| Validated identity types | `value-objects` | `docs` — **no `docs/wiki`** | +| Aspire hosting resources | `aspire-resourcekit` | `docs/wiki`, `skills/purview-resourcekit-*` | +| Architecture diagrams generated from source | `aspirec4` | `docs/wiki`, `skills/purview-aspirec4-*` | +| LikeC4 diagrams inside an Astro or Starlight site | `astroc4` | `docs/wiki`, `skills/purview-astroc4-*` — an npm package, not .NET | +| Source generator authoring | `sourcegenerator-framework` | `docs/wiki`, `skills/purview-sourcegenerator-*` | +| Skill-set design, and the dated-snapshot pattern | `pm-skills` | `skills/pm-*` — **no documentation folder at all** | +| OpenAPI generated from XML comments | `openapi-xmlcomments` | `docs/wiki`, `skills/purview-openapi-xmlcomments-*` | +| The reusable conventions themselves | `engineering-playbook` | `docs/wiki`, `skills/purview-*` | +| The public project catalogue | `purview-dev.github.io` | `src/src/data/projects/*.yml` | + +`purview-dev.github.io` is the catalogue, and a catalogue is an index rather than a membership list: +an entry is added by hand, so a repository missing from it is silent. `gh repo list` is the authority. + +## The overlaps worth knowing about + +**`build-sdk` and `engineering-playbook` both answer "review this repository".** They are not +duplicates, and the split is worth stating because a harvest will hit it immediately: + +- `build-sdk` owns *what the tooling does* — project detection, the props and targets, the analyzer + set, the packaged test-support rule sets. +- `engineering-playbook` owns *why a repository is shaped that way, and what it costs* — the rule, + the failure it prevents, and what a smaller repository does instead. + +A question about a property name goes to `build-sdk`. A question about whether a project should exist +goes here. + +**`containers` and `aspire-resourcekit` both look like "local dependencies".** `containers` owns the +test-time resource — a real SQL Server, PostgreSQL or queue — and `aspire-resourcekit` owns the +development-time orchestration. A test that needs a database is `containers`; an AppHost that needs +one is `aspire-resourcekit`. + +**`aspirec4` and `astroc4` both draw LikeC4 diagrams.** `aspirec4` owns the diagram generated from a +running Aspire resource graph; `astroc4` owns putting a `.c4` model inside an Astro or Starlight site — +the fence transform and the LikeC4 syntax highlighting. A question about the .NET host is `aspirec4`; a +question about the documentation site is `astroc4`, which is the one owner here that is not a .NET +package, so it is read for its TypeScript conventions rather than for its MSBuild ones. + +## Reading a repository you have not cloned + +Tier 1 first — the shape, for everything: + +```bash +gh api repos/purview-dev//git/trees/main?recursive=1 --jq '.tree[].path' +``` + +Then Tier 2 — the content, for the one owner: + +```bash +gh api -H 'Accept: application/vnd.github.raw' \ + repos/purview-dev//contents/docs/wiki/_Sidebar.md +``` + +`_Sidebar.md` is the fastest index of a wiki, because the repository's own documentation rule says a +page that is not in it does not exist. Where a repository publishes no wiki, its `AGENTS.md` is the +next best entry point, and where it has neither, read `skills/*/SKILL.md` — the trigger in each +`description` says what the repository thinks it is for. diff --git a/.agents/skills/purview-org-harvest/references/harvest-commands.md b/.agents/skills/purview-org-harvest/references/harvest-commands.md new file mode 100644 index 0000000..f4b4653 --- /dev/null +++ b/.agents/skills/purview-org-harvest/references/harvest-commands.md @@ -0,0 +1,121 @@ +# Harvest commands + +Every invocation here is safe to run read-only, and each one is followed by the trap that cost +somebody time. Both shells are shown because the harvest runs on a developer's Windows machine and on +a Linux agent, and the quoting rules are not the same. + +## Membership + +```bash +gh auth status +gh repo list purview-dev --limit 200 --json name,visibility,isArchived,pushedAt,defaultBranchRef +``` + +```powershell +gh auth status +gh repo list purview-dev --limit 200 --json name,visibility,isArchived,pushedAt,defaultBranchRef | + ConvertFrom-Json | Sort-Object name | + Format-Table name, visibility, isArchived, pushedAt +``` + +**Trap: the default limit is 30.** Without `--limit` the list is silently short, and a short list is +worse than a failed one because the omission never announces itself. + +**Trap: `gh repo list` shows what your token can see.** An organisation member sees private +repositories too; filter to `visibility -eq 'PUBLIC'` and `-not isArchived` before recording anything. + +## The SHA a convention was read at + +```bash +gh api repos/purview-dev//git/ref/heads/main --jq .object.sha +``` + +**Trap: a renamed repository resolves by redirect.** Asking for a repository under its former name +answers `purview-dev/purview-dev.github.io` and exits 0, so a stale name never fails. Compare the +returned `full_name` with the name you asked for. + +## Tier 1: the shape + +```bash +gh api repos/purview-dev//git/trees/main?recursive=1 --jq '.tree[].path' +``` + +**Trap: `truncated`.** GitHub caps a recursive tree, and the response says so with `"truncated": true` +rather than erroring. A truncated tree under-counts skills and pages, so check the flag before +believing a zero. + +**Trap: the tree lists blobs and trees.** Filter to `type == "blob"` when counting files, or a folder +whose name matches your pattern is counted as its contents. + +## Tier 2: the content + +```bash +gh api -H 'Accept: application/vnd.github.raw' \ + repos/purview-dev//contents/docs/wiki/_Sidebar.md +``` + +**Trap: `contents` returns JSON unless you ask for raw.** Without the `Accept` header the response is +a base64 `content` field, and the analysis quietly proceeds on a JSON document. + +**Trap: `contents` caps at 1000 files for a directory listing.** Use the tree API for enumerating and +`contents` for reading one file. + +## Windows PowerShell, specifically + +**Embedded double quotes are stripped from a native command's arguments.** This looks correct and is +not: + +```powershell +gh repo list purview-dev --jq '.[] | [.name, (.pushedAt // "-")] | @tsv' +``` + +`gh` receives `// -` and answers `failed to parse jq expression ... unexpected token`. Either keep the +jq program free of string literals, or do the shaping in PowerShell: + +```powershell +$repos = gh repo list purview-dev --limit 200 --json name,visibility | ConvertFrom-Json +$repos | Sort-Object name | ForEach-Object { '{0}|{1}' -f $_.name, $_.visibility } +``` + +**A filtered result is a scalar when it is one item.** `(... | Where-Object { ... }).Count` returns +nothing rather than `1`, so `.Count` in a summary line reads as empty. Wrap it: `@(...).Count`. + +## The snapshot + +```bash +node skills/purview-org-harvest/scripts/inventory.mjs +node skills/purview-org-harvest/scripts/inventory.mjs --write +node skills/purview-org-harvest/scripts/inventory.mjs --repo containers --write +``` + +It resolves the snapshot path from `git rev-parse --show-toplevel`, so it writes into the repository +you are standing in rather than into the one the script was installed from. That is what lets another +repository adopt it unchanged. + +`--repo` merges on write, so reading one repository does not erase the other sixteen. + +## Replaying a harvest, with no network + +Two flags replace the two `gh` calls. This is how the repository's own tests exercise the script on +every platform without touching GitHub, and how a reviewer reproduces what a harvest decided: + +```bash +node skills/purview-org-harvest/scripts/inventory.mjs \ + --from recorded-membership.json \ + --details recorded-details.json \ + --print +``` + +`--from` takes exactly what `gh repo list --json name,visibility,isArchived,pushedAt,defaultBranchRef` +printed. `--details` takes an object keyed by repository name, each with the `commitSha` a tier-1 +harvest found and the `paths` the tree contained. A repository that is in the membership and missing +from the details is an error, not a silent omission — a replay that quietly skipped a repository would +look like a clean run. + +Two behaviours worth knowing before you trust a run: + +- **A snapshot carrying a byte-order mark is accepted.** It is the one file in this workflow a person + is likely to open and re-save, and a BOM is not a corruption. +- **A snapshot containing anything that is not public and unarchived is refused**, and nothing is + written. This includes an entry a hand edit left behind, which the merge would otherwise carry + forward: delete it and re-run `--write`. diff --git a/.agents/skills/purview-org-harvest/scripts/inventory.mjs b/.agents/skills/purview-org-harvest/scripts/inventory.mjs new file mode 100644 index 0000000..affc46f --- /dev/null +++ b/.agents/skills/purview-org-harvest/scripts/inventory.mjs @@ -0,0 +1,466 @@ +#!/usr/bin/env node +/** + * Snapshots every public, non-archived `purview-dev` repository at `main` into + * `.config/org-inventory.json`, and reports the drift between that snapshot and the organisation. + * + * The snapshot is what makes the harvest rule checkable offline: `OrgInventoryTests` reads the file + * the pipeline has, never the network. This script is the only thing that writes it, so a repository + * cannot appear in the snapshot by being remembered. + * + * Zero dependencies: it shells out to `gh` and so reuses the CLI's own authentication rather than + * asking for a token. + * + * node inventory.mjs # print the drift against the snapshot (default) + * node inventory.mjs --write # rewrite the snapshot from the organisation + * node inventory.mjs --repo containers # narrow to one repository; merges on --write + * node inventory.mjs --out # write somewhere else, e.g. while adopting the skill + * node inventory.mjs --help + * + * Exit codes: 0 on success, including when drift is found - a stale snapshot is a review obligation, + * not a red pull request for whoever happened to push next. 1 when `gh` is missing, unauthenticated, + * or the organisation cannot be read. + */ +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +/** The organisation this playbook describes. */ +const ORG = "purview-dev"; + +/** The branch a convention is read at. A local clone is a cache; this is the authority. */ +const BRANCH = "main"; + +/** Bumped when a field is added or its meaning changes. */ +const SCHEMA_VERSION = 1; + +/** Past this age the snapshot is reported as stale. Advisory: nothing fails on a clock. */ +const STALE_AFTER_DAYS = 30; + +/** Files a repository may put at its root to guide an agent. Recorded in this order. */ +const AGENT_GUIDANCE_FILES = ["AGENTS.md", "CLAUDE.md", ".github/copilot-instructions.md", "llms.txt"]; + +/** Directories a published documentation site is read from, most specific first. */ +const DOCS_DIRECTORIES = ["docs/wiki", "docs"]; + +/** + * The concern each repository is best-in-class for, so a harvest reads one owner instead of sixteen + * repositories. `references/best-in-class.md` is the long form of this table and is the file to edit + * first; this map only records the answer in the snapshot. + */ +const CONCERNS = new Map([ + ["architests", ["architecture policy", "dependency and naming rules"]], + ["aspire-resourcekit", ["Aspire hosting resources"]], + ["aspirec4", ["architecture diagrams from source"]], + ["astroc4", ["architecture diagrams in an Astro site"]], + ["build", ["pipeline", "release"]], + ["build-sdk", ["repository shape", "analyzers and style", "MSBuild defaults", "skill authoring"]], + ["containers", ["local dependencies in tests"]], + ["engineering-playbook", ["the conventions themselves"]], + ["event-sourcing", ["event streams", "event storage"]], + ["openapi-xmlcomments", ["OpenAPI from XML comments"]], + ["pm-skills", ["skill-set design"]], + ["purview-dev.github.io", ["the public project catalogue"]], + ["results", ["operation outcomes"]], + ["sourcegenerator-framework", ["source generator authoring"]], + ["telemetry-sourcegenerator", ["logging", "tracing", "metrics"]], + ["value-objects", ["validated identity types"]], + ["zodsharp", ["TypeScript contracts from C#"]], +]); + +/** Runs `gh` and returns stdout, turning a CLI failure into an actionable message. */ +function gh(args) { + try { + return execFileSync("gh", args, { + encoding: "utf8", + maxBuffer: 256 * 1024 * 1024, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + const detail = (error.stderr ?? "").toString().trim() || error.message; + throw new Error( + `gh ${args.join(" ")} failed.\n${detail}\n\n` + + "This script reads the organisation, not a local clone. Run `gh auth status`, then retry.", + ); + } +} + +/** Calls one GitHub REST endpoint and parses the JSON response. */ +function api(endpoint) { + return JSON.parse(gh(["api", endpoint])); +} + +/** The root of the repository the command was run in, so the skill works from any repository. */ +function repositoryRoot() { + try { + return execFileSync("git", ["rev-parse", "--show-toplevel"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + } catch { + throw new Error("Not inside a git repository. Run this from a clone, or pass --out ."); + } +} + +/** Membership, as the organisation reports it. The catalogue is a convenience index, not the list. */ +function readMembership(source) { + const listed = source + ? JSON.parse(readFileSync(source, "utf8")) + : JSON.parse( + gh([ + "repo", + "list", + ORG, + "--limit", + "200", + "--json", + "name,visibility,isArchived,pushedAt,defaultBranchRef", + ]), + ); + + return ( + listed + // Deny by default. The filter is "exactly public and not archived" rather than "not + // private", so a visibility value this script has never seen fails closed instead of + // falling through into a file that is committed to a public repository. + .filter((repository) => repository.visibility === "PUBLIC" && repository.isArchived === false) + .sort((left, right) => left.name.localeCompare(right.name, "en")) + ); +} + +/** The recorded commit and file list for each repository, when a run replays a previous harvest. */ +function readDetails(source) { + return new Map(Object.entries(JSON.parse(readFileSync(source, "utf8")))); +} + +/** The Markdown pages directly inside a docs directory. */ +function countPages(paths, directory) { + const prefix = `${directory}/`; + return paths.filter((path) => { + if (!path.startsWith(prefix)) { + return false; + } + + const remainder = path.slice(prefix.length); + return remainder.endsWith(".md") && !remainder.includes("/"); + }).length; +} + +/** A skill is a folder under `skills/` that carries a `SKILL.md`. */ +function authoredSkills(paths) { + const names = paths + .filter((path) => /^skills\/[^/]+\/SKILL\.md$/.test(path)) + .map((path) => path.split("/")[1]); + + return [...new Set(names)].sort((left, right) => left.localeCompare(right, "en")); +} + +/** Everything the snapshot records about one repository, read at `BRANCH`. */ +function describeRepository(repository, details) { + // The filter above already excluded these, and this is the second lock on the same door: a + // private repository must never reach the snapshot, so the guard is here as well as there. + if (repository.visibility !== "PUBLIC" || repository.isArchived !== false) { + throw new Error( + `${ORG}/${repository.name} is ${repository.visibility} and archived ${repository.isArchived}. ` + + "The snapshot records public, unarchived repositories only, so nothing was written.", + ); + } + + const defaultBranch = repository.defaultBranchRef?.name ?? BRANCH; + if (defaultBranch !== BRANCH) { + throw new Error( + `${ORG}/${repository.name} has default branch '${defaultBranch}', not '${BRANCH}'. ` + + "The snapshot pins one branch, so either the rule or the repository has to change.", + ); + } + + const recorded = details?.get(repository.name); + if (details && !recorded) { + throw new Error( + `${repository.name} has no recorded detail, so --details cannot describe it. ` + + "Replay a membership the details file was captured with, or run without --details.", + ); + } + + const commitSha = recorded?.commitSha ?? api(`repos/${ORG}/${repository.name}/git/ref/heads/${BRANCH}`).object.sha; + const paths = recorded?.paths ?? readTree(repository.name); + const docsDir = DOCS_DIRECTORIES.find((candidate) => + paths.some((path) => path.startsWith(`${candidate}/`)), + ); + + return { + name: repository.name, + visibility: repository.visibility.toLowerCase(), + archived: repository.isArchived, + commitSha, + pushedAt: repository.pushedAt, + docsDir: docsDir ?? null, + docsPages: docsDir ? countPages(paths, docsDir) : 0, + skills: authoredSkills(paths), + agentGuidance: AGENT_GUIDANCE_FILES.filter((file) => paths.includes(file)), + concerns: CONCERNS.get(repository.name) ?? [], + }; +} + +/** The blob paths of one repository at `BRANCH`. */ +function readTree(name) { + const tree = api(`repos/${ORG}/${name}/git/trees/${BRANCH}?recursive=1`); + + if (tree.truncated) { + process.stderr.write( + `warning: ${name}'s tree is truncated, so its skills and docs are undercounted\n`, + ); + } + + return tree.tree.filter((entry) => entry.type === "blob").map((entry) => entry.path); +} + +/** The snapshot document. */ +function snapshotOf(repositories) { + return { + schemaVersion: SCHEMA_VERSION, + org: ORG, + branch: BRANCH, + fetchedAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), + repositories, + }; +} + +/** The snapshot on disk, or null when the repository has not committed one yet. */ +function readSnapshot(path) { + if (!existsSync(path)) { + return null; + } + + // The snapshot is the one file in this workflow a person is likely to open and save by hand, and + // an editor that writes a byte-order mark produces a file JSON.parse rejects with a message that + // names neither the file nor the cause. + return JSON.parse(readFileSync(path, "utf8").replace(/^\uFEFF/, "")); +} + +/** A field's value in the form the drift report prints. */ +function render(value) { + if (value === null || value === undefined) { + return "none"; + } + + if (Array.isArray(value)) { + return value.length === 0 ? "none" : value.join(", "); + } + + return String(value); +} + +/** A field's value in the form the drift report compares: structured values compare as one string. */ +function fingerprint(value) { + return JSON.stringify(value ?? null); +} + +/** The fields whose change is worth reporting, in the order a reader cares about them. */ +const COMPARED_FIELDS = [ + "commitSha", + "docsDir", + "docsPages", + "skills", + "agentGuidance", + "concerns", + "visibility", + "archived", +]; + +/** The differences between the organisation and the snapshot. */ +function drift(existing, observed) { + const findings = []; + const before = new Map((existing?.repositories ?? []).map((entry) => [entry.name, entry])); + const after = new Map(observed.map((entry) => [entry.name, entry])); + + for (const [name, entry] of after) { + if (!before.has(name)) { + findings.push(`${name}: not in the snapshot`); + continue; + } + + const previous = before.get(name); + for (const field of COMPARED_FIELDS) { + if (fingerprint(previous[field]) === fingerprint(entry[field])) { + continue; + } + + findings.push(`${name}: ${field} ${render(previous[field])} -> ${render(entry[field])}`); + } + } + + for (const name of before.keys()) { + if (!after.has(name)) { + findings.push(`${name}: in the snapshot but no longer a public repository`); + } + } + + return findings; +} + +/** How long ago the snapshot was taken, in whole days, or null when it carries no usable date. */ +function ageInDays(fetchedAt) { + const fetched = Date.parse(fetchedAt ?? ""); + return Number.isNaN(fetched) ? null : Math.floor((Date.now() - fetched) / 86_400_000); +} + +/** Parses the command line. Unknown arguments are a mistake worth failing on. */ +function parseArguments(argv) { + const options = { write: false, repos: [], out: null, from: null, details: null, help: false }; + + for (let index = 0; index < argv.length; index++) { + const argument = argv[index]; + if (argument === "--write") { + options.write = true; + } else if (argument === "--print") { + options.write = false; + } else if (argument === "--help" || argument === "-h") { + options.help = true; + } else if (argument === "--repo") { + const value = argv[++index]; + if (!value) { + throw new Error("--repo needs a repository name."); + } + + options.repos.push(value); + } else if (argument === "--out") { + const value = argv[++index]; + if (!value) { + throw new Error("--out needs a path."); + } + + options.out = value; + } else if (argument === "--from") { + const value = argv[++index]; + if (!value) { + throw new Error("--from needs a path to a recorded `gh repo list` response."); + } + + options.from = value; + } else if (argument === "--details") { + const value = argv[++index]; + if (!value) { + throw new Error("--details needs a path to a recorded detail file."); + } + + options.details = value; + } else { + throw new Error(`Unknown argument '${argument}'. Run with --help.`); + } + } + + return options; +} + +/** The documented usage. */ +const HELP = `Snapshot every public, non-archived ${ORG} repository at ${BRANCH}. + + node inventory.mjs print the drift between the organisation and the snapshot + node inventory.mjs --write rewrite the snapshot from the organisation + node inventory.mjs --repo narrow to one repository; merges when writing + node inventory.mjs --out write somewhere other than .config/org-inventory.json + node inventory.mjs --from replay a recorded "gh repo list --json" response instead of + asking the organisation + node inventory.mjs --details replay recorded commits and file lists, so a run touches no + network at all; pair it with --from + node inventory.mjs --help this text + +The snapshot is generated, never hand-edited. A stale snapshot is a review obligation rather than a +build failure, so drift prints and the exit code stays 0.`; + +/** Runs the command. */ +function main() { + const options = parseArguments(process.argv.slice(2)); + if (options.help) { + console.log(HELP); + return; + } + + const snapshotPath = options.out ?? join(repositoryRoot(), ".config", "org-inventory.json"); + const existing = readSnapshot(snapshotPath); + + let observed = readMembership(options.from); + if (options.repos.length > 0) { + const known = new Set(observed.map((repository) => repository.name)); + for (const name of options.repos) { + if (!known.has(name)) { + throw new Error( + `'${name}' is not a public, non-archived ${ORG} repository. ` + + `Run without --repo to include all ${observed.length}.`, + ); + } + } + + observed = observed.filter((repository) => options.repos.includes(repository.name)); + } + + const details = options.details ? readDetails(options.details) : null; + const described = observed.map((repository) => describeRepository(repository, details)); + + if (options.write) { + const merged = new Map((existing?.repositories ?? []).map((entry) => [entry.name, entry])); + for (const entry of described) { + merged.set(entry.name, entry); + } + + const written = snapshotOf( + [...merged.values()].sort((left, right) => left.name.localeCompare(right.name, "en")), + ); + + // The merge carries forward whatever the file already held, which is how a hand-edited entry + // would survive a regeneration. Refuse to write a snapshot that records anything else. + const notPublic = written.repositories.filter( + (entry) => entry.visibility !== "public" || entry.archived !== false, + ); + if (notPublic.length > 0) { + throw new Error( + "Refusing to write a snapshot that records a repository which is not public and " + + `unarchived: ${notPublic.map((entry) => entry.name).join(", ")}. ` + + "Delete the entries and re-run --write.", + ); + } + + mkdirSync(dirname(snapshotPath), { recursive: true }); + writeFileSync(snapshotPath, `${JSON.stringify(written, null, "\t")}\n`, "utf8"); + console.log( + `wrote ${written.repositories.length} repositories to ${snapshotPath} at ${written.fetchedAt}`, + ); + return; + } + + const findings = drift(existing, described); + const age = ageInDays(existing?.fetchedAt); + + console.log(`${ORG}: ${observed.length} public, non-archived repositories at ${BRANCH}`); + if (existing) { + console.log( + `snapshot: ${existing.repositories?.length ?? 0} repositories, fetched ${existing.fetchedAt}` + + (age === null ? "" : ` (${age} day${age === 1 ? "" : "s"} old)`), + ); + } else { + console.log(`no snapshot at ${snapshotPath} - run with --write to create one`); + } + + if (findings.length === 0) { + console.log("no drift"); + } else { + console.log(`${findings.length} difference${findings.length === 1 ? "" : "s"}:`); + for (const finding of findings) { + console.log(` ${finding}`); + } + } + + if (age !== null && age > STALE_AFTER_DAYS) { + console.log( + `warning: the snapshot is older than ${STALE_AFTER_DAYS} days. ` + + "Re-run with --write and review the diff.", + ); + } +} + +try { + main(); +} catch (error) { + console.error(`error: ${error.message}`); + process.exitCode = 1; +} diff --git a/.agents/skills/purview-persistence/SKILL.md b/.agents/skills/purview-persistence/SKILL.md new file mode 100644 index 0000000..7d6ad91 --- /dev/null +++ b/.agents/skills/purview-persistence/SKILL.md @@ -0,0 +1,149 @@ +--- +name: purview-persistence +description: >- + Use when a .NET project actually persists data and you are choosing a storage approach, integrating + or abstracting EF Core, deriving a well-known identifier, or selecting a GUID primary-key strategy. + Gives provider-specific guidance — SQL Server and PostgreSQL differ and must not be treated alike — + and stays silent for projects that do not persist anything. +license: MIT +metadata: + author: Purview-Dev + version: "1.2" + repository: purview-dev/engineering-playbook +--- + +# Purview persistence + +**Activation condition:** this skill applies only when persistence is real. If the project is a +stateless CLI, a pure library, or a service that calls an API and stores nothing, stop here — none of +this is relevant, and raising a database GUID strategy with that project is noise. + +Confirm persistence from the repository before proceeding (see `purview-architecture`): a data-access +`PackageReference`, a `DbContext`, a connection string, or an explicit requirement to store state. + +## 1. Shape the data access proportionally + +| Scale | Shape | +| -- | -- | +| Small | `DbContext` used directly from the code that needs it. No repository, no unit-of-work wrapper — `DbContext` already is both. | +| Medium | A boundary when domain rules must not know about EF Core, or when a query is complex enough to deserve a name. Often that is a few focused query types, not a generic `IRepository`. | +| Large | Independent infrastructure implementations where there genuinely are several, with the dependency direction enforced by a test. | + +**Do not wrap EF Core in a repository for testability.** `Purview.Containers` gives you a real +database in tests, which is both more faithful and less code than an abstraction built to be mocked. +A repository earns its place when you must swap the store or when `DbContext` is genuinely leaking +into domain decisions — not before. + +## 2. Local dependencies + +`Purview.Containers` provides Aspire resource integrations for the dependencies you need in +development and integration tests — including `MsSql`, `PostgreSql`, `MySql`, `Redis`, `Nats`, +`RabbitMq`, `Azurite` and an Azure Key Vault emulator. Prefer a real container over an in-memory +provider: the in-memory provider does not enforce the constraints that break you in production. + +## 3. EF Core integration + +When EF Core is the store, keep the integration in four places: + +- **`DbContext` is the boundary.** Use it directly; do not add `IRepository` or a unit-of-work wrapper + for testability. `Purview.Containers` gives you a real database in tests. +- **Choose the provider where the context is registered.** Carry the choice as context-options data, not + a `Database.ProviderName` branch at model-build time. +- **Map value objects once.** Configure converters and comparers in the model, not per property. A + value-object key needs a converter-aware value generator. +- **Keep design time and runtime in step.** Both paths make the same provider, strategy and + `ReplaceService` choices, or the migration snapshot describes a different model. + +Migrations are code, reviewed and applied by a deliberate step; the API does not migrate at startup in +production. The full rule, composition sample and checks are in `references/ef-core-integration.md`. + +## 4. Identifiers — derived or generated + +Two decisions live here, and they are independent. Ask them in this order. + +### The deterministic-identifier gate + +Derive an identifier only when its **value must be known before storage** — a system tenant, a seeded +reference row, an installation's service principal, or an ingested object whose provider key is stable +and never recycled. Then derive it with UUIDv5 over an installation-scoped namespace and configure +`ValueGeneratedNever()`, so the sequential convention below leaves it alone. A project with random keys +and no seeding has no use for UUIDv5. An outbox row that merely needs an id before `SaveChanges` takes +the generated strategy below, not a derived one. + +The full rule, catalogue, naming rules, migration caveat and tests are in +`references/well-known-identifiers.md`. + +### The generated-key decision + +Resolve these before recommending anything; most are answered by the repository: + +1. Are GUIDs primary keys, or merely external identifiers over an integer key? +2. Is the value generated by the application or by the database? +3. Which provider, and which version? +4. Does index locality matter at the expected write volume? +5. Must the identifier exist before the row is persisted? + +Question 5 usually decides it. If the application needs the identifier before `SaveChanges` — for an +outbox row, a correlation value, or to build an object graph client-side — the database cannot be the +generator, and the remaining questions are about *which* client-side strategy. + +### The rule that matters most + +**SQL Server and PostgreSQL do not benefit from the same sequential GUID implementation.** Their +index structures and their GUID comparison semantics differ, and a "sequential GUID" that helps one +can be no better than random on the other. Never carry a strategy from one to the other without +re-deriving it. + +- SQL Server specifics: `references/sqlserver.md` +- PostgreSQL specifics: `references/postgresql.md` +- Choosing between them, and when a custom generator is justified: `references/guid-generation.md` + +### Prefer built-in capability + +Use the framework or database capability when it is sufficient — `Guid.CreateVersion7()` on .NET 9+, +`NEWSEQUENTIALID()`, EF Core's own value generators, a provider's native UUIDv7 function. A custom +generator is a maintenance obligation and a correctness risk; introduce one only against a measured +requirement. + +**No NuGet package provides this pattern.** A reviewed generator such as `FastGuid` supplies a +store-ordered implementation, but the strategy witness, options extension, value generator with +value-converter support, and model-finalising convention are repository-local: copy them, do not depend +on a package that claims to own the wiring. Introduce it only for a measured requirement: a second +provider, or an identifier needed before `SaveChanges`. Until the second provider exists, a built-in +capability is the answer. + +## 5. Value objects as keys + +`Purview.ValueObjects` gives strongly-typed identifiers that stop an `OrderId` being passed where a +`CustomerId` belongs. They need an EF Core value converter; configure it once in the model rather +than per property. + +This is about type safety at the boundary, not about the generation strategy — the two decisions are +independent. + +## 6. Migrations and schema + +- Migrations are code: reviewed, tested against a real container, and applied by a deliberate step — + not by the application at startup in production. +- Expect the schema to be ahead of or behind the code during a deployment; design changes to be + compatible in both directions. +- Keep the connection string out of source. Keep it out of telemetry too. + +## Checks before you answer + +- Have you confirmed persistence is actually required? +- If the project uses EF Core, are you recommending the `DbContext` directly rather than a repository + wrapper added for testability? +- Does the design-time path make the same provider and strategy choices as the runtime path? +- Does every generated value have one owner — application, store or derivation — and no second writer? +- If you are deriving an identifier, is its value genuinely known before storage, and is it keyed + `ValueGeneratedNever()`? +- If you are recommending a GUID strategy, do you know the provider *and* its version? +- If you are proposing the strategy/convention, is there a second provider or a pre-save identifier? +- Are you proposing an abstraction you can justify by something other than testability? +- Have you preferred a built-in capability over a custom generator? + +## See also + +- `purview-architecture` — context discovery and proportional structure +- `purview-telemetry` — instrument the data access too; it is where the latency is diff --git a/.agents/skills/purview-persistence/references/ef-core-integration.md b/.agents/skills/purview-persistence/references/ef-core-integration.md new file mode 100644 index 0000000..77c7573 --- /dev/null +++ b/.agents/skills/purview-persistence/references/ef-core-integration.md @@ -0,0 +1,83 @@ +# EF Core integration + +Read this when the project actually uses EF Core. The identifier decisions are in `guid-generation.md` +and `well-known-identifiers.md`; this page is the rest of the integration. + +## The rule + +1. **`DbContext` is the boundary.** Read and write through it directly, or through a narrow query type + when a query deserves a name. Do not add `IRepository` or a unit-of-work wrapper for testability. + `DbContext` already is both, and a real container is a more faithful test than a substitute. +2. **The provider is chosen where the context is registered.** Carry the choice as context-options data; + never branch on `Database.ProviderName` while the model is being built. The model is built once and + cached, so that branch serves one provider's model to the other. +3. **Map a value object once.** Configure the converter and comparer in the model, not on each property. + The `Purview.ValueObjects` generator emits the converters when EF Core is referenced. A value-object + key also needs a converter-aware value generator, or the property receives a raw `Guid`. +4. **One owner per generated value.** The application supplies it, the store generates it, or it is + derived — never two. A derived identifier is `ValueGeneratedNever()`; a store default or computed + column is left to the store. +5. **Design time and runtime agree.** The design-time factory or host registration makes the same + provider, strategy and `ReplaceService` choices as the application. Otherwise the migration snapshot + describes a model the application does not run. +6. **The model cache key covers every input that varies the model.** Provider, strategy, tenant and + design time. EF's service-provider cache and model cache are separate; both need a key that matches + the decision. +7. **Migrations are code.** Persistence owns the definitions; a deliberate step applies them. The API + does not migrate at startup in production. +8. **Integration tests use a real store.** The in-memory provider does not enforce foreign keys, unique + indexes, computed columns or SQL semantics. Use `Purview.Containers` and a `.IntegrationTests` + project. +9. **The connection string is configuration.** Keep it out of source, out of `appsettings.json` in the + repository, and out of telemetry. A redaction test is worth more than a convention. + +## The composition + +The provider, the strategy and the service replacements belong together, where the context is +registered: + +```csharp +services.AddDbContext>((serviceProvider, options) => +{ + ConfigureStore(options, connectionString); + + options + .UseSequentialGuids() + .ReplaceService() + .ReplaceService>(); +}); + +services.AddSingleton>(); +``` + +- `ConfigureStore` is the only place that names `UseSqlServer` or `UseNpgsql`. +- `UseSequentialGuids` adds an options extension; the model reads it instead of asking about the provider. +- `IModelCacheKeyFactory` is replaced because the service-provider hash alone does not key the model + cache. +- The application-facing factory is registered separately, because EF's internal registration is not + reachable from an outbox writer or a correlation-id generator. + +## Migrations + +- Generation and application are separate: `dotnet ef migrations add` in development, a controlled + `Migrator` step in a deployment. +- A migration and `HasData` are compiled and replayed, so they need a literal GUID. A derived identifier + cannot be computed there; print the constant from the derivation and guard it with a test. +- Expect the schema to be ahead of or behind the code during a rollout, and design the change to be + compatible in both directions. +- Never let the API apply migrations at startup in production: a deploy then mutates the schema without + review and without a clean rollback. + +## Checks to write + +| Check | Shape | +| -- | -- | +| Model covers every GUID key | Each non-foreign-key GUID key is generated on add with the expected factory | +| Foreign keys are not generated | A foreign-key GUID keeps the value copied from its principal | +| Explicit configuration wins | A `ValueGeneratedNever()` key keeps its supplied value through `SaveChanges` | +| Two strategies, one context type | Different strategies produce different service providers and models | +| Design time matches runtime | Both models produce the same migration snapshot | +| Real store semantics | A unique index, a foreign key and a computed column behave as the model says in a container | + +The Engineering Playbook's *EF Core Integration* page states the same rules with the sample as evidence; +the runnable implementation is `samples/Acme.Persistence` in that repository, run with `just samples`. diff --git a/.agents/skills/purview-persistence/references/guid-generation.md b/.agents/skills/purview-persistence/references/guid-generation.md new file mode 100644 index 0000000..fb644bb --- /dev/null +++ b/.agents/skills/purview-persistence/references/guid-generation.md @@ -0,0 +1,103 @@ +# Choosing a GUID generation strategy + +The cross-provider decision procedure. Provider detail lives in `sqlserver.md` and `postgresql.md`. + +## Step 0 — is this question in scope? + +Ask nothing and recommend nothing unless **both** are true: + +- The project persists data, and +- GUIDs are (or are about to be) primary keys. + +A stateless CLI, a pure library, a service with an integer key, or a project using GUIDs only as +external correlation identifiers does not need this decision. Raising it anyway is the failure this +page is written to prevent. + +## Step 1 — must the identifier exist before persistence? + +This question decides more than any other. + +**Yes** — the application needs the value before `SaveChanges` for an outbox row, a correlation +identifier, an object graph built client-side, or an identifier returned before commit. The database +cannot be the generator. Go to step 2 with client-side options only. + +**No** — the database may generate it, and database generation is available to you. + +## Step 2 — which provider, and which version? + +Do not skip the version. It changes the available options materially: + +| Provider | Version matters because | +| -- | -- | +| SQL Server | `NEWSEQUENTIALID()` is long-standing; the comparison semantics that make byte-ordered UUIDs useless have never changed | +| PostgreSQL | Native `uuidv7()` exists only from 18; `gen_random_uuid()` is built in from 13 | +| .NET | `Guid.CreateVersion7()` exists from .NET 9 | + +**The two databases do not share an answer.** SQL Server compares `uniqueidentifier` with the last +six bytes most significant, so a byte-ordered UUIDv7 is effectively random to its clustered index. +PostgreSQL compares `uuid` in byte order, so UUIDv7 is genuinely sequential there. A strategy that is +optimal on one is unremarkable or wrong on the other. + +## Step 3 — does locality actually matter? + +Sequential keys exist to reduce index fragmentation and page splits under write pressure. If the +table takes tens of inserts a minute, this is not your problem and a random GUID is the simpler, +more private choice. + +Reach for a sequential strategy when you can point at write volume, measured fragmentation, page +splits, or buffer-pool pressure. "It's best practice" is not a measurement. + +Note also that on SQL Server the problem is often better solved by making the clustered index +something other than the GUID key — see `sqlserver.md`. + +## Step 4 — is unpredictability required? + +Sequential and time-ordered identifiers are guessable, and UUIDv7 discloses creation time to the +millisecond. Where an identifier appears in a URL, is handed to a third party, or acts as a +capability, use a random UUID — or keep the ordered value internal and expose a random surrogate. + +Never use `NEWSEQUENTIALID()` output as a secret. + +## Decision summary + +| Provider | Needs value before insert | Locality matters | Recommendation | +| -- | -- | -- | -- | +| SQL Server | No | Yes | `NEWSEQUENTIALID()` as a store default, or EF Core's client-side `SequentialGuidValueGenerator` | +| SQL Server | Yes | Yes | EF Core `SequentialGuidValueGenerator`, or a store-ordered `FastGuid` strategy when both providers share a model | +| SQL Server | Either | No | `Guid.NewGuid()` | +| PostgreSQL | Yes | Yes | `Guid.CreateVersion7()` with `ValueGeneratedNever()` | +| PostgreSQL | No | Yes | `uuidv7()` on 18+, otherwise generate v7 in the application | +| PostgreSQL | Either | No | `Guid.NewGuid()` or `gen_random_uuid()` | +| SQL Server **and** PostgreSQL, one context type | Either | Yes | A store-specific strategy carried as context-options data. One strategy per provider; see below. | +| Any | Either | Either, but must be unpredictable | Random v4 | + +The two-provider row is the one that needs code: an `ISequentialGuidProvider` witness, an options +extension and a model-finalising convention. Copy them from the Engineering Playbook's *GUID Key +Strategy* page. Never expect one byte-ordered generator to order for both stores. + +A value whose identity is known before storage does not belong in this table. Derive it with UUIDv5 and +key it `ValueGeneratedNever()`; see `well-known-identifiers.md`. + +## Prefer built-in capability + +In order of preference: + +1. A framework capability — `Guid.CreateVersion7()`, EF Core's value generators. +2. A database capability — `NEWSEQUENTIALID()`, `uuidv7()`, `gen_random_uuid()`. +3. A reviewed, tested implementation of a published standard, when neither of the above is available + on your target versions — `FastGuid` is the one the sample uses for store-ordered values. +4. A bespoke generator — only against a measured requirement the first three cannot meet. + +A hand-written sequential GUID generator is easy to get subtly wrong — endianness, clock regression, +counter rollover within a millisecond, and thread safety are all live hazards, and the failure mode +is duplicate or badly-ordered keys found in production. + +## No NuGet package provides this + +No NuGet package provides the strategy/convention pattern. It is repository-local: copy the strategy +witness, options extension, value generator and model-finalising convention from the Engineering +Playbook's *GUID Key Strategy* page. Do not depend on a package that claims to own it, and do not +create one without a measured requirement the built-in options cannot meet. + +The abstraction is earned by a second provider, or by an identifier needed before `SaveChanges` on +both — not by anticipation. Until then a built-in capability is the answer. diff --git a/.agents/skills/purview-persistence/references/postgresql.md b/.agents/skills/purview-persistence/references/postgresql.md new file mode 100644 index 0000000..603987d --- /dev/null +++ b/.agents/skills/purview-persistence/references/postgresql.md @@ -0,0 +1,142 @@ +# PostgreSQL UUID keys + +Read this only when the provider is PostgreSQL and UUIDs are primary keys. The SQL Server answer is +different — see `sqlserver.md`. + +## Why PostgreSQL differs from SQL Server + +Two differences drive everything on this page: + +1. **PostgreSQL has no clustered index.** Tables are heaps; the primary key is a separate B-tree. A + random UUID therefore does not cause row-level page splits the way it does on a clustered SQL + Server table. The cost is in the index: random inserts touch scattered index pages, which hurts + cache locality and WAL volume, but it is a smaller and differently-shaped problem. +2. **`uuid` compares in byte order.** Unlike SQL Server, PostgreSQL orders UUIDs by their bytes. A + UUID that is sequential in byte layout *is* sequential to the index. + +The consequence: **UUIDv7 works here.** It is the natural answer on PostgreSQL and the wrong answer +on SQL Server. Never carry a sequential-GUID choice across the two. + +## The options + +### `Guid.CreateVersion7()` — .NET 9+, application-generated + +The default recommendation for new PostgreSQL schemas with UUID keys. + +```csharp +public sealed class Order +{ + public Guid Id { get; init; } = Guid.CreateVersion7(); +} +``` + +- Time-ordered in byte layout, so index locality is good. +- The value exists before persistence, so outbox rows, correlation and client-side graphs all work. +- Standard (RFC 9562), so it is portable and readable by other systems. +- Embeds a millisecond timestamp — which is also its disclosure caveat. + +Tell EF Core the application supplies it: + +```csharp +builder.Property(o => o.Id).ValueGeneratedNever(); +``` + +Without that, EF Core may treat the key as store-generated and skip your value. + +### Application-generated, store-ordered strategy + +When one context type serves both PostgreSQL and SQL Server, or the identifier must exist before +`SaveChanges` on both, use a store-specific strategy carried as context-options data. The PostgreSQL +implementation returns `FastGuid.NewPostgreSqlGuid()`; a model-finalising convention applies it to +every GUID key and leaves explicitly keyed derived identifiers alone. + +```csharp +public sealed class PostgreSqlSequentialGuidProvider : ISequentialGuidProvider +{ + public static string Name => "PostgreSql"; + + public static Guid NewGuid() => FastGuid.NewPostgreSqlGuid(); + + public static Guid NewGuid(DateTime timestampUtc) => + FastGuid.NewPostgreSqlGuid(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static DateTime GetTimestamp(Guid value) => FastGuid.PostgreSql.GetTimestamp(value); + + public static Guid MinValueFor(DateTime timestampUtc) => + FastGuid.PostgreSql.MinGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static Guid MaxValueFor(DateTime timestampUtc) => + FastGuid.PostgreSql.MaxGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); +} +``` + +- PostgreSQL compares `uuid` in plain big-endian byte order, so both `FastGuid.NewPostgreSqlGuid()` + and `Guid.CreateVersion7()` are genuinely sequential here. `FastGuid` is also correct for SQL Server, + which is the point of the strategy abstraction. +- Set `ValueGeneratedNever()` through the convention; never let EF fetch a store-generated value that + does not exist. + +#### RFC caveat + +`FastGuid.NewPostgreSqlGuid()` values are **not** RFC 9562 UUIDs. They are ordinary GUIDs with a +PostgreSQL-ordered layout, so PostgreSQL's `uuid_extract_timestamp()` will not read them even though +they are valid `uuid` values. Use the provider's `GetTimestamp` (above) instead. If another system +needs a standard timestamp, generate `Guid.CreateVersion7()` there and accept that the layout differs. + +The full sample is the Engineering Playbook's *GUID Key Strategy* page. The abstraction is earned by +the second provider, not by anticipation. + +### Database-generated UUIDv7 — PostgreSQL 18+ + +PostgreSQL 18 added a native `uuidv7()` function. Earlier versions do not have it; on 13–17 you have +`gen_random_uuid()` (v4, from the built-in pgcrypto-derived function) and nothing native for v7. + +**Check the server version before recommending `uuidv7()`.** It is the single most common way this +guidance goes wrong — the function simply does not exist on the version most deployments are running. + +```sql +-- PostgreSQL 18 and later only +ALTER TABLE orders ALTER COLUMN id SET DEFAULT uuidv7(); +``` + +Prefer application-side generation anyway unless rows are inserted by paths that bypass the +application, because the value-before-persistence property is worth more than the marginal +difference. + +### `gen_random_uuid()` — database-generated v4 + +Available without an extension from PostgreSQL 13. Random, so index locality is poor, but +unpredictable and simple. + +### `Guid.NewGuid()` — application-generated v4 + +Random and unpredictable. Fine at low to moderate write volume, which covers most applications. + +## Npgsql and EF Core notes + +- `Guid` maps to `uuid` natively. Do not store UUIDs as `text` or `varchar` — it costs space, loses + index efficiency and defeats comparison. +- Npgsql's `uuid` mapping is byte-order faithful, so a v7 value sorts in the index as you expect. +- If the application generates the key, set `ValueGeneratedNever()` so EF Core does not fetch a + store-generated value that does not exist. +- A legacy database may have UUIDs in `text` columns. Migrating to `uuid` is usually worth it, but it + is a data migration, not a model change. + +## Choosing + +| Situation | Use | +| -- | -- | +| New schema, .NET 9+, UUID keys | `Guid.CreateVersion7()`, `ValueGeneratedNever()` | +| .NET 8 or earlier, high write volume | A reviewed UUIDv7 implementation, or accept v4 after measuring | +| Rows inserted outside the application, PostgreSQL 18+ | `uuidv7()` as a column default | +| Rows inserted outside the application, PostgreSQL 13–17 | `gen_random_uuid()` | +| One model serving PostgreSQL and SQL Server | A store-specific `FastGuid` strategy carried as context-options data — never one byte-ordered generator for both | +| Key must be unpredictable | `Guid.NewGuid()` or `gen_random_uuid()` — v7 leaks creation time | +| Low or moderate volume | v4 — the locality difference will not be your bottleneck | + +## The timestamp caveat + +A UUIDv7 exposes when the row was created, to the millisecond. Where identifiers are public, that can +leak signup times, order rates or activity patterns — and it makes identifiers partially guessable by +time window. Where that matters, use a random UUID for the public identifier and keep the v7 value +internal, or use a surrogate. diff --git a/.agents/skills/purview-persistence/references/sqlserver.md b/.agents/skills/purview-persistence/references/sqlserver.md new file mode 100644 index 0000000..2262972 --- /dev/null +++ b/.agents/skills/purview-persistence/references/sqlserver.md @@ -0,0 +1,144 @@ +# SQL Server GUID keys + +Read this only when the provider is SQL Server and GUIDs are primary keys. The PostgreSQL answer is +different — see `postgresql.md`. + +## Why SQL Server is a special case + +A clustered index determines physical row order. A `uniqueidentifier` primary key is clustered by +default, so random GUIDs insert into the middle of the index, causing page splits, fragmentation and +a larger buffer-pool working set. On a write-heavy table this is measurable. + +The complication is that **SQL Server does not compare `uniqueidentifier` values the way the bytes +are laid out.** Its ordering weights the last six bytes (node field) most significantly, then earlier +groups. So a GUID that is sequential in big-endian byte order — including a standard UUIDv7 — is +**not** sequential under SQL Server's comparison. This is precisely why the SQL Server and PostgreSQL +answers diverge. + +## The options + +### `NEWSEQUENTIALID()` — database-generated + +A column default that produces values ascending in SQL Server's own ordering. Best index locality +available. + +```sql +ALTER TABLE [Orders] ADD CONSTRAINT [DF_Orders_Id] + DEFAULT NEWSEQUENTIALID() FOR [Id]; +``` + +EF Core does **not** map `ValueGeneratedOnAdd` to `NEWSEQUENTIALID()` by default. For a `Guid` key on +SQL Server the provider generates the value client-side with `SequentialGuidValueGenerator` (below). +Configure a store default explicitly when the database must own the value: + +```csharp +builder.Property(o => o.Id).HasDefaultValueSql("NEWSEQUENTIALID()"); +``` + +Trade-offs: + +- The value does not exist until the row is inserted. EF Core retrieves it, but you cannot build an + object graph around the key beforehand. +- Values are **not** unpredictable: the sequence restarts on service restart but is otherwise + guessable. Never use one as a security token or capability URL. +- It reveals rough insert ordering, which can be an information-disclosure concern on a public + surface. + +### EF Core's `SequentialGuidValueGenerator` — client-generated + +EF Core ships a value generator that produces GUIDs ordered for SQL Server's comparison semantics, +generated in the application. + +```csharp +builder.Property(o => o.Id) + .HasValueGenerator() + .ValueGeneratedOnAdd(); +``` + +Use this when the identifier must exist before `SaveChanges`. It keeps most of the index locality +while letting the application own the value. + +### Application-generated, store-ordered strategy + +When one context type serves both SQL Server and PostgreSQL, or the identifier must exist before +`SaveChanges` on both, a single built-in generator is not enough: the two stores order GUIDs +differently. The answer is a store-specific strategy carried as context-options data — a +`static abstract` witness whose SQL Server implementation returns `FastGuid.NewSqlServerGuid()`, plus a +value generator and a model-finalising convention that applies it to every GUID key. + +```csharp +public sealed class SqlServerSequentialGuidProvider : ISequentialGuidProvider +{ + public static string Name => "SqlServer"; + + public static Guid NewGuid() => FastGuid.NewSqlServerGuid(); + + public static Guid NewGuid(DateTime timestampUtc) => + FastGuid.NewSqlServerGuid(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static DateTime GetTimestamp(Guid value) => FastGuid.SqlServer.GetTimestamp(value); + + public static Guid MinValueFor(DateTime timestampUtc) => + FastGuid.SqlServer.MinGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); + + public static Guid MaxValueFor(DateTime timestampUtc) => + FastGuid.SqlServer.MaxGuidForTimestamp(UtcTimestamp.EnsureUtc(timestampUtc)); +} +``` + +- The increasing part must sit where SQL Server sorts it. The trailing six bytes dominate + `uniqueidentifier`; a byte-ordered UUIDv7 does not. +- The generator must hand the value through the property's value converter, or a `readonly record + struct OrderId` key receives a raw `Guid`. +- Apply it at convention precedence, so explicit `ValueGeneratedNever()`, a store default and a + computed column all win. + +The full sample is the Engineering Playbook's *GUID Key Strategy* page. The abstraction is earned by +the second provider; on one SQL Server provider `NEWSEQUENTIALID()` or EF Core's generator is enough. + +### `Guid.NewGuid()` — random + +Correct, portable and unpredictable; poor index locality. + +Appropriate when write volume is low (the fragmentation never matters), when the key is +non-clustered, or when unpredictability is a requirement. Do not dismiss it by default — for most +applications it is fine, and "most applications" includes more than people expect. + +### `Guid.CreateVersion7()` — .NET 9+ + +Ordered in byte layout, but **not** ordered under SQL Server's comparison. On SQL Server it behaves +roughly like a random GUID for clustered-index purposes. It is the right answer on PostgreSQL and the +wrong answer here, which is the trap this page exists to prevent. + +Use it on SQL Server only when you want the embedded timestamp for application reasons and accept +random-like insert locality. + +## Decoupling the key from the index + +Often the best answer is not a different GUID but a different index. Keep the GUID as the primary key +and make the clustered index something naturally ascending: + +```csharp +builder.HasKey(o => o.Id).IsClustered(false); +builder.HasIndex(o => o.CreatedAt).IsClustered(); +``` + +This removes the page-split problem entirely and leaves the key unpredictable. Consider it before +reaching for a sequential generator. + +## Choosing + +| Situation | Use | +| -- | -- | +| High insert volume, key may be database-generated | `NEWSEQUENTIALID()` | +| High insert volume, identifier needed before persisting | EF Core `SequentialGuidValueGenerator`, or a store-ordered `FastGuid` strategy when both providers share a model | +| Key must be unpredictable (public surface, security boundary) | `Guid.NewGuid()`, with a non-clustered PK | +| Low or moderate volume | `Guid.NewGuid()` — do not optimise what does not hurt | +| You want a sortable timestamp in the key and accept the locality cost | `Guid.CreateVersion7()` | + +## Measure before optimising + +Index fragmentation from random GUIDs is real but frequently irrelevant. Before changing a key +strategy, confirm it matters: check page splits, fragmentation and buffer-pool pressure on the actual +table at the actual volume. Changing a primary key strategy on a live table is expensive; do it +against evidence. diff --git a/.agents/skills/purview-persistence/references/well-known-identifiers.md b/.agents/skills/purview-persistence/references/well-known-identifiers.md new file mode 100644 index 0000000..372d585 --- /dev/null +++ b/.agents/skills/purview-persistence/references/well-known-identifiers.md @@ -0,0 +1,93 @@ +# Deriving well-known identifiers with UUIDv5 + +Read this only when an identifier's **value must be known before storage**: a system tenant, a seeded +reference row, an installation's service principal, or an ingested object whose provider key is stable +and never recycled. A project with random keys and no seeding has no use for UUIDv5 — say nothing and +move on. An outbox row that merely needs an id before `SaveChanges` takes a generated strategy instead. + +## The rule + +1. Derive the value: `Uuid5.Create(namespace, name)` is RFC 4122 version 5 (SHA-1), byte-identical for + the same namespace and name in every process, language and restart. +2. Scope the namespace to the isolation boundary — one installation or one deployment. Never a global + constant shared by every deployment. +3. Fix the name as a canonical constant of the identity kind. Not a display name, not user data, not a + secret. +4. Once persisted, the value is authoritative. Never recompute it and update the row. +5. Key it explicitly with `ValueGeneratedNever()`, so a sequential-GUID convention never generates over + a value the application already knows. + +## The catalogue + +Write the known identities down in one place. + +| Identity | Namespace | Name | Derived when | Immutable | +| -- | -- | -- | -- | -- | +| System tenant of an installation | installation id | `system-tenant` | first bootstrap | yes | +| System principal of an installation | installation id | `system-service-principal` | first bootstrap | yes | +| Seeded reference row | installation id | `:` | seed step | yes | +| Ingested external object (optional) | installation id | `:` | first ingest | yes | + +Derive the ingested row only when the provider guarantees the external key is stable and never +recycled. If it can be recycled, keep the local key sequential and make the external key a unique +column. + +## Naming rules + +- Lowercase ASCII, hyphen-separated, no whitespace, no locale-dependent casing. +- Never from a display name, email, URL, title, or anything a user can edit. +- Never from a secret, and never as a capability token. A name is guessable. +- Renaming a name creates a new identity. Renaming a persisted one is a data migration. + +## Bootstrap sequence + +Read the persisted identity; if present it wins, and a configured value must match or the operation +fails. If absent, insert it. Derive dependants from the persisted id. Save once. A second run changes +nothing. + +The guard matters as much as the insert: + +```csharp +if (persisted is not null && persisted.Id != configured) +{ + throw new InvalidOperationException( + $"Configured installation id '{configured.Value}' does not match the persisted id '{persisted.Id.Value}'."); +} +``` + +## Migrations cannot call the helper + +`HasData` and migration SQL need a literal GUID. A small script prints ready-to-paste constants from +the same derivation, and a test asserts every printed constant equals `Uuid5.Create(...)`. Changing the +namespace or a name after a migration has shipped is a data migration, not an edit. + +## Checks to write + +| Check | Shape | +| -- | -- | +| RFC 4122 conformance | Fixed vectors for DNS/URL/OID/X500 and the empty name; exact GUIDs | +| Determinism | Same pair twice equal; different names differ | +| Version and variant bits | Byte 6 high nibble `0x5`; byte 8 high bits `10` | +| Cross-language equality | Python `uuid.uuid5` or Node `crypto` reproduces the vectors | +| Catalogue uniqueness | The catalogue's constants are distinct | +| Bootstrap idempotency | Seeding twice changes zero rows; a mismatched configured id throws | +| Explicit keying | The key is `ValueGenerated.Never`, and the sequential convention leaves it alone | + +```text +uuid5(6ba7b810-9dad-11d1-80b4-00c04fd430c8, "example.com") = cfbff0d1-9375-5685-968c-48ce8b15ae17 +uuid5(6ba7b811-9dad-11d1-80b4-00c04fd430c8, "www.example.com") = b63cdfa4-3df9-568e-97ae-006c5b8fd652 +uuid5(6ba7b812-9dad-11d1-80b4-00c04fd430c8, "1.2.3.4") = 67448b45-6d15-536b-bef7-8a78c3d10ac6 +uuid5(6ba7b814-9dad-11d1-80b4-00c04fd430c8, "CN=Bob") = 0840e90d-9ca0-5f14-970e-26294d44cb1d +``` + +The hash path is big-endian. `Guid.ToByteArray()` and `new Guid(byte[])` use a mixed-endian layout and +produce a different hash; that is the most common way a "UUIDv5" implementation is wrong. + +**UUIDv5 is not sequential.** Never use it for a high-write table's primary key. It is for a small, +known set of identities; everything else takes `guid-generation.md`. + +RFC 4122 is the version-5 specification; RFC 9562 is the current UUID specification and retains the +algorithm. The .NET BCL has no UUIDv5 API, so the helper is a small hand-written function. + +The Engineering Playbook's *Well-Known Identifiers* page carries the full helper, catalogue and +bootstrap samples, and their test shapes. diff --git a/.agents/skills/purview-sourcegenerator-attribute-data-models/SKILL.md b/.agents/skills/purview-sourcegenerator-attribute-data-models/SKILL.md new file mode 100644 index 0000000..714ddbf --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-attribute-data-models/SKILL.md @@ -0,0 +1,146 @@ +--- +name: purview-sourcegenerator-attribute-data-models +description: >- + Use when declaring or fixing a [Generate] attribute-data model in Purview.SourceGeneratorFramework - + mapping constructor and named arguments with [Argument]/[Property], defaults, reusing a shape with + [NestedModel], reading an ADM0001-ADM0015 diagnostic, or testing that a model reads the values an + attribute application supplies. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Attribute-data models + +`AttributeDataModelGenerator` turns a `readonly partial record struct` marked `[Generate(typeof(TAttribute))]` +into a parser for `TAttribute`'s `AttributeData`: `Exists`, `Empty`, `TargetAttribute`, the guarded +`FromAttributeData`/`TryFromAttributeData`/`AllAttributeData` overloads, and `FromAttributeDataUnchecked`. +The generated model is a value type, so it is safe to hold in an incremental pipeline (an `AttributeData` +is not). + +The one thing to keep in mind: **the generated code looks names up case-sensitively at run time, and a +name it cannot find quietly yields the default value.** A mapping mistake compiles and produces `null`/`0`. +The naming analyzer (`ADM0011`–`ADM0015`) exists to report those mistakes where they are declared; do not +suppress it without understanding the finding, and test the model against real applications. + +## Mapping each property + +Each primary-constructor parameter becomes a property, PascalCased (`message_template` → `MessageTemplate`). +Its sources are read in this order, first hit wins: + +| Declaration | Reads | +| --- | --- | +| *(no mapping attribute)* | the named argument with the property's name: `[Log(EventId = 7)]` | +| `[Property]` / `[Property(Name = "X")]` | the named argument `X` (default: the property name) | +| `[Argument("messageTemplate")]` | the constructor parameter **named** `messageTemplate` | +| `[Argument]` | the constructor parameter named after the property, camel-cased: `MessageTemplate` → `messageTemplate` | +| `[Argument(1)]` | the constructor argument at **position** 1 | +| `[Argument(...)] [Property]` | the named argument first, then the constructor argument | +| `[NestedModel]` | another generated model, read from the same attribute (below) | +| `[GenericTypeArgument]` | a generic type argument of the attribute class, as a `TypeIdentity` | +| `[Exclude]` | nothing; the parameter is left out of the model | + +Methodology: + +- **Map a constructor parameter by name, not by index.** A name survives a new overload or a reordered + parameter list; an index silently starts reading a different parameter. +- **Map both sources when the attribute accepts both.** `[Argument("name")] [Property] string? Name` reads + `[A(name: "x")]`, `[A("x")]` and `[A(Name = "x")]`. The named argument is read first because it is assigned + after the constructor runs, so it is the attribute instance's effective value. +- **Only map `[Property]` to a public settable property or field.** A property with a private or `internal` + setter can never be a named argument; read it through the constructor parameter that sets it (`ADM0012`). +- **Match case exactly.** `[Argument("MessageTemplate")]` never matches a parameter called + `messageTemplate` (`ADM0011`). +- **Give every property that might be absent a `DefaultValue`**: `[Property(DefaultValue = -1)] int EventId`. + A missing argument takes it; without one, it takes `default(T)`. `Empty` always uses `default(T)`. +- **Use `IsEnum = true` with a `string` property** when the enum type is not available to the generator; a + bare member-name default (`DefaultValue = "Inherit"`) is expanded to the fully-qualified member. +- **Never name a property `Exists`, `Empty`, `TargetAttribute`, `AllAttributeData`, `FromAttributeData`, + `TryFromAttributeData` or `FromAttributeDataUnchecked`** — they are generated (`ADM0014`, generation is + skipped). Keyword names (`Event`, `Class`) are fine. + +## Reusing a shape with `[NestedModel]` + +A `[NestedModel]` parameter is populated by the nested model's `FromAttributeDataUnchecked`, from the +**outer** attribute's arguments, with no `TargetAttribute` check. The outer attribute does not have to derive +from the nested model's target; it only has to use the same constructor parameter and property names. + +```csharp +[Generate(typeof(LogAttribute))] +readonly partial record struct LogAttributeData( + [Argument("level")] [Property(DefaultValue = 99)] int Level, + [Argument("messageTemplate")] [Property] string? MessageTemplate, + [Argument("eventId")] [Property(DefaultValue = -1)] int EventId, + [Property] string? Name +); + +// InfoAttribute has messageTemplate, eventId and Name, but no level. +[Generate(typeof(InfoAttribute))] +readonly partial record struct InfoAttributeData([NestedModel] LogAttributeData Log); +``` + +- The nested model's `Exists` is always `true`; a mapping the outer attribute does not declare (`level` + here) takes its `DefaultValue`. `ADM0013` (info) lists such mappings, so an intended absence is visible + and an accidental one is caught. +- Map nested properties **by name**. A constructor index means a different parameter on each attribute + (`ADM0015`). +- `MatchByInheritance = true` is for using a model on its own against derived attributes. A nested model + does not need it. +- Call `FromAttributeData`/`TryFromAttributeData` from generator code. `FromAttributeDataUnchecked` skips the + type check and is for nested models. + +## Diagnostics + +| Rule | Severity | Meaning and fix | +| --- | --- | --- | +| `ADM0001` | Error | The `[Generate]` target cannot be resolved. Use `typeof`, a resolvable full name, or a type-library `…FullName` constant. | +| `ADM0002` | Error | Unsupported property type (array, pointer, function pointer). | +| `ADM0003` | Error | `[Argument("name")]`/`[Argument(index)]` names no constructor parameter of the target. | +| `ADM0004` | Error | A `[NestedModel]` type is not itself a `[Generate]` model. | +| `ADM0005` | Error | A `DefaultValue` cannot be emitted for the property type. | +| `ADM0006` | Error | A non-nullable reference property has no default. Add a `DefaultValue` or make it nullable. | +| `ADM0007` | Error | `AutoDiscover` needs the `typeof` target overload. | +| `ADM0008` | Error | A `[GenericTypeArgument]` property must be `TypeIdentity`. | +| `ADM0009` | Error | An `IsEnum` property must be `string`/`string?`. | +| `ADM0010` | Error | An `ISymbol`/`Type` property is not cacheable; use `TypeIdentity` or a string. | +| `ADM0011` | Warning | A mapped name matches an attribute member only when case is ignored, so it is never read. Fix the case. | +| `ADM0012` | Warning | The mapped named argument is not a public settable member of the attribute, or a bare `[Argument]` implies a constructor parameter it lacks. Map the member that actually carries the value. | +| `ADM0013` | Info | A nested model property reads only names the outer attribute lacks, so it always takes its default. Expected for a deliberately absent value; otherwise rename to match. | +| `ADM0014` | Error | A property has a generated member's name. Rename the parameter. | +| `ADM0015` | Warning | A nested model reads a constructor index that names a different parameter on the outer attribute. Map it by name. | + +## Testing a model + +Test the attribute side and the model side separately, against what Roslyn actually produces. Use +`AttributeDataFactory` from `Purview.SourceGeneratorFramework.Testing` to compile an application, and the +`AttributeDataAssertions` from the TUnit package to pin each argument by ordinal, parameter name or named +argument, including how it was supplied (`AttributeArgumentForm`): + +```csharp +var options = new MyTestOptions { AdditionalNamespaces = ["My.Attributes"] }; +var attribute = AttributeDataFactory.FromUsage("[Info(\"custom {Value}\", Name = \"N\")]", options); + +await Assert.That(attribute).HasConstructorArgument("messageTemplate", "custom {Value}"); +await Assert.That(attribute).HasConstructorArgument("eventId", -1, AttributeArgumentForm.Defaulted); +await Assert.That(attribute).HasNamedArgument("Name", "N"); + +var model = InfoAttributeData.FromAttributeData(attribute); +await Assert.That(model.Log.MessageTemplate).IsEqualTo("custom {Value}"); +await Assert.That(model.Log.Level).IsEqualTo(99); +``` + +Cover, for each model: positional, by-name and named-argument applications; an application omitting every +optional argument (proves the `DefaultValue`s); for a nested model, an outer attribute that lacks a mapped +member; and the nested target's own `FromAttributeData` returning `Empty` for the outer attribute. +`FromUsage` places the application on a declaration its `AttributeUsage` allows and throws on a compile +error, so a misspelt argument name fails the test. `FromSource` reads applications from a full source. + +Generated-code assertions (`Contains("FromAttributeDataUnchecked(attributeData)")`) prove *what* the +generator emits; the tests above prove the model *reads the right values*. Prefer the latter. + +## Related skills + +`purview-sourcegenerator-testing` for the runner layer, `AttributeDataFactory` and `AttributeArguments`; +`purview-sourcegenerator-tunit-tests` for the assertion extensions; `purview-sourcegenerator-pipeline` for +holding models in an incremental pipeline; `purview-sourcegenerator-diagnostics` for the other analyzer +families. diff --git a/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md b/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md new file mode 100644 index 0000000..ee363c9 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-codewriter/SKILL.md @@ -0,0 +1,334 @@ +--- +name: purview-sourcegenerator-codewriter +description: >- + Use when implementing, reviewing or refactoring C# source generators and analysers built on + Purview.SourceGeneratorFramework. Covers CodeWriter and XmlCommentWriter emission, scope safety, + incremental pipeline design, value equality, deterministic output, and migrating a legacy + string/StringBuilder emitter to CodeWriter. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Source generator CodeWriter modernization + +Use this skill for any work involving C# source generators, analysers, or generated output in `Purview.SourceGeneratorFramework`. It combines CodeWriter/XmlCommentWriter emission guidance with the incremental-source-generator and analyser best practices that ship with the framework. + +Refactoring an existing emitter off `string`/`StringBuilder`? The step-by-step playbook is in +[references/legacy-emitter-migration.md](references/legacy-emitter-migration.md). + +## Required implementation pattern + +When creating source output, favor this shape: + +1. Build immutable, value-equatable pipeline values first. +2. Register source output. +3. Inside the callback: `var writer = generationContext.CreateCodeWriter();` +4. Write header/usings/namespace. +5. Write structured types and members using declaration options records. +6. Add source once per output artifact. + +### CodeWriter lifetime: created in the output callback, never in pipeline state + +The line between "fine" and "forbidden" is the **incremental-cache boundary**, not the act of +creating a writer or handing it to a helper. + +**Fine — output-scoped use inside a `RegisterSourceOutput` callback.** Create the writer inside the +callback and pass it to any emitter/helper methods called from that same callback. It may be held in +local variables, passed as a parameter, or wrapped in a short-lived output context: + +```csharp +context.RegisterSourceOutput( + targets.CombineWithContext(contextProvider), + static (spc, pair) => + { + var (model, generationContext) = pair; + var writer = generationContext.CreateCodeWriter(); + EmitHeader(writer, model); + EmitType(writer, model); + spc.AddSource($"{model.Name}.g.cs", writer.ToString()); + } +); +``` + +**Forbidden — persisting the writer across the incremental-cache boundary.** Do not create it earlier +in the pipeline and pass it down, and do not store it anywhere Roslyn caches or another callback can +observe it: + +- Do not create a `CodeWriter` in a provider stage and carry it through the pipeline. +- Do not store a `CodeWriter` as a property or field on `GenerationContext` or a custom context. +- Do not return a `CodeWriter` (or an object holding one) from an incremental provider. +- Do not cache or reuse a writer for a later callback or a different output. + +A cached writer can retain previously written source, mix output from concurrently running +callbacks, and defeat scope tracking. A writer created in the callback and used only within that +callback is safe and expected. + +## Source generator & analyser best practices + +Apply the following rules to every generator, analyser, and refactor. + +### 1. Core principles + +- **Analyser for validation; generator for generation.** +- **Syntax for syntax, symbols for declarations, operations for executable semantics.** +- **Use `ForAttributeWithMetadataName` for attribute-driven generators.** +- **Remove Roslyn objects from the incremental pipeline as early as possible.** +- **Every value crossing a pipeline boundary must have meaningful value equality.** +- **Prefer many small incremental stages over one large transform.** +- **Keep broad inputs such as `Compilation` away from downstream generation.** +- **Generate deterministic output.** +- **Compile against the oldest Roslyn API version containing the functionality you need.** +- **Test caching, not just generated text.** + +The guiding principle for an incremental generator is: + +> Extract semantic information once, convert it into a small value model, and make everything downstream operate only on that value model. + +### 2. Analyser vs source generator + +Use a `DiagnosticAnalyser` when the question is: + +> Is the source code valid according to this library's rules? + +Use an `IIncrementalGenerator` when the question is: + +> Given valid source code, what source should be generated? + +| Requirement | Prefer | +| --- | --- | +| Require a class to be `partial` | Analyser | +| Require an attribute on a declaration | Analyser | +| Validate a method signature | Analyser | +| Reject unsupported property types | Analyser | +| Detect invalid attribute arguments | Analyser | +| Detect unsupported API usage | Analyser | +| Offer an automatic fix | Analyser + `CodeFixProvider` | +| Generate members for a marked class | Incremental generator | +| Generate serializers/validators/mappers | Incremental generator | +| Generate a registry from discovered types | Incremental generator | +| Read a schema file and generate C# | Incremental generator | +| Internal generation failure | Generator diagnostic | + +### 3. Choosing an analyser action + +Use the narrowest API that represents the concept being analysed: + +- `RegisterSyntaxNodeAction` — exact source syntax (e.g., modifier presence). +- `RegisterSymbolAction` — declaration semantics (e.g., attributes, interfaces, accessibility). +- `RegisterOperationAction` — executable behaviour (e.g., invocation, assignment, object creation). +- `RegisterOperationBlockStart/EndAction` — stateful method analysis. +- `RegisterSymbolStart/EndAction` — type-wide analysis across members. +- `RegisterCompilationStartAction` — resolve known framework symbols once. +- `RegisterAdditionalFileAction` — analyse `AdditionalFiles`. +- Avoid `RegisterSyntaxTreeAction`, `RegisterSemanticModelAction`, and compilation-end actions unless genuinely necessary. + +### 4. Syntax vs symbol vs operation + +Decision tree: + +1. Does exact source spelling/structure matter? → **Syntax** +2. Otherwise, is it a declaration? → **Symbol** +3. Otherwise, is it executable behaviour? → **Operation** + +Use `SymbolEqualityComparer.Default.Equals(...)` when comparing symbols. + +### 5. Analyser best practices + +- Enable concurrent execution with `context.EnableConcurrentExecution()`. +- Explicitly configure generated-code analysis with `context.ConfigureGeneratedCodeAnalysis(...)`. +- Resolve known framework/library symbols once in a `RegisterCompilationStartAction`. +- Prefer narrow registrations over scanning entire syntax trees or compilations. +- Treat diagnostic IDs as public contracts and maintain release tracking files when publishing public diagnostics. + +### 6. Incremental generator golden rules + +Implement `IIncrementalGenerator`. Simply implementing it is not enough; the pipeline must be incremental. + +> **Pipeline values must be immutable and value-equatable.** + +Never keep these in persistent pipeline models: + +| Type | Verdict | +| --- | --- | +| `ISymbol` / `INamedTypeSymbol` / `IMethodSymbol` / `IPropertySymbol` | Never retain | +| `Compilation` | Do not propagate | +| `SemanticModel` | Do not propagate | +| `IOperation` | Do not propagate | +| `SyntaxTree` | Do not propagate | +| `SyntaxNode` | Remove ASAP | +| `Location` | Remove ASAP | +| `AdditionalText` | Project immediately | +| `T[]` / `List` | Avoid | +| `ImmutableArray` | Wrap with sequence equality | + +Use immutable records and `EquatableArray` (sequence equality) for collection members. + +### 7. Designing the pipeline + +Pipeline shape: + +```text +Roslyn Input + ↓ +Cheap discovery + ↓ +Semantic extraction + ↓ +Small equatable model + ↓ +Validation/transformation + ↓ +Generation model + ↓ +Source output +``` + +Guidelines: + +- Project the semantic transform as the boundary where Roslyn objects disappear. +- Prefer `static` callbacks to avoid capturing generator state. +- Honour cancellation tokens. +- Split transformations into many small stages. +- Keep syntax predicates cheap. +- Avoid indirect discovery (every interface implementation, every subclass, entire-compilation scans). + +### 8. Syntax discovery + +- Prefer `context.SyntaxProvider.ForAttributeWithMetadataName(...)` for attribute-driven generators. +- Use `context.SyntaxProvider.CreateSyntaxProvider(...)` only when syntax itself is the trigger and there is no marker attribute. +- The predicate must be cheap; do not walk the tree or do semantic work in it. + +### 9. `Collect`, `Combine`, and invalidation + +- `Collect()` turns per-item outputs into one aggregate. Changing any item invalidates the aggregate. +- Use `Collect()` only for genuinely global output: registries, lookups, duplicate detection, aggregate switches. +- Prefer per-item `RegisterSourceOutput`. +- `Combine()` is correct when the output depends on two providers. +- Avoid `models.Combine(context.CompilationProvider)` — project the compilation to a tiny capability fact first. +- Use `.WithComparer(...)` only when logical equality differs from the default. + +### 10. Diagnostics + +- Prefer a separate `DiagnosticAnalyser` for normal user validation. +- Use generator diagnostics only for malformed additional files, generator-only configuration, conflicting output, or failures that cannot be expressed by an analyser. +- Report diagnostics on the most useful user-authored `Location`. +- Do not keep `Location` in long-lived pipeline models. + +### 11. Output generation + +- Output must be deterministic: no timestamps, random GUIDs, process IDs, machine paths, culture-dependent output, or unordered dictionary output. +- Hint names must be deterministic, unique, and stable. +- Prefer text generation or `CodeWriter` over building Roslyn syntax trees just to stringify them. +- Use `RegisterPostInitializationOutput` for constant source such as marker attributes. +- Add generated source once per output artifact. + +### 12. Testing incrementally + +- Snapshot-testing generated source is not enough. +- Test first execution, cached second execution, unrelated changes remaining cached, per-target invalidation, deletion, renaming, global options, additional files, and global registry invalidation. +- Use `GeneratorDriverOptions` with `trackIncrementalGeneratorSteps: true` and inspect reasons: `New`, `Modified`, `Unchanged`, `Cached`, `Removed`. + +### 13. Roslyn version compatibility and packaging + +- The `Microsoft.CodeAnalysis.*` version used to compile the analyser/generator sets the minimum compiler-host requirement. +- The consumer's `TargetFramework` does not determine analyser compatibility. +- Choose the oldest Roslyn version that contains the APIs you need. +- Common baselines: Roslyn 4.8 for VS 17.8 / .NET 8, 4.12 for VS 17.12 / .NET 9, 5.0 for VS 2026 18.0 / .NET 10. +- Ship one `netstandard2.0` analyser/generator binary unless you have a deliberate multi-version strategy. +- Do not mistake multi-targeting for automatic analyser asset selection. +- Use `PrivateAssets="all"` for Roslyn development dependencies. +- Enable `EnforceExtendedAnalyzerRules` and investigate `RSxxxx` diagnostics before suppressing them. + +### 14. Recommended project configuration + +A generator project should normally include: + +```xml + + netstandard2.0 + latest + enable + true + false + true + true + + + + + + + + + + +``` + +## Preferred API map for CodeWriter + +### File and namespace + +- `AutoGeneratedHeader(...)` +- `Using(...)` +- `FileScopedNamespace(...)` or `BlockNamespace(...)` +- `OpenPragmasScope(...)` for warning suppression scopes + +### Types and members + +- Types: `Class`, `Struct`, `RecordClass`, `RecordStruct`, `Interface`, `Enum`, `Delegate` +- Members: `Method`, `MethodScope`, `Property`, `Field`, `Constructor` +- Attributes: `AttributeDeclarationOptions`, `AttributeArgumentOptions` +- Type syntax: `TypeReference` (nullable/generic/array/pointer-safe composition) + +### XML documentation + +Use `XmlCommentWriter` extension methods on `CodeWriter`: + +- `XmlSummary(...)`, `XmlParam(...)`, `XmlTypeParam(...)`, `XmlReturn(...)`, `XmlRemarks(...)`, `XmlExample(...)` +- `XmlCode(...)` / `XmlCodeBlock(...)` +- `XmlList(...)`, `XmlSeeAlso(...)`, `XmlException(...)` + +Static helpers: `CodeWriter.XmlInlineCode(...)`, `CodeWriter.XmlSee(...)`, `CodeWriter.XmlParamRef(...)`, `CodeWriter.XmlText(...)`. + +## Refactoring guide: string/StringBuilder -> CodeWriter + +Apply this checklist in order: + +1. **Move emission boundaries** — replace giant string assembly with phases: header, namespace, type, members. +2. **Replace manual braces/indentation** — use `using` scopes (`ClassScope`, `MethodScope`, `OpenBlockScope`, `IndentedScope`). +3. **Replace handwritten signatures** — use declaration option records. +4. **Replace raw XML lines** — use XML extension methods (`XmlSummary`, `XmlParam`, etc.). +5. **Normalize type strings** — use `TypeReference`. +6. **Preserve semantics and ordering** — generated members and diagnostics must remain equivalent. +7. **Validate scope safety** — keep or enable `PurviewSourceGeneratorFrameworkValidateCodeWriterScopes` for tests/dev. + +## Anti-patterns to remove during refactors + +- `StringBuilder.AppendLine("public class ...")` for declarations that can be structured. +- Manually writing `{` / `}` around methods and types where scope APIs exist. +- Hard-coded nullable type suffixes and generic syntax in arbitrary strings when `TypeReference` is available. +- Raw XML tag string composition when XML extension methods can enforce consistency. +- Creating a `CodeWriter` before the output callback and passing it through the pipeline. +- Storing a `CodeWriter` on `GenerationContext`, a custom context, or any incremental pipeline model. +- Sharing one `CodeWriter` across multiple generated outputs or callbacks. +- Keeping Roslyn objects, `CodeWriter`, or mutable state in incremental pipeline models. + +## Review checklist for pull requests + +- Generated declarations use structured APIs for types and members. +- XML docs use XML extension methods rather than raw `///` fragments. +- `CodeWriter` is created inside each output callback and never persists in pipeline state; passing it + to helper methods within that callback is expected. +- Header and generated attributes are deterministic and consistent. +- Existing diagnostics, generated member names, and public behavior are preserved. +- Roslyn objects are removed from pipeline models; `EquatableArray` is used for collections. +- Per-target output is preferred over collected global output unless global knowledge is required. +- `CompilationProvider` is not casually combined into output. +- Deterministic hint names and source text are used. +- Incremental caching behavior is tested, not just generated text. + +## See also + +- `agents/source-generator-framework-writer.agent.md` — specialist agent for `Purview.SourceGeneratorFramework` emitter authoring. +- `prompts/refactor-source-generator-to-codewriter.prompt.md` — prompt template for legacy-emitter refactor tasks. diff --git a/.agents/skills/purview-sourcegenerator-codewriter/references/legacy-emitter-migration.md b/.agents/skills/purview-sourcegenerator-codewriter/references/legacy-emitter-migration.md new file mode 100644 index 0000000..54efd09 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-codewriter/references/legacy-emitter-migration.md @@ -0,0 +1,58 @@ +# Migrating a legacy emitter to CodeWriter + +A playbook for moving a source generator's emission from manual `string` / `StringBuilder` +construction to `CodeWriter` plus the `XmlCommentWriter` documentation extensions, with behaviour +parity. + +## Before you start, collect + +- The emitter file(s) being changed. +- The generator type name, and the version string it stamps into generated attributes or headers. +- Whether the output must stay **byte-identical**. If it must, every whitespace decision below is a + constraint rather than a preference, and the diff of the generated snapshots is the acceptance + test. + +## Requirements + +1. Use the structured declaration APIs rather than hand-written declaration text: + `Class` / `Struct` / `RecordClass` / `Interface` / `Enum`, and `Method`, `Property`, `Field`, + `Constructor`. +2. Use the XML helper extensions instead of composing `///` lines: `XmlSummary`, `XmlParam`, + `XmlReturn`, `XmlRemarks`, `XmlCode` / `XmlCodeBlock`. +3. Use `TypeReference` once the type text gets complex — nullability, generics, arrays. +4. Keep the writer **output-scoped**: `generationContext.CreateCodeWriter()` *inside* the output + callback. Never create it earlier in the pipeline, and never store it in incremental provider + state or in a custom context. +5. Preserve behaviour, diagnostics and generated names. +6. Keep the change minimal. Do not reformat logic the migration does not touch. + +## Strategy + +Work phase by phase, because each phase has a different mechanical replacement: + +| Emitter phase | Replace with | +| --- | --- | +| header | the header helper, not a literal banner string | +| namespace | the namespace scope API | +| type declarations | declaration option records, not signature strings | +| member declarations | `Method` / `Property` / `Field` / `Constructor` | +| indentation and braces | scoped APIs — never manual `{`/`}` and `\t` | +| `///` comment composition | `XmlSummary`, `XmlParam`, `XmlReturn`, `XmlRemarks`, `XmlCode` | + +Call out any intentional semantic delta explicitly; a migration that quietly changes emitted names or +diagnostics is the failure mode this playbook exists to avoid. + +## Verification + +- Run the impacted tests. +- Confirm the generated files still compile. +- Confirm there are **no scope leaks**: `OpenScopeCount == 0` when the writer is materialized. Enable + CodeWriter scope validation in the test run. +- Where byte-identical output was required, diff the generated snapshots. + +## Report + +1. Files changed. +2. Why each change was necessary. +3. Risks and behaviour differences, if any. +4. The verification that actually ran. diff --git a/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md b/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md new file mode 100644 index 0000000..b80ebe7 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-diagnostics/SKILL.md @@ -0,0 +1,156 @@ +--- +name: purview-sourcegenerator-diagnostics +description: >- + Use when a Purview.SourceGeneratorFramework build reports a PSGFR, PSGF, PRSGD, TLB or ADM diagnostic, + when choosing which code fix applies, or when a source generator, diagnostic analyzer or code fix + provider is silently not running at all. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Purview.SourceGeneratorFramework diagnostics + +`Purview.SourceGeneratorFramework` ships `Purview.SourceGeneratorFramework.Analyzers` and +`Purview.SourceGeneratorFramework.CodeFixers` as analyzer assets inside the package, under +`analyzers/dotnet/cs/`. They are enabled automatically in any project that references the framework — +there is no separate package to add. + +Two compiler-analyzer families, plus a set of MSBuild-time diagnostics: + +- **Incremental generator best practice** — `PSGFR11`–`PSGFR33`: pipeline design, `CodeWriter` usage, + Roslyn component discovery, extension-class conventions. +- **C# 14 extension-member conventions** — `PSGFR34`–`PSGFR38`, with + `ReorganizeExtensionClassCodeFixProvider` and `ConvertToExtensionBlockCodeFixProvider`. +- **Merge and packaging** — `PSGFR39`–`PSGFR43`, where `PSGFR43` is the merge tool's blocked + public-surface code. + +## Start here when nothing runs + +A compiler host loads a generator, analyzer or code fix provider only when **three** conditions hold. +Missing any one means the component is **silently ignored** — no error, no output, a green build: + +1. **The type is public** (`PSGFR27`). +2. **The type is decorated** — `[Generator]` (`PSGFR26`), `[DiagnosticAnalyzer]` (`PSGFR25`), or + `[ExportCodeFixProvider]` (`PSGFR24`). +3. **The assembly is loaded as an analyzer** — packed under `analyzers/dotnet/cs/` in a package, or + referenced with `OutputItemType="Analyzer"` on a `ProjectReference`. + +A code fix provider additionally appears only when a diagnostic ID in its `FixableDiagnosticIds` is +actually produced by an analyzer loaded alongside it (`PSGFR28`). Visual Studio MEF-composes fix +providers when the analyzer set loads, so after adding or updating a fixer assembly you must restart +Visual Studio or reload the project before the fixes show up. + +## Build-time validation diagnostics + +These are MSBuild diagnostics, not compiler analyzers, so they are **not** tracked in +`AnalyzerReleases.*.md`: + +| Code | Raised by | Summary | +| --- | --- | --- | +| `PSGF0001` | `Purview.BuildSdk` | A Roslyn component did not produce (or declare) a source-generator analyzer file. | +| `PSGF0003` | `Purview.SourceGeneratorFramework` | A `PurviewGeneratorVisibleProperty` is not compiler-visible in the declaring project or its `Sdk/build`/`Sdk/buildTransitive` assets, so consumers cannot read `build_property.`. | +| `PRSGD0005` | `Purview.BuildSdk` | A file in the returned analyzer closure references an assembly that is neither in the closure nor a compiler-host assembly. | +| `PSGFR41` | merge tool | A component's public surface exposes framework types the merge internalizes. Reported by the analyzer at design time, and by the merge pass as `message` (default), `warning` or `error`. | +| `PSGFR43` | merge tool | The merged analyzer still exposes a public framework type, so it is not self-contained. Always an error; the merge fails with exit code 5. | + +Opt out with `PurviewSourceGeneratorFrameworkAnalyzerValidation=false` (`PRSGD0005`) or +`PurviewSourceGeneratorFrameworkGeneratorPropertyValidation=false` (`PSGF0003`). + +## Incremental pipeline rules + +| Rule | Summary | +| --- | --- | +| `PSGFR11` | Prefer `SyntaxProvider.ForAttributeWithMetadataName` over `CreateSyntaxProvider` for attribute-based detection. | +| `PSGFR12` | Use `IIncrementalGenerator` / `RegisterSourceOutput` instead of `ISourceGenerator`. | +| `PSGFR14` | Avoid `RegisterImplementationSourceOutput` unless implementation-only output is required. | +| `PSGFR15` | Pipeline model collection members should use sequence equality (`EquatableArray`). | +| `PSGFR30` | Prefer `static` lambdas in pipeline methods, so the compiler never allocates a closure on the per-item hot path. | +| `PSGFR31` | Prefer `GeneratorAttributeSyntaxContext.TargetSymbol` over `SemanticModel.GetDeclaredSymbol(ctx.TargetNode)`. | +| `PSGFR33` | Pipeline models must not retain Roslyn objects (`ISymbol`, `SyntaxNode`, `Location`, …); extract the information into value types. | + +## CodeWriter rules + +| Rule | Summary | +| --- | --- | +| `PSGFR16` | Prefer the nullable-context `Nullable()`/`MakeNullable()` overload, so annotations honour the target compilation. | +| `PSGFR17` | Consume scope-returning methods (`…Scope`, `IndentedScope`) with `using`. | +| `PSGFR18` | Prefer structured declaration APIs (`Class`, `Method`, `Property`, `Field`) over raw declaration text. | +| `PSGFR19` | Prefer structured statement APIs (`Return`, `MethodCall`, `Throw`, `Assignment`, `Using`, `Comment`) over raw statement text. | +| `PSGFR20` | Prefer the minimal `CodeWriter` overloads over hand-constructing `*DeclarationOptions` values. | +| `PSGFR21` | Prefer `HashDefines`/`HashDefinesScope` for `#if`/`#endif`. | +| `PSGFR22` | Prefer `PragmaDisable`/`OpenPragmasScope` for `#pragma warning`. | +| `PSGFR23` | Prefer structured `IfBlock`/`ElseIf`/`Else` over raw `if` block text. | +| `PSGFR29` | Do not embed a `CodeWriter` in a string; use `XmlCommentWriter.XmlInlineCode`. | +| `PSGFR32` | Avoid `NormalizeWhitespace` when generating source; use an indented text writer such as `CodeWriter`. | + +## Component, extension and merge rules + +| Rule | Summary | +| --- | --- | +| `PSGFR24` | `CodeFixProvider` is not marked `[ExportCodeFixProvider]`; Visual Studio will never discover it. | +| `PSGFR25` | `DiagnosticAnalyzer` is not marked `[DiagnosticAnalyzer]`; it will never run. | +| `PSGFR26` | A generator type is not marked `[Generator]`; it will never run. | +| `PSGFR27` | A Roslyn component type is not public; the compiler host cannot instantiate it. | +| `PSGFR28` | `FixableDiagnosticIds` names a diagnostic no analyzer in the compilation produces; the fix will never be shown. | +| `PSGFR34` | Prefer C# 14 `extension(Receiver)` blocks over classic static `this`-parameter extension methods. | +| `PSGFR35` | Extension class name must match the extended type (`{Receiver}Extensions`). | +| `PSGFR36` | Extension classes belong in the extended type's namespace, under an `Extensions` folder. | +| `PSGFR37` | One extension class per receiver type; split a class that extends several. | +| `PSGFR38` | Extension classes should carry `[EditorBrowsable(EditorBrowsableState.Never)]`. | +| `PSGFR39` | A non-packable Roslyn component opted out of the default self-contained analyzer output (`PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles=false`) while embedding the framework; otherwise the package embeds the loose framework DLL under `analyzers/`. | +| `PSGFR40` | In a Roslyn component (`IsRoslynComponent=true`), reference SGF types as inline code (`Type`) rather than a `cref`: copied documentation must not depend on cref resolution. | +| `PSGFR41` | A public member (or generic constraint) whose signature references an SGF type in a component whose framework implementation is merged. The merge internalizes every SGF type, so the signature ends up referring to an internal type. Make the member, or its declaring type, non-public. | +| `PSGFR42` | A disposable Testing-harness result (`DriverRunResult`) is produced and never disposed, leaking the collectible `AssemblyLoadContext` and `MetadataLoadContext` of a compiled run. `warning` when the run syntactically compiles to an assembly (a `Compile` options member, or `CompileToAssembly = true`), otherwise `info`. Awaiting an assertion, or calling a helper handed the result — an extension on it, or a method taking it as a parameter — is not a production: both hand the same instance back, so only a task-like await or a genuine producer is reported. | + +## Type-library and attribute-model diagnostics + +The bundled generators carry their own families: `TypeLibraryValidationAnalyzer` +(`TLB0001`–`TLB0019`, plus `TLB0021`) and the attribute-data-model analyzers. They are documented on their +own wiki pages, `Type-Library.md` and `Attribute-Data-Models.md`. + +The attribute-data-model rules come from three analyzers: + +| Rules | Analyzer | Covers | +| --- | --- | --- | +| `ADM0001`–`ADM0009` | `AttributeDataModelValidationAnalyzer` | the target, property types, defaults, explicit constructor members | +| `ADM0010` | `AttributeDataModelSymbolPropertyAnalyzer` | non-cacheable `ISymbol`/`Type` properties | +| `ADM0011`–`ADM0015` | `AttributeDataModelNamingAnalyzer` | mapped names the generated code would never find, generated-member name clashes, `[NestedModel]` mappings the outer attribute does not line up with | + +Treat the naming rules as real defects: the generated code reads names case-sensitively and silently falls +back to a default, so a naming mistake never fails the build on its own. `ADM0013` is `Info` because a +nested model may deliberately read a value the outer attribute does not have; check each one. The +`purview-sourcegenerator-attribute-data-models` skill has the full table and the fix for each rule. + +## Code fixes + +Most rules above have a fix in `Purview.SourceGeneratorFramework.CodeFixers`: + +| Fix provider | Rule | +| --- | --- | +| `AddGeneratorAttributeCodeFixProvider` | `PSGFR26` | +| `AddDiagnosticAnalyzerAttributeCodeFixProvider` | `PSGFR25` | +| `AddExportCodeFixProviderAttributeCodeFixProvider` | `PSGFR24` | +| `MakeRoslynComponentPublicCodeFixProvider` | `PSGFR27` | +| `RemoveOrphanedFixableDiagnosticIdCodeFixProvider` | `PSGFR28` | +| `PreferTargetSymbolCodeFixProvider` | `PSGFR31` | +| `PreferStaticLambdaCodeFixProvider` | `PSGFR30` | +| `PreferNullableContextOverloadCodeFixProvider` (project-wide Fix All) | `PSGFR16` | +| `PipelineModelReferenceEqualityCollectionCodeFixProvider` | `PSGFR15` | +| `PreferStructuredCodeWriterIfBlockCodeFixProvider` | `PSGFR23` | +| `CodeWriterToStringCodeFixProvider` | `PSGFR29` | +| `PreferInlineCodeForFrameworkCrefCodeFixProvider` | `PSGFR40` | +| `MakeComponentSurfaceNonPublicCodeFixProvider` | `PSGFR41` | +| `DisposeTestResultCodeFixProvider` | `PSGFR42` | +| `ReorganizeExtensionClassCodeFixProvider` (renames, splits, moves, updates referencing files) | `PSGFR35`, `PSGFR36`, `PSGFR37` | +| `ConvertToExtensionBlockCodeFixProvider` | `PSGFR34` | +| `AddExtensionClassMetadataCodeFixProvider` | `PSGFR38` | +| `MakeTypeLibrarySpecNonPublicCodeFixProvider` | `TLB0021` | +| `AttributeDataModelSymbolPropertyCodeFixProvider` | attribute-data-model symbol properties | +| `TypeLibraryMemberAccessibilityCodeFixProvider`, `TypeLibraryMarkerDefaultInitializerCodeFixProvider`, `MakeTypeLibrarySpecPartialCodeFixProvider`, `RenameTypeLibrarySpecCodeFixProvider`, `TypeLibraryMemberTypeCodeFixProvider` | type-library rules | + +## Related skills + +`purview-sourcegenerator-codewriter` for the emission patterns the `CodeWriter` rules push you +towards, `purview-sourcegenerator-testing` for proving a pipeline actually caches, and +`purview-sourcegenerator-attribute-data-models` for the `ADM` rules. diff --git a/.agents/skills/purview-sourcegenerator-packaging/SKILL.md b/.agents/skills/purview-sourcegenerator-packaging/SKILL.md new file mode 100644 index 0000000..a0e7f80 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-packaging/SKILL.md @@ -0,0 +1,249 @@ +--- +name: purview-sourcegenerator-packaging +description: >- + Use when packaging or referencing a source generator built on Purview.SourceGeneratorFramework - + the self-contained merged analyzer, GetSourceGeneratorAnalyzerFiles, embedding a generator in + another package, PurviewGeneratorVisibleProperty, analyzer-closure validation, Roslyn version + compatibility, and errors such as CS0433, CS8784 or MSB3073 from a loaded analyzer. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Packaging a source generator + +The rule everything else follows from: **a shipped Roslyn component carries its own copy of the +framework and references no loose `Purview.SourceGeneratorFramework.dll`.** Two generators built +against different framework versions must be able to load into one compiler host. A shared DLL under +`analyzers/` cannot give you that — whichever version loads first wins, and the other fails with +`MissingMethodException` or `CS0433`. + +The framework delivers this by IL-merging and internalizing its implementation into each component's +**intermediate** output, leaving the component's `bin` unmerged. + +## Referencing the framework from a component + +```xml + + true + + + + + +``` + +And from a consuming project, as an analyzer: + +```xml + +``` + +`Purview.BuildSdk` invokes `GetSourceGeneratorAnalyzerFiles` automatically — naming +`Targets="GetSourceGeneratorAnalyzerFiles"` explicitly still works but is not required. + +## The three consumption paths + +| Path | Trigger | What you get | +| --- | --- | --- | +| `GetSourceGeneratorAnalyzerFiles` (default) | a consuming project references the component as an analyzer | the **merged** component, from the content-addressed `purview-merged//`. `bin` stays unmerged, so an in-process test harness keeps shared framework type identity and `InternalsVisibleTo` access without `CS0433`. | +| `GetSourceGeneratorAnalyzerFiles` with `PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles=false` | opt-out | unmerged component **plus** the loose framework DLL from the package `lib/`. | +| `GetPurviewMergedAnalyzerFile` | the framework's own bundled-component pack, or a third-party package embedding the generator | the merged component; `bin` is never overwritten. | +| `GetPurviewMergedAnalyzerFileForPack` (pack-time, via `TargetsForTfmSpecificContentInPackage`) | packing a standalone packable generator | the merged DLL and PDB contributed straight to `analyzers/dotnet/cs`. `bin` is never mutated, so its shape does not depend on whether `build` or `pack` ran last. | + +Two properties exist and both should normally be left alone: + +- `PurviewEmbedSourceGeneratorFramework=false` — only for a project that *is* the framework + compile-time library. A published generator package must never disable embedding. +- `PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles=false` — only when the analyzer-files + consumer must keep the unmerged + loose-DLL shape (a generator shipped into one compiler process + alongside an incompatible framework version). + +`PSGFR39` errors when a **non-packable** component opts out while embedding the framework, because +that shape, packed through the GASF path, forces the loose DLL back under `analyzers/`. + +## A component that references another component + +A code-fix or analyzer component can reference the generator component normally (`ProjectReference`, +`ReferenceOutputAssembly` **not** `false`) when it needs the generator's internal diagnostic +identity. Both are `IsRoslynComponent`; only the generator references the framework. + +- The referenced component is returned as **its own merged assembly** and is never IL-merged into the + dependent one — that would duplicate its types, including `InternalsVisibleTo`-visible internals, + inside a second analyzer in the same host. +- The framework assembly is copied beside the generator's `bin` (a plain copy, not copy-to-output + content). A dependent component that references the generator as an assembly gets it copied + beside its own `bin` by ordinary assembly-reference resolution, which is what lets Visual Studio + load the dependent code-fix provider without a `FileNotFoundException` for the framework assembly. +- A project that references a component only as an analyzer (`ReferenceOutputAssembly="false"`) + never receives the framework assembly, and neither does any application downstream of it. Since + 1.2.2 it no longer leaks into app build output, `deps.json`, publish output, single-file bundles or + Blazor WebAssembly `_framework`, where it broke Native AOT and WebAssembly AOT publishing. +- Visual Studio's project system resolves an `OutputItemType=Analyzer` project reference to the + referenced project's **unmerged** `bin` assembly and adds it to the compiler's analyzers — the + command-line build does not. `Purview.BuildSdk` removes that item before adding the resolved + closure, so Roslyn only ever receives the merged artifact. Without that removal the unmerged copy + drags `Purview.SourceGeneratorFramework` into the compiler host + (`CS8784 FileNotFoundException: Could not load file or assembly 'Purview.SourceGeneratorFramework'`) + and every generator registers twice. + +## Analyzer-closure validation + +`Purview.BuildSdk` validates the whole closure `GetSourceGeneratorAnalyzerFiles` returns: every +file's PE `AssemblyRef` must resolve to another file in the returned set, or to a compiler-host +assembly (`Microsoft.CodeAnalysis*`, `System.Composition.*`, `System.*`, `netstandard`, …). A missing +component artifact fails the build with `PRSGD0005`. + +Extend the permitted set with `` or the +`PurviewAnalyzerClosurePermittedReferences` property. Opt out with +`PurviewSourceGeneratorFrameworkAnalyzerValidation=false`. + +## Generator-read MSBuild properties + +A generator reading `build_property.` is only correct if `` is a +`CompilerVisibleProperty` **wherever the generator runs** — including in a consumer's project. +Declare what the component reads: + +```xml + + + +``` + +`PSGF0003` fails the build when a declared property is neither a `CompilerVisibleProperty` in the +project nor declared by the project's own `Sdk/build` or `Sdk/buildTransitive` assets — which is what +consumers actually receive. Opt out with +`PurviewSourceGeneratorFrameworkGeneratorPropertyValidation=false`. + +NuGet imports `buildTransitive` assets for **PackageReference** consumers only. Inside the package +repository every project uses `ProjectReference`, so `Purview.BuildSdk`'s +`ImportProjectReferencedBuildTransitiveAssets` registers the referenced project's +`Sdk/buildTransitive/*.props|*.targets` `CompilerVisibleProperty` items for in-repo consumers. Opt +out with `PurviewImportProjectReferenceBuildTransitive=false`. + +## Embedding a generator in another package + +Pack the **merged** assembly, so the outer package ships no loose framework DLL: + +```xml + + false + $(TargetsForTfmSpecificContentInPackage);PackMyGenerator + + + + + + + + + analyzers/dotnet/cs/ + + + +``` + +If the generator is also embedded at compile time for the outer package's consumers, the outer +package must make the framework's compiler-visible properties visible to them — build assets from +`Purview.SourceGeneratorFramework` are **not** copied into the outer package. Ship a `.props` that +declares the property and its `CompilerVisibleProperty` entry, packed under the outer package's own +ID so NuGet imports it: + +```xml + +``` + +## `IsExternalInit` + +The framework assembly defines `System.Runtime.CompilerServices.IsExternalInit` **publicly**, so the +bundled generators can emit `init`-based attribute types into any consumer compilation and the merge +has a single marker definition to internalize. + +**Do not declare your own.** A generator-local marker gives calls to the framework's `init` setters a +different required-custom-modifier identity from the setter definitions. The merge tool normalizes +those modifiers in a temporary copy for compatibility with legacy tooling, but removing the redundant +marker is the preferred configuration. + +## Roslyn version compatibility + +> The version of `Microsoft.CodeAnalysis.*` you compile the component against sets a **minimum +> compiler-host API requirement**. The consumer's `` has nothing to do with it — +> analyzer code runs inside the compiler/IDE host. + +| Roslyn package | Minimum Visual Studio | Language / .NET generation | +| ---: | --- | --- | +| 4.8 | VS 2022 17.8 | C# 12 / .NET 8 | +| 4.12 | VS 2022 17.12 | C# 13 / .NET 9 | +| 5.0 | VS 2026 18.0 | C# 14 / .NET 10 | + +**This framework is built against Roslyn 5.0**, so a host loading it must be Roslyn 5.0 or later +(.NET 10 SDK / Visual Studio 2026 18.0). The testing packages multi-target `net8.0`–`net10.0`, and +Roslyn 5.x ships `net8.0`/`net9.0` assets, so those test targets still load the runner. + +## A generator project to start from + +```xml + + + netstandard2.0 + latest + enable + true + false + true + true + + + + + + + + + + + + +``` + +`Purview.BuildSdk` already defaults most of this for a project it classifies as a Roslyn component, +so in an SDK repository the project file is usually far shorter. The exact Roslyn baseline is a +product-support decision. + +## Release gates + +Acceptance criteria for self-contained packaging — check metadata, never "the sample compiled": + +1. **No framework reference, no public framework types.** Every `analyzers/dotnet/cs/*.dll` has no + assembly reference to `Purview.SourceGeneratorFramework` and exposes no public type in a + framework-owned namespace (including the generated `Generators` attribute set and the + framework-emitted `Microsoft.CodeAnalysis.EmbeddedAttribute`). The one exception is the + component's own Roslyn entry points, which must stay public — Roslyn only instantiates public + components (`PSGFR27`). +2. **No loose framework DLL in any package**, and no `*.pdb` in the `.nupkg` — symbols ship only + through the `.snupkg`. +3. **Merged entry points survive**: each merged DLL still exposes its `IIncrementalGenerator`, + `DiagnosticAnalyzer` or `CodeFixProvider`. +4. **Installed-package consumer test**: a generator project referencing the framework by + `PackageReference` packs one self-contained DLL under `analyzers/dotnet/cs`, and a consumer that + installs it builds with the generator producing output. +5. **Two-version coexistence test**: build Generator A against the current framework and Generator B + against an intentionally binary-incompatible version, install both into one consumer, and build + with both package-reference orders. Both must run against their own embedded copy. + +## Going deeper + +The merge pass, the content-addressed artifact, framework type internalization and the properties +that tune it are in [references/merge-internals.md](references/merge-internals.md) — read it when a +merge fails, when a type leaks, or when you hit `MSB3073` / `CS0433` / `CS8784` from a loaded +analyzer. diff --git a/.agents/skills/purview-sourcegenerator-packaging/references/merge-internals.md b/.agents/skills/purview-sourcegenerator-packaging/references/merge-internals.md new file mode 100644 index 0000000..5d8b6c9 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-packaging/references/merge-internals.md @@ -0,0 +1,139 @@ +# The merge pass and the self-contained analyzer + +## Where the merged artifact lives + +`_PurviewMergeSourceGeneratorFramework` only ever writes to the component's **intermediate** output. +`GetSourceGeneratorAnalyzerFiles` returns that result by substituting the merged path into +`TargetPathWithTargetPlatformMoniker` immediately before its body runs, leaving `GetTargetPath` — +which resolves assembly references — pointing at the unmerged `bin`. That split is what keeps the +in-repo test harness working while shipped assemblies stay self-contained. + +The artifact is **content-addressed**: + +```text +$(IntermediateOutputPath)purview-merged//$(TargetFileName) +``` + +`` is a SHA-256 over the component, the framework assembly and the merge tool (the tool +computes it in its `--tag` mode). The tag is in the **directory**, never the file name, because +ILRepack derives the merged assembly's simple name from the output file name and an analyzer must +keep `.dll`. + +Two properties follow, and both matter: + +- A rebuild with identical inputs reuses the existing file and **skips the merge**, so + `_PurviewMergeSourceGeneratorFramework` is idempotent across the per-consumer — and potentially + parallel — invocations of `GetSourceGeneratorAnalyzerFiles`. +- Changed inputs select a **new directory**, so the merge never overwrites an assembly a compiler + host (csc, `VBCSCompiler`, Visual Studio) already has loaded. Overwriting such a file fails on + Windows with a sharing violation, which surfaced as `MSB3073` (merge tool exit code + `-532462766`) and left consumers loading a stale or partially written analyzer — the root cause of + `CS8784 FileNotFoundException: Could not load file or assembly 'Purview.SourceGeneratorFramework'`. + +The tool merges into a per-process `.staging-` directory and publishes it with a +non-overwriting directory rename. A concurrent invocation that loses the race simply observes the +published artifact rather than failing. + +Packaging needs the stable `.dll` name, so the pack paths +(`GetPurviewMergedAnalyzerFile`, `GetPurviewMergedAnalyzerFileForPack`) copy the content-addressed +artifact into `$(IntermediateOutputPath)purview-pack/` and pack that plainly named staging copy. + +## Framework type internalization + +ILRepack's `Internalize` is best-effort, and two gaps matter: + +- a framework type that reaches the merged component's public API surface stays public; and +- the types the framework's own generators emit **into** the component — the + `Purview.SourceGeneratorFramework.Generators` attribute set, generated type libraries, marker + attributes — are not part of the merged framework assembly at all, so ILRepack never sees them. + +Either gap leaks framework types out of what must be a self-contained analyzer, and any project +loading that analyzer alongside the real framework assembly fails with `CS0433` ambiguity for every +leaked type. + +So the merge tool runs a deterministic internalization pass (`FrameworkTypeInternalizer`) after +ILRepack finishes: + +- Every type in a framework-owned namespace (`Purview.SourceGeneratorFramework` and its children, + including the generated `Generators` attribute set) becomes non-public. Ownership is evaluated + through the **declaring chain**, because Mono.Cecil reports an empty namespace for a nested type: + the framework's nested containers — generated type-library namespace classes, nested operator/enum + groups, `CodeWriter` scopes — are only reachable through their declaring type. +- Types the framework's generators emitted into the component, recognized by the tool name on their + `System.CodeDom.Compiler.GeneratedCodeAttribute` (`TypeLibraryGenerator`, + `AttributeDataModelGenerator`), become non-public too, so a generated type library can never leak a + `TypeIdentity`/`TypeReference` member as public API. +- `Microsoft.CodeAnalysis.EmbeddedAttribute`, the framework-emitted marker, becomes non-public. +- **Roslyn component entry points are never internalized.** A generator, analyzer, code fix provider + or refactoring provider reachable from the framework namespace stays public, because Roslyn only + instantiates public components (`PSGFR27`). This is what keeps the framework's own bundled + analyzers working after the pass. +- The pass reports the result. Leftover public framework types fail the merge with **exit code 5** + (`PSGFR43`). Public component members whose signature exposes a framework type are logged + (`PSGFR41`) so the author can make them, or their declaring type, non-public. Visibility is + followed through the declaring chain here too: a public nested type inside an internal type — for + example the compiler-synthesised `$`/`$` extension containers emitted for an extension class + — is not reachable from outside the component, so it is not part of the public surface and is not + reported. + +Ownership is seeded with the framework assembly's own type identities, so a framework type living +*outside* the framework namespace (`Microsoft.CodeAnalysis.*Extensions`, `System.StringExtensions`) +is internalized as well. The artifact's assembly-level `InternalsVisibleTo` / +`IgnoresAccessChecksTo` grants are removed, because a shipped analyzer is not the component's own +assembly. + +## Tuning the pass + +| Property | Default | Effect | +| --- | --- | --- | +| `PurviewMergeOwnedNamespaces` | empty | Extra namespace prefixes (semicolon-separated) treated as framework-owned. | +| `PurviewMergeOwnedTypeFullNames` | empty | Extra type full names (semicolon-separated) treated as framework-owned. | +| `PurviewMergePublicSurfaceSeverity` | `message` | How a finding is reported: `message` (plain log text), `warning`/`error` (MSBuild diagnostics with code `PSGFR41`), or `none`. | +| `PurviewMergePublicSurfaceValidation` | `true` | `false` sets the severity to `none`. | +| `PurviewMergePublicSurfaceOrigin` | the merged component assembly | The origin reported with an MSBuild finding; point it at a source or project path for IDE navigation. | + +All five participate in the content tag, so changing one re-runs the merge — and re-emits its +findings — rather than reusing a cached artifact. The compiler analyzer with the same id (`PSGFR41`) +reports the same condition while you edit, so the surface is usually fixed before the merge runs. + +## Generated accessibility: bin versus merged artifact + +The component's own generated types — the type library, the attribute data models — keep their +generated accessibility in the component's **bin output**. `TLB0015` requires a hand-written partial +to be `public static partial` so it can merge with the generated library, and in-repo consumers such +as code fixers and sibling assemblies compile against it. + +Only the **merged analyzer artifact** internalizes them. Self-containment is therefore enforced at +the merge boundary without rewriting the accessibility an in-repo consumer compiles against, and that +split is safe precisely because a merged artifact is never referenced as a compile-time dependency. + +> A merged component is an analyzer artifact. Never reference it as a compile-time dependency. Tests +> that need to run a *packaged* generator load it out of band. + +## How the framework package itself is assembled + +`Purview.SourceGeneratorFramework` is dual-role: the framework assembly ships in `lib/` so generator +projects compile against it, and `analyzers/` carries self-contained generator, analyzer and +code-fixer assemblies. The bundled projects are: + +- `SourceGeneratorFramework.Generators` — `AttributeDataModelGenerator`, `TypeLibraryGenerator`; +- `SourceGeneratorFramework.Analyzers` — the `PSGFR*` and `TLB*` analyzers; +- `SourceGeneratorFramework.CodeFixers` — the code fix providers. + +Each has the framework implementation merged and internalized into it. The package deliberately does +**not** put `Purview.SourceGeneratorFramework.dll` under `analyzers/dotnet/cs`, so generators built +against different framework versions never ask Roslyn to load competing versions of a same-named +dependency. + +The shared models and helpers that once shipped as a separate +`Purview.SourceGeneratorFramework.Shared.dll` are compiled directly into the framework assembly +(`SourceGeneratorShared.Link.targets` links the sources). Consumers get one +`Purview.SourceGeneratorFramework.dll`, which removes the version-skew hazard a separately bundled +Shared assembly caused — generator packages loading a different Shared version in-process failed with +binary-incompatibility errors, such as removed `PurviewTypeLibrary` fields. + +Those projects are `IsRoslynComponent = true` and are **not** packable on their own. The +`SourceGeneratorFramework` project builds them via `GetPurviewMergedAnalyzerFile` and packs them +under `analyzers/dotnet/cs/` in `BuildAndPackBundledAnalyzerAssemblies`. They were previously +consumed as analyzer project references, but they now reference the framework assembly for the shared +types, which would form a project-reference cycle. diff --git a/.agents/skills/purview-sourcegenerator-pipeline/SKILL.md b/.agents/skills/purview-sourcegenerator-pipeline/SKILL.md new file mode 100644 index 0000000..373942f --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-pipeline/SKILL.md @@ -0,0 +1,231 @@ +--- +name: purview-sourcegenerator-pipeline +description: >- + Use when composing an incremental pipeline with Purview.SourceGeneratorFramework - + IncrementalPipeline helpers, GenerationContext and custom contexts, CreateCodeWriter lifetime, + GeneratorResult and blocking versus non-blocking diagnostics, disabling a generator from MSBuild, + scope validation, test logging, and stage tracking names. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Incremental pipeline composition + +`IncrementalPipeline` composes `IncrementalValueProvider` and `IncrementalValuesProvider`: +attribute-based discovery, generation-context creation, disable-property checks, and thin +source-output registration. + +Everything here follows from one rule: **pipeline values must be immutable and value-equatable.** +Roslyn caches the complete value a provider publishes, and offers no way to exclude one property of +it from caching. + +## `GenerationContext` + +A base execution-services context carrying the Roslyn `Compilation`, immutable `GenerationSettings`, +an optional `ISourceGenLogger`, and a factory for independently owned `CodeWriter` instances. + +```csharp +var contextProvider = IncrementalPipeline.DefaultGenerationContextValueProvider(context); +``` + +### Custom contexts + +A custom context does not need to accept or read build properties itself: + +```csharp +public sealed class MyGenerationContext : GenerationContext +{ + public MyGenerationContext(Compilation compilation, GenerationSettings settings, ISourceGenLogger? logger) + : base(compilation, settings, logger) + { + } +} + +var contextProvider = IncrementalPipeline.GenerationContextValueProvider( + context, + nameof(MyGenerator), + "1.0.0", + factory: static (compilation, settings, logger, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + return new MyGenerationContext(compilation, settings, logger); + }, + disablePropertyName: "MyGenerator_Disable" +); +``` + +The provider resolves scope validation, generator disabling and test logging from analyzer-config +properties *before* invoking the factory. The logger is created internally only when logging is +enabled and a sink is registered for that run. + +## `CodeWriter` lifetime — the rule that bites + +Treat `GenerationContext` as **cached incremental state** and each `CodeWriter` as **mutable, +output-scoped execution state**. Create the writer inside the registered source-output callback, +after the incremental cache boundary: + +```csharp +IncrementalPipeline.RegisterSourceOutput( + context, + targets, + contextProvider, + static (spc, target, generationContext) => + { + var writer = generationContext.CreateCodeWriter(); + EmitTarget(generationContext, writer, target); + spc.AddSource($"{target.Name}.g.cs", writer.ToString()); + } +); +``` + +Creating it in the callback and passing it to emitter helpers called from that same callback is the +intended pattern. The only forbidden thing is **persisting the writer in pipeline state**. + +Why, concretely: + +- Roslyn caches the whole published value; you cannot exempt a field. +- `CodeWriter` is mutable, so a cached one retains previously written source when the context is + reused for another output or run. +- Source-output callbacks may process independent targets **concurrently**; a shared writer mixes + their output and races. +- A fresh writer gives each generated source independent scope tracking and deterministic ownership. + +`CreateCodeWriter()` returns a new, independently owned instance every call. + +**Never add a `CodeWriter` property or field to a class derived from `GenerationContext`.** A custom +context is still produced by an incremental provider and cached as one complete value. Store only +compilation-derived services and immutable configuration there. + +When an emitter needs both services and writing, pass the context and the writer separately, or +compose a short-lived wrapper created inside the callback — which must never be returned from a +provider: + +```csharp +public sealed class GenerationOutputContext : ISourceGenLogger + where TContext : GenerationContext +{ + public GenerationOutputContext(TContext generation) + { + Generation = generation; + Writer = generation.CreateCodeWriter(); + } + + public TContext Generation { get; } + + public CodeWriter Writer { get; } + + public void Log(SourceGenLogLevel level, int indentation, string message, params object[] args) => + Generation.Log(level, indentation, message, args); +} +``` + +## `GeneratorResult` and diagnostics that do not stop generation + +`RegisterSourceOutput` combines targets with the generation context, reports diagnostics, and runs +the callback only for successful results: + +```csharp +var targets = IncrementalPipeline.ForAttributeWithMetadataName( + context, + AttributeType, + static (ctx, ct) => + { + var symbol = ctx.TargetSymbol; + + return symbol is null + ? GeneratorResult.Empty + : GeneratorResult.Create(symbol.Name); + } +); +``` + +The callback runs only when `GeneratorResult.ShouldProcess` is `true` — the result carries a value +**and** none of its diagnostics are blocking. + +`ReportableDiagnostic.IsBlocking` is an explicit per-diagnostic decision, **independent of +severity**. An `Error`-severity diagnostic can still allow generation to continue, which is what you +want when the generated code is what helps the developer fix the problem — a generator emitting an +abstract base class can report an error per missing override while still emitting the base class, so +the user can see exactly what to implement: + +```csharp +static readonly DiagnosticDescriptor MissingOverride = new( + "MYGEN001", + "Missing override", + "Type '{0}' must override '{1}'", + "Usage", + DiagnosticSeverity.Error, + isEnabledByDefault: true +); + +// IsBlocking is false, so ShouldProcess stays true and the base class is still emitted. +var diagnostic = ReportableDiagnostic.Create(MissingOverride, isBlocking: false, symbol, symbol.Name, "Execute"); + +return GeneratorResult.Create(model, diagnostic); +``` + +`isBlocking: true` stops generation for that target while still reporting. +`HasBlockingDiagnostics` reports whether any carried diagnostic blocked; +`HasErrorDiagnostics` reports the severity-based view. + +## Disabling a generator at build time + +Pass the compiler-visible disable property to the context provider; its resolved value lands in +`GenerationSettings` automatically: + +```xml + + true + +``` + +```csharp +var contextProvider = IncrementalPipeline.DefaultGenerationContextValueProvider( + context, + nameof(MyGenerator), + "1.0.0", + disablePropertyName: "MyGenerator_Disable" +); + +// In the output stage: +if (generationContext.Settings.IsSourceGeneratorDisabled) + return; +``` + +`IsDisabledValueProvider` is still there for when expensive upstream transforms must be filtered +before being combined with the generation context. + +Declare the property with `PurviewGeneratorVisibleProperty` so it is compiler-visible wherever the +generator runs — `PSGF0003` otherwise fails the build. See the `purview-sourcegenerator-packaging` +skill. + +## Scope validation + +The default context provider reads +`PurviewSourceGeneratorFrameworkValidateCodeWriterScopes` and threads it into +`GenerationSettings.ValidateCodeWriterScopes`. When enabled, `ToString()` throws +`CodeWriterScopeValidationException` if `OpenScopeCount` is not zero. + +## Test logging + +Framework logging is off in ordinary compiler runs. The testing integration enables it by registering +an isolated sink and supplying a per-run session ID through analyzer config. Context providers create +the internal logger automatically — generators implement no logging interface and no logging-support +source is generated. + +The sink registry stores **callbacks only**; it never buffers entries. With logging disabled, a +missing session ID, or no matching sink, the provider supplies no logger and log calls are discarded. +Test sinks own whatever they capture and are removed when the run completes. + +## Tracking names + +The pipeline helpers assign a tracking name to every stage, so a cache test can assert which stages +were recomputed. The `purview-sourcegenerator-testing` skill covers the stage matrix and the four +scenarios a cache test should assert. + +## Related skills + +`purview-sourcegenerator-codewriter` for emission, `purview-sourcegenerator-diagnostics` for the +`PSGFR` rules that enforce most of the above, and `purview-sourcegenerator-testing` for proving the +pipeline caches. diff --git a/.agents/skills/purview-sourcegenerator-testing/SKILL.md b/.agents/skills/purview-sourcegenerator-testing/SKILL.md new file mode 100644 index 0000000..a6da554 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-testing/SKILL.md @@ -0,0 +1,444 @@ +--- +name: purview-sourcegenerator-testing +description: >- + Use when writing or fixing tests for source generators, diagnostic analyzers, code fixes or + refactorings built on Purview.SourceGeneratorFramework - picking the right runner and base class, + configuring SourceGeneratorTestOptions, querying produced code with CodeQuery, testing attribute-data + models against real AttributeData with AttributeDataFactory, and asserting incremental pipeline caching + stage by stage. +metadata: + product: Purview.SourceGeneratorFramework + repository: purview-dev/sourcegenerator-framework +--- + +# Testing source generators, analyzers, code fixes and refactorings + +Use this skill whenever a task involves authoring, fixing, or modernising tests for Roslyn components +(generators, diagnostic analyzers, code fix providers, refactoring providers) built with +`Purview.SourceGeneratorFramework`. It covers the framework-agnostic test runner layer and the +`CodeQuery` syntax-lookup API. For the TUnit base classes and assertion extensions, also load the +`purview-sourcegenerator-tunit-tests` skill. + +Modernising an existing suite onto `CodeQuery` and adding a stage-by-stage cache test? The playbook +is in [references/test-modernisation.md](references/test-modernisation.md). + +## Picking the right runner + +| Roslyn type | Runner | +|---|---| +| `IIncrementalGenerator` / `ISourceGenerator` | `SourceGeneratorTestRunner` | +| `DiagnosticAnalyzer` | `DiagnosticAnalyzerTestRunner` | +| `CodeFixProvider` | `CodeFixTestRunner` (single) | +| — | `CodeFixTestRunner.RunFixAllAsync` (project-wide) | +| `CodeRefactoringProvider` | `RefactoringTestRunner` | + +TUnit projects should prefer the matching base class instead (see `purview-sourcegenerator-tunit-tests`): +`TUnitSourceGeneratorTestBase`, `TUnitDiagnosticAnalyzerTestBase`, `TUnitCodeFixTestBase`, +`TUnitRefactoringTestBase`. + +## Result types + +- `DriverRunResult` (generator) — `DriverResult`, `AllSyntaxTrees`/`PrimarySyntaxTrees`, + `CompilationResult.Compilation`, `GetGeneratedTree`, `GetSource`, `GetTypeByMetadataName`, `LogEntries`. +- `AnalyzerTestResult` — `Diagnostics`, `Compilation`. +- `CodeFixTestResult` — `Diagnostics`, `CodeActions`, `FixedSource`, `Compilation`, `ChangedSolution`. +- `CodeFixFixAllResult` — `Diagnostics`, `CodeActions`, `FixedSources`, `ChangedSolution`. +- `RefactorTestResult` — `CodeActions`, `FixedSources`, `ChangedSolution`, `Compilation`. + +Use `DriverRunResultExtensions` (`AssertNoCompilationErrors`, `AssertNoGenerationExceptions`, +`AssertSingleGeneratedSource`, `AssertGeneratedSourceContains`, …) for quick checks, but prefer +`CodeQuery` for structural assertions. + +## Querying produced code with `CodeQuery` + +Every result exposes a `CodeQuery` via extensions in `CodeQueryResultExtensions`: + +```csharp +result.Generated() // DriverRunResult: generated trees (default, generated-first) +result.Output() // DriverRunResult: entire output compilation (user + generated) +analyzerResult.Code() // AnalyzerTestResult: input compilation +codeFixResult.Code() // CodeFixTestResult: input compilation +codeFixResult.FixedCode() // CodeFixTestResult: parsed fixed source (or post-fix solution) +fixAllResult.FixedCode() // CodeFixFixAllResult: post-fix documents +refactorResult.FixedCode() // RefactorTestResult: post-refactor documents +``` + +Every `Get` has an accompanying `Has` (bool) and `TryGet` (out): `GetMethod`/`HasMethod`/`TryGetMethod`, +`GetClass`, `GetStruct`, `GetInterface`, `GetEnum`, `GetDelegate`, `GetRecord`, `GetProperty`, +`GetField`, `GetConstructor`, `GetNamespace`, `GetTypeDeclaration`, plus generic `Get`/`Has` +and `GetSyntaxTree`/`HasSyntaxTree`. `Get` throws `SyntaxNotFoundException` when nothing matches. + +Type lookups accept an optional generic arity — `GetClass(name, arity)` / `HasClass(name, arity)` — and the +`TypeReference`/`TypeIdentity` overloads match arity automatically from the identity, so +`new TypeIdentity("ResourceDefinition", ns, arity: 1)` finds `ResourceDefinition` without matching the +non-generic `ResourceDefinition`. + +Every `Get` returns a `CodeQueryResult` — the matched syntax node (`Node`) plus a query scoped to it +(`Query`), with implicit conversions to both the node and the scoped query. Use `.Node` for direct syntax +access, or chain member queries (the originating query is carried by the result, so it is not passed again). + +Types can be matched against `TypeReference`/`TypeIdentity`, resolved through the compilation's semantic +model (nullable value types are significant, so `int?` never matches `int`): + +```csharp +result.Generated().HasMethod("DoWork", TypeReference.Create(), TypeReference.Create().Nullable(), complexType); +result.Generated().HasReturnType("Compute", TypeReference.Create()); +result.Generated().GetMethod("Format").HasParameters(TypeReference.Create(), objectReference); +``` + +When a test expects a nullable annotation, prefer the test-only `query.MakeNullable(type)` extension (on a +`CodeQuery`, accepting a `TypeReference` or `TypeIdentity`). It resolves the annotation against the query's +compilation and, unlike `TypeReference.Nullable()`/`TypeIdentity.MakeNullable()`, does not trigger the +`PSGFR16` context-overload suggestion (tests have no generation context to pass). + +Member chaining from a type declaration (`MemberQueryExtensions`): + +```csharp +var service = result.Generated().GetClass("ServiceCollectionExtensions"); // or GetClass(name, "Namespace") +service.HasProperty("Count", intType); +service.HasIndexer(stringType, intType); +service.HasMethod("Add", intType, complexType); +service.HasMethodReturnType("Add", stringType); +service.HasConstructor(stringType); +service.HasAttribute("SomeAttribute"); +``` + +Node-inspection checks on scoped results: + +```csharp +result.Generated().GetClass("Service").HasAccessibility(Accessibility.Public); // resolves C# defaults +result.Generated().GetClass("Service").GetProperty("Name").HasSetterAccessibility(Accessibility.Private); +result.Generated().GetClass("ResourceDefinition", 1).HasGenericTypeParameters("TResourceType"); +result.Generated().GetClass("ResourceDefinition", 1).HasBaseType(new TypeIdentity("ResourceDefinition", ns)); +result.Generated().GetClass("Service").HasNestedType("Builder"); // GetNestedType(...) to fetch +result.Generated().GetClass("Service").IsInNamespace("Example.Models"); // IsInGlobalNamespace() for no namespace +result.Generated().IsInNamespace(cls.Node, "Example.Models"); // or on the query directly +``` + +## Configuring options and a reusable starting point + +`SourceGeneratorTestOptions` is the base record. Common knobs: + +- `AdditionalNamespaces` / `IncludeDefaultNamespaces` — namespaces prepended to test source. +- `AdditionalAssemblyTypes` / `AdditionalReferences` — assemblies referenced by the test compilation + (use `AdditionalAssemblyTypes = [typeof(SomeType)]` to pull in a whole assembly). +- `AdditionalSources` — extra source files added to every run. +- `AnalyzerConfigOptions` — `build_property.*` values; keys without the prefix are also exposed as MSBuild + properties. +- `DisableSourceGeneratorPropertyName` / `DisableSourceGeneratorValue` — generator disable toggle. +- `NullableContextOptions`, `OutputKind`, `LanguageVersion`, `CompileToAssembly`. +- `ValidateCodeWriterScopes`, `EnableLogging`. +- `ExcludeGeneratedSourceHintNames` — hides generated marker trees from `PrimarySyntaxTrees`. + +**Easy starting point recipe.** Derive an options record that seeds the namespaces and assemblies your +generator needs, so every test gets a working compilation with no boilerplate: + +```csharp +public sealed record MyGeneratorTestOptions : SourceGeneratorTestOptions +{ + public MyGeneratorTestOptions() + { + AdditionalNamespaces = AdditionalNamespaces.Add("My.Namespace"); + AdditionalAssemblyTypes = AdditionalAssemblyTypes.AddRange( + typeof(SomeDependencyType), + typeof(TypeIdentity) // the framework's Shared assembly, when needed + ); + DisableSourceGeneratorPropertyName = PropertyLibrary.DisableMyGenerator; + } +} +``` + +`Compile()` returns a copy with `CompileToAssembly = true`, preserving the derived options type. Use the +base class hooks `OnBeforeRun`/`OnBeforeRunAsync`/`OnAfterRun` to mutate sources/options per run (for +example to append a marker attribute source via `WithAdditionalSources`). + +When `CompileToAssembly` is enabled, emission is fully in-memory. On .NET 8+ the assembly loads into a +collectible `AssemblyLoadContext`, so the result is `IDisposable` — `using var result = ...` unloads it and +keeps repeated runs from polluting the default context. `result.CompilationResult.Assembly` is the runnable +assembly (generated code can execute); `result.CompilationResult.Metadata` / `.MetadataAssembly` give a +metadata-only reflection view (types, members, attributes) over the emitted assembly without executing code. + +**Always write `using var result = ...`.** `PSGFR42` ("Dispose test harness results with a using +declaration") flags a `DriverRunResult` that is produced and never disposed — including the indirect case +where a helper creates a result, projects a value out of it and returns only the projection. It is a +`warning` when the run syntactically compiles to an assembly (`.Compile()`, a `Compile` options member, or +`CompileToAssembly = true`) and `info` otherwise; the `DisposeTestResultCodeFixProvider` fix inserts the +`using`. A result that is returned to the caller, or disposed explicitly, is not flagged. Neither is awaiting +an assertion, nor calling a helper that is handed the result — an extension on it, or a method taking it as a +parameter: both hand the same instance back, so `await Assert.That(result).SomeAssertion()` and +`result.AssertNoGenerationExceptions()` are not flagged. + +## Testing attribute applications and attribute-data models + +A generator that reads attributes has two halves to test: what Roslyn gives it for an application, and what +its parsing (usually a generated attribute-data model) makes of that. `AttributeDataFactory` produces the +real `AttributeData` without running a generator; `AttributeArguments` describes it. + +```csharp +var options = new MyTestOptions { AdditionalNamespaces = ["My.Attributes"] }; + +// One application. Placed on a class, or on the first declaration its AttributeUsage allows. +AttributeData attribute = AttributeDataFactory.FromUsage("[Log(\"text\", eventId: 7, Name = \"N\")]", options); + +// Every application on a type, or on one of its members. +var onMember = AttributeDataFactory.FromSource(source, "Sample.IEvents", "Raised", options); + +var arguments = AttributeArguments.From(attribute); +arguments.TryGetConstructorArgument(0, out var byOrdinal); // #0 message: "text" +arguments.TryGetConstructorArgument("eventId", out var byName); // Form = NamedParameter +arguments.TryGetNamedArgument("Name", out var named); // Form = NamedArgument +arguments.ToString(); // [LogAttribute(#0 message: "text", #1 eventId: 7 (by name), Name = "N")] + +var model = LogAttributeData.FromAttributeData(attribute); +``` + +- References and usings come from the options: `AdditionalAssemblyTypes` for the assembly declaring the + attribute, `AdditionalNamespaces` for its namespace, or declare the attribute in `AdditionalSources`. +- A compilation error throws `InvalidOperationException` listing the errors, so a misspelt parameter name + fails the test rather than producing an application with a missing argument. +- `AttributeArgumentForm` tells `Positional`, `NamedParameter` (`message: "x"`), `Defaulted` (an optional + parameter that was not written) and `NamedArgument` (`Name = "x"`) apart, which `AttributeData` alone + cannot. +- `AttributeArgument.ValueEquals` compares numbers across types, enums by value or member name, `typeof` + by `Type`/`TypeIdentity`/name, and arrays element-wise. Names are case-sensitive, as generated models are. +- The TUnit assertions over these (`HasConstructorArgument`, `HasNamedArgument`, …) are in + `purview-sourcegenerator-tunit-tests`; what to cover for a model is in + `purview-sourcegenerator-attribute-data-models`. + +## Best practices + +- **Test attribute parsing against real `AttributeData`** (`AttributeDataFactory`), not only by asserting + on emitted parsing code: emitted code can look right and still read the wrong name. +- **Deterministic output**: `WriteAutoGeneratedHeader` is timestamp-free; assert with + `ContainsGeneratedCode`/`GeneratesCode` (whitespace-flattened) or `CodeQuery`, never with timestamps. +- **Generator references**: to use a generated type in the test project AND pass the generator type to a + runner, reference the generator project twice — once `OutputItemType="Analyzer"` and once as a normal + reference. +- **Multi-target**: build generators against the Roslyn version that supports the test matrix; the + framework is built against Roslyn 5.0 (net8.0/net9.0 assets keep a .NET 8–10 matrix loading); keep + `System.Collections.Immutable` version pinned to the shared one. +- **Scope validation**: keep `PurviewSourceGeneratorFrameworkValidateCodeWriterScopes` enabled; it makes + undisposed `CodeWriter` scopes fail tests. +- **Prefer `CodeQuery` over string matching** for structural assertions (members, signatures, namespaces). +- **Dispose results**: `using var result = ...`, enforced by `PSGFR42`. +- **Cap parallelism**: the harness shares one `MetadataReference` set per process, so memory is largely flat + in the degree of parallelism, but the platform's auto-detected default is both slower and heavier than an + explicit ceiling. Wall clock saturates around four concurrent tests; prefer + `--maximum-parallel-tests 8` (or a `TestingPlatformCommandLineArguments` equivalent) for a project that + multi-targets and runs several TFM executables at once. + +## Incremental cache testing (`RunIncrementalAsync`) + +To prove the pipeline caches correctly stage-by-stage, use `SourceGeneratorTestRunner.RunIncrementalAsync` +(or `GenerateIncrementalAsync` on the TUnit base). It runs a sequence of source sets over a **single shared +`GeneratorDriver`** and captures each run's `TrackedSteps`, keyed by tracking name. The canonical reference +implementation is `IncrementalPipelineCacheTests` in `SourceGeneratorShared.UnitTests`; the end-to-end +generator variant is `ServiceRegistrationCacheTests` in +`SourceGeneratorFramework.ExampleGenerator.UnitTests`. Copy the pattern into your own test project — do +not expect the source repo's files locally. + +### What is being asserted and why + +Roslyn reports one `IncrementalStepRunReason` per step output on each run: + +- `New` — the step ran for the first time. +- `Modified` — the step ran and produced a different value than the previous run. +- `Unchanged` — the step ran but produced the same value. +- `Cached` — the step was skipped and its previous result reused from the incremental cache. + +A pipeline is "caching correctly" when an unchanged input keeps every stage `Cached`/`Unchanged`, and a +targeted change marks **only** the stages whose inputs actually changed `Modified` while unrelated stages +stay `Cached`. If a generator accidentally leaks `Compilation`, `SemanticModel`, `ISymbol`, +`SyntaxNode`, or `Location` into a pipeline model, unrelated stages will report `Modified`/`New` on rerun — +these tests fail the build and catch the regression. + +### The four scenarios every cache test should cover + +1. **First run → all `New`.** Nothing can be cached on the first run; this confirms every stage is tracked + under the expected name. +2. **Identical rerun → all `Cached`/`Unchanged`.** `RunIncrementalAsync(sources, ...)` runs the same source + set twice for exactly this case. This is the strongest "it caches" proof. +3. **Source-only change → only the source/attribute stage `Modified`.** Changing an attributed class must + mark `ForAttribute_*` (and downstream output) `Modified` while property/config stages stay `Cached`. +4. **Property-only change → only the property/configuration stage `Modified`.** Toggling an MSBuild + property (via `IncrementalRunInput.AnalyzerConfig`) must mark `GetMSBuildPropertyValue_*` / + `GetGenerationConfiguration` / `GetGenerationContext_*` `Modified` while `ForAttribute_*` stays `Cached`. + +### How the runner makes this possible + +`RunIncrementalAsync` creates **one** driver, enables incremental step tracking, and **reuses the same +`Compilation` instance for identical source sets** (keyed by prepared source text). Without that reuse, +Roslyn would see a fresh compilation on the second run and report stages `Modified`/`New` even though the +sources are byte-identical — the "cached" assertion would fail. + +### Log entries per run + +`IncrementalCacheRun.LogEntries` carries the framework log entries that run wrote, in order. The sink is +registered for the whole sequence and drained between runs, so entries never accumulate. A rerun whose +output step is cached logs nothing, which makes it a second signal alongside the step reasons: + +```csharp +await Assert.That(result.Runs[0].LogEntries).IsNotEmpty(); +await Assert.That(result.Runs[1].LogEntries).IsEmpty(); +``` + +`DriverRunResult.LogEntries` (the `RunAsync` path) is likewise in write order. Both are empty when +`EnableLogging` is disabled. + +### The `StepReasons` helper + +`IncrementalCacheRun.Steps` is `ImmutableDictionary>` +keyed by tracking name. Flatten each step's `Outputs` into the reasons list so assertions read cleanly: + +```csharp +static ImmutableDictionary> StepReasons(IncrementalCacheRun run) +{ + var builder = ImmutableDictionary.CreateBuilder>(); + foreach (var pair in run.Steps) + builder[pair.Key] = [.. pair.Value.SelectMany(step => step.Outputs.Select(static output => output.Reason))]; + return builder.ToImmutable(); +} +``` + +### Framework pipeline stage names + +- `GetMSBuildPropertyValue_{Property}` — `IncrementalPipeline.PropertyValueProvider`. +- `GetGenerationConfiguration` — `IncrementalPipeline.GenerationContextValueProvider`. +- `GetGenerationContext_{Capabilities}` — e.g. `GetGenerationContext_EmptyCapabilities`. +- `ForAttribute_{AttributeType}` — `IncrementalPipeline.ForAttributeWithMetadataName`. + +The framework reference (`IncrementalPipelineCacheTests`, framework-agnostic runner) shows all four +scenarios against `TestGenerator`/`DiagnosticTestGenerator`: + +```csharp +using System.Collections.Immutable; +using Purview.SourceGeneratorFramework.TestGenerators; +using StepReason = Microsoft.CodeAnalysis.IncrementalStepRunReason; + +public class IncrementalPipelineCacheTests +{ + const string AttributedSource = """ + [TestAttribute] + public partial class MyClass { } + """; + const string ChangedAttributedSource = """ + [TestAttribute] + public partial class AnotherClass { } + """; + const string TestAttributeSource = """ + [System.AttributeUsage(System.AttributeTargets.Class)] + public sealed class TestAttribute : System.Attribute { } + """; + + static SourceGeneratorTestOptions CreateOptions() => + new SourceGeneratorTestOptions() + .WithAdditionalSources(TestAttributeSource) + .WithExcludeGeneratedSourceHintNames("TestAttribute"); + + static ImmutableDictionary> StepReasons(IncrementalCacheRun run) { /* as above */ } + + [Test] + public async Task FirstRun_AllStagesAreNew(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner().RunIncrementalAsync( + [new IncrementalRunInput([AttributedSource])], + CreateOptions(), + cancellationToken); + + var reasons = StepReasons(result.Runs[0]); + await Assert.That(reasons).IsNotEmpty(); + await Assert.That(reasons.Values.SelectMany(r => r).All(r => r == StepReason.New)).IsTrue(); + } + + [Test] + public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner() + .RunIncrementalAsync([AttributedSource], CreateOptions(), cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second.Values.SelectMany(r => r).All(r => r is StepReason.Cached or StepReason.Unchanged)).IsTrue(); + } + + [Test] + public async Task SourceChange_MarksAttributeStageModified_PropertyStagesStayCached(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner().RunIncrementalAsync( + [new IncrementalRunInput([AttributedSource]), new IncrementalRunInput([ChangedAttributedSource])], + CreateOptions(), + cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second["ForAttribute_TestAttribute"]).Contains(StepReason.Modified); + await Assert.That(second["GetMSBuildPropertyValue_DisableTestGenerator"].All(r => r == StepReason.Cached)).IsTrue(); + } + + [Test] + public async Task PropertyChange_MarksPropertyStageModified_AttributeStageStaysCached(CancellationToken cancellationToken) + { + var result = await new SourceGeneratorTestRunner().RunIncrementalAsync( + [ + new IncrementalRunInput([AttributedSource]), + new IncrementalRunInput([AttributedSource], [("build_property.DisableTestGenerator", "true")]), + ], + CreateOptions(), + cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second["GetMSBuildPropertyValue_DisableTestGenerator"]).Contains(StepReason.Modified); + await Assert.That(second["ForAttribute_TestAttribute"].All(r => r == StepReason.Cached)).IsTrue(); + } +} +``` + +### End-to-end generator variant (`GenerateIncrementalAsync`) + +TUnit tests derive from the base class and call `GenerateIncrementalAsync` (which wires the +`OnBeforeRun`/`OnBeforeRunAsync` hooks and the derived options). `ServiceRegistrationCacheTests` in +`SourceGeneratorFramework.ExampleGenerator.UnitTests` is the reference: + +```csharp +public class ServiceRegistrationCacheTests + : TUnitSourceGeneratorTestBase +{ + const string Source = """ + namespace Test; + + [GenerateService] + public class MyService { } + """; + + [Test] + public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) + { + var result = await GenerateIncrementalAsync([Source], cancellationToken: cancellationToken); + + var second = StepReasons(result.Runs[1]); + string[] frameworkStages = + [ + "GetMSBuildPropertyValue_EmitServiceRegistrationInfo", + "GetGenerationConfiguration", + "GetGenerationContext_EmptyCapabilities", + "ForAttribute_GenerateServiceAttribute", + ]; + await Assert.That( + frameworkStages.All(stage => + second.TryGetValue(stage, out var reasons) + && reasons.All(r => r is StepReason.Cached or StepReason.Unchanged))).IsTrue(); + } +} +``` + +**Why the example filters to `frameworkStages`:** `ServiceRegistrationGenerator` emits its own +`GenerateServiceAttribute` via post-initialization output, which is regenerated as a new `SyntaxTree` each +run. That makes Roslyn's *internal* `ForAttributeWithMetadataName` `Compilation` step legitimately report +`Modified` on an identical rerun. Asserting on the framework-named stages (which stay `Cached`/`Unchanged`) +is the meaningful check. If your generator does not depend on its own post-init output, the stricter +"every tracked step is `Cached`/`Unchanged`" assertion (as in the framework `IncrementalPipelineCacheTests`) +is correct. + +Per-run MSBuild-property changes are supplied with `new IncrementalRunInput(sources, [("build_property.X", "value")])`. + +## License + +This project is licensed under the MIT license. \ No newline at end of file diff --git a/.agents/skills/purview-sourcegenerator-testing/references/test-modernisation.md b/.agents/skills/purview-sourcegenerator-testing/references/test-modernisation.md new file mode 100644 index 0000000..6db4780 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-testing/references/test-modernisation.md @@ -0,0 +1,63 @@ +# Modernising a Roslyn test suite + +A playbook for moving an existing test suite onto `CodeQuery` plus the TUnit assertion extensions, +and adding a stage-by-stage incremental cache test. + +## Before you start, collect + +- The test file(s) being changed. +- The Roslyn component under test — generator, analyzer, code fix or refactor (infer it from the + base class if the task does not say). +- The component's type name. + +## Requirements + +### 1. The right base class and method + +| Component | Base class | Method | +| --- | --- | --- | +| Generator | `TUnitSourceGeneratorTestBase` | `GenerateAsync` | +| Analyzer | `TUnitDiagnosticAnalyzerTestBase` | `AnalyzeAsync` | +| Code fix | `TUnitCodeFixTestBase` | `ApplyCodeFixAsync` / `ApplyFixAllAsync` | +| Refactor | `TUnitRefactoringTestBase` | `RefactorAsync` | + +### 2. `CodeQuery` instead of string matching + +Replace `GetGeneratedTree(...)` + `string.Contains(...)` with `CodeQuery` +(`result.Generated().Get/Has/TryGet…`) and the terminal assertion extensions — +`await Assert.That(result).HasGeneratedMethod/Class/Property/Field/SyntaxTree(…)` — which return the +node so a follow-up assertion has something to work with. + +### 3. `TypeReference` instead of signature strings + +Match parameter and return types structurally. A signature string breaks on a whitespace or +nullability change that means nothing. + +### 4. Options in one place + +Derive a `SourceGeneratorTestOptions` record that seeds the required namespaces and additional +assemblies, and remove the per-test duplication it replaces. + +### 5. A stage-by-stage cache test + +Use `RunIncrementalAsync`, or `GenerateIncrementalAsync` on the TUnit base, and assert the four +scenarios. `ServiceRegistrationCacheTests` and `IncrementalPipelineCacheTests` are the reference +pattern. + +| Scenario | Expected | +| --- | --- | +| First run | every framework stage `New` | +| Identical rerun (`RunIncrementalAsync(sources, …)` runs the same source twice) | framework stages `Cached`/`Unchanged` | +| Source-only change | `ForAttribute_*` `Modified`; property/config stages stay `Cached` | +| Property-only change (`new IncrementalRunInput(sources, [("build_property.X", "value")])`) | `GetMSBuildPropertyValue_*`, `GetGenerationConfiguration`, `GetGenerationContext_*` `Modified`; `ForAttribute_*` stays `Cached` | + +Use the `StepReasons(IncrementalCacheRun)` flattening helper. If the generator depends on its own +post-init output, assert on the framework-named stages rather than on every tracked step. + +### 6. Minimal change + +Keep behaviour equivalent, and do not reformat unrelated tests. + +## Verification + +Build the test project and run its suite before finishing. diff --git a/.agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md b/.agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md new file mode 100644 index 0000000..2a3a061 --- /dev/null +++ b/.agents/skills/purview-sourcegenerator-tunit-tests/SKILL.md @@ -0,0 +1,246 @@ +--- +name: purview-sourcegenerator-tunit-tests +description: >- + Use when writing TUnit tests for source generators, diagnostic analyzers, code fixes or + refactorings built on Purview.SourceGeneratorFramework - choosing the correct TUnit base class and + method, customising SourceGeneratorTestOptions, using the TUnit assertion extensions (including + attribute-argument assertions for testing attribute-data models), and + modernising existing tests. +metadata: + product: Purview.SourceGeneratorFramework.Testing.TUnit + repository: purview-dev/sourcegenerator-framework +--- + +# TUnit test authoring for Roslyn components + +Use this skill whenever a task involves authoring, fixing, or modernising **TUnit** tests for Roslyn +components built with `Purview.SourceGeneratorFramework`. It tells you which base class to derive from, +which method to call, how to customise options with an easy starting point, and how to use the TUnit +assertion extensions. For the framework-agnostic runner layer and the `CodeQuery` API, also load the +`purview-sourcegenerator-testing` skill. + +## Base class → method matrix + +| Roslyn type | Base class | Method to call | +|---|---|---| +| `IIncrementalGenerator` / `ISourceGenerator` | `TUnitSourceGeneratorTestBase` | `GenerateAsync(source, options, ct)` | +| `DiagnosticAnalyzer` | `TUnitDiagnosticAnalyzerTestBase` | `AnalyzeAsync(source, options, ct)` | +| `CodeFixProvider` (single fix) | `TUnitCodeFixTestBase` | `ApplyCodeFixAsync(source, options, ct)` | +| `CodeFixProvider` (fix-all) | `TUnitCodeFixTestBase` | `ApplyFixAllAsync(sources, options, ct)` | +| `CodeRefactoringProvider` | `TUnitRefactoringTestBase` | `RefactorAsync(source, options, ct)` | + +For cache tests, `TUnitSourceGeneratorTestBase` also exposes `GenerateIncrementalAsync(...)`. + +Framework-agnostic equivalents (no TUnit): `SourceGeneratorTestRunner`, `DiagnosticAnalyzerTestRunner`, +`CodeFixTestRunner`, `RefactoringTestRunner`. + +## Easy starting point: derive your options record + +Create a test-options record that seeds the namespaces and assemblies your component needs, then pass it +to every test via `new MyTestOptions()`: + +```csharp +public sealed record MyGeneratorTestOptions : SourceGeneratorTestOptions +{ + public MyGeneratorTestOptions() + { + AdditionalNamespaces = AdditionalNamespaces.Add("My.Namespace"); + AdditionalAssemblyTypes = AdditionalAssemblyTypes.AddRange( + typeof(SomeDependencyType), + typeof(TypeIdentity) // framework Shared assembly, when needed + ); + DisableSourceGeneratorPropertyName = "DisableMyGenerator"; + } +} + +public class MyGeneratorTests : TUnitSourceGeneratorTestBase +{ + [Test] + public async Task GeneratesExpectedSource(CancellationToken ct) => + await GenerateAsync("...source...", ct); +} +``` + +Use the base hooks to customise per-run: `OnBeforeRun`/`OnBeforeRunAsync` (mutate sources/options, e.g. +`options.WithAdditionalSources(markerAttributeSource)`) and `OnAfterRun`/`OnAfterRunAsync`. Use +`options.Compile()` to opt into `CompileToAssembly` while preserving the derived options type. + +Write `using var result = await GenerateAsync(...)`: `DriverRunResult` is `IDisposable`, and `PSGFR42` +flags a result that is produced and never disposed (a `warning` once the run compiles to an assembly, +`info` otherwise). It also catches the indirect case — a helper that creates a result, projects a value out +of it and returns only the projection. Awaiting an assertion, or calling a helper that is handed the result — +an extension on it, or a method taking it as a parameter — is not a production: both hand the same instance +back, so neither is flagged. + +For code fixes/refactorings, select a specific registered action via `CodeFixTestOptions.EquivalenceKey` +or `CodeActionIndex`, and `RefactorTestOptions.Span`/`NodeSelector` (e.g. +`NodeSelector = query => query.GetMethod("M")`). + +## TUnit assertion extensions + +All assertion extensions live under `Purview.SourceGeneratorFramework.Testing.TUnit.Assertions` +(globally imported by the package's props). `Assert.That(...)` calls are terminal and **return the value** +when awaited. + +- **`CodeQueryAssertions`** — return `CodeQueryResult` (the matched node via `.Node`, plus a query + scoped to it via `.Query`): `HasGeneratedMethod` (optionally with `TypeReference[]` parameter types), + `HasGeneratedMethodReturnType`, `HasGeneratedClass` (by name or `TypeReference`/`TypeIdentity` identity), + `HasGeneratedProperty`, `HasGeneratedField`, `HasGeneratedSyntaxTree`; `HasFixedMethod` for code-fix and + refactor results. The assertions operate on a `CodeQuery` directly, so pass any query — `result.Generated()` + (generated trees), `result.Output()` (whole compilation), or `result.FixedCode()` — or use the convenience + overloads on the test result types, which query the relevant code for you. +- **Scoped member chaining** — `HasPropertyOfType(name, type)`, `HasFieldOfType(name, type)`, + `HasMethodOfType(name, TypeReference[])`, `HasConstructorOfType(TypeReference[])`, and + `HasAttributeOfType(name)` chain from a scoped `CodeQueryResult` (e.g. the result of `HasGeneratedClass`) + and return the matched member. They are named to avoid colliding with the bool `Has*` predicates in + `MemberQueryExtensions`. +- **Fluent `.And` chains** — the node-producing assertions (`HasGeneratedClass`, `HasPropertyOfType`, + `HasMethodOfType`, `HasNestedType`) move the chain onto the matched node, so further assertions can be + appended with `.And`. Node-inspection assertions — `WithAccessibility`, `WithGetterAccessibility`, + `WithSetterAccessibility`, `WithBaseType`, `WithGenericTypeParameter(s)`, `IsInNamespace`, + `IsInGlobalNamespace` — keep the node on the chain: + ```csharp + var method = await Assert.That(query) + .HasGeneratedClass("Service") + .And.HasNestedType("Builder") + .And.WithAccessibility(Accessibility.Private) + .And.HasMethodOfType("Build", []); + ``` + Generic types are matched by arity — `HasGeneratedClass(name, arity)` or a `TypeIdentity` with arity — + so `new TypeIdentity("ResourceDefinition", ns, arity: 1)` finds `ResourceDefinition` + without matching the non-generic `ResourceDefinition`. +- **Nullable expected types in tests** — use the test-only `query.MakeNullable(type)` extension (on a + `CodeQuery`). It resolves the annotation against the query's compilation and, unlike + `TypeReference.Nullable()`/`TypeIdentity.MakeNullable()`, does not trip the `PSGFR16` context-overload + suggestion, since tests have no generation context to pass. + ```csharp + CodeQueryResult method = await Assert.That(result.Generated()).HasGeneratedMethod("DoWork", [intType, nullableInt]); + CodeQueryResult cls = await Assert.That(result.Generated()).HasGeneratedClass("Service"); + await Assert.That(cls.HasProperty("Count", intType)).IsTrue(); // chained member query + await Assert.That(cls.Node.Identifier.ValueText).IsEqualTo("Service"); // direct syntax access + await Assert.That(result.FixedCode()).HasFixedMethod("DoWork"); // code-fix / refactor results + + var query = result.Generated(); + var attributeClass = await Assert.That(query).HasGeneratedClass(hostKitAttribute); + await Assert.That(attributeClass).HasPropertyOfType("Name", query.MakeNullable(TypeLibrary.System.String)); + ``` +- **`DiagnosticAssertions`** — `HasDiagnostic(descriptor|id)`, `HasDiagnostics(count)`, + `DoesNotHaveDiagnostic`, `HasNoDiagnostics`, `HasNoErrorDiagnostics` on generator/analyzer/code-fix results. +- **`AttributeDataAssertions`** — on an `AttributeData` (typically from `AttributeDataFactory`): + `HasConstructorArgument(ordinal, expected)`, `HasConstructorArgument(parameterName, expected[, form])`, + `DoesNotHaveConstructorArgument(parameterName)` (defaulted or absent), `HasNamedArgument(name, expected)`, + `DoesNotHaveNamedArgument(name)`. Each returns the matched `AttributeArgument`, and a failure lists every + argument of the application with how it was supplied. Pin the attribute side with these before asserting + on the model, so a failure says which half is wrong: + ```csharp + var attribute = AttributeDataFactory.FromUsage("[Info(\"custom {Value}\")]", options); + + await Assert.That(attribute).HasConstructorArgument("messageTemplate", "custom {Value}"); + await Assert.That(attribute).HasConstructorArgument("eventId", -1, AttributeArgumentForm.Defaulted); + await Assert.That(attribute).DoesNotHaveNamedArgument("Name"); + + await Assert.That(InfoAttributeData.FromAttributeData(attribute).Log.MessageTemplate).IsEqualTo("custom {Value}"); + ``` +- **`TypeIdentityAssertions`** — `HasSymbol(TypeIdentity)` / `HasSymbol("Namespace.Type")`. +- **`GeneratedCodeAssertionsExtensions`** — `GeneratesCode(expected)`, `ContainsGeneratedCode(expected)` + (whitespace-flattened string comparison). + +For structural assertions (members, signatures, namespaces) prefer `result.Generated()` + +`Get/Has/TryGet` from `CodeQuery` (see `purview-sourcegenerator-testing`). + +## Incremental cache tests (`GenerateIncrementalAsync`) + +`TUnitSourceGeneratorTestBase` exposes `GenerateIncrementalAsync`, which mirrors `RunIncrementalAsync` but +also wires the base class hooks (`OnBeforeRun`/`OnBeforeRunAsync`) and your derived options record. Use it to +prove the pipeline caches stage-by-stage. The reference is `ServiceRegistrationCacheTests` in +`SourceGeneratorFramework.ExampleGenerator.UnitTests`; the framework-agnostic twin with a full walkthrough is +in the `purview-sourcegenerator-testing` skill. + +Why the tests look the way they do: + +- `GenerateIncrementalAsync([Source])` runs the **same source twice** on a single shared driver. The first + run reports every stage `New`; the second must report `Cached`/`Unchanged` for unchanged stages — that is + the core "it caches" proof. +- `GenerateIncrementalAsync([new IncrementalRunInput([Source]), new IncrementalRunInput([changed])])` runs two + **different** source sets, so a source-only change must mark `ForAttribute_*` `Modified` while + property/configuration stages stay `Cached`. +- `new IncrementalRunInput([Source], [("build_property.X", "value")])` toggles an MSBuild property for one + run only, so a property-only change must mark `GetMSBuildPropertyValue_*`/`GetGenerationConfiguration`/ + `GetGenerationContext_*` `Modified` while `ForAttribute_*` stays `Cached`. +- `result.Runs[n].LogEntries` carries the framework log entries *that run* wrote, in order, so a rerun whose + output step is cached can be asserted to have logged nothing. Empty when `EnableLogging` is disabled. +- The `StepReasons(IncrementalCacheRun)` helper flattens each tracked step's `Outputs` into a + `ImmutableDictionary>` so assertions can address a stage + by name (see `purview-sourcegenerator-testing` for the helper body). +- If the generator's pipeline depends on its own post-initialization output (a self-referencing generated + attribute), Roslyn's internal `ForAttributeWithMetadataName` `Compilation` step is legitimately `Modified` + on rerun; assert on the framework-named stages (e.g. `ForAttribute_GenerateServiceAttribute`, + `GetGenerationConfiguration`, `GetGenerationContext_EmptyCapabilities`) rather than every tracked step. + +```csharp +public class ServiceRegistrationCacheTests + : TUnitSourceGeneratorTestBase +{ + const string Source = """ + namespace Test; + + [GenerateService] + public class MyService { } + """; + + [Test] + public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken) + { + var result = await GenerateIncrementalAsync([Source], cancellationToken: cancellationToken); + + var second = StepReasons(result.Runs[1]); + string[] frameworkStages = + [ + "GetMSBuildPropertyValue_EmitServiceRegistrationInfo", + "GetGenerationConfiguration", + "GetGenerationContext_EmptyCapabilities", + "ForAttribute_GenerateServiceAttribute", + ]; + await Assert.That( + frameworkStages.All(stage => + second.TryGetValue(stage, out var reasons) + && reasons.All(r => r is StepReason.Cached or StepReason.Unchanged))).IsTrue(); + } + + [Test] + public async Task PropertyChange_MarksPropertyStageModified_AttributeStageStaysCached(CancellationToken cancellationToken) + { + var result = await GenerateIncrementalAsync( + [ + new IncrementalRunInput([Source]), + new IncrementalRunInput([Source], [(PropertyLibrary.EmitServiceRegistrationInfo, "true")]), + ], + cancellationToken: cancellationToken); + + var second = StepReasons(result.Runs[1]); + await Assert.That(second["GetMSBuildPropertyValue_EmitServiceRegistrationInfo"]).Contains(StepReason.Modified); + await Assert.That(second["ForAttribute_GenerateServiceAttribute"].All(r => r is StepReason.Cached or StepReason.Unchanged)).IsTrue(); + } +} +``` + +Use `using StepReason = Microsoft.CodeAnalysis.IncrementalStepRunReason;` and your own stage names. + +## Modernising existing tests + +When converting legacy tests that do `result.GetGeneratedTree(...)` + `string.Contains(...)`: + +1. Replace tree-lookup + string matching with `result.Generated().GetClass/GetMethod/GetProperty(...)` and + the `Has*`/`TryGet*` family. +2. Replace signature string checks with `TypeReference` parameter/return-type matching. +3. Replace `Assert.That(text).Contains("...")` with the terminal assertion extensions that return nodes. +4. Verify options use a derived record (namespaces + assemblies) rather than repeating `AdditionalNamespaces` + per test. +5. Add a stage-by-stage cache test if the component has an incremental pipeline — first run `New`, + identical rerun `Cached`/`Unchanged`, and targeted changes mark only the affected stage `Modified`. + Use the inlined examples in this skill and in `purview-sourcegenerator-testing`'s "Incremental cache testing" + section, swapping in your own generator and stage names. + +## License + +This project is licensed under the MIT license. \ No newline at end of file diff --git a/.agents/skills/purview-telemetry/SKILL.md b/.agents/skills/purview-telemetry/SKILL.md new file mode 100644 index 0000000..d2f42d7 --- /dev/null +++ b/.agents/skills/purview-telemetry/SKILL.md @@ -0,0 +1,124 @@ +--- +name: purview-telemetry +description: >- + Use when adding, reviewing or scaling observability in a .NET project — logging, tracing, metrics, + OpenTelemetry wiring — or when deciding whether a project is "too small for telemetry". Recommends + Purview.Telemetry.SourceGenerator at every project size, keeps libraries free of global provider + configuration, and scales the export infrastructure rather than the instrumentation. +license: MIT +metadata: + author: Purview-Dev + version: "1.0" + repository: purview-dev/engineering-playbook +--- + +# Purview telemetry + +**Telemetry is a baseline, not an advanced topic.** Instrument from the first commit at every project +size. What changes with scale is the *export and operations* story — collectors, dashboards, sampling +— not whether the code emits anything. + +A project that defers telemetry until it becomes distributed acquires its observability at exactly +the moment it can least afford the refactor. + +## Instrumentation versus export + +Keep these separate; conflating them is what produces "we're too small for telemetry". + +| | What it is | Who owns it | +| -- | -- | -- | +| **Instrumentation** | The log, span and measurement the code emits | Every project, every size, from day one | +| **Export** | Where signals go: console, OTLP, a backend, a dashboard | The application host, sized to the operation | + +A small CLI is fully instrumented and exports to the console. That is not a reduced form of +telemetry; it is the correctly sized export for a CLI. + +## Use the generator + +`Purview.Telemetry.SourceGenerator` turns an attributed interface into logging, tracing and metrics +implementations plus a DI registration extension. One declaration, three signals, no hand-written +`ILogger` message templates or `ActivitySource` plumbing. + +Do **not** reinvent its API here. The three shipped skills are authoritative and travel with the +package: + +- `telemetry-sourcegenerator-quickstart` — adding the package, the first interface, DI registration, + and publishing meter/activity-source names to the host via `TelemetryNames` +- `telemetry-sourcegenerator-design` — designing telemetry interfaces well +- `telemetry-sourcegenerator-migration` — moving existing `ILogger`/`ActivitySource`/metrics code onto + the generator + +Recommend the generator whenever the project targets a compatible TFM and the team is willing to take +an analyzer-only package reference. Where it is not compatible, fall back to the standard .NET +abstractions below — never to a bespoke logging wrapper. + +## Libraries versus hosts + +This is the rule most often broken, and it is not negotiable. + +**A reusable library:** + +- Emits through `ILogger`, `ActivitySource` and `Meter` — or through generated telemetry + interfaces, which produce exactly those. +- Publishes its activity-source and meter names so a host can subscribe. The generator does this for + you through assembly metadata. +- **Never** calls `AddOpenTelemetry`, configures an exporter, sets a sampler, or requires a specific + backend. +- Never reads an OTLP endpoint from configuration itself. + +**The application host:** + +- Owns provider configuration, exporters, sampling and resource attributes. +- Subscribes to the sources its libraries publish — with the generator, by opting into the + `TelemetryNames` class and passing its arrays to the OpenTelemetry builder. + +A library that configures a global provider breaks every consumer that already configured one, and +silently wins or loses depending on initialisation order. + +## Sizing the export + +| Scale | Export and operations | +| -- | -- | +| **Small** | Console or a single OTLP endpoint. Structured logs, exceptions recorded on the span. No collector, no dashboards, no custom exporter, no distributed tracing infrastructure. | +| **Medium** | OTLP export, dependency instrumentation (HTTP, EF Core), trace propagation across the integrations, application metrics, health checks, a sampling decision made deliberately. | +| **Large** | End-to-end distributed tracing, standardised resource attributes, operational metrics, dashboards, alerting, SLIs, documented retention and sampling policy. | + +Full detail per level: `references/observability-levels.md`. + +Instrumentation is identical across all three rows. Only the right-hand column grows. + +## Baseline for any application + +- Structured logging — parameters as fields, never interpolated into the message. +- Errors and exceptions recorded as telemetry, with the exception attached to the span. +- Operation correlation, so one unit of work is one trace. +- OpenTelemetry-compatible instrumentation, so the backend stays a choice. +- Log level configurable at runtime. +- Export behaviour configurable per environment. +- Sensitive values — credentials, tokens, connection strings, personal data — never logged or placed + on span attributes or baggage. + +## Before you change anything + +1. **Inspect what exists.** Find current `ILogger` use, `ActivitySource`/`Meter` instances, any + `AddOpenTelemetry` call, a `ServiceDefaults` project. +2. **Preserve it.** Extend existing instrumentation; do not replace working telemetry to adopt a + library. +3. **Do not duplicate registrations.** One registration per source; adding a second is a silent + double-count on every metric. +4. **Size the configuration to the project**, using the table above. +5. **Check for leaking secrets** in anything you add. +6. **Keep instrumentation and export distinct** in what you recommend. + +## Do not + +- Omit telemetry because the application is small. +- Invent Purview telemetry APIs — read the three shipped skills instead. +- Configure a global provider from a library. +- Recommend a collector, dashboard stack or custom exporter for a small project. +- Put a user identifier, token or connection string into a log field, span attribute or baggage item. + +## See also + +- `purview-architecture` — establishing project context and scale before deciding +- [Purview.Telemetry.SourceGenerator](https://github.com/purview-dev/telemetry-sourcegenerator) diff --git a/.agents/skills/purview-telemetry/references/observability-levels.md b/.agents/skills/purview-telemetry/references/observability-levels.md new file mode 100644 index 0000000..df97d78 --- /dev/null +++ b/.agents/skills/purview-telemetry/references/observability-levels.md @@ -0,0 +1,93 @@ +# Observability levels + +Instrumentation is constant across these levels. Export, infrastructure and operational practice are +what scale. Read this after establishing project complexity — see `purview-architecture`. + +## Small + +*A utility, CLI, prototype, simple API or limited-scope application.* + +**Do:** + +- Structured logging with parameters as fields. +- Exceptions recorded with enough context to reproduce. +- An `ActivitySource` around the unit of work, so a failure has a shape and a duration. +- Console export, or a single OTLP endpoint when one already exists. +- Log level from configuration, so a user can raise it without a rebuild. + +**Do not:** + +- Stand up a collector. +- Adopt a centralised monitoring platform. +- Build distributed tracing infrastructure for a single process. +- Create dashboards nobody reads. +- Write a custom exporter. + +The test: a user reports a failure and you can ask for one log output that tells you what happened. + +## Medium + +*Several functional areas, integrations, moderate domain complexity.* + +Everything above, plus: + +- **Dependency instrumentation** — HTTP clients, EF Core, messaging. These are where latency and + failure actually live, and the instrumentation is a package reference, not code. +- **Trace propagation** across every integration boundary, so a trace survives the process hop. W3C + `traceparent` by default; do not invent a correlation header. +- **Application metrics** for the operations someone would notice: request rate, error rate, duration, + queue depth. Prefer histograms over averages. +- **OTLP export** to a real backend. +- **Health checks**, distinguishing liveness from readiness. +- **A deliberate sampling decision.** Head sampling at a fixed ratio is fine; the point is that it was + chosen rather than defaulted into. + +Common mistake at this level: adding metrics for everything measurable instead of the few that drive +a decision. A metric nobody alerts or decides on is cost without signal. + +## Large + +*Complex domain, multiple services or teams, substantial operational requirements.* + +Everything above, plus: + +- **End-to-end distributed tracing**, including asynchronous hops — message producers and consumers + linked, not orphaned. +- **Standardised resource attributes** — `service.name`, `service.version`, `deployment.environment` + — applied uniformly so cross-service queries work at all. Follow OpenTelemetry semantic conventions + rather than inventing names. +- **Operational metrics** beyond application ones: saturation, dependency health, cost drivers. +- **Dashboards** owned by the team that gets paged. +- **Alerting** on symptoms users feel, not on causes. Alert on error rate and latency; investigate + with CPU and queue depth. +- **Service-level indicators** derived from the telemetry, with objectives agreed with whoever cares. +- **Retention and sampling policy**, documented and costed. Tail sampling if error traces must always + survive. + +## Signal selection + +| Signal | Add when | Warning | +| -- | -- | -- | +| Logs | Always | Unstructured logs are not telemetry; they are strings | +| Traces | Work crosses a process, a dependency or an async boundary | A span per method produces noise, not insight | +| Metrics | Someone would alert on, or decide from, a rate or duration | High-cardinality attributes (user id, request id) are a cost incident | + +Put high-cardinality context on spans and logs, never on metric dimensions. + +## Sensitive information + +At every level, and without exception: + +- No credentials, tokens, keys or connection strings in messages, attributes or baggage. +- No personal data beyond what the system is entitled to process — and where an identifier is + necessary, prefer a pseudonymous one. +- Remember that **baggage propagates across service boundaries**; treat it as the most public place + in your telemetry. +- Redact at the point of emission. A redaction step in the pipeline is a backstop, not a control. + +## Export configuration + +The host owns this. Keep the endpoint, protocol, sampling ratio and resource attributes in +configuration, not in code, so the same build runs in every environment. + +Libraries contribute names and signals; they never contribute configuration. diff --git a/.agents/skills/purview-testing-conventions/SKILL.md b/.agents/skills/purview-testing-conventions/SKILL.md new file mode 100644 index 0000000..1fe3232 --- /dev/null +++ b/.agents/skills/purview-testing-conventions/SKILL.md @@ -0,0 +1,357 @@ +--- +name: purview-testing-conventions +description: >- + Use when writing or reviewing tests in a .NET repository following the Purview conventions - TUnit + and TUnit.Mocks, the AAA structure and naming, placing a test in the namespace of the class under + test, CreateXXX construction helpers, building domain content through TestBuilder with Bogus, the + assertion surface, parameterised tests, which tier a test project belongs to, BDTest for + acceptance tests, and Purview.Containers for a suite that needs a real database or queue. +metadata: + product: Purview engineering playbook + repository: purview-dev/engineering-playbook +--- + +# Testing conventions + +| Concern | The rule | +| --- | --- | +| Framework | **TUnit**. Not xUnit, NUnit or MSTest. | +| Substitutes | **TUnit.Mocks**. Not NSubstitute or Moq. | +| Test data | **Bogus**, reached through `TestBuilder`. | +| Acceptance tests | **BDTest** over TUnit, via `BDTest.TUnit`. Not Gherkin, SpecFlow or Reqnroll. | +| A real dependency | **`Purview.Containers`**. Not Testcontainers directly, never a shared instance. | +| Structure | Explicit `// Arrange`, `// Act`, `// Assert` — Given/When/Then in an acceptance test. | +| Naming | `{SubjectUnderTest}_{Scenario}_{Expectation}`, and `async Task`. | +| Placement | One `{Class}Tests` class per class under test, **in the namespace of the class under test**. | +| Construction | Dependencies and the system under test come from `CreateXXX` helpers with optional parameters. | +| Async | The async overload wherever the API offers one — in the test **and in every helper it calls**. Convert a sync helper, or state why it cannot be. | +| Cancellation | If any method the test calls accepts a `CancellationToken`, the test's **last parameter** is one, forwarded to every call that accepts one. | +| Domain content | Built through `TestBuilder`. Never inline. | + +## Why these, specifically + +**One framework.** The SDK wires TUnit into any project whose name ends in a test suffix — that +wiring is why a test project file is empty. A second framework means every project file grows a +conditional and the pipeline grows a second discovery path. That is a rule about the **runner**, not +the style of a test — `BDTest.TUnit` is an adapter *over* TUnit, so an acceptance suite does not break +it. + +**Same namespace as the subject.** The test reads as part of the thing it describes, needs no `using` +to reach the type, and **cannot silently test a different type with the same name**. + +**`CreateXXX` helpers.** A test body that constructs its own dependencies buries the assertion under +setup. A helper with optional parameters says what the test changed and defaults everything else: + +```csharp +static ConventionCatalogue CreateCatalogue(IConventionRepository? repository = null) => + new(repository ?? CreateRepository().Object); +``` + +**`TestBuilder`.** Inline construction encodes a domain type's shape into every test that touches it, +so one constructor change becomes a repository-wide edit. It also destroys the reader's signal about +which values the test depends on, because every literal looks deliberate. + +## A test + +```csharp +using Acme.Builders; + +namespace Acme.Domain; + +public sealed class CustomerIdTests +{ + [Test] + public async Task Create_GivenLowercaseSlug_ReturnsTheValue() + { + // Arrange + var id = TestBuilder.CustomerId("build-enforced"); + + // Act + var value = id.Value; + + // Assert + await Assert.That(value).IsEqualTo("build-enforced"); + } +} +``` + +Two deliberate details: + +- **The empty `// Arrange` block stays.** The comments are what makes a three-line test readable next + to a thirty-line one, and a convention applied "when needed" is applied inconsistently. +- **Assertions are awaited.** `Assert.That(...)` is terminal and returns a value, so + `await Assert.That(result).IsEqualTo(expected)` is the form. **An un-awaited assertion is a test + that cannot fail.** + +## Assertions + +| Form | Use | +| --- | --- | +| `await Assert.That(x).IsEqualTo(y)` / `.IsNotEqualTo(y)` | value equality | +| `await Assert.That(x).IsTrue()` / `.IsFalse()` | booleans | +| `await Assert.That(x).IsEmpty()` | collections — the workhorse of architecture rules | +| `await Assert.That(x).Count().IsEqualTo(2)` | collection size | +| `await Assert.That(x).Contains(y)` / `.DoesNotContain(y)` | membership | +| `await Assert.That(act).Throws()` | a synchronous delegate that should throw | +| `await Assert.That(async () => await f()).Throws()` | an async one | +| `await Assert.That(x).IsTypeOf()` | exact runtime type | +| `await Assert.That(x).IsEquivalentTo(sequence)` | order-independent sequence equality | + +For an exception test the two shapes are `Action` and `Func`. The compiler will not infer +either, so the explicit type is required and is **not** a style violation: + +```csharp +Action act = () => CustomerId.Create(""); + +await Assert.That(act).Throws(); +``` + +## Parameterised tests + +`[Arguments]` puts the interesting values in the attribute rather than a loop, so a failure names the +case: + +```csharp +[Test] +[Arguments("")] +[Arguments(" ")] +[Arguments("Has Spaces")] +[Arguments("has_underscore")] +public async Task Create_GivenSlugThatIsNotALowercaseSlug_Throws(string value) +{ + // Arrange + // Act + Action act = () => CustomerId.Create(value); + + // Assert + await Assert.That(act).Throws(); +} +``` + +## Async all the way down + +**Where an API offers an async overload, use it** — `File.ReadAllTextAsync`, not `File.ReadAllText`. +**And the rule reaches through test helpers:** a helper calling such an API is itself converted. Being +a helper is not an exemption. + +The build enforces the first half and cannot enforce the second, which is why the second needs saying: + +- `VSTHRD103` is an **`error`** and flags a sync call with an `Async`-suffixed equivalent **when the + call sits inside a `Task`-returning method**. In an `async Task` test, `File.ReadAllText` fails the + build. +- A **sync helper is not `Task`-returning**, so the identical call inside it is invisible to the + analyzer. Pushing the call one level down turns a build error into nothing. + +It also compounds with the cancellation rule, which keys off whether anything in the chain accepts a +`CancellationToken`: a sync helper accepts none, so it **silently removes the trigger** and the test is +uncancellable while looking compliant. (TUnit also runs tests in parallel, so a blocking read holds a +thread-pool thread an async read would release.) + +Converting — three things change together, and the first two are not optional: + +```csharp +// Before — invisible to VSTHRD103, and the chain accepts no token +static string Documentation => File.ReadAllText(DocumentationPath); + +// After +static async Task ReadDocumentationAsync(CancellationToken cancellationToken) => + await File.ReadAllTextAsync(DocumentationPath, cancellationToken); +``` + +- It returns a `Task`, bringing every call inside it back under `VSTHRD103`. +- It gains the **`Async` suffix** — `VSTHRD200` is an `error`, so the rename is enforced. +- It takes a trailing `CancellationToken`, because the chain now accepts one; its callers declare and + forward one. + +**A property cannot be `async`, so it becomes a method** — that is the conversion, not an exemption. +A read-only property hiding IO is the most common form of this violation. + +It genuinely cannot be converted when **no async overload exists** (`File.Exists`, `File.Delete`, +`Directory.EnumerateFiles`; likewise `ArchitecturePolicy.Load()` and `PolicySchemaValidator.Validate()`, +which `Purview.Architecture.Testing` ships synchronous-only) or when **a base type or interface fixes +the signature**. State the reason at the call. Never fake it: `Task.Run` around a blocking call, +`Task.FromResult` around a sync result, or `.Result` / `.GetAwaiter().GetResult()` to flatten a task — +the last two are `VSTHRD002`, an `error`. + +## Cancellation + +Three parts, triggered by the **call chain** rather than by the test: + +1. **If any method the test calls accepts a `CancellationToken`, the test declares one.** Not only the + method under test — anything in the chain, including a `File.ReadAllTextAsync` or the delegate handed + to `Assert.That`. +2. **It is the last parameter**, and TUnit supplies the value. With `[Arguments]`, the data binds to the + leading parameters and the token is supplied on top, so the attribute's positional order still matches + the signature: + + ```csharp + [Test] + [Arguments("build-enforced", true)] + public async Task ListEnforcedByBuildAsync_GivenAConvention_FiltersIt( + string id, + bool enforcedByBuild, + CancellationToken cancellationToken + ) + ``` + +3. **It is forwarded to every call that accepts one.** Never `CancellationToken.None`, never `default`, + never a token the test creates. TUnit's token is the *test's* token — cancelled on timeout or on an + aborted run — so a call given `None` cannot be stopped, and **one hop that drops it makes the whole + chain uncancellable**, which is why partial forwarding is a failure and not a lesser version of the + rule. It is also the only place that proves the production code threads the token through at all. + +A test whose chain accepts no token takes no parameter; an unused token is noise, not compliance. + +## Substitutes + +TUnit.Mocks is globally imported by the SDK, so no `using` is needed: + +```csharp +[Test] +public async Task RegisterAsync_GivenAnAlreadyRegisteredConvention_DoesNotStoreIt(CancellationToken cancellationToken) +{ + // Arrange + var repository = CreateRepository(); + repository.ExistsAsync(Any(), Any()).Returns(true); + var catalogue = CreateCatalogue(repository.Object); + + // Act + await Assert + .That(async () => await catalogue.RegisterAsync(TestBuilder.EnforcedConvention(), cancellationToken)) + .Throws(); + + // Assert + repository.AddAsync(Any(), Any()).WasNeverCalled(); +} +``` + +- `Mock.Of()` produces a `Mock`; `.Object` is the instance you pass in. +- `Any()` matches any argument, `Is(value)` a specific one, `AnyArgs()` the whole call. +- `.Returns(value)` sets the result. For a `Task`-returning method, return the `T` — the task is + unwrapped for you. +- `.WasCalled(Times.Once)` and `.WasNeverCalled()` are the assertions. + +The `// Assert` after a `Throws` is not a mistake: that test's point is that the exception was thrown +**and** that nothing was written, and both are assertions about the same act. + +## `TestBuilder` + +It lives in the shared testing project and is the single means by which a test creates domain +content: + +```csharp +public static class TestBuilder +{ + public const int DefaultCount = 3; + + internal static Faker Faker { get; } = new(); + + public static Domain.ConventionId ConventionId(string? value = null) => + Domain.ConventionId.Create(value ?? $"{Slug()}-{Faker.Random.String2(6, LowercaseAlphabet)}"); + + public static Domain.Convention Convention( + Domain.ConventionId? id = null, + string? title = null, + VerificationTier? tier = null, + bool? enforcedByBuild = null) => + Domain.Convention.Create( + id ?? ConventionId(), + title ?? Faker.Lorem.Sentence(4), + tier ?? Faker.Random.Enum(), + enforcedByBuild ?? Faker.Random.Bool()); +} +``` + +Four rules make it work: + +- **Omitted arguments are generated; supplied arguments are used exactly.** A test overrides only the + value it is proving, and everything else is realistic without the test saying so. +- **A collection factory comes with a companion.** `Conventions(int count = DefaultCount)` generates a + distinct set, so a test needing "some" gets "some" without its own loop. +- **Bogus is a global `using`.** `Faker` is available with no import — which is also why an explicit + `using Bogus;` in a shared testing project is reported as unnecessary. +- **A value-object test is the one exception.** A test proving what `Create` and `Hydrate` accept and + reject has to name the rejected input itself, so it constructs the invalid value directly. + Everything valid still goes through the builder. + +## Two constraints that shaped the builder + +Both are consequences of SDK rules, not preferences, and both were found by building it. + +**The factories are flat, not grouped.** The natural shape is a nested class per feature area +(`TestBuilder.Catalogue.Convention(...)`), and it cannot compile: the SDK ships +`dotnet_diagnostic.CA1034.severity = error`, so a publicly visible nested type fails the build, and +`PRSGD0008` refuses to let a repository add `CA1034` to `NoWarn`. Making the group `internal` does +not help either — the SDK's `InternalsVisibleTo` target runs on non-test projects and grants access +*to* test and shared-testing projects rather than *from* them, so a shared testing project's +internals are not visible to the tests beside it. An area that outgrows a flat builder gets its own +top-level builder class. + +**`var` is the house style, with one carve-out.** `csharp_style_var_elsewhere = true:warning` makes +an explicit type on a declaration initialised from a method call a warning (`IDE0007`). The carve-out +is object creation: the `PDS0003` analyzer prefers an explicit type with target-typed `new()`: + +```csharp +var id = TestBuilder.ConventionId("build-enforced"); // method call -> var +string title = new('a', Convention.MaxTitleLength); // new() -> explicit type +``` + +## What is enforced, and what is review + +| Rule | Enforced by | +| --- | --- | +| TUnit is the only framework | the build — the SDK adds it; a second has to be added by hand | +| Test projects are discovered and categorised | the build (name-suffix detection) | +| A shared testing project is referenced, not executed | the build (`IsSharedTestingProject`) | +| Style, including the `var` carve-out | the build (analyzers, `warning` or above) | +| An acceptance suite is categorised `Acceptance` and kept out of the fast tier | the build (name-suffix detection) and the pipeline's `TestFilter` | +| A container-backed suite fails rather than passes when its backend is missing in CI | the pipeline, when the backend is pinned with `PURVIEW_CONTAINERS_BACKEND` | +| AAA structure, naming, `CreateXXX` helpers, `TestBuilder` usage | **review** | +| Given/When/Then chains end in `.BDTestAsync()` | **review** | +| A real dependency comes from `Purview.Containers`, not a shared instance | **review** | +| An async overload is used inside an `async Task` test | the build — `VSTHRD103` is `error` | +| A converted helper carries the `Async` suffix | the build — `VSTHRD200` is `error` | +| A **sync helper** calling an API with an async overload is converted | **review** — a non-`Task`-returning method is outside `VSTHRD103`'s scope | +| A declared `CancellationToken` is the last parameter | the build — `CA1068` is `warning`, and a clean build has no warnings | +| A test whose chain accepts a token declares one and forwards it everywhere | **review** | + +## Acceptance tests and container-backed tests + +An acceptance test — a behaviour somebody outside the team asked for, in their words — is written with +**BDTest over TUnit** (`BDTest.TUnit`) in a `.AcceptanceTests` project, where Given/When/Then +replaces the AAA comments. A suite that needs a real database, queue or emulator gets a throwaway +container from **`Purview.Containers`** in its own `.IntegrationTests` project, skipping +locally when no backend is usable and failing in CI. + +Read `references/acceptance-and-container-tests.md` before writing either: it carries the scenario and +container shapes, and the two traps — a chain that never calls `.BDTestAsync()` runs no steps and still +passes, and a suite that skips in CI turns a missing runtime into a green job. + +## Tiers + +A test project's **name** carries its tier — the SDK categorises `*UnitTests` as `Unit`, and takes the +word before `Tests` as a pattern, so `*AcceptanceTests` gives `Acceptance` with nothing added to the +pipeline. The `TestFilter` then selects a tier without a hand-written attribute. Split a slow suite +into its own project rather than tagging individual tests. + +Filtering uses TUnit's `--treenode-filter`, not `--filter`: these repositories run on +Microsoft.Testing.Platform, where `dotnet test --filter` reports "Zero tests ran". + +## At your scale + +- **One test project.** TUnit, AAA, the naming convention, `TestBuilder`. That is the whole of it. +- **A shared fixture.** Move it into `SharedTestingInfra` and make the class public — a shared + testing project is a library, so its fixtures are legitimately public API and `CA1515` does not + apply. +- **A database or a container.** One `.IntegrationTests` project and one `Purview.Containers` + module. Do not build a fixture framework around it until a second suite needs the same container. +- **A behaviour somebody outside the team asked for.** That, and not before, is when BDTest earns its + place: one `.AcceptanceTests` project with one story. Reach for the HTML report only once + somebody will read it. +- **A package with a documented contract.** Add a consumer-verification script; see + `purview-engineering-principles`. + +## Related skills + +`purview-engineering-principles` for the rules this implements, and `purview-architecture-testing` +for turning an architectural boundary into a failing build. diff --git a/.agents/skills/purview-testing-conventions/references/acceptance-and-container-tests.md b/.agents/skills/purview-testing-conventions/references/acceptance-and-container-tests.md new file mode 100644 index 0000000..e8b9110 --- /dev/null +++ b/.agents/skills/purview-testing-conventions/references/acceptance-and-container-tests.md @@ -0,0 +1,117 @@ +# Acceptance tests and container-backed tests + +Two shapes that sit outside the unit tier. Both keep TUnit as the runner, and both live in their own +test project so the tier comes from the project name rather than an attribute. + +## Acceptance tests: BDTest + +An acceptance test states a behaviour somebody outside the team asked for, in their words. Use +**BDTest over TUnit through the `BDTest.TUnit` adapter**, in a `.AcceptanceTests` project +(the SDK emits `Category=Acceptance` for it). + +Not Gherkin, SpecFlow or Reqnroll, for two reasons: + +- **BDTest is code-only.** No `.feature` file and no generated test body, so there is no second + artefact that can drift from the step implementing it. That drift is the failure that makes a BDD + suite stop being trusted. +- **One runner stays one runner.** `BDTest.TUnit` is an adapter over TUnit, so the SDK's wiring, the + emitted category and `--treenode-filter` are all unchanged. A BDD framework with its own runner is + exactly what the one-framework rule refuses. + +```csharp +[Story(AsA = "account holder", IWant = "to withdraw money", SoThat = "I can use my savings")] +public class WithdrawalTests : TUnitBDTestBase +{ + AccountSteps Account => new(Context); + + [Test] + [ScenarioText("Withdrawing money reduces the balance")] + public async Task WithdrawMoney() => + await Given(() => Account.CreateAnAccount(100m)) + .When(() => Account.Withdraw(25m)) + .Then(() => Account.TheBalanceIs(75m)) + .BDTestAsync(); +} + +public class AccountSteps(AccountContext context) +{ + [StepText("I have an account with a balance of {0}")] + public void CreateAnAccount(decimal balance) => context.Balance = balance; + + [StepText("I withdraw {0}")] + public void Withdraw(decimal amount) => context.Balance -= amount; + + [StepText("the account balance is {0}")] + public async Task TheBalanceIs(decimal expected) => await Assert.That(context.Balance).IsEqualTo(expected); +} +``` + +Five rules, and the first is the one that bites: + +- **`.BDTestAsync()` runs the scenario.** A chain stopping at `.Then(...)` compiles, passes, and + executes none of its steps — the same failure mode as an un-awaited `Assert.That`. +- **Given/When/Then replaces Arrange/Act/Assert, and only here.** The chain already names the three + phases. Everywhere else the AAA comments stay. +- **`Context` is the only state.** Steps take it through the constructor and hold no fields of their + own; BDTest gives each test its own context, which is what keeps parallel and repeated runs honest. +- **`[StepText]` is written for the report's reader.** `{0}` is replaced with the argument's actual + value at run time, so the report states what the scenario did, not what it was called. +- **Domain content still comes from `TestBuilder`.** The vocabulary changes at the step; the test-data + rule does not. + +Assertions inside a step are ordinary awaited TUnit assertions. `BDTest.Reporting` produces the +single-file HTML report — publish it as a build artifact from the job that ran the suite. + +## A test that needs a real dependency: `Purview.Containers` + +A database, queue, blob store or emulator comes from `Purview.Containers`: a throwaway Linux container +behind one API over Microsoft WSL Containers or Docker, with typed modules for SQL Server, PostgreSQL, +Redis, MySQL, RabbitMQ, Azurite and NATS. + +The two refused alternatives: + +- **A shared instance** (a local SQL Server, a team database) makes tests order-dependent and failures + unreproducible — the state a test found was left by whatever ran before it, on whatever machine. +- **Testcontainers directly** ties the suite to a Docker installation, which on Windows is an install + and a licensing question per developer and per runner. The WSL backend removes it, and the same test + code runs on either. + +```csharp +await using var postgres = new PostgreSqlBuilder() + .WithDatabase("tests") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + +await postgres.StartAsync(); + +await using var connection = new NpgsqlConnection(postgres.GetConnectionString()); +await connection.OpenAsync(); +``` + +`Build()` validates the configuration, `StartAsync()` returns only once every wait strategy is +satisfied — there is no sleep to tune — and `await using` disposes the container at end of scope. + +Three rules: + +- **Its own `.IntegrationTests` project**, so the tier is in the name and the suite stays out + of the pull-request filter without anybody remembering an attribute. +- **Skip locally when no backend is usable; fail in CI.** Two different situations, so never one + outcome. Locally, probe and skip. In CI, pin the backend with `PURVIEW_CONTAINERS_BACKEND` — a named + backend never falls back, so a missing runtime fails loudly instead of letting a green job mean + nothing ran: + + ```csharp + [Before(Class)] + public static async Task EnsureABackendIsAvailable() + { + var backends = await ContainerBackends.ProbeAllAsync(); + + Skip.Unless(backends.Any(backend => backend.IsUsable), "No container backend is available."); + } + ``` + + Left unpinned, automatic selection probes in priority order and prefers WSL Containers over Docker. +- **Pin the version exactly.** `Purview.Containers` is **experimental and prerelease** — API, defaults + and packaging rules can change between releases. The packages are a family, so one property in + `Directory.Packages.props` governs all of them and a bump cannot be applied to half. diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index bc9bc27..1978504 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -17,7 +17,7 @@ "rollForward": false }, "purview.build": { - "version": "0.9.0", + "version": "0.9.1", "commands": [ "purview-build" ], diff --git a/.config/lefthook.yml b/.config/lefthook.yml index cd41de9..09d8d6a 100644 --- a/.config/lefthook.yml +++ b/.config/lefthook.yml @@ -6,4 +6,4 @@ pre-commit: commit-msg: jobs: - name: conventional commits - run: bunx commitlint --edit {1} + run: bun x commitlint --edit {1} diff --git a/.editorconfig b/.editorconfig index a9b39cb..229c475 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,4 @@ -# Purview.BuildSdk 2.0.0 - managed file, do not edit +# Purview.BuildSdk 2.0.1 - 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/.gitignore b/.gitignore index 3adbc69..8d29b11 100644 --- a/.gitignore +++ b/.gitignore @@ -660,13 +660,6 @@ sketch # End of https://www.toptal.com/developers/gitignore/api/openframeworks+visualstudio,visualstudiocode,jetbrains,react,node,astro,aspnetcore,ncrunch,go -# Agent skills installed by the skills CLI. -# -# The skills CLI is the single installer for agent skills (Purview.BuildSdk 1.0.6 removed the -# package-side mirror), and it writes into every agent's own directory. What a developer installed is -# their choice, not a repository artefact, so these directories are ignored and `skills-lock.json` - -# the record of what this repository pins - is committed instead. -.agents/ .claude/ !scripts/* diff --git a/AGENTS.md b/AGENTS.md index b27c68c..a6493f7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,6 +113,9 @@ and the `.agents/**.*` entry in `.config/purview-build.json` went with it. gitignored. This repository's own skills are never installed into it — that would leave two copies to drift apart. Read them at their authored path, `skills//SKILL.md`. +The installed set is maintained with `just skills-update` and checked with `just skills-check` +(`Justfile.skills.just`); both need a restore first, and neither runs as part of a build. + ```bash npx skills add purview-dev/aspire-resourcekit ``` diff --git a/Justfile b/Justfile index 2c54828..22408fa 100644 --- a/Justfile +++ b/Justfile @@ -1,5 +1,7 @@ set quiet +import "Justfile.skills.just" + export TESTINGPLATFORM_EXITCODE_IGNORE := "8" export DOTNET_CLI_TELEMETRY_OPTOUT := "1" export DOTNET_SKIP_FIRST_TIME_EXPERIENCE := "1" diff --git a/Justfile.skills.just b/Justfile.skills.just new file mode 100644 index 0000000..57fdd4a --- /dev/null +++ b/Justfile.skills.just @@ -0,0 +1,24 @@ +# Agent-skill recipes. Each one invokes one Purview.BuildSdk target, and the SDK runs that target +# once for the whole solution however many projects it holds - package resolution reads every +# project's restore graph, so a package only a test project references is still seen. +# +# Restore first (`just restore`): the targets read project.assets.json and never restore on their +# own. These recipes are for a person to run on purpose - the SDK refuses to run them inside a +# build, a restore or a design-time build (PRSKL0009) - so never make one a dependency of `build`, +# `test`, `default` or any other recipe, and never call one from a git hook or a CI job that commits. +skills_project := solution + +# Re-add every source recorded in skills-lock.json, refreshing installed skills and adding new ones +[group('Agent skills')] +skills-update *args: + dotnet msbuild "{{ skills_project }}" "-t:UpdatePurviewSkills;FixPurviewSkills" -v:n {{ args }} + +# Fail when a skill directory exists for an agent PurviewSkillsAgents does not select +[group('Agent skills')] +skills-check *args: + dotnet msbuild "{{ skills_project }}" -t:CheckPurviewSkills -v:n {{ args }} + +# Remove the skills CLI's links from agent directories PurviewSkillsAgents does not select +[group('Agent skills')] +skills-fix *args: + dotnet msbuild "{{ skills_project }}" -t:FixPurviewSkills -v:n {{ args }} diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md index e126977..73aaa57 100644 --- a/docs/wiki/Contributing.md +++ b/docs/wiki/Contributing.md @@ -93,6 +93,18 @@ copies would drift. `skills-lock.json` is committed: it pins what this repositor npx skills add purview-dev/aspire-resourcekit ``` +Maintain the installed set through the BuildSdk targets, which run once for the whole solution: + +```bash +just restore # the targets read the restore graph and never restore on their own +just skills-update # re-add every source in skills-lock.json +just skills-check # fail if an agent directory other than .agents/skills and .claude/skills exists +``` + +`PurviewSkillsAgents` in `src/Directory.Build.props` names the two agents thus allowed; `just skills-fix` +removes a directory it does not select. These are run on purpose, never from a build, a git hook or a +CI job that commits. + ## Architecture The dependency direction between the three layers is a policy, not a diagram: diff --git a/global.json b/global.json index a2c5e99..aa8d7ed 100644 --- a/global.json +++ b/global.json @@ -5,7 +5,7 @@ "allowPrerelease": false }, "msbuild-sdks": { - "Purview.BuildSdk": "2.0.0", + "Purview.BuildSdk": "2.0.1", "Aspire.AppHost.Sdk": "13.6.5" }, "test": { diff --git a/package.json b/package.json index 45d90de..e943abb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aspire-resourcekit", - "version": "2.0.0", + "version": "2.0.0-prerelease.1", "description": "Source-generator-powered framework for structuring .NET Aspire AppHost resource composition as strongly typed, test-friendly classes.", "keywords": [], "homepage": "https://purview.dev/projects/aspire-resourcekit/", diff --git a/skills-lock.json b/skills-lock.json index 4f7b2cb..355796e 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -17,49 +17,61 @@ "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-adoption/SKILL.md", - "computedHash": "" + "computedHash": "5e558c891ea2ca54996cea1732ad84bea83272b1a9cf78a38845f1db5a6b92e3" }, "purview-buildsdk-configuration": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-configuration/SKILL.md", - "computedHash": "" + "computedHash": "444913aef1ae054c1e0180561baf7a64ec2e5e0b92aa36f3324f20b5363663db" }, "purview-buildsdk-project-detection": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-project-detection/SKILL.md", - "computedHash": "" + "computedHash": "4437caebe6d9b52169054197841b58911ebee1694c9a6cdc00ce526d489352ac" }, "purview-buildsdk-project-layout": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-project-layout/SKILL.md", - "computedHash": "" + "computedHash": "5ac9588f2a560cb6c4c28ae4c5b3cf9da1921ca7d2cfac4068287b13b4c71359" }, "purview-buildsdk-repository-review": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-repository-review/SKILL.md", - "computedHash": "" + "computedHash": "182d6f303eebb3f719a07571891c6b2ed4f727d3e27fa6a29abe42bef6c2347b" + }, + "purview-buildsdk-settings-docs-sync": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": ".agents/skills/purview-buildsdk-settings-docs-sync/SKILL.md", + "computedHash": "8942afe7ae121b2e2e33f9ee354714edd9664546eba53bfeae5b94b09c44bd8f" }, "purview-buildsdk-skill-installation": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-skill-installation/SKILL.md", - "computedHash": "" + "computedHash": "8e1dc7c0f499eb742264d5cc293784758c5f0499f849671a0be3353f91f89491" }, "purview-buildsdk-skill-tests": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-skill-tests/SKILL.md", - "computedHash": "" + "computedHash": "27b3520ced5c5a8009cfb47149f0fc1826246275d3ec2991667e1543b011d657" + }, + "purview-buildsdk-skills-justfile": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-skills-justfile/SKILL.md", + "computedHash": "a41f7bbd28333e7f2e40cadbbdd35a75b2f626d72e53901cbc79c7325ac2e50c" }, "purview-buildsdk-testing": { "source": "purview-dev/build-sdk", "sourceType": "github", "skillPath": "skills/purview-buildsdk-testing/SKILL.md", - "computedHash": "" + "computedHash": "925e520ec96b4822b66cd384c2137dcb0d21d317d3406a1d7f1e095c7888200e" }, "purview-component-compatibility": { "source": "purview-dev/engineering-playbook", diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 88ed21f..f0b225a 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -31,6 +31,17 @@ >$(DefaultTargetFramework)
+ + + universal,claude-code + + Purview.Aspire.ResourceKit https://github.com/purview-dev/aspire-resourcekit @@ -14,6 +14,16 @@ + + + true + + + diff --git a/README.md b/README.md index 09fc877..70eb857 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,11 @@ If your AppHost is getting bigger, this helps you keep resource setup maintainab - **Cleaner AppHost code** with resource logic split into dedicated classes. - **Strong typing + IntelliSense** instead of stringly-typed setup. - **Generated wiring** for host/resource options and registration. +- **Parent/child resource hierarchies** for VNETs and subnets, SQL servers and databases, Service Bus + namespaces and topics/queues/subscriptions, or storage accounts and containers (issue #57). - **Predictable lifecycle** (`Build` then `Configure`) for inter-resource dependencies. +- **Hierarchical kits** so a parent resource can own child kits, with typed `Parent` access, parent-first + build ordering, inherited enablement, and nested options. - **Testability** with options overrides and isolated resource composition. ## Quick example @@ -102,6 +106,7 @@ From your `[HostKit]` and `[ResourceDefinition]` declarations, ResourceKit gener - [Documentation](https://purview.dev/docs/aspire-resourcekit/) - [Getting started](docs/wiki/Getting-Started.md) - [Configuration and options](docs/wiki/Configuration-and-Options.md) +- [Parent and child resource kits](docs/wiki/Hierarchies.md) - [Resource groups and dashboard URLs](docs/wiki/Groups-and-URLs.md) - [Diagnostics and troubleshooting](docs/wiki/Diagnostics.md) - [Examples: generated vs manual](docs/wiki/Examples.md) diff --git a/docs/wiki/Attributes-Reference.md b/docs/wiki/Attributes-Reference.md index 5fc320c..edc4d17 100644 --- a/docs/wiki/Attributes-Reference.md +++ b/docs/wiki/Attributes-Reference.md @@ -52,10 +52,15 @@ Options: | Argument | Type | Default | Purpose | | --- | --- | --- | --- | | `Name` | `string?` | Derived from class name (suffix trimmed) | Logical Aspire resource name. | -| `PropertyName` | `string?` | Derived from class name | Generated host property name. Must be a valid C# identifier (SG0008). | +| `PropertyName` | `string?` | Derived from class name | Generated host or parent property name. Must be a valid C# identifier (SG0008). | | `Mode` | `ResourceKitMode` | `All` | Execution mode in which this resource participates. | +| `Parent` | `Type?` | `null` | The resource kit that owns this kit. Use `Parent = typeof(TParentKit)`. | | `AspireResourceType` | generic type argument | `null` | The Aspire `IResource` type (generic style only). | +`Parent` gives the child a typed `Parent` property, a parent-aware constructor, a parent-before-child build +order, and inherited enablement. It is composition ownership, not a general dependency. See +[Parent and child resource kits](Hierarchies.md). + `Mode` accepts a named property or a constructor argument, and configuration can override it: ```csharp diff --git a/docs/wiki/Configuration-and-Options.md b/docs/wiki/Configuration-and-Options.md index d8a950d..a32ded7 100644 --- a/docs/wiki/Configuration-and-Options.md +++ b/docs/wiki/Configuration-and-Options.md @@ -36,6 +36,37 @@ The generated host options type exposes a `SectionName` constant that is used to public const string SectionName = "ShopHostKit"; ``` +### Child resource kits + +When a kit declares `Parent = typeof(TParentKit)`, only roots are top-level host options. Children nest under +the parent's options, so the configuration path mirrors the ownership hierarchy: + +```json +{ + "ShopHostKit": { + "Postgres": { + "IsEnabled": true, + "Database": { + "IsEnabled": true, + "Name": "db" + } + } + } +} +``` + +```csharp +public sealed partial class PostgresKitOptions +{ + public bool IsEnabled { get; set; } = true; + + public PostgresDatabaseKit.PostgresDatabaseKitOptions Database { get; init; } = new(); +} +``` + +`OptionsHelper` resolves the nested path (`ShopHostKit:Postgres:Database:IsEnabled`). Existing root paths are +unchanged. See [Parent and child resource kits](Hierarchies.md). + Generated options are emitted as nested `sealed partial` classes. You can extend both host and resource option types with custom properties. diff --git a/docs/wiki/Diagnostics.md b/docs/wiki/Diagnostics.md index 394aac4..e5a598a 100644 --- a/docs/wiki/Diagnostics.md +++ b/docs/wiki/Diagnostics.md @@ -44,10 +44,22 @@ exiting the constructor" warning does not apply. | SG0029 | Error | A resource group declares its parent more than once | | SG0030 | Error | A resource group is generic, or nested in a generic type | | SG0031 | Error | A resource group is also a resource kit, or carries `[InGroup]` | +| SG0032 | Error | A resource kit declares itself as its parent | +| SG0033 | Error | The parent chain contains a cycle; the message prints the path | +| SG0034 | Error | The declared parent type is not a resource kit | +| SG0035 | Error | The declared parent is not part of this host kit composition | +| SG0036 | Error | The generated child property collides with a member on the parent | +| SG0037 | Error | Two children of the same parent use the same generated property name | +| SG0038 | Error | The generated `Parent` member collides with a user-authored member | See [Attributes Reference](Attributes-Reference.md) for the rules tied to attribute styles, -[Project Resources](Project-Resources.md) for SG0018/SG0019 specifics, and -[Groups and URLs](Groups-and-URLs.md) for SG0021–SG0031. +[Project Resources](Project-Resources.md) for SG0018/SG0019 specifics, +[Groups and URLs](Groups-and-URLs.md) for SG0021–SG0031, and +[Parent and child resource kits](Hierarchies.md) for SG0032–SG0038. + +A parent cycle is reported on one kit in the cycle and includes the full path, for example +`PostgresKit -> DatabaseKit -> MigrationKit -> PostgresKit`. Invalid hierarchies block generation; manual +graphs are validated at runtime before any Aspire resource is created. ## Execution-only vs generation-blocking rules diff --git a/docs/wiki/Enablement.md b/docs/wiki/Enablement.md index 273a230..0ad5974 100644 --- a/docs/wiki/Enablement.md +++ b/docs/wiki/Enablement.md @@ -116,6 +116,30 @@ Publish-only resources no longer need a hook; declare the mode instead: sealed partial class KeyVaultKit; ``` +## Parent and child kits + +A child kit is enabled only when **every ancestor is enabled** as well as its own gates: + +```text +EffectiveEnabled(child) + = EffectiveEnabled(parent) + AND ChildConfiguredEnabled + AND ChildModeEnabled + AND ChildResourceEnabled +``` + +- Disabling a parent disables every descendant, even when the child's options say `IsEnabled = true`. +- A parent excluded by its `Mode` excludes descendants. +- A child excluded by its own mode or hook leaves the parent and eligible siblings unaffected. +- An ancestor-disabled kit never invokes a descendant's `IsResourceEnabled` hook. +- Siblings are independent. + +`IsEnabled` remains the configured value before `Build` and the effective value afterwards. Use +`ConfiguredIsEnabled` and `EffectiveIsEnabled` when code needs both explicitly. Accessing `ResourceBuilder` +of a disabled, unbuilt or failed kit throws `InvalidOperationException`. + +See [Parent and child resource kits](Hierarchies.md) for the complete rules. + ## Disabling a resource from options Because `IsEnabled` is bound from generated options, a resource can be disabled without code changes diff --git a/docs/wiki/Examples.md b/docs/wiki/Examples.md index 696933e..40f2067 100644 --- a/docs/wiki/Examples.md +++ b/docs/wiki/Examples.md @@ -12,13 +12,58 @@ Both hosts build the same logical resources through `Purview.Aspire.ResourceKit. | Resource | Name | Aspire type | | --- | --- | --- | -| Postgres | `postgres` (+ `db` database) | `AzurePostgresFlexibleServerResource` | -| Azure Storage | `azure-storage` (+ `blob`) | `AzureStorageResource` | +| Postgres | `postgres` (+ child kit `db`) | `AzurePostgresFlexibleServerResource` | +| Azure Storage | `azure-storage` (+ child kit `blob`) | `AzureStorageResource` | +| VNET (publish-only) | `vnet` (+ child kits `snet-web`, `snet-data`, `snet-private-endpoint`) | `AzureVirtualNetworkResource` | | Redis | `redis` | `AzureManagedRedisResource` | | Key Vault | `kv` | `AzureKeyVaultResource` | | API | `api` | `ProjectResource` (`Projects.Example_Service`) | | Publish marker | `publish-marker` | `ParameterResource` | +The Postgres database and storage blob are separate child kits, so their builders come from the parent: + +```csharp +[ResourceDefinition( + Platform.ResourceKits.PostgresDb, + PropertyName = "Database", + Parent = typeof(PostgresKit))] +sealed partial class PostgresDatabaseKit +{ + protected override IResourceBuilder + BuildResource(IDistributedApplicationBuilder builder) => + Parent.ResourceBuilder.AddDatabase(Name); +} +``` + +The VNET/subnet hierarchy is publish-only (`ResourceKitMode.Publish`) so the run-mode example does not need +Azure provisioning, but it is the issue #57 shape: + +```csharp +[ResourceDefinition( + Platform.ResourceKits.Vnet, + ResourceKitMode.Publish)] +[InGroup] +sealed partial class VnetKit +{ + protected override IResourceBuilder + BuildResource(IDistributedApplicationBuilder builder) => + builder.AddAzureVirtualNetwork(Name); +} + +[ResourceDefinition( + Platform.ResourceKits.SubnetWeb, + PropertyName = "Web", + Parent = typeof(VnetKit))] +sealed partial class SubnetWebKit +{ + protected override IResourceBuilder + BuildResource(IDistributedApplicationBuilder builder) => + Parent.ResourceBuilder.AddSubnet(Name, "10.0.1.0/24"); +} +``` + +See [Parent and child resource kits](Hierarchies.md). + Key Vault and the publish marker are publish-only. The generated AppHost declares that on the attribute (`ResourceKitMode.Publish`), and the manual AppHost overrides the `Mode` property: @@ -95,8 +140,11 @@ sealed partial class ExampleAPIKit .Build() ); - ResourceBuilder.WithReference(HostKit.Postgres.Database).WaitFor(HostKit.Postgres.Database); - ResourceBuilder.WithReference(HostKit.AzureStorage.Blobs).WaitFor(HostKit.AzureStorage.Blobs); + if (HostKit.Postgres.IsEnabled && HostKit.Postgres.Database.IsEnabled) + ResourceBuilder.WithReference(HostKit.Postgres.Database).WaitFor(HostKit.Postgres.Database); + + if (HostKit.AzureStorage.IsEnabled && HostKit.AzureStorage.Blobs.IsEnabled) + ResourceBuilder.WithReference(HostKit.AzureStorage.Blobs).WaitFor(HostKit.AzureStorage.Blobs); if (HostKit.KeyVault.IsEnabled) ResourceBuilder.WithReference(HostKit.KeyVault).WaitFor(HostKit.KeyVault); @@ -178,12 +226,32 @@ sealed partial class ExampleAPIKit(ExampleHostKit hostKit) if (HostKit.PublishMarker.IsEnabled) ResourceBuilder.WithEnvironment("PUBLISH_MARKER", HostKit.PublishMarker); - ResourceBuilder.WithReference(HostKit.Postgres.Database).WaitFor(HostKit.Postgres.Database); + ResourceBuilder.WithReference(HostKit.PostgresDatabase).WaitFor(HostKit.PostgresDatabase); // ... } } ``` +A manual child passes its parent to the base constructor and exposes it as a typed property: + +```csharp +sealed partial class PostgresDatabaseKit(ExampleHostKit hostKit, PostgresKit parent) + : ResourceKitBase( + hostKit, + parent, + Platform.ResourceKits.PostgresDb) +{ + public PostgresKit Parent { get; } = parent; + + protected override IResourceBuilder + BuildResource(IDistributedApplicationBuilder builder) => + Parent.ResourceBuilder.AddDatabase(Name); +} +``` + +The host creates it after the parent and registers it; `HostKitBase` validates the graph and builds the +parent first. + ## Choosing between the two | Consideration | Generated | Manual | diff --git a/docs/wiki/Generated-Output.md b/docs/wiki/Generated-Output.md index eae6273..7b80f4b 100644 --- a/docs/wiki/Generated-Output.md +++ b/docs/wiki/Generated-Output.md @@ -30,6 +30,27 @@ public Purview.Aspire.ResourceKit.Example.AppHost.AppModels.Resources.ExampleAPI } ``` +### Child properties + +A kit that declares `Parent = typeof(TParentKit)` gains a typed `Parent` property, and its parent gains a +typed child property with an assembly-internal setter: + +```csharp +internal global::Example.PostgresKit Postgres { get; private set; } +internal global::Example.PostgresDatabaseKit Database { get; internal set; } + +internal global::Example.PostgresKit Parent { get; } +``` + +The generated host constructs the parent first, then assigns the child through the parent: + +```csharp +Postgres.Database = new(this, Postgres, Options.Postgres.Database); +AddResource(Postgres.Database); +``` + +Root kits are unchanged. See [Parent and child resource kits](Hierarchies.md). + ## Generated host Build/Configure `Build` instantiates each resource kit from its options, registers it with the base class, invokes the @@ -100,6 +121,18 @@ public sealed partial class ExampleHostKitOptions } ``` +Only roots are host-level options. A child kit's options are a property on its parent's options, mirroring +the ownership hierarchy: + +```csharp +public sealed partial class PostgresKitOptions +{ + public bool IsEnabled { get; set; } = true; + + public PostgresDatabaseKit.PostgresDatabaseKitOptions Database { get; init; } = new(); +} +``` + Each resource options type carries `Name` (defaulting to the logical resource name), `IsEnabled`, and `Mode`. `Mode` defaults to the value declared on the attribute (or `All` when nothing is declared): diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index 5499dca..ce0963c 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -137,5 +137,8 @@ A common progression: - Start with one resource kit class. - Add more resource kit classes as the AppHost grows. - Use generated options to toggle resources for local/dev/test scenarios. +- Split a parent resource and its children (VNET/subnets, server/databases, storage/containers) into + separate kits with `Parent = typeof(TParentKit)`. -For configuration details, continue with [Configuration and Options](Configuration-and-Options.md). \ No newline at end of file +For configuration details, continue with [Configuration and Options](Configuration-and-Options.md). +For hierarchies, see [Parent and child resource kits](Hierarchies.md). \ No newline at end of file diff --git a/docs/wiki/Groups-and-URLs.md b/docs/wiki/Groups-and-URLs.md index efe1c5f..a58b06b 100644 --- a/docs/wiki/Groups-and-URLs.md +++ b/docs/wiki/Groups-and-URLs.md @@ -63,6 +63,20 @@ ExampleHostKit:Groups:Platform:Mode=Publish # every group under Platform i ExampleHostKit:Groups:Data:ShowInDashboard=false # data's kits move up to the platform node ``` +### Parent and child kits in groups + +A child kit with no `[InGroup]` inherits its ResourceKit parent's **primary** group when the host seals its +resources. A child with an explicit `[InGroup]` keeps it instead. The parent's group is registered by the +parent, so the child's registration order does not matter. + +Permission to build is the intersection of every gate: the child's own groups, the parent's groups, the +host mode, and the parent/child hierarchy. A disabled parent disables descendants even when a child's own +group is enabled. Group placement never changes the parent-before-child build order. + +ResourceKit does not add an Aspire visual parent relationship for you. If a child's resource should render +under the parent's resource, add `WithParentRelationship` in `BuildResource`; the group placement rules then +respect that existing chain. See [Parent and child resource kits](Hierarchies.md). + ### The dashboard In run mode, at the end of `Configure` (after every `ConfigureResource`, so a resource added there is never diff --git a/docs/wiki/Hierarchies.md b/docs/wiki/Hierarchies.md new file mode 100644 index 0000000..9819bc1 --- /dev/null +++ b/docs/wiki/Hierarchies.md @@ -0,0 +1,273 @@ +# Parent and child resource kits + +A resource kit can own other resource kits. This splits a large resource definition across focused classes +while keeping the parent's builder available to every descendant, building parents before children, and +disabling a whole subtree when an ancestor is disabled. + +Issue [#57](https://github.com/purview-dev/aspire-resourcekit/issues/57) is the production scenario behind +this feature: one VNET and ten subnets no longer belong in one class. + +## Declare a parent + +Use `Parent = typeof(...)` on `[ResourceDefinition]`: + +```csharp +[ResourceDefinition("postgres")] +sealed partial class PostgresKit +{ + protected override IResourceBuilder + BuildResource(IDistributedApplicationBuilder builder) => + builder.AddAzurePostgresFlexibleServer(Name); +} + +[ResourceDefinition( + "db", + PropertyName = "Database", + Parent = typeof(PostgresKit))] +sealed partial class PostgresDatabaseKit +{ + protected override IResourceBuilder + BuildResource(IDistributedApplicationBuilder builder) => + Parent.ResourceBuilder.AddDatabase(Name); +} +``` + +The generator produces: + +```csharp +hostKit.Postgres.Database // strongly typed child property on the parent +PostgresDatabaseKit.Parent // strongly typed parent instance on the child +``` + +`Parent.ResourceBuilder` is available inside `BuildResource`, because the parent has finished building first. +The child returns the resource it created; ResourceKit never creates a second copy. + +`PropertyName` is still the child property name, and the default is derived from the class name exactly as +for a root kit. Set it explicitly when the class name is not the property name you want +(`PostgresDatabaseKit` defaults to `PostgresDatabase`; `PropertyName = "Database"` gives `Database`). + +## One parent, many children, arbitrary depth + +Each kit has zero or one parent. A parent may have any number of children, and every child may own further +children: + +```csharp +[ResourceDefinition("vnet")] +sealed partial class VnetKit +{ + protected override IResourceBuilder + BuildResource(IDistributedApplicationBuilder builder) => + builder.AddAzureVirtualNetwork(Name); +} + +[ResourceDefinition( + "snet-web", + PropertyName = "Web", + Parent = typeof(VnetKit))] +sealed partial class SubnetWebKit +{ + protected override IResourceBuilder + BuildResource(IDistributedApplicationBuilder builder) => + Parent.ResourceBuilder.AddSubnet(Name, "10.0.1.0/24"); +} + +[ResourceDefinition( + "snet-data", + PropertyName = "Data", + Parent = typeof(VnetKit))] +sealed partial class SubnetDataKit +{ + protected override IResourceBuilder + BuildResource(IDistributedApplicationBuilder builder) => + Parent.ResourceBuilder.AddSubnet(Name, "10.0.2.0/24"); +} +``` + +Then: + +```csharp +hostKit.Vnet.Web +hostKit.Vnet.Data +``` + +Construction and build order are parent-first, in a stable topological order. The generated host constructs +the roots, then each child with its parent instance, validates the whole graph, and only then builds enabled +resources. You never register children by hand for generated kits. + +## Manual kits + +Manual composition uses the parent-aware base constructor: + +```csharp +sealed partial class PostgresDatabaseKit(ShopHostKit hostKit, PostgresKit parent) + : ResourceKitBase( + hostKit, + parent, + "db") +{ + public PostgresKit Parent { get; } = parent; + + protected override IResourceBuilder + BuildResource(IDistributedApplicationBuilder builder) => + Parent.ResourceBuilder.AddDatabase(Name); +} +``` + +Register the parent before or after the child; `HostKitBase` validates and orders the graph itself: + +```csharp +Postgres = new(this); +PostgresDatabase = new(this, Postgres); +AddResource(Postgres); +AddResource(PostgresDatabase); +``` + +The graph is validated before any Aspire resource is created. A self-parent, a cycle, an unregistered +parent, a duplicate registration, or a parent from another host throws `InvalidOperationException` with a +useful message. + +## Enablement inheritance + +A child participates only when every gate passes: + +```text +effective = + host Mode + AND every group gate + AND every ancestor's effective enablement + AND the configured IsEnabled + AND the kit Mode + AND IsResourceEnabled(builder) +``` + +Consequences: + +- Disabling a parent disables every descendant. A child cannot re-enable itself with `IsEnabled = true`. +- A parent excluded by its `Mode` excludes every descendant. +- A child excluded by its `Mode`, or by its own hook, leaves the parent and eligible siblings alone. +- A disabled ancestor never invokes a descendant's `IsResourceEnabled` hook. +- Siblings are independent. +- A disabled or unbuilt kit creates no Aspire resource. + +`IsEnabled` keeps its historical behaviour: it is the configured value before `Build` and the effective value +afterwards. Two additional read-only properties make the distinction explicit: +`ConfiguredIsEnabled` (the value entering the build phase) and `EffectiveIsEnabled` (the final decision). + +`ResourceBuilder` throws `InvalidOperationException` while the kit is disabled, before it has built, or after +a build/configure failure. It never returns a silent `null`. + +## Configuration + +Root options keep their existing path. Children nest under their parent's options: + +```json +{ + "ExampleHostKit": { + "Postgres": { + "IsEnabled": true, + "Name": "postgres", + "Database": { + "IsEnabled": true, + "Name": "db" + } + } + } +} +``` + +```csharp +public sealed partial class PostgresKitOptions +{ + public bool IsEnabled { get; set; } = true; + + public PostgresDatabaseKit.PostgresDatabaseKitOptions Database { get; init; } = new(); +} +``` + +`OptionsHelper` resolves the nested path: + +```csharp +OptionsHelper.Assign( + static o => o.Postgres.Database.IsEnabled = false) + .Build(); // --ExampleHostKit:Postgres:Database:IsEnabled=false +``` + +Each level has its own `Name`, `IsEnabled` and `Mode`. `Mode` defaults from the attribute, exactly as for a +root kit. With `[HostKit(GenerateOptions = false)]` children still work; the generated host passes the +resource names from the attribute instead of options. + +## Failure policy + +- `BuildResource` and `ConfigureResource` exceptions propagate unchanged. +- A failed parent aborts the host build; no descendant is built. +- `Configure` skips a kit whose parent did not build or configure successfully. +- A host builds and configures at most once. Repeated calls are no-ops; a failed first build is not + silently retried. +- Lifecycle orchestration lives in `HostKitBase`, not in parent `BuildResource` methods. A parent never + recursively calls a child's `Build`. + +## Parentage is not a general dependency + +ResourceKit parentage means **composition ownership**: construction context, ordering, inherited enablement, +and optional group inheritance. It is not a general resource dependency graph. An API that needs Redis and +PostgreSQL remains an independent kit and wires those references in `ConfigureResource`: + +```csharp +protected override void ConfigureResource() +{ + if (HostKit.Redis.IsEnabled) + ResourceBuilder.WithReference(HostKit.Redis).WaitFor(HostKit.Redis); + + if (HostKit.Postgres.Database.IsEnabled) + ResourceBuilder.WithReference(HostKit.Postgres.Database).WaitFor(HostKit.Postgres.Database); +} +``` + +## Groups and visual placement + +Three different concepts stay separate: + +| Concept | What it controls | +| --- | --- | +| ResourceKit parent/child | Build ordering, parent builder access, inherited enablement | +| `[ResourceGroup]` / `[InGroup]` | Enablement and dashboard grouping | +| Aspire `WithParentRelationship` | The dashboard's single visual parent for a resource | + +A child with no `[InGroup]` inherits its parent's **primary** group for membership and dashboard placement. +A child with an explicit `[InGroup]` keeps it instead. Grouping never changes build ordering, and a disabled +parent still disables descendants even when a child's own group is enabled. + +ResourceKit does **not** add an Aspire visual parent relationship automatically. If you want a child's +resource to render under the parent's resource, add it explicitly in `BuildResource`: + +```csharp +var database = Parent.ResourceBuilder.AddDatabase(Name); +database.WithParentRelationship(Parent.ResourceBuilder); +return database; +``` + +The group placement rules then respect that existing chain, exactly as they do for any integration that +builds its own resource tree. See [Resource groups and dashboard URLs](Groups-and-URLs.md). + +## Diagnostics + +| ID | Meaning | +| --- | --- | +| SG0032 | A kit declares itself as its parent | +| SG0033 | The parent chain contains a cycle (the message prints the path) | +| SG0034 | The parent type is not a resource kit | +| SG0035 | The parent is not part of this host kit composition | +| SG0036 | The generated child property collides with a member on the parent | +| SG0037 | Two children of one parent use the same property name | +| SG0038 | The generated `Parent` member collides with a user-authored member | + +Invalid graphs block generation; the runtime validates manual graphs before creating any Aspire resource. +See [Diagnostics](Diagnostics.md). + +## Compatibility + +- Root kits need no changes. +- Existing configuration paths are unchanged. +- Existing `Mode`, `IsEnabled`, `Name`, custom options, custom `PropertyName` and manual kits continue to + work. +- Groups, URLs and existing parent relationship annotations continue to work. diff --git a/docs/wiki/Lifecycle-Build-Configure.md b/docs/wiki/Lifecycle-Build-Configure.md index b478498..94cd592 100644 --- a/docs/wiki/Lifecycle-Build-Configure.md +++ b/docs/wiki/Lifecycle-Build-Configure.md @@ -7,11 +7,14 @@ reason about. When the generated extension method is invoked, ResourceKit performs: -1. Instantiate resource kits from options. -2. `Build` each enabled resource. -3. `Configure` each enabled resource. +1. Instantiate root resource kits from options, then each child with its parent instance. +2. Validate the complete ownership graph and compute a stable parent-before-child order. +3. Resolve the composition gates (host mode, groups and ancestors). +4. `Build` each enabled resource, parents before children. +5. `Configure` each enabled resource in the same order. -This happens before `DistributedApplication.Build()` completes. +This happens before `DistributedApplication.Build()` completes. Graph validation runs before any Aspire +resource is created, so an invalid manual composition fails fast. ## The two lifecycle pairs @@ -71,6 +74,19 @@ protected override void ConfigureResource() Because `ConfigureResource()` runs after every resource is built, you can safely reach other resources through the host kit's generated properties. +A child can use `Parent.ResourceBuilder` inside its own `BuildResource`, because the parent has completed its +build first. The relationship is fixed at construction; a child never triggers its parent's or its own +`Build` recursively. + +## Failure policy + +- Exceptions from `BuildResource` and `ConfigureResource` propagate unchanged. +- A failed parent aborts the build; descendants are not built. +- `Configure` skips a kit whose parent failed. +- A host builds and configures at most once; repeated calls are no-ops. + +See [Parent and child resource kits](Hierarchies.md). + ## Enablement gates both phases Before `BuildResource(...)` runs, ResourceKit checks whether the resource should participate: diff --git a/docs/wiki/Release-Notes.md b/docs/wiki/Release-Notes.md index 77f0b93..251e5da 100644 --- a/docs/wiki/Release-Notes.md +++ b/docs/wiki/Release-Notes.md @@ -24,6 +24,14 @@ the [GitHub releases page](https://github.com/purview-dev/aspire-resourcekit/rel options. A relative URL adds one link for one endpoint, instead of rewriting every endpoint URL as `WithUrl("/path")` does. - **Diagnostics SG0021-SG0031** for groups and URLs. A group can never become its own ancestor or member. +- **Parent and child resource kits** (issue #57). `Parent = typeof(TParentKit)` gives the child a typed + `Parent`, the parent a typed child property, parent-before-child build ordering, inherited enablement, and + nested options (`::`). VNET/subnets, SQL server/database, Service Bus and storage + account/container hierarchies are supported without a second composition framework. See + [Parent and child resource kits](Hierarchies.md). +- **Diagnostics SG0032-SG0038** for self-parenting, cycles, unavailable parents, child property collisions, + duplicate child property names, and generated `Parent` member collisions. Manual graphs are validated at + runtime before any Aspire resource is created. ## 1.0.0 diff --git a/docs/wiki/Source-Generator-Behaviors.md b/docs/wiki/Source-Generator-Behaviors.md index 1ef044a..d9c623e 100644 --- a/docs/wiki/Source-Generator-Behaviors.md +++ b/docs/wiki/Source-Generator-Behaviors.md @@ -20,11 +20,14 @@ a single package reference. `HostKitGenerator` is an `IIncrementalGenerator`. It: -1. Registers the embedded attributes (`HostKitAttribute`, `ResourceDefinitionAttribute`) as - post-initialization output. -2. Runs a set of incremental value providers over the compilation. -3. Emits a single hint-name file per host kit that contains the host kit, resource kit skeletons, - options, the typed `ResourceKitBase`, and the AppHost extension method. +1. Registers the embedded attributes (`HostKitAttribute`, `ResourceDefinitionAttribute`, + `ResourceGroupAttribute`, `ResourceUrlAttribute`, and their generic forms) as post-initialization output. +2. Runs a set of incremental value providers over the compilation. The resource-kit aggregate is sorted by + metadata name and then evaluated as a parent/child graph (cycles, unavailable parents, duplicate child + property names) and topologically ordered before output. +3. Emits a single hint-name file per host kit that contains the host kit, resource kit skeletons + (including typed `Parent`/child properties and nested options), the typed + `ResourceKitBase`, resource groups, and the AppHost extension method. Generation is skipped when the generator is disabled via settings (`IsSourceGeneratorDisabled`). diff --git a/docs/wiki/Testing-with-ResourceKit.md b/docs/wiki/Testing-with-ResourceKit.md index 8239fea..4a3f12e 100644 --- a/docs/wiki/Testing-with-ResourceKit.md +++ b/docs/wiki/Testing-with-ResourceKit.md @@ -120,6 +120,23 @@ protected override string[] Args => ]; ``` +Child options use the nested path, so the same helper works at any depth: + +```csharp +OptionsHelper.Assign( + static o => o.Postgres.Database.IsEnabled = false).Build(); +``` + +See [Parent and child resource kits](Hierarchies.md) for the configuration shape. + +### Testing a hierarchy + +`ResourceKit.UnitTests` builds real Aspire models for a PostgreSQL server/database, a VNET with several +subnets, storage account/container, and a Service Bus namespace/topic/queue/subscription. Those tests assert +resource creation through `Parent.ResourceBuilder`, plus inherited enablement and graph validation, without +starting the AppHost or provisioning infrastructure. The example AppHost tests inspect the generated +hierarchy model and its nested configuration paths. + See [OptionsHelper](OptionsHelper.md) for `Assign`, `AsEnvironmentVariables`, and the `SG0020` single-property-path rule. diff --git a/scripts/verify-consumers/Kits.cs b/scripts/verify-consumers/Kits.cs index 5cc4358..cdd7d86 100644 --- a/scripts/verify-consumers/Kits.cs +++ b/scripts/verify-consumers/Kits.cs @@ -13,3 +13,15 @@ partial class ParameterKit protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => builder.AddParameter(Name, "value"); } + +// Proves the packaged analyzer emits the typed Parent/child surface (issue #57) in a real +// PackageReference consumer, on both target frameworks. +[ResourceDefinition("c", Parent = typeof(ParameterKit))] +partial class ChildParameterKit +{ + protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) + { + _ = Parent.ResourceBuilder; + return builder.AddParameter(Name, "child-value"); + } +} diff --git a/skills/purview-resourcekit-authoring/SKILL.md b/skills/purview-resourcekit-authoring/SKILL.md index 580c864..ee51c7f 100644 --- a/skills/purview-resourcekit-authoring/SKILL.md +++ b/skills/purview-resourcekit-authoring/SKILL.md @@ -4,7 +4,8 @@ description: >- Use when composing an Aspire AppHost with Purview.Aspire.ResourceKit - declaring a [HostKit] and [ResourceDefinition] resource kits, choosing the generic or non-generic attribute style, the Build/BuildResource and Configure/ConfigureResource lifecycle, project resources through - AddProject, grouping kits with [ResourceGroup]/[InGroup], named dashboard URLs with + AddProject, parent/child kits with Parent = typeof(TParentKit) and inherited enablement, grouping + kits with [ResourceGroup]/[InGroup], named dashboard URLs with [ResourceUrl]/[EndpointUrl], and registering the generated AddAspireResourceKit extension. metadata: product: Purview.Aspire.ResourceKit @@ -68,8 +69,9 @@ need more. Every participating class must be `partial` (`SG0001`). | Argument | Type | Default | Purpose | | --- | --- | --- | --- | | `Name` | `string?` | derived from the class name, suffix trimmed | the logical Aspire resource name | -| `PropertyName` | `string?` | derived from the class name | the generated host property name; must be a valid C# identifier (`SG0008`) | +| `PropertyName` | `string?` | derived from the class name | the generated host or parent property name; must be a valid C# identifier (`SG0008`) | | `Mode` | `ResourceKitMode` | `All` | execution mode for this resource | +| `Parent` | `Type?` | `null` | the owning resource kit: `Parent = typeof(TParentKit)` | | `AspireResourceType` | generic type argument | `null` | the Aspire `IResource` type — generic style only | `Mode` accepts a named property or a constructor argument on both attributes, and configuration can @@ -126,6 +128,47 @@ An attributed class must not declare a parameterised constructor or an executabl (`SG0012`). The generator supplies the wiring — a resource kit's primary constructor receives the host kit and its options. +## Parent and child kits + +`Parent = typeof(TParentKit)` turns a kit into a child. The generator emits a typed `Parent` property on the +child and a typed child property on the parent, and constructs parents before children: + +```csharp +[ResourceDefinition("postgres")] +sealed partial class PostgresKit +{ + protected override IResourceBuilder + BuildResource(IDistributedApplicationBuilder builder) => + builder.AddAzurePostgresFlexibleServer(Name); +} + +[ResourceDefinition( + "db", PropertyName = "Database", Parent = typeof(PostgresKit))] +sealed partial class PostgresDatabaseKit +{ + protected override IResourceBuilder + BuildResource(IDistributedApplicationBuilder builder) => + Parent.ResourceBuilder.AddDatabase(Name); +} +``` + +- The child reads `Parent.ResourceBuilder`; the parent has already finished `BuildResource`. +- `hostKit.Postgres.Database` is the strongly typed child; `Parent` is the strongly typed parent. +- One parent per kit, any number of children, any depth. +- A disabled parent disables every descendant, and a disabled ancestor never invokes a descendant's + `IsResourceEnabled` hook. Siblings are independent. +- Child options nest under the parent (`ShopHostKit:Postgres:Database:IsEnabled`). +- A child with no `[InGroup]` inherits its parent's **primary** group. Grouping never changes build order. +- ResourceKit parentage is composition ownership, not a general dependency, and it does **not** create an + Aspire visual parent relationship. Add `WithParentRelationship(Parent.ResourceBuilder)` yourself when you + want that. +- Manual kits use `ResourceKitBase(hostKit, parent, name)` and register both kits with the host; the host + validates the graph before creating resources. + +The VNET/subnet shape from issue #57 is the same: a `VnetKit` root and one `SubnetKit` per subnet with +`Parent = typeof(VnetKit)`, each building with `Parent.ResourceBuilder.AddSubnet(Name, prefix)`. Invalid +graphs report `SG0032`–`SG0038`. + ## Build versus Configure Two lifecycle pairs, and keeping them separate is what makes the composition deterministic: @@ -147,7 +190,8 @@ kit's generated properties: ```csharp protected override void ConfigureResource() { - ResourceBuilder.WithReference(HostKit.Postgres.Database).WaitFor(HostKit.Postgres.Database); + if (HostKit.Postgres.IsEnabled && HostKit.Postgres.Database.IsEnabled) + ResourceBuilder.WithReference(HostKit.Postgres.Database).WaitFor(HostKit.Postgres.Database); if (HostKit.Redis.IsEnabled) ResourceBuilder.WithReference(HostKit.Redis).WaitFor(HostKit.Redis); diff --git a/skills/purview-resourcekit-diagnostics/SKILL.md b/skills/purview-resourcekit-diagnostics/SKILL.md index a29a638..abdbc1b 100644 --- a/skills/purview-resourcekit-diagnostics/SKILL.md +++ b/skills/purview-resourcekit-diagnostics/SKILL.md @@ -55,6 +55,13 @@ assigned. | `SG0029` | Error | A group declares its parent more than once (`ResourceGroup` and `Parent`, or both attributes). | | `SG0030` | Error | A group is generic, or nested in a generic type. | | `SG0031` | Error | A group is also a resource kit, or carries `[InGroup]`. | +| `SG0032` | Error | A resource kit declares itself as its parent. | +| `SG0033` | Error | The parent chain contains a cycle; the message prints the path. | +| `SG0034` | Error | The declared parent type is not a resource kit. | +| `SG0035` | Error | The declared parent is not part of this host kit composition. | +| `SG0036` | Error | The generated child property collides with a member on the parent. | +| `SG0037` | Error | Two children of one parent use the same generated property name. | +| `SG0038` | Error | The generated `Parent` member collides with a user-authored member. | ## Execution-only versus generation-blocking @@ -129,6 +136,21 @@ sealed partial class SqlServerKit - `SG0027` / `SG0028`: a group, or a kit for a resource without endpoints such as a `ParameterResource`, can only show an **absolute** URL. +### `SG0032`–`SG0038` — parent/child hierarchies + +- `SG0032` / `SG0033`: a kit can never be its own parent or form a parent chain that loops. Move the + intended root up the chain. The cycle diagnostic prints the path, for example + `PostgresKit -> DatabaseKit -> MigrationKit -> PostgresKit`. +- `SG0034` / `SG0035`: `Parent = typeof(T)` must name a `[ResourceDefinition]` kit in the same compilation + and host composition. +- `SG0036` / `SG0038`: the generated child property or `Parent` accessor collides with a member you already + declared. Rename the member or set a different `PropertyName`. +- `SG0037`: child property names must be unique **within one parent**. Two different parents can both have a + `Database` child. + +Manual graphs are validated at runtime before any Aspire resource is created; a self-parent, cycle, missing +parent or foreign-host parent throws `InvalidOperationException` even when the generator was not involved. + ### `SG0020` — one property path per `Assign` action ```csharp diff --git a/skills/purview-resourcekit-migration/SKILL.md b/skills/purview-resourcekit-migration/SKILL.md index e23db7a..344fc6f 100644 --- a/skills/purview-resourcekit-migration/SKILL.md +++ b/skills/purview-resourcekit-migration/SKILL.md @@ -68,6 +68,37 @@ partial class ApiResourceKit } ``` +#### 2.1) Extract a child resource created from another resource + +When the existing AppHost creates a resource from another resource's builder — `vnet.AddSubnet(...)`, +`server.AddDatabase(...)`, `storage.AddBlobs(...)`, `namespace.AddTopic(...)` — model it as a child kit +instead of keeping both responsibilities in one class: + +1. Keep the parent creation in the parent `BuildResource`. +2. Remove the child creation from the parent. +3. Add `Parent = typeof(TParentKit)` (and `PropertyName` when the class name is not the property name you + want) to the child `[ResourceDefinition]`. +4. Build the child with `Parent.ResourceBuilder.Add...`, and return that builder. +5. Use `Parent` or the generated `hostKit.Parent.Child` from `ConfigureResource`. +6. Guard cross-resource wiring with `HostKit.Parent.IsEnabled && HostKit.Parent.Child.IsEnabled`, because a + disabled ancestor disables the child. + +```csharp +[ResourceDefinition("vnet")] +sealed partial class VnetKit { /* builder.AddAzureVirtualNetwork(Name) */ } + +[ResourceDefinition("snet-web", PropertyName = "Web", Parent = typeof(VnetKit))] +sealed partial class SubnetWebKit +{ + protected override IResourceBuilder BuildResource( + IDistributedApplicationBuilder builder) => + Parent.ResourceBuilder.AddSubnet(Name, "10.0.1.0/24"); +} +``` + +Move the old child config key from `{Host}:{Child}:IsEnabled` to the nested +`{Host}:{Parent}:{Child}:IsEnabled`. See `purview-resourcekit-authoring` for the full hierarchy rules. + ### 3) Move wiring to configure For dependencies/references among resources: @@ -87,6 +118,7 @@ Map old configuration into generated options conventions: - Resource naming → `{HostKitOptionsSection}:{ResourceProperty}:Name` - Enable/disable flags → `{HostKitOptionsSection}:{ResourceProperty}:IsEnabled` +- Child resources → `{HostKitOptionsSection}:{Parent}:{Child}:{Name|IsEnabled|Mode}` If tests or bootstrap code currently pass args directly, convert to `OptionsHelper.Assign(...)`: diff --git a/skills/purview-resourcekit-options/SKILL.md b/skills/purview-resourcekit-options/SKILL.md index 33c5115..12599dc 100644 --- a/skills/purview-resourcekit-options/SKILL.md +++ b/skills/purview-resourcekit-options/SKILL.md @@ -41,6 +41,27 @@ Host options bind to `SectionName` and `ValidateOnStart()` is called; resource o generated resource property name. Typical keys: `ShopHostKit:API:Name`, `ShopHostKit:API:IsEnabled`, `ShopHostKit:API:Mode`, `ShopHostKit:Mode`. +### Child kits + +Only roots are host-level options. A kit declared with `Parent = typeof(TParentKit)` nests its options +under the parent, so the configuration path mirrors the ownership hierarchy: + +```csharp +public sealed partial class PostgresKitOptions +{ + public bool IsEnabled { get; set; } = true; + public PostgresDatabaseKit.PostgresDatabaseKitOptions Database { get; init; } = new(); +} +``` + +```json +{ "ShopHostKit": { "Postgres": { "Database": { "IsEnabled": false } } } } +``` + +`OptionsHelper.Assign(o => o.Postgres.Database.IsEnabled = false)` produces +`--ShopHostKit:Postgres:Database:IsEnabled=false`. A nested child can be disabled independently, but never +re-enabled while an ancestor is disabled. See `purview-resourcekit-authoring` for the hierarchy rules. + Two further option families appear when they are declared: - **Resource groups.** `[ResourceGroup]` adds `ShopHostKit:Groups::{IsEnabled|Mode|ShowInDashboard}`. @@ -156,6 +177,11 @@ Two consequences worth stating plainly: - **`ResourceBuilder` cannot be accessed while the resource is disabled** — the property guards itself and throws `InvalidOperationException`. +A child adds the ancestor gate. If any ancestor is disabled, the child is effectively disabled even when +its own `IsEnabled` is `true`, and its hook is not invoked. `IsEnabled` is the configured value before +`Build` and the effective value afterwards; `ConfiguredIsEnabled` and `EffectiveIsEnabled` expose both +explicitly. + Override the hook when enablement depends on runtime state that is not a mode: ```csharp diff --git a/skills/purview-resourcekit-testing/SKILL.md b/skills/purview-resourcekit-testing/SKILL.md index 29aa615..2ec87eb 100644 --- a/skills/purview-resourcekit-testing/SKILL.md +++ b/skills/purview-resourcekit-testing/SKILL.md @@ -70,6 +70,12 @@ public sealed class ExampleAppHostIntegrationTests(ExampleAppHostFixture +/// Gives the integration tests a supported way to compose the example's model without starting the AppHost. +/// The generated host and its extension method are internal to this example. +///
+public static class ExampleAppHostModel +{ + public static void AddResourceKit(IDistributedApplicationBuilder builder) => builder.AddAspireResourceKit(); +} diff --git a/src/src/Example.AppHost/AppModels/Resources/AzureStorageBlobKit.cs b/src/src/Example.AppHost/AppModels/Resources/AzureStorageBlobKit.cs new file mode 100644 index 0000000..deb4d9f --- /dev/null +++ b/src/src/Example.AppHost/AppModels/Resources/AzureStorageBlobKit.cs @@ -0,0 +1,15 @@ +using Aspire.Hosting.Azure; + +namespace Purview.Aspire.ResourceKit.Example.AppHost.AppModels.Resources; + +[ResourceDefinition( + Platform.ResourceKits.AzureStorageBlob, + PropertyName = "Blobs", + Parent = typeof(AzureStorageKit) +)] +sealed partial class AzureStorageBlobKit +{ + protected override IResourceBuilder BuildResource( + IDistributedApplicationBuilder builder + ) => Parent.ResourceBuilder.AddBlobs(Name); +} diff --git a/src/src/Example.AppHost/AppModels/Resources/AzureStorageKit.cs b/src/src/Example.AppHost/AppModels/Resources/AzureStorageKit.cs index a75463f..92ca769 100644 --- a/src/src/Example.AppHost/AppModels/Resources/AzureStorageKit.cs +++ b/src/src/Example.AppHost/AppModels/Resources/AzureStorageKit.cs @@ -8,16 +8,12 @@ namespace Purview.Aspire.ResourceKit.Example.AppHost.AppModels.Resources; [InGroup] sealed partial class AzureStorageKit { - public IResourceBuilder Blobs { get; private set; } - protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) { var azureStorage = builder.AddAzureStorage(Name); if (builder.Environment.IsDevelopment()) azureStorage.RunAsEmulator(); - Blobs = azureStorage.AddBlobs(Platform.ResourceKits.AzureStorageBlob); - return azureStorage; } } diff --git a/src/src/Example.AppHost/AppModels/Resources/ExampleAPIKit.cs b/src/src/Example.AppHost/AppModels/Resources/ExampleAPIKit.cs index 08f49a1..ee3d1b4 100644 --- a/src/src/Example.AppHost/AppModels/Resources/ExampleAPIKit.cs +++ b/src/src/Example.AppHost/AppModels/Resources/ExampleAPIKit.cs @@ -37,8 +37,11 @@ protected override void ConfigureResource() .Build() ); - ResourceBuilder.WithReference(HostKit.Postgres.Database).WaitFor(HostKit.Postgres.Database); - ResourceBuilder.WithReference(HostKit.AzureStorage.Blobs).WaitFor(HostKit.AzureStorage.Blobs); + if (HostKit.Postgres.IsEnabled && HostKit.Postgres.Database.IsEnabled) + ResourceBuilder.WithReference(HostKit.Postgres.Database).WaitFor(HostKit.Postgres.Database); + + if (HostKit.AzureStorage.IsEnabled && HostKit.AzureStorage.Blobs.IsEnabled) + ResourceBuilder.WithReference(HostKit.AzureStorage.Blobs).WaitFor(HostKit.AzureStorage.Blobs); if (HostKit.KeyVault.IsEnabled) ResourceBuilder.WithReference(HostKit.KeyVault).WaitFor(HostKit.KeyVault); diff --git a/src/src/Example.AppHost/AppModels/Resources/PostgresDatabaseKit.cs b/src/src/Example.AppHost/AppModels/Resources/PostgresDatabaseKit.cs new file mode 100644 index 0000000..c4e60d1 --- /dev/null +++ b/src/src/Example.AppHost/AppModels/Resources/PostgresDatabaseKit.cs @@ -0,0 +1,15 @@ +using Aspire.Hosting.Azure; + +namespace Purview.Aspire.ResourceKit.Example.AppHost.AppModels.Resources; + +[ResourceDefinition( + Platform.ResourceKits.PostgresDb, + PropertyName = "Database", + Parent = typeof(PostgresKit) +)] +sealed partial class PostgresDatabaseKit +{ + protected override IResourceBuilder BuildResource( + IDistributedApplicationBuilder builder + ) => Parent.ResourceBuilder.AddDatabase(Name); +} diff --git a/src/src/Example.AppHost/AppModels/Resources/PostgresKit.cs b/src/src/Example.AppHost/AppModels/Resources/PostgresKit.cs index f8d417a..7d3f364 100644 --- a/src/src/Example.AppHost/AppModels/Resources/PostgresKit.cs +++ b/src/src/Example.AppHost/AppModels/Resources/PostgresKit.cs @@ -7,8 +7,6 @@ namespace Purview.Aspire.ResourceKit.Example.AppHost.AppModels.Resources; [InGroup] sealed partial class PostgresKit { - public IResourceBuilder Database { get; private set; } - protected override IResourceBuilder BuildResource( IDistributedApplicationBuilder builder ) @@ -16,8 +14,6 @@ IDistributedApplicationBuilder builder var postgres = builder.AddAzurePostgresFlexibleServer(Name); postgres.RunAsContainer(c => c.WithPgWeb(p => p.WithParentRelationship(postgres))); - Database = postgres.AddDatabase(Platform.ResourceKits.PostgresDb); - return postgres; } } diff --git a/src/src/Example.AppHost/AppModels/Resources/SubnetDataKit.cs b/src/src/Example.AppHost/AppModels/Resources/SubnetDataKit.cs new file mode 100644 index 0000000..1708d16 --- /dev/null +++ b/src/src/Example.AppHost/AppModels/Resources/SubnetDataKit.cs @@ -0,0 +1,14 @@ +using Aspire.Hosting.Azure; + +namespace Purview.Aspire.ResourceKit.Example.AppHost.AppModels.Resources; + +[ResourceDefinition( + Platform.ResourceKits.SubnetData, + PropertyName = "Data", + Parent = typeof(VnetKit) +)] +sealed partial class SubnetDataKit +{ + protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => + Parent.ResourceBuilder.AddSubnet(Name, "10.0.2.0/24"); +} diff --git a/src/src/Example.AppHost/AppModels/Resources/SubnetPrivateEndpointKit.cs b/src/src/Example.AppHost/AppModels/Resources/SubnetPrivateEndpointKit.cs new file mode 100644 index 0000000..907f622 --- /dev/null +++ b/src/src/Example.AppHost/AppModels/Resources/SubnetPrivateEndpointKit.cs @@ -0,0 +1,14 @@ +using Aspire.Hosting.Azure; + +namespace Purview.Aspire.ResourceKit.Example.AppHost.AppModels.Resources; + +[ResourceDefinition( + Platform.ResourceKits.SubnetPrivateEndpoint, + PropertyName = "PrivateEndpoint", + Parent = typeof(VnetKit) +)] +sealed partial class SubnetPrivateEndpointKit +{ + protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => + Parent.ResourceBuilder.AddSubnet(Name, "10.0.3.0/24"); +} diff --git a/src/src/Example.AppHost/AppModels/Resources/SubnetWebKit.cs b/src/src/Example.AppHost/AppModels/Resources/SubnetWebKit.cs new file mode 100644 index 0000000..e04db18 --- /dev/null +++ b/src/src/Example.AppHost/AppModels/Resources/SubnetWebKit.cs @@ -0,0 +1,14 @@ +using Aspire.Hosting.Azure; + +namespace Purview.Aspire.ResourceKit.Example.AppHost.AppModels.Resources; + +[ResourceDefinition( + Platform.ResourceKits.SubnetWeb, + PropertyName = "Web", + Parent = typeof(VnetKit) +)] +sealed partial class SubnetWebKit +{ + protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => + Parent.ResourceBuilder.AddSubnet(Name, "10.0.1.0/24"); +} diff --git a/src/src/Example.AppHost/AppModels/Resources/VnetKit.cs b/src/src/Example.AppHost/AppModels/Resources/VnetKit.cs new file mode 100644 index 0000000..99fefbe --- /dev/null +++ b/src/src/Example.AppHost/AppModels/Resources/VnetKit.cs @@ -0,0 +1,13 @@ +using Aspire.Hosting.Azure; +using Purview.Aspire.ResourceKit.Example.AppHost.AppModels.Groups; + +namespace Purview.Aspire.ResourceKit.Example.AppHost.AppModels.Resources; + +[ResourceDefinition(Platform.ResourceKits.Vnet, ResourceKitMode.Publish)] +[InGroup] +sealed partial class VnetKit +{ + protected override IResourceBuilder BuildResource( + IDistributedApplicationBuilder builder + ) => builder.AddAzureVirtualNetwork(Name); +} diff --git a/src/src/Example.AppHost/Example.AppHost.csproj b/src/src/Example.AppHost/Example.AppHost.csproj index fec1d82..23c6b16 100644 --- a/src/src/Example.AppHost/Example.AppHost.csproj +++ b/src/src/Example.AppHost/Example.AppHost.csproj @@ -24,7 +24,12 @@ dotnet build -p:AspireUseCliBundle=true --> false - $(NoWarn);CA1034;CA1062; + + $(NoWarn);ASPIREAZURE003;CA1034;CA1062; $(NoWarn);ASPIRE010 + $(NoWarn);ASPIREAZURE003 + + + + + + diff --git a/src/tests/ResourceKit.UnitTests/ResourceKitHierarchyAspireModelTests.cs b/src/tests/ResourceKit.UnitTests/ResourceKitHierarchyAspireModelTests.cs new file mode 100644 index 0000000..9fb7448 --- /dev/null +++ b/src/tests/ResourceKit.UnitTests/ResourceKitHierarchyAspireModelTests.cs @@ -0,0 +1,254 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Azure; + +namespace Purview.Aspire.ResourceKit; + +/// +/// Builds real Aspire models for the resource hierarchies in issue #57. The tests register resources through +/// Parent.ResourceBuilder and inspect the application model; they never provision cloud infrastructure. +/// +public sealed class ResourceKitHierarchyAspireModelTests +{ + [Test] + public async Task Build_GivenPostgresServerAndDatabase_CreatesTheDatabaseThroughTheParentBuilder() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + ModelHostKit host = new(); + PostgresServerKit postgres = new(host, "postgres"); + PostgresDatabaseKit database = new(host, postgres, "db"); + host.AddResource(postgres); + host.AddResource(database); + + // Act + host.Build(builder); + + // Assert + await Assert.That(builder.Resources.Any(static resource => resource.Name == "postgres")).IsTrue(); + await Assert.That(builder.Resources.Any(static resource => resource.Name == "db")).IsTrue(); + await Assert.That(database.ResourceBuilder.Resource).IsTypeOf(); + await Assert + .That(database.ResourceBuilder.ApplicationBuilder) + .IsSameReferenceAs(postgres.ResourceBuilder.ApplicationBuilder); + await Assert.That(database.Parent).IsSameReferenceAs(postgres); + } + + [Test] + public async Task Build_GivenVnetAndSeveralSubnets_RegistersEverySubnetThroughTheVnetBuilder() + { + // Arrange — VNET/subnet are provisioning resources, so Aspire only adds them to the model in publish mode. + var builder = DistributedApplication.CreateBuilder( + new DistributedApplicationOptions { Args = ["--publisher", "manifest"] } + ); + ModelHostKit host = new(); + VnetKit vnet = new(host, "vnet"); + SubnetKit web = new(host, vnet, "snet-web", "10.0.1.0/24"); + SubnetKit data = new(host, vnet, "snet-data", "10.0.2.0/24"); + SubnetKit privateEndpoint = new(host, vnet, "snet-private-endpoint", "10.0.3.0/24"); + + host.AddResource(vnet); + host.AddResource(web); + host.AddResource(data); + host.AddResource(privateEndpoint); + + // Act + host.Build(builder); + + // Assert + await Assert.That(builder.Resources.Count(static resource => resource.Name == "vnet")).IsEqualTo(1); + foreach ( + var (name, prefix) in new[] + { + ("snet-web", "10.0.1.0/24"), + ("snet-data", "10.0.2.0/24"), + ("snet-private-endpoint", "10.0.3.0/24"), + } + ) + { + var subnet = await Assert + .That(builder.Resources) + .HasSingleItem(resource => resource.Name == name && resource is AzureSubnetResource); + + await Assert.That(((AzureSubnetResource)subnet).AddressPrefix).IsEqualTo(prefix); + } + + await Assert.That(web.Parent).IsSameReferenceAs(vnet); + await Assert.That(web.BuiltWithParentBuilder).IsTrue(); + await Assert.That(data.BuiltWithParentBuilder).IsTrue(); + await Assert.That(privateEndpoint.BuiltWithParentBuilder).IsTrue(); + } + + [Test] + public async Task Build_GivenStorageBlobContainerHierarchy_RegistersTheGrandchildContainer() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + ModelHostKit host = new(); + StorageAccountKit storage = new(host, "storage"); + BlobContainerKit container = new(host, storage, "documents", "documents"); + host.AddResource(storage); + host.AddResource(container); + + // Act + host.Build(builder); + + // Assert + await Assert.That(builder.Resources.Any(static resource => resource.Name == "storage")).IsTrue(); + await Assert.That(container.ResourceBuilder.Resource).IsTypeOf(); + await Assert.That(container.Parent).IsSameReferenceAs(storage); + } + + [Test] + public async Task Build_GivenServiceBusNamespaceTopicQueueAndSubscription_RegistersEveryChild() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + ModelHostKit host = new(); + ServiceBusNamespaceKit serviceBus = new(host, "servicebus"); + ServiceBusTopicKit topic = new(host, serviceBus, "orders"); + ServiceBusSubscriptionKit subscription = new(host, topic, "orders-processor"); + ServiceBusQueueKit queue = new(host, serviceBus, "orders-queue"); + + host.AddResource(serviceBus); + host.AddResource(topic); + host.AddResource(subscription); + host.AddResource(queue); + + // Act + host.Build(builder); + + // Assert + await Assert.That(builder.Resources.Any(static resource => resource.Name == "servicebus")).IsTrue(); + await Assert.That(builder.Resources.Any(static resource => resource.Name == "orders")).IsTrue(); + await Assert.That(builder.Resources.Any(static resource => resource.Name == "orders-processor")).IsTrue(); + await Assert.That(builder.Resources.Any(static resource => resource.Name == "orders-queue")).IsTrue(); + await Assert.That(topic.Parent).IsSameReferenceAs(serviceBus); + await Assert.That(subscription.Parent).IsSameReferenceAs(topic); + await Assert.That(queue.Parent).IsSameReferenceAs(serviceBus); + } + + [Test] + public async Task Build_GivenDisabledVnet_DoesNotRegisterAnySubnet() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + ModelHostKit host = new(); + VnetKit vnet = new(host, "vnet") { IsEnabled = false }; + SubnetKit web = new(host, vnet, "snet-web", "10.0.1.0/24"); + SubnetKit data = new(host, vnet, "snet-data", "10.0.2.0/24"); + + host.AddResource(vnet); + host.AddResource(web); + host.AddResource(data); + + // Act + host.Build(builder); + + // Assert + await Assert.That(vnet.IsEnabled).IsFalse(); + await Assert.That(web.IsEnabled).IsFalse(); + await Assert.That(data.IsEnabled).IsFalse(); + await Assert + .That(builder.Resources.Any(static resource => resource.Name.StartsWith("snet-", StringComparison.Ordinal))) + .IsFalse(); + } + + sealed class ModelHostKit : HostKitBase; + + sealed class PostgresServerKit(ModelHostKit hostKit, string name) + : ResourceKitBase(hostKit, name) + { + protected override IResourceBuilder BuildResource( + IDistributedApplicationBuilder builder + ) => builder.AddAzurePostgresFlexibleServer(Name); + } + + sealed class PostgresDatabaseKit(ModelHostKit hostKit, PostgresServerKit parent, string name) + : ResourceKitBase(hostKit, parent, name) + { + public PostgresServerKit Parent { get; } = parent; + + protected override IResourceBuilder BuildResource( + IDistributedApplicationBuilder builder + ) => Parent.ResourceBuilder.AddDatabase(Name); + } + + sealed class VnetKit(ModelHostKit hostKit, string name) + : ResourceKitBase(hostKit, name) + { + protected override IResourceBuilder BuildResource( + IDistributedApplicationBuilder builder + ) => builder.AddAzureVirtualNetwork(Name); + } + + sealed class SubnetKit(ModelHostKit hostKit, VnetKit parent, string name, string prefix) + : ResourceKitBase(hostKit, parent, name) + { + public VnetKit Parent { get; } = parent; + + public bool BuiltWithParentBuilder { get; private set; } + + protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) + { + BuiltWithParentBuilder = ReferenceEquals(Parent.ResourceBuilder.ApplicationBuilder, builder); + return Parent.ResourceBuilder.AddSubnet(Name, prefix); + } + } + + sealed class StorageAccountKit(ModelHostKit hostKit, string name) + : ResourceKitBase(hostKit, name) + { + protected override IResourceBuilder BuildResource( + IDistributedApplicationBuilder builder + ) => builder.AddAzureStorage(Name); + } + + sealed class BlobContainerKit(ModelHostKit hostKit, StorageAccountKit parent, string name, string containerName) + : ResourceKitBase(hostKit, parent, name) + { + public StorageAccountKit Parent { get; } = parent; + + protected override IResourceBuilder BuildResource( + IDistributedApplicationBuilder builder + ) => Parent.ResourceBuilder.AddBlobContainer(Name, containerName); + } + + sealed class ServiceBusNamespaceKit(ModelHostKit hostKit, string name) + : ResourceKitBase(hostKit, name) + { + protected override IResourceBuilder BuildResource( + IDistributedApplicationBuilder builder + ) => builder.AddAzureServiceBus(Name); + } + + sealed class ServiceBusTopicKit(ModelHostKit hostKit, ServiceBusNamespaceKit parent, string name) + : ResourceKitBase(hostKit, parent, name) + { + public ServiceBusNamespaceKit Parent { get; } = parent; + + protected override IResourceBuilder BuildResource( + IDistributedApplicationBuilder builder + ) => Parent.ResourceBuilder.AddServiceBusTopic(Name, Name); + } + + sealed class ServiceBusSubscriptionKit(ModelHostKit hostKit, ServiceBusTopicKit parent, string name) + : ResourceKitBase(hostKit, parent, name) + { + public ServiceBusTopicKit Parent { get; } = parent; + + protected override IResourceBuilder BuildResource( + IDistributedApplicationBuilder builder + ) => Parent.ResourceBuilder.AddServiceBusSubscription(Name, Name); + } + + sealed class ServiceBusQueueKit(ModelHostKit hostKit, ServiceBusNamespaceKit parent, string name) + : ResourceKitBase(hostKit, parent, name) + { + public ServiceBusNamespaceKit Parent { get; } = parent; + + protected override IResourceBuilder BuildResource( + IDistributedApplicationBuilder builder + ) => Parent.ResourceBuilder.AddServiceBusQueue(Name, Name); + } +} diff --git a/src/tests/ResourceKit.UnitTests/ResourceKitHierarchyEnablementTests.cs b/src/tests/ResourceKit.UnitTests/ResourceKitHierarchyEnablementTests.cs new file mode 100644 index 0000000..5ab2e6b --- /dev/null +++ b/src/tests/ResourceKit.UnitTests/ResourceKitHierarchyEnablementTests.cs @@ -0,0 +1,161 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; + +namespace Purview.Aspire.ResourceKit; + +/// +/// The enablement matrix for a two-level hierarchy: host mode, parent and child modes, the configured flags, +/// and the runtime hooks. It proves that a disabled ancestor suppresses descendant hooks and builds. +/// +public sealed class ResourceKitHierarchyEnablementTests +{ + [Test] + [Arguments(false, true, true, true, true, false, false)] + [Arguments(true, false, true, true, true, false, false)] + [Arguments(true, true, false, true, true, true, false)] + [Arguments(true, true, true, false, true, false, false)] + [Arguments(true, true, true, true, false, true, false)] + [Arguments(true, true, true, true, true, true, true)] + [Arguments(true, true, false, true, false, true, false)] + public async Task Build_GivenEnablementCombination_BuildsExactlyWhatTheGatesAllow( + bool hostEnabled, + bool parentEnabled, + bool childEnabled, + bool parentHook, + bool childHook, + bool parentExpected, + bool childExpected + ) + { + // Arrange + var builder = TestApplicationBuilderFactory.Create(DistributedApplicationOperation.Run); + EnablementHostKit host = new(hostEnabled ? ResourceKitMode.All : ResourceKitMode.Publish); + EnablementKit parent = new(host, "parent", hook: parentHook) { IsEnabled = parentEnabled }; + EnablementKit child = new(host, parent, "child", hook: childHook) { IsEnabled = childEnabled }; + host.AddResource(parent); + host.AddResource(child); + + // Act + host.Build(builder); + host.Configure(); + + // Assert + await Assert.That(parent.BuildCalled).IsEqualTo(parentExpected); + await Assert.That(parent.ConfigureCalled).IsEqualTo(parentExpected); + await Assert.That(child.BuildCalled).IsEqualTo(childExpected); + await Assert.That(child.ConfigureCalled).IsEqualTo(childExpected); + + // The separate tests below assert hook suppression; this matrix asserts the resulting build decisions. + } + + [Test] + public async Task Build_GivenParentExcludedByMode_DoesNotInvokeChildHook() + { + // Arrange + var builder = TestApplicationBuilderFactory.Create(DistributedApplicationOperation.Run); + EnablementHostKit host = new(ResourceKitMode.All); + EnablementKit parent = new(host, "parent", mode: ResourceKitMode.Publish); + EnablementKit child = new(host, parent, "child"); + host.AddResource(parent); + host.AddResource(child); + + // Act + host.Build(builder); + + // Assert + await Assert.That(child.HookCalled).IsFalse(); + await Assert.That(child.BuildCalled).IsFalse(); + await Assert.That(child.EffectiveIsEnabled).IsFalse(); + } + + [Test] + public async Task Build_GivenReEnabledParentInAFreshHost_ChildKeepsItsConfiguredValue() + { + // Arrange + EnablementHostKit firstHost = new(ResourceKitMode.All); + EnablementKit firstParent = new(firstHost, "parent") { IsEnabled = false }; + EnablementKit firstChild = new(firstHost, firstParent, "child"); + firstHost.AddResource(firstParent); + firstHost.AddResource(firstChild); + firstHost.Build(TestApplicationBuilderFactory.Create(DistributedApplicationOperation.Run)); + + EnablementHostKit secondHost = new(ResourceKitMode.All); + EnablementKit secondParent = new(secondHost, "parent"); + EnablementKit secondChild = new(secondHost, secondParent, "child"); + secondHost.AddResource(secondParent); + secondHost.AddResource(secondChild); + + // Act + secondHost.Build(TestApplicationBuilderFactory.Create(DistributedApplicationOperation.Run)); + + // Assert + await Assert.That(firstChild.EffectiveIsEnabled).IsFalse(); + await Assert.That(firstChild.ConfiguredIsEnabled).IsTrue(); + await Assert.That(secondChild.EffectiveIsEnabled).IsTrue(); + await Assert.That(secondChild.BuildCalled).IsTrue(); + } + + sealed class EnablementHostKit(ResourceKitMode mode) : HostKitBase + { + protected override ResourceKitMode Mode => mode; + } + + sealed class EnablementKit : ResourceKitBase + { + readonly bool _hook; + readonly ResourceKitMode _mode; + + public EnablementKit( + EnablementHostKit hostKit, + string name, + bool hook = true, + ResourceKitMode mode = ResourceKitMode.All + ) + : base(hostKit, name) + { + _hook = hook; + _mode = mode; + } + + public EnablementKit( + EnablementHostKit hostKit, + EnablementKit parent, + string name, + bool hook = true, + ResourceKitMode mode = ResourceKitMode.All + ) + : this(hostKit, name, hook, mode) + { + ParentKit = parent; + } + + public bool HookCalled { get; private set; } + + public bool BuildCalled { get; private set; } + + public bool ConfigureCalled { get; private set; } + + protected override ResourceKitMode Mode => _mode; + + protected override bool IsResourceEnabled(IDistributedApplicationBuilder builder) + { + HookCalled = true; + return _hook; + } + + protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) + { + BuildCalled = true; + return default!; + } + + protected override void ConfigureResource() => ConfigureCalled = true; + } + + sealed class EnablementResource : IResource + { + public string Name => nameof(EnablementResource); + + public ResourceAnnotationCollection Annotations { get; } = []; + } +} diff --git a/src/tests/ResourceKit.UnitTests/ResourceKitHierarchyGroupTests.cs b/src/tests/ResourceKit.UnitTests/ResourceKitHierarchyGroupTests.cs new file mode 100644 index 0000000..dc09dbc --- /dev/null +++ b/src/tests/ResourceKit.UnitTests/ResourceKitHierarchyGroupTests.cs @@ -0,0 +1,210 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; + +namespace Purview.Aspire.ResourceKit; + +/// +/// Covers the interaction between the parent/child ownership graph and ResourceKit groups: inherited group +/// membership, dashboard placement, disabled hierarchies, and the precedence between hierarchy and groups. +/// +public sealed class ResourceKitHierarchyGroupTests +{ + [Test] + public async Task Build_GivenChildWithoutAGroup_InheritsTheParentsPrimaryGroup() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + HierarchyHostKit host = new(); + ResourceKitGroup data = new("data"); + HierarchyTestKit parent = new(host, "parent", []); + HierarchyTestKit child = new(host, parent, "child", []); + host.AddResource(parent, data); + host.AddResource(child); + + // Act + host.Build(builder); + host.Configure(); + + // Assert + await Assert.That(host.GetGroups(child)).Contains(data); + await Assert.That(data.Resources).Contains(child); + await Assert.That(data.DashboardResource).IsNotNull(); + await Assert.That(ParentOf(child.Resource)).IsSameReferenceAs(data.DashboardResource!.Resource); + await Assert.That(ParentOf(parent.Resource)).IsSameReferenceAs(data.DashboardResource.Resource); + } + + [Test] + public async Task Build_GivenChildWithAnExplicitGroup_DoesNotInheritTheParentsGroup() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + HierarchyHostKit host = new(); + ResourceKitGroup data = new("data"); + ResourceKitGroup services = new("services"); + HierarchyTestKit parent = new(host, "parent", []); + HierarchyTestKit child = new(host, parent, "child", []); + host.AddResource(parent, data); + host.AddResource(child, services); + + // Act + host.Build(builder); + host.Configure(); + + // Assert + var groups = host.GetGroups(child); + await Assert.That(groups.Count).IsEqualTo(1); + await Assert.That(groups[0]).IsSameReferenceAs(services); + await Assert.That(data.Resources).DoesNotContain(child); + await Assert.That(services.Resources).Contains(child); + await Assert.That(parent.IsEnabled).IsTrue(); + await Assert.That(child.IsEnabled).IsTrue(); + } + + [Test] + public async Task Build_GivenParentDisabledByItsGroup_DisablesChildEvenInAnEnabledGroup() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + HierarchyHostKit host = new(); + ResourceKitGroup data = new("data") { IsEnabled = false }; + ResourceKitGroup services = new("services"); + HierarchyTestKit parent = new(host, "parent", []); + HierarchyTestKit child = new(host, parent, "child", []); + host.AddResource(parent, data); + host.AddResource(child, services); + + // Act + host.Build(builder); + host.Configure(); + + // Assert + await Assert.That(parent.IsEnabled).IsFalse(); + await Assert.That(child.IsEnabled).IsFalse(); + await Assert.That(child.BuildCalled).IsFalse(); + await Assert.That(data.DashboardResource).IsNull(); + await Assert.That(services.DashboardResource).IsNull(); + } + + [Test] + public async Task Build_GivenGrandchildWithoutGroups_InheritsThroughTheParentChain() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + HierarchyHostKit host = new(); + ResourceKitGroup data = new("data"); + HierarchyTestKit root = new(host, "root", []); + HierarchyTestKit child = new(host, root, "child", []); + HierarchyTestKit grandchild = new(host, child, "grandchild", []); + host.AddResource(root, data); + host.AddResource(child); + host.AddResource(grandchild); + + // Act + host.Build(builder); + host.Configure(); + + // Assert + await Assert.That(host.GetGroups(child)).Contains(data); + await Assert.That(host.GetGroups(grandchild)).Contains(data); + await Assert.That(data.AllResources).Contains(grandchild); + await Assert.That(data.DashboardResource).IsNotNull(); + } + + [Test] + public async Task Build_GivenTwoParentsInOneGroup_EachKeepsItsOwnChildrenAndEnablement() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + HierarchyHostKit host = new(); + ResourceKitGroup data = new("data"); + HierarchyTestKit first = new(host, "first", []); + HierarchyTestKit firstChild = new(host, first, "first-child", []); + HierarchyTestKit second = new(host, "second", []); + HierarchyTestKit secondChild = new(host, second, "second-child", [], enabled: false); + host.AddResource(first, data); + host.AddResource(firstChild); + host.AddResource(second, data); + host.AddResource(secondChild); + + // Act + host.Build(builder); + host.Configure(); + + // Assert + await Assert.That(firstChild.IsEnabled).IsTrue(); + await Assert.That(second.IsEnabled).IsTrue(); + await Assert.That(secondChild.IsEnabled).IsFalse(); + await Assert.That(firstChild.BuildCalled).IsTrue(); + await Assert.That(secondChild.BuildCalled).IsFalse(); + await Assert.That(data.Resources).Contains(first); + await Assert.That(data.Resources).Contains(second); + } + + [Test] + public async Task Build_GivenUngroupedHierarchy_DoesNotCreateADashboardNode() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + HierarchyHostKit host = new(); + HierarchyTestKit parent = new(host, "parent", []); + HierarchyTestKit child = new(host, parent, "child", []); + host.AddResource(parent); + host.AddResource(child); + + // Act + host.Build(builder); + host.Configure(); + + // Assert + await Assert.That(host.GetGroups(child)).IsEmpty(); + await Assert.That(builder.Resources.OfType()).IsEmpty(); + } + + sealed class HierarchyHostKit : HostKitBase; + + sealed class HierarchyTestKit : ResourceKitBase + { + readonly List _events; + + public HierarchyTestKit(HierarchyHostKit hostKit, string name, List events, bool enabled = true) + : base(hostKit, name) + { + _events = events; + IsEnabled = enabled; + } + + public HierarchyTestKit( + HierarchyHostKit hostKit, + HierarchyTestKit parent, + string name, + List events, + bool enabled = true + ) + : this(hostKit, name, events, enabled) + { + ParentKit = parent; + } + + public bool BuildCalled { get; private set; } + + protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) + { + BuildCalled = true; + _events.Add($"Build:{Name}"); + return builder.AddResource(new HierarchyResource(Name)); + } + } + + sealed class HierarchyResource(string name) : IResource + { + public string Name { get; } = name; + + public ResourceAnnotationCollection Annotations { get; } = []; + } + + static IResource? ParentOf(IResource resource) => + resource + .Annotations.OfType() + .FirstOrDefault(static annotation => annotation.Type == "Parent") + ?.Resource; +} diff --git a/src/tests/ResourceKit.UnitTests/ResourceKitHierarchyTests.cs b/src/tests/ResourceKit.UnitTests/ResourceKitHierarchyTests.cs new file mode 100644 index 0000000..46bc335 --- /dev/null +++ b/src/tests/ResourceKit.UnitTests/ResourceKitHierarchyTests.cs @@ -0,0 +1,544 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; + +namespace Purview.Aspire.ResourceKit; + +/// +/// Covers the parent/child ownership contract added for issue #57: construction ordering, the parent builder +/// being available while a child builds, graph validation, inherited enablement, and lifecycle failure policy. +/// +public sealed class ResourceKitHierarchyTests +{ + [Test] + public async Task Build_GivenParentAndChild_BuildsParentFirstAndExposesParentBuilder() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + List events = []; + TestHostKit host = new(events); + InstrumentedKit parent = new(host, "parent", events); + InstrumentedKit child = new(host, parent, "child", events); + + // Register the child first on purpose; the host must still construct and build the parent first. + host.AddResource(child); + host.AddResource(parent); + + // Act + host.Build(builder); + host.Configure(); + + // Assert + await Assert + .That(events) + .IsEquivalentTo(["Build:parent", "Build:child", "Configure:parent", "Configure:child"]); + await Assert.That(child.ParentKit).IsSameReferenceAs(parent); + await Assert.That(child.ResourceBuilder.Resource.Name).IsEqualTo("child"); + await Assert.That(parent.ResourceBuilder.Resource.Name).IsEqualTo("parent"); + await Assert.That(builder.Resources.Contains(parent.ResourceBuilder.Resource)).IsTrue(); + await Assert.That(builder.Resources.Contains(child.ResourceBuilder.Resource)).IsTrue(); + } + + [Test] + public async Task Build_GivenDeepHierarchy_BuildsEveryAncestorBeforeItsDescendants() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + List events = []; + TestHostKit host = new(events); + InstrumentedKit root = new(host, "root", events); + InstrumentedKit level1 = new(host, root, "level1", events); + InstrumentedKit level2 = new(host, level1, "level2", events); + InstrumentedKit level3 = new(host, level2, "level3", events); + InstrumentedKit level4 = new(host, level3, "level4", events); + + host.AddResource(level4); + host.AddResource(level3); + host.AddResource(level2); + host.AddResource(level1); + host.AddResource(root); + + // Act + host.Build(builder); + + // Assert + await Assert + .That(events) + .IsEquivalentTo(["Build:root", "Build:level1", "Build:level2", "Build:level3", "Build:level4"]); + } + + [Test] + public async Task Build_GivenParentDisabled_DoesNotBuildOrInvokeHookOnDescendants() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + List events = []; + TestHostKit host = new(events); + InstrumentedKit parent = new(host, "parent", events) { IsEnabled = false }; + InstrumentedKit child = new(host, parent, "child", events); + InstrumentedKit grandchild = new(host, child, "grandchild", events); + + host.AddResource(parent); + host.AddResource(child); + host.AddResource(grandchild); + + // Act + host.Build(builder); + + // Assert + await Assert.That(events).IsEmpty(); + await Assert.That(parent.IsEnabled).IsFalse(); + await Assert.That(child.IsEnabled).IsFalse(); + await Assert.That(grandchild.IsEnabled).IsFalse(); + await Assert.That(child.HookCalled).IsFalse(); + await Assert.That(grandchild.HookCalled).IsFalse(); + await Assert.That(child.ConfiguredIsEnabled).IsTrue(); + await Assert.That(child.EffectiveIsEnabled).IsFalse(); + } + + [Test] + public async Task Build_GivenParentModeExcluded_DisablesEveryDescendant() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + List events = []; + TestHostKit host = new(events); + InstrumentedKit parent = new(host, "parent", events, mode: ResourceKitMode.Publish); + InstrumentedKit child = new(host, parent, "child", events); + + host.AddResource(parent); + host.AddResource(child); + + // Act + host.Build(builder); + + // Assert + await Assert.That(parent.IsEnabled).IsFalse(); + await Assert.That(child.IsEnabled).IsFalse(); + await Assert.That(child.HookCalled).IsFalse(); + await Assert.That(events).IsEmpty(); + } + + [Test] + public async Task Build_GivenChildModeExcluded_LeavesParentAndSiblingEligible() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + List events = []; + TestHostKit host = new(events); + InstrumentedKit parent = new(host, "parent", events); + InstrumentedKit excluded = new(host, parent, "excluded", events, mode: ResourceKitMode.Publish); + InstrumentedKit sibling = new(host, parent, "sibling", events); + + host.AddResource(parent); + host.AddResource(excluded); + host.AddResource(sibling); + + // Act + host.Build(builder); + + // Assert + await Assert.That(parent.IsEnabled).IsTrue(); + await Assert.That(excluded.IsEnabled).IsFalse(); + await Assert.That(sibling.IsEnabled).IsTrue(); + await Assert.That(excluded.HookCalled).IsFalse(); + await Assert.That(events).IsEquivalentTo(["Build:parent", "Build:sibling"]); + } + + [Test] + public async Task Build_GivenChildHookDisabled_DisablesChildAndGrandchildOnly() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + List events = []; + TestHostKit host = new(events); + InstrumentedKit parent = new(host, "parent", events); + InstrumentedKit child = new(host, parent, "child", events, hook: false); + InstrumentedKit grandchild = new(host, child, "grandchild", events); + + host.AddResource(parent); + host.AddResource(child); + host.AddResource(grandchild); + + // Act + host.Build(builder); + + // Assert + await Assert.That(parent.IsEnabled).IsTrue(); + await Assert.That(child.IsEnabled).IsFalse(); + await Assert.That(grandchild.IsEnabled).IsFalse(); + await Assert.That(grandchild.HookCalled).IsFalse(); + await Assert.That(events).IsEquivalentTo(["Build:parent"]); + } + + [Test] + public async Task Build_GivenParentBuildThrows_ChildCannotBuildOrAccessItsBuilder() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + List events = []; + TestHostKit host = new(events); + InstrumentedKit parent = new(host, "parent", events, throwOnBuild: true); + InstrumentedKit child = new(host, parent, "child", events); + + host.AddResource(parent); + host.AddResource(child); + + // Act + var exception = await Assert.That(() => host.Build(builder)).ThrowsExactly(); + + // Assert + await Assert.That(exception!.Message).Contains("parent"); + await Assert.That(child.BuildCalled).IsFalse(); + await Assert.That(() => _ = child.ResourceBuilder).ThrowsExactly(); + } + + [Test] + public async Task Build_GivenParentFailsDuringConfigure_ChildDoesNotConfigure() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + List events = []; + TestHostKit host = new(events); + InstrumentedKit parent = new(host, "parent", events, throwOnConfigure: true); + InstrumentedKit child = new(host, parent, "child", events); + + host.AddResource(parent); + host.AddResource(child); + host.Build(builder); + + // Act + await Assert.That(host.Configure).ThrowsExactly(); + + // Assert + await Assert.That(child.ConfigureCalled).IsFalse(); + } + + [Test] + public async Task Build_WhenCalledTwice_DoesNotCreateResourcesTwice() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + List events = []; + TestHostKit host = new(events); + InstrumentedKit parent = new(host, "parent", events); + InstrumentedKit child = new(host, parent, "child", events); + + host.AddResource(parent); + host.AddResource(child); + + // Act + host.Build(builder); + host.Build(builder); + host.Configure(); + host.Configure(); + + // Assert + await Assert + .That(events) + .IsEquivalentTo(["Build:parent", "Build:child", "Configure:parent", "Configure:child"]); + await Assert.That(parent.BuildCount).IsEqualTo(1); + await Assert.That(child.ConfigureCount).IsEqualTo(1); + } + + [Test] + public async Task Build_GivenSelfParenting_ThrowsBeforeCreatingResources() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + TestHostKit host = new([]); + MutableKit kit = new() { HostKit = host, Name = "self" }; + kit.ParentKit = kit; + host.AddResource(kit); + + // Act + var exception = await Assert.That(() => host.Build(builder)).ThrowsExactly(); + + // Assert + await Assert.That(exception!.Message).Contains("own parent"); + await Assert.That(kit.BuildCalled).IsFalse(); + } + + [Test] + public async Task Build_GivenParentCycle_ThrowsBeforeCreatingResources() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + TestHostKit host = new([]); + MutableKit first = new() { HostKit = host, Name = "first" }; + MutableKit second = new() { HostKit = host, Name = "second" }; + first.ParentKit = second; + second.ParentKit = first; + host.AddResource(first); + host.AddResource(second); + + // Act + var exception = await Assert.That(() => host.Build(builder)).ThrowsExactly(); + + // Assert + await Assert.That(exception!.Message).Contains("first"); + await Assert.That(exception!.Message).Contains("second"); + await Assert.That(exception!.Message).Contains("cycle"); + await Assert.That(first.BuildCalled).IsFalse(); + await Assert.That(second.BuildCalled).IsFalse(); + } + + [Test] + public async Task Build_GivenUnregisteredParent_ThrowsBeforeCreatingResources() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + TestHostKit host = new([]); + MutableKit parent = new() { HostKit = host, Name = "parent" }; + MutableKit child = new() { HostKit = host, Name = "child" }; + child.ParentKit = parent; + host.AddResource(child); + + // Act + var exception = await Assert.That(() => host.Build(builder)).ThrowsExactly(); + + // Assert + await Assert.That(exception!.Message).Contains("not registered"); + await Assert.That(child.BuildCalled).IsFalse(); + } + + [Test] + public async Task Build_GivenDuplicateRegistration_ThrowsBeforeCreatingResources() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + TestHostKit host = new([]); + InstrumentedKit kit = new(host, "kit", []); + host.AddResource(kit); + host.AddResource(kit); + + // Act + var exception = await Assert.That(() => host.Build(builder)).ThrowsExactly(); + + // Assert + await Assert.That(exception!.Message).Contains("more than once"); + } + + [Test] + public async Task Build_GivenForeignHostParent_ThrowsBeforeCreatingResources() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + TestHostKit host = new([]); + TestHostKit otherHost = new([]); + InstrumentedKit parent = new(otherHost, "parent", []); + + // The child's parent has to be the parent instance, but its HostKit differs. + InstrumentedKit child = new(host, parent, "child", []); + host.AddResource(parent); + host.AddResource(child); + + // Act + var exception = await Assert.That(() => host.Build(builder)).ThrowsExactly(); + + // Assert + await Assert.That(exception!.Message).Contains("different host kit"); + } + + [Test] + [Timeout(30_000)] + public async Task Build_GivenDeepGraph_ValidatesAndBuildsWithoutStackOverflow(CancellationToken cancellationToken) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + const int depth = 2_000; + var builder = TestApplicationBuilderFactory.Create(DistributedApplicationOperation.Run); + TestHostKit host = new([]); + MutableKit? previous = null; + List resources = []; + + for (var index = 0; index < depth; index++) + { + MutableKit kit = new() { HostKit = host, Name = $"kit-{index:D4}" }; + if (previous is not null) + kit.ParentKit = previous; + + resources.Add(kit); + previous = kit; + } + + // Register in reverse so the host has to resolve the entire chain. + for (var index = resources.Count - 1; index >= 0; index--) + host.AddResource(resources[index]); + + // Act + host.Build(builder); + + // Assert + await Assert.That(resources.All(static kit => kit.BuildCalled)).IsTrue(); + } + + [Test] + [Timeout(30_000)] + public async Task Build_GivenWideGraph_BuildsEveryChildOnce(CancellationToken cancellationToken) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + const int parents = 3; + const int children = 100; + var builder = TestApplicationBuilderFactory.Create(DistributedApplicationOperation.Run); + TestHostKit host = new([]); + List roots = []; + List all = []; + + for (var parentIndex = 0; parentIndex < parents; parentIndex++) + { + MutableKit root = new() { HostKit = host, Name = $"parent-{parentIndex}" }; + roots.Add(root); + all.Add(root); + + for (var childIndex = 0; childIndex < children; childIndex++) + { + MutableKit child = new() + { + HostKit = host, + Name = $"child-{parentIndex}-{childIndex}", + ParentKit = root, + }; + all.Add(child); + } + } + + foreach (var kit in all) + host.AddResource(kit); + + // Act + host.Build(builder); + + // Assert + await Assert.That(all.All(static kit => kit.BuildCalled)).IsTrue(); + await Assert + .That(all.Select(static kit => kit.Name).Distinct(StringComparer.Ordinal).Count()) + .IsEqualTo(all.Count); + } + + [Test] + public async Task Build_GivenUnbuiltKit_ResourceBuilderThrowsAClearException() + { + // Arrange + TestHostKit host = new([]); + InstrumentedKit kit = new(host, "kit", []); + + // Act + var exception = await Assert.That(() => _ = kit.ResourceBuilder).ThrowsExactly(); + + // Assert + await Assert.That(exception!.Message).Contains("has not been built"); + } + + sealed class TestHostKit(List events) : HostKitBase + { + public List Events { get; } = events; + } + + sealed class InstrumentedKit : ResourceKitBase + { + readonly bool _hook; + readonly ResourceKitMode _mode; + readonly bool _throwOnBuild; + readonly bool _throwOnConfigure; + readonly List _events; + + public InstrumentedKit( + TestHostKit hostKit, + string name, + List events, + bool hook = true, + ResourceKitMode mode = ResourceKitMode.All, + bool throwOnBuild = false, + bool throwOnConfigure = false + ) + : base(hostKit, name) + { + _hook = hook; + _mode = mode; + _throwOnBuild = throwOnBuild; + _throwOnConfigure = throwOnConfigure; + _events = events; + } + + public InstrumentedKit( + TestHostKit hostKit, + InstrumentedKit parent, + string name, + List events, + bool hook = true, + ResourceKitMode mode = ResourceKitMode.All, + bool throwOnBuild = false, + bool throwOnConfigure = false + ) + : this(hostKit, name, events, hook, mode, throwOnBuild, throwOnConfigure) + { + ParentKit = parent; + } + + public bool BuildCalled { get; private set; } + + public int BuildCount { get; private set; } + + public bool ConfigureCalled { get; private set; } + + public int ConfigureCount { get; private set; } + + public bool HookCalled { get; private set; } + + protected override ResourceKitMode Mode => _mode; + + protected override bool IsResourceEnabled(IDistributedApplicationBuilder builder) + { + HookCalled = true; + return _hook; + } + + protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) + { + BuildCalled = true; + BuildCount++; + _events.Add($"Build:{Name}"); + + if (_throwOnBuild) + throw new InvalidOperationException($"The '{Name}' parent failed to build."); + + return builder.AddResource(new TestResource(Name)); + } + + protected override void ConfigureResource() + { + ConfigureCalled = true; + ConfigureCount++; + _events.Add($"Configure:{Name}"); + + if (_throwOnConfigure) + throw new InvalidOperationException($"The '{Name}' parent failed to configure."); + } + } + + sealed class TestResource(string name) : IResource + { + public string Name { get; } = name; + + public ResourceAnnotationCollection Annotations { get; } = []; + } + + // A hand-written IResourceKit used to prove the host validates graphs that the generator never saw. + sealed class MutableKit : IResourceKit + { + public TestHostKit HostKit { get; init; } = null!; + + public string Name { get; init; } = string.Empty; + + public bool IsEnabled { get; set; } = true; + + public IResourceKit? ParentKit { get; set; } + + public bool BuildCalled { get; private set; } + + public void Build(IDistributedApplicationBuilder builder) => BuildCalled = true; + + public void Configure() { } + } +} diff --git a/src/tests/SourceGeneration.IntegrationTests/ResourceKitHierarchyGenerationTests.cs b/src/tests/SourceGeneration.IntegrationTests/ResourceKitHierarchyGenerationTests.cs new file mode 100644 index 0000000..f94e960 --- /dev/null +++ b/src/tests/SourceGeneration.IntegrationTests/ResourceKitHierarchyGenerationTests.cs @@ -0,0 +1,689 @@ +using Purview.Aspire.ResourceKit.SourceGeneration.Helpers; + +namespace Purview.Aspire.ResourceKit.SourceGeneration; + +/// +/// Verifies what emits for parent/child resource kits and the hierarchy +/// diagnostics reported by the analyzer and generator (SG0032-SG0038). +/// +public sealed class ResourceKitHierarchyGenerationTests : ResourceKitSourceGeneratorTestBase +{ + const string Kit = """ + protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => + throw new global::System.NotImplementedException(); + """; + + [Test] + public async Task Generate_GivenParentAndChild_GeneratesTypedParentChildAndNestedOptions( + CancellationToken cancellationToken + ) + { + // Arrange + var source = $$""" + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceDefinition("postgres")] + sealed partial class PostgresKit + { + {{Kit}} + } + + [ResourceDefinition("db", PropertyName = "Database", Parent = typeof(PostgresKit))] + sealed partial class DatabaseKit + { + {{Kit}} + } + """; + + // Act + using var result = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasNoErrorDiagnostics(); + + var generated = result.GetSource(); + await Assert.That(generated).Contains("public global::Testing.PostgresKit Postgres"); + await Assert.That(generated).Contains("public global::Testing.DatabaseKit Database"); + await Assert.That(generated).Contains("public global::Testing.PostgresKit Parent { get; }"); + await Assert.That(generated).Contains("Database = new(this, Postgres, Options.Postgres.Database);"); + await Assert.That(generated).Contains("AddResource(Postgres.Database);"); + await Assert.That(generated).Contains("Database { get; init; } = new();"); + } + + [Test] + public async Task Generate_GivenParentAndChild_DoesNotExposeChildAsAHostOptionsProperty( + CancellationToken cancellationToken + ) + { + // Arrange + var source = $$""" + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceDefinition("postgres")] + sealed partial class PostgresKit + { + {{Kit}} + } + + [ResourceDefinition("db", Parent = typeof(PostgresKit))] + sealed partial class DatabaseKit + { + {{Kit}} + } + """; + + // Act + using var result = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasNoErrorDiagnostics(); + + var generated = result.GetSource(); + await Assert.That(generated).Contains("public global::Testing.PostgresKit.PostgresKitOptions Postgres"); + await Assert.That(generated).DoesNotContain("new(this, Options.Database)"); + await Assert.That(generated).Contains("Database = new(this, Postgres, Options.Postgres.Database);"); + } + + [Test] + public async Task Generate_GivenOneParentWithSeveralChildren_GeneratesEachChildProperty( + CancellationToken cancellationToken + ) + { + // Arrange + var source = $$""" + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceDefinition("vnet")] + sealed partial class VnetKit + { + {{Kit}} + } + + [ResourceDefinition("snet-1", PropertyName = "Subnet1", Parent = typeof(VnetKit))] + sealed partial class Subnet1Kit + { + {{Kit}} + } + + [ResourceDefinition("snet-2", PropertyName = "Subnet2", Parent = typeof(VnetKit))] + sealed partial class Subnet2Kit + { + {{Kit}} + } + + [ResourceDefinition("snet-3", PropertyName = "Subnet3", Parent = typeof(VnetKit))] + sealed partial class Subnet3Kit + { + {{Kit}} + } + """; + + // Act + using var result = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasNoErrorDiagnostics(); + + var generated = result.GetSource(); + await Assert.That(generated).Contains("public global::Testing.Subnet1Kit Subnet1"); + await Assert.That(generated).Contains("public global::Testing.Subnet2Kit Subnet2"); + await Assert.That(generated).Contains("public global::Testing.Subnet3Kit Subnet3"); + await Assert.That(generated).Contains("Vnet.Subnet1 = new(this, Vnet, Options.Vnet.Subnet1);"); + await Assert.That(generated).Contains("Vnet.Subnet2 = new(this, Vnet, Options.Vnet.Subnet2);"); + await Assert.That(generated).Contains("Vnet.Subnet3 = new(this, Vnet, Options.Vnet.Subnet3);"); + } + + [Test] + public async Task Generate_GivenFiveLevelHierarchy_ConstructsEachParentBeforeItsChild( + CancellationToken cancellationToken + ) + { + // Arrange + var source = $$""" + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceDefinition("level1")] + sealed partial class Level1Kit + { + {{Kit}} + } + + [ResourceDefinition("level2", Parent = typeof(Level1Kit))] + sealed partial class Level2Kit + { + {{Kit}} + } + + [ResourceDefinition("level3", Parent = typeof(Level2Kit))] + sealed partial class Level3Kit + { + {{Kit}} + } + + [ResourceDefinition("level4", Parent = typeof(Level3Kit))] + sealed partial class Level4Kit + { + {{Kit}} + } + + [ResourceDefinition("level5", Parent = typeof(Level4Kit))] + sealed partial class Level5Kit + { + {{Kit}} + } + """; + + // Act + using var result = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasNoErrorDiagnostics(); + + var generated = result.GetSource(); + var level1 = generated.IndexOf("AddResource(Level1);", StringComparison.Ordinal); + var level2 = generated.IndexOf("AddResource(Level1.Level2);", StringComparison.Ordinal); + var level3 = generated.IndexOf("AddResource(Level1.Level2.Level3);", StringComparison.Ordinal); + var level4 = generated.IndexOf("AddResource(Level1.Level2.Level3.Level4);", StringComparison.Ordinal); + var level5 = generated.IndexOf("AddResource(Level1.Level2.Level3.Level4.Level5);", StringComparison.Ordinal); + + await Assert.That(level1).IsGreaterThan(-1); + await Assert.That(level2).IsGreaterThan(level1); + await Assert.That(level3).IsGreaterThan(level2); + await Assert.That(level4).IsGreaterThan(level3); + await Assert.That(level5).IsGreaterThan(level4); + } + + [Test] + public async Task Generate_GivenChildInAnotherNamespace_CompilesFullyQualifiedParentAndChild() + { + // Arrange + var source = $$""" + namespace ParentNamespace + { + [HostKit] + partial class TestingHostKit; + + [ResourceDefinition("postgres")] + sealed partial class PostgresKit + { + {{Kit}} + } + } + + namespace ChildNamespace + { + [ResourceDefinition("db", PropertyName = "Database", Parent = typeof(ParentNamespace.PostgresKit))] + sealed partial class DatabaseKit + { + {{Kit}} + } + } + """; + + // Act + using var result = await GenerateAsync(source, CancellationToken.None); + + // Assert + await Assert.That(result).HasNoErrorDiagnostics(); + + var generated = result.GetSource(); + await Assert.That(generated).Contains("global::ChildNamespace.DatabaseKit Database"); + await Assert.That(generated).Contains("global::ParentNamespace.PostgresKit Parent"); + } + + [Test] + public async Task Generate_GivenNonGenericChildWithExplicitBase_GeneratesParentAwareConstructor() + { + // Arrange + var source = $$""" + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceDefinition("postgres")] + sealed partial class PostgresKit + { + {{Kit}} + } + + [ResourceDefinition("db", PropertyName = "Database", Parent = typeof(PostgresKit))] + sealed partial class DatabaseKit : ResourceKitBase + { + {{Kit}} + } + """; + + // Act + using var result = await GenerateAsync(source, CancellationToken.None); + + // Assert + await Assert.That(result).HasNoErrorDiagnostics(); + + var generated = result.GetSource(); + await Assert.That(generated).Contains("public DatabaseKit("); + await Assert.That(generated).Contains("global::Testing.PostgresKit parent"); + await Assert.That(generated).Contains("ParentKit = Parent;"); + } + + [Test] + public async Task Generate_GivenGenerateOptionsDisabled_UsesExplicitResourceNameForChild() + { + // Arrange + var source = $$""" + namespace Testing; + + [HostKit(GenerateOptions = false)] + partial class TestingHostKit; + + [ResourceDefinition("postgres")] + sealed partial class PostgresKit + { + {{Kit}} + } + + [ResourceDefinition("db", Parent = typeof(PostgresKit))] + sealed partial class DatabaseKit + { + {{Kit}} + } + """; + + // Act + using var result = await GenerateAsync(source, CancellationToken.None); + + // Assert + await Assert.That(result).HasNoErrorDiagnostics(); + + var generated = result.GetSource(); + await Assert.That(generated).Contains("Database = new(this, Postgres, \"db\");"); + await Assert.That(generated).DoesNotContain("Options.Postgres.Database"); + } + + [Test] + public async Task Generate_GivenIdenticalShortNamesUnderDifferentParents_DoesNotReportADuplicate() + { + // Arrange + var source = $$""" + namespace First + { + [HostKit] + partial class TestingHostKit; + + [ResourceDefinition("first-parent")] + sealed partial class FirstParentKit + { + {{Kit}} + } + + [ResourceDefinition("first-db", PropertyName = "Database", Parent = typeof(FirstParentKit))] + sealed partial class FirstDatabaseKit + { + {{Kit}} + } + } + + namespace Second + { + [ResourceDefinition("second-parent")] + sealed partial class SecondParentKit + { + {{Kit}} + } + + [ResourceDefinition("second-db", PropertyName = "Database", Parent = typeof(SecondParentKit))] + sealed partial class SecondDatabaseKit + { + {{Kit}} + } + } + """; + + // Act + using var result = await GenerateAsync(source, CancellationToken.None); + + // Assert + await Assert.That(result).HasNoErrorDiagnostics(); + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.DuplicateResourceKitChildPropertyName); + } + + [Test] + public async Task Generate_GivenParentsWithTheSameShortNameInDifferentNamespaces_KeepsTypeIdentity() + { + // Arrange + var source = $$""" + namespace First + { + [ResourceDefinition("first-parent", PropertyName = "FirstParent")] + sealed partial class ParentKit + { + {{Kit}} + } + + [ResourceDefinition("first-child", Parent = typeof(ParentKit))] + sealed partial class ChildKit + { + {{Kit}} + } + } + + namespace Second + { + [HostKit] + partial class TestingHostKit; + + [ResourceDefinition("second-parent", PropertyName = "SecondParent")] + sealed partial class ParentKit + { + {{Kit}} + } + + [ResourceDefinition("second-child", Parent = typeof(ParentKit))] + sealed partial class ChildKit + { + {{Kit}} + } + } + """; + + // Act + using var result = await GenerateAsync(source, CancellationToken.None); + + // Assert + await Assert.That(result).HasNoErrorDiagnostics(); + + var generated = result.GetSource(); + await Assert.That(generated).Contains("global::First.ParentKit Parent"); + await Assert.That(generated).Contains("global::Second.ParentKit Parent"); + } + + [Test] + public async Task Generate_RegardlessOfSourceOrder_ProducesTheSameOutput(CancellationToken cancellationToken) + { + // Arrange + var parentSource = $$""" + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceDefinition("postgres")] + sealed partial class PostgresKit + { + {{Kit}} + } + """; + var childSource = $$""" + namespace Testing; + + [ResourceDefinition("db", Parent = typeof(PostgresKit))] + sealed partial class DatabaseKit + { + {{Kit}} + } + """; + + // Act + using var forward = await GenerateAsync([parentSource, childSource], cancellationToken); + using var reverse = await GenerateAsync([childSource, parentSource], cancellationToken); + + // Assert + await Assert.That(forward.GetSource()).IsEqualTo(reverse.GetSource()); + } + + [Test] + public async Task Generate_GivenSelfParent_ReportsCannotParentItself() + { + // Arrange + var source = $$""" + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceDefinition("self", Parent = typeof(SelfKit))] + sealed partial class SelfKit + { + {{Kit}} + } + """; + + // Act + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + CancellationToken.None + ); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ResourceKitCannotParentItself); + } + + [Test] + public async Task Generate_GivenTwoNodeCycle_ReportsCycleWithPath() + { + // Arrange + var source = $$""" + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceDefinition("a", Parent = typeof(BKit))] + sealed partial class AKit + { + {{Kit}} + } + + [ResourceDefinition("b", Parent = typeof(AKit))] + sealed partial class BKit + { + {{Kit}} + } + """; + + // Act + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + CancellationToken.None + ); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ResourceKitParentCycle); + } + + [Test] + public async Task Generate_GivenThreeNodeCycle_ReportsCycle() + { + // Arrange + var source = $$""" + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceDefinition("a", Parent = typeof(BKit))] + sealed partial class AKit + { + {{Kit}} + } + + [ResourceDefinition("b", Parent = typeof(CKit))] + sealed partial class BKit + { + {{Kit}} + } + + [ResourceDefinition("c", Parent = typeof(AKit))] + sealed partial class CKit + { + {{Kit}} + } + """; + + // Act + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + CancellationToken.None + ); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ResourceKitParentCycle); + } + + [Test] + public async Task Generate_GivenParentThatIsNotAResourceKit_ReportsNotAResourceKit() + { + // Arrange + var source = $$""" + namespace Testing; + + public sealed class NotAKit; + + [HostKit] + partial class TestingHostKit; + + [ResourceDefinition("child", Parent = typeof(NotAKit))] + sealed partial class ChildKit + { + {{Kit}} + } + """; + + // Act + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + CancellationToken.None + ); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ResourceKitParentNotResourceKit); + } + + [Test] + public async Task Generate_GivenDuplicateChildPropertyNameUnderOneParent_ReportsDuplicate() + { + // Arrange + var source = $$""" + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceDefinition("parent")] + sealed partial class ParentKit + { + {{Kit}} + } + + [ResourceDefinition("one", PropertyName = "Database", Parent = typeof(ParentKit))] + sealed partial class OneKit + { + {{Kit}} + } + + [ResourceDefinition("two", PropertyName = "Database", Parent = typeof(ParentKit))] + sealed partial class TwoKit + { + {{Kit}} + } + """; + + // Act + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + CancellationToken.None + ); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.DuplicateResourceKitChildPropertyName); + } + + [Test] + public async Task Generate_GivenChildPropertyCollidingWithParentMember_ReportsCollision() + { + // Arrange + var source = $$""" + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceDefinition("parent")] + sealed partial class ParentKit + { + public string Database { get; } = "existing"; + + {{Kit}} + } + + [ResourceDefinition("db", PropertyName = "Database", Parent = typeof(ParentKit))] + sealed partial class DatabaseKit + { + {{Kit}} + } + """; + + // Act + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + CancellationToken.None + ); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ResourceKitChildPropertyCollision); + } + + [Test] + public async Task Generate_GivenUserAuthoredParentMember_ReportsParentCollision() + { + // Arrange + var source = $$""" + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceDefinition("parent")] + sealed partial class ParentKit + { + {{Kit}} + } + + [ResourceDefinition("db", Parent = typeof(ParentKit))] + sealed partial class DatabaseKit + { + public ParentKit Parent { get; } = null!; + + {{Kit}} + } + """; + + // Act + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + CancellationToken.None + ); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ResourceKitParentMemberCollision); + } +} diff --git a/src/tests/SourceGeneration.UnitTests/Helpers/ResourceKitHierarchyRulesTests.cs b/src/tests/SourceGeneration.UnitTests/Helpers/ResourceKitHierarchyRulesTests.cs new file mode 100644 index 0000000..3326498 --- /dev/null +++ b/src/tests/SourceGeneration.UnitTests/Helpers/ResourceKitHierarchyRulesTests.cs @@ -0,0 +1,192 @@ +using Purview.Aspire.ResourceKit.SourceGeneration.Models; + +namespace Purview.Aspire.ResourceKit.SourceGeneration.Helpers; + +/// +/// Covers the graph rules directly, including cases the generator blocks before emission: cycles, missing +/// parents, duplicate child property names, and deep/wide ordering. +/// +public sealed class ResourceKitHierarchyRulesTests +{ + const int TimeoutMilliseconds = 30_000; + + [Test] + public async Task ParentsFirst_GivenChildBeforeParent_OrdersParentFirst() + { + // Arrange + var child = Kit("ChildKit", parent: "ParentKit"); + var parent = Kit("ParentKit"); + + // Act + var ordered = ResourceKitHierarchyOrdering.ParentsFirst([child, parent], CancellationToken.None); + + // Assert + await Assert.That(Names(ordered)).IsEqualTo("ParentKit,ChildKit"); + } + + [Test] + public async Task ParentsFirst_GivenIndependentSiblings_OrdersDeterministically() + { + // Arrange + var siblingB = Kit("BKit"); + var siblingA = Kit("AKit"); + + // Act + var ordered = ResourceKitHierarchyOrdering.ParentsFirst([siblingB, siblingA], CancellationToken.None); + + // Assert + await Assert.That(Names(ordered)).IsEqualTo("AKit,BKit"); + } + + [Test] + [Timeout(TimeoutMilliseconds)] + public async Task ParentsFirst_GivenSelfParent_ReturnsEmpty(CancellationToken cancellationToken) + { + // Arrange + var self = Kit("SelfKit", parent: "SelfKit"); + + // Act + var ordered = ResourceKitHierarchyOrdering.ParentsFirst([self], cancellationToken); + + // Assert + await Assert.That(ordered.IsEmpty).IsTrue(); + } + + [Test] + [Timeout(TimeoutMilliseconds)] + public async Task ParentsFirst_GivenCycle_ReturnsEmpty(CancellationToken cancellationToken) + { + // Arrange + var a = Kit("AKit", parent: "BKit"); + var b = Kit("BKit", parent: "CKit"); + var c = Kit("CKit", parent: "AKit"); + var root = Kit("RootKit"); + + // Act + var ordered = ResourceKitHierarchyOrdering.ParentsFirst([a, b, c, root], cancellationToken); + + // Assert + await Assert.That(ordered.IsEmpty).IsTrue(); + } + + [Test] + [Timeout(TimeoutMilliseconds)] + public async Task ParentsFirst_GivenMissingParent_ReturnsEmpty(CancellationToken cancellationToken) + { + // Arrange + var orphan = Kit("ChildKit", parent: "MissingKit"); + + // Act + var ordered = ResourceKitHierarchyOrdering.ParentsFirst([orphan], cancellationToken); + + // Assert + await Assert.That(ordered.IsEmpty).IsTrue(); + } + + [Test] + [Timeout(TimeoutMilliseconds)] + public async Task ParentsFirst_GivenDeepChain_OrdersWithoutStackOverflow(CancellationToken cancellationToken) + { + // Arrange + const int depth = 2_000; + List kits = []; + for (var index = 0; index < depth; index++) + kits.Add(Kit($"Kit{index:D4}", parent: index == 0 ? null : $"Kit{index - 1:D4}")); + + // Act + var ordered = ResourceKitHierarchyOrdering.ParentsFirst(kits, cancellationToken); + + // Assert + await Assert.That(ordered.Count).IsEqualTo(depth); + await Assert.That(ordered[0].ResourceKitType.Name).IsEqualTo("Kit0000"); + await Assert.That(ordered[depth - 1].ResourceKitType.Name).IsEqualTo($"Kit{depth - 1:D4}"); + } + + [Test] + public async Task EvaluateHierarchy_GivenSelfParent_ReportsSelfParenting() + { + // Arrange + var node = Node("SelfKit", "SelfKit"); + + // Act + var findings = ResourceKitRules.EvaluateHierarchy([node]); + + // Assert + await Assert.That(findings).HasSingleItem(static f => f.Descriptor.Id == "SG0032"); + } + + [Test] + public async Task EvaluateHierarchy_GivenThreeNodeCycle_ReportsOneCycleWithPath() + { + // Arrange + ResourceKitRules.HierarchyNode[] nodes = [Node("AKit", "BKit"), Node("BKit", "CKit"), Node("CKit", "AKit")]; + + // Act + var findings = ResourceKitRules.EvaluateHierarchy([.. nodes]); + + // Assert + var cycle = await Assert.That(findings).HasSingleItem(static f => f.Descriptor.Id == "SG0033"); + var message = string.Join("|", cycle.MessageArgs); + await Assert.That(message).Contains("AKit"); + await Assert.That(message).Contains("BKit"); + await Assert.That(message).Contains("CKit"); + } + + [Test] + public async Task EvaluateHierarchy_GivenMissingParent_ReportsUnavailable() + { + // Arrange + var node = Node("ChildKit", "MissingKit"); + + // Act + var findings = ResourceKitRules.EvaluateHierarchy([node]); + + // Assert + await Assert.That(findings).HasSingleItem(static f => f.Descriptor.Id == "SG0035"); + } + + [Test] + public async Task EvaluateHierarchy_GivenDuplicateChildPropertyNames_ReportsTheDuplicate() + { + // Arrange + ResourceKitRules.HierarchyNode[] nodes = + [ + Node("ParentKit", null, "Parent"), + Node("FirstKit", "ParentKit", "Database"), + Node("SecondKit", "ParentKit", "Database"), + ]; + + // Act + var findings = ResourceKitRules.EvaluateHierarchy([.. nodes]); + + // Assert + await Assert.That(findings).HasSingleItem(static f => f.Descriptor.Id == "SG0037"); + } + + static string Names(EquatableArray kits) => + string.Join(",", kits.AsImmutableArray().Select(static kit => kit.ResourceKitType.Name)); + + static ResourceKitModel Kit(string name, string? parent = null) => + new( + ResourceKitType: new(name, "Testing", 0), + OptionsType: TypeIdentity.Empty, + AspireResourceType: TypeIdentity.Empty, + ParentType: parent is null ? TypeIdentity.Empty : new(parent, "Testing", 0), + Accessibility: null, + PropertyName: name, + ResourceName: name, + HasExplicitBaseType: false, + Mode: null, + Groups: EquatableArray.Empty, + Urls: EquatableArray.Empty, + Location: default + ); + + static ResourceKitRules.HierarchyNode Node(string name, string? parent, string? propertyName = null) => + new( + new(name, "Testing", 0), + parent is null ? TypeIdentity.Empty : new(parent, "Testing", 0), + name, + propertyName ?? name + ); +} From 38a94df7119f205d153bafb25d226961346b2b5e Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 12 Nov 2026 14:22:44 +0000 Subject: [PATCH 07/12] feat: warn when a child kit uses a different primary group from its parent (#57) Adds SG0039 so a parent in one group and a child in another is visible at build time. The child still builds; visual placement follows its own group, or an existing Aspire parent chain. Adds placement tests covering both cases. --- CHANGELOG.md | 2 + docs/wiki/Diagnostics.md | 1 + docs/wiki/Groups-and-URLs.md | 4 + docs/wiki/Hierarchies.md | 11 ++ docs/wiki/Release-Notes.md | 2 + .../purview-resourcekit-diagnostics/SKILL.md | 6 + .../AnalyzerReleases.Unshipped.md | 1 + .../Helpers/DiagnosticLibrary.cs | 10 ++ .../Helpers/ResourceKitRules.cs | 19 ++- .../Helpers/SourceGenLibrary.cs | 39 +++++++ .../ResourceKitHierarchyGroupTests.cs | 54 ++++++++- .../ResourceKitHierarchyGenerationTests.cs | 108 ++++++++++++++++++ 12 files changed, 241 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b29f60..7a1061d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **Diagnostics SG0032–SG0038** for self-parenting, parent cycles, unavailable or non-kit parents, child property collisions, duplicate child property names, and generated `Parent` member collisions. Invalid graphs block generation; manual graphs are rejected at runtime before resource creation. +- **SG0039** warns when a child's explicit primary group differs from its parent's. The child still builds, + but visual placement follows its own group (or an existing Aspire parent chain), which is easy to miss. ### Changed diff --git a/docs/wiki/Diagnostics.md b/docs/wiki/Diagnostics.md index e5a598a..7028f71 100644 --- a/docs/wiki/Diagnostics.md +++ b/docs/wiki/Diagnostics.md @@ -51,6 +51,7 @@ exiting the constructor" warning does not apply. | SG0036 | Error | The generated child property collides with a member on the parent | | SG0037 | Error | Two children of the same parent use the same generated property name | | SG0038 | Error | The generated `Parent` member collides with a user-authored member | +| SG0039 | Warning | A child uses an explicit primary group different from its parent's; visual placement follows the child's group | See [Attributes Reference](Attributes-Reference.md) for the rules tied to attribute styles, [Project Resources](Project-Resources.md) for SG0018/SG0019 specifics, diff --git a/docs/wiki/Groups-and-URLs.md b/docs/wiki/Groups-and-URLs.md index a58b06b..9f6a0d0 100644 --- a/docs/wiki/Groups-and-URLs.md +++ b/docs/wiki/Groups-and-URLs.md @@ -77,6 +77,10 @@ ResourceKit does not add an Aspire visual parent relationship for you. If a chil under the parent's resource, add `WithParentRelationship` in `BuildResource`; the group placement rules then respect that existing chain. See [Parent and child resource kits](Hierarchies.md). +An explicit child group whose primary group differs from its parent's raises **SG0039** (warning). The +child still builds, but visual placement follows its own group, or the existing Aspire parent chain when one +is present. A child with no `[InGroup]` inherits the parent's primary group and does not warn. + ### The dashboard In run mode, at the end of `Configure` (after every `ConfigureResource`, so a resource added there is never diff --git a/docs/wiki/Hierarchies.md b/docs/wiki/Hierarchies.md index 9819bc1..c910749 100644 --- a/docs/wiki/Hierarchies.md +++ b/docs/wiki/Hierarchies.md @@ -237,6 +237,17 @@ A child with no `[InGroup]` inherits its parent's **primary** group for membersh A child with an explicit `[InGroup]` keeps it instead. Grouping never changes build ordering, and a disabled parent still disables descendants even when a child's own group is enabled. +An explicit child group whose primary group differs from the parent's produces **SG0039**, a warning. The +child still builds; the warning exists because the visual result may not be what the declaration suggests: + +- With no Aspire parent relationship, the child renders in **its own** group, not under the parent. +- With `WithParentRelationship(Parent.ResourceBuilder)`, Aspire has one visual parent, so the child renders + under the parent and its declared group is **not** where it appears. The child's group node can still be + created because the group has a member. Use the same group, or accept that placement explicitly. + +SG0039 does not fire when the child declares no group (it inherits) or when the primary groups already +match. + ResourceKit does **not** add an Aspire visual parent relationship automatically. If you want a child's resource to render under the parent's resource, add it explicitly in `BuildResource`: diff --git a/docs/wiki/Release-Notes.md b/docs/wiki/Release-Notes.md index 251e5da..3e57959 100644 --- a/docs/wiki/Release-Notes.md +++ b/docs/wiki/Release-Notes.md @@ -32,6 +32,8 @@ the [GitHub releases page](https://github.com/purview-dev/aspire-resourcekit/rel - **Diagnostics SG0032-SG0038** for self-parenting, cycles, unavailable parents, child property collisions, duplicate child property names, and generated `Parent` member collisions. Manual graphs are validated at runtime before any Aspire resource is created. +- **SG0039** warns when a child's explicit primary group differs from its parent's, because visual placement + follows the child's group (or an existing Aspire parent chain), not the ResourceKit hierarchy. ## 1.0.0 diff --git a/skills/purview-resourcekit-diagnostics/SKILL.md b/skills/purview-resourcekit-diagnostics/SKILL.md index abdbc1b..c9cbbd4 100644 --- a/skills/purview-resourcekit-diagnostics/SKILL.md +++ b/skills/purview-resourcekit-diagnostics/SKILL.md @@ -62,6 +62,7 @@ assigned. | `SG0036` | Error | The generated child property collides with a member on the parent. | | `SG0037` | Error | Two children of one parent use the same generated property name. | | `SG0038` | Error | The generated `Parent` member collides with a user-authored member. | +| `SG0039` | Warning | A child's explicit primary group differs from its parent's; visual placement follows the child's group. | ## Execution-only versus generation-blocking @@ -151,6 +152,11 @@ sealed partial class SqlServerKit Manual graphs are validated at runtime before any Aspire resource is created; a self-parent, cycle, missing parent or foreign-host parent throws `InvalidOperationException` even when the generator was not involved. +`SG0039` is the group/hierarchy warning: a child with no `[InGroup]` inherits its parent's primary group, but +an explicit child group overrides that. ResourceKit parentage does not add an Aspire visual parent, so the +child then renders in its own group; if an Aspire parent relationship exists, it renders under the parent and +the child's group is not where it appears. Use the same primary group or accept the placement deliberately. + ### `SG0020` — one property path per `Assign` action ```csharp diff --git a/src/src/SourceGeneration/AnalyzerReleases.Unshipped.md b/src/src/SourceGeneration/AnalyzerReleases.Unshipped.md index 47ee3fd..34cdd68 100644 --- a/src/src/SourceGeneration/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGeneration/AnalyzerReleases.Unshipped.md @@ -20,3 +20,4 @@ SG0035 | Purview.Aspire.ResourceKit.SourceGenerator | Error | Parent resource ki SG0036 | Purview.Aspire.ResourceKit.SourceGenerator | Error | Generated child property collides with a member on the parent SG0037 | Purview.Aspire.ResourceKit.SourceGenerator | Error | Duplicate child property name SG0038 | Purview.Aspire.ResourceKit.SourceGenerator | Error | Generated Parent member collides with a user-authored member +SG0039 | Purview.Aspire.ResourceKit.SourceGenerator | Warning | Child resource kit uses a different primary group than its parent diff --git a/src/src/SourceGeneration/Helpers/DiagnosticLibrary.cs b/src/src/SourceGeneration/Helpers/DiagnosticLibrary.cs index 4ed828d..b3982dc 100644 --- a/src/src/SourceGeneration/Helpers/DiagnosticLibrary.cs +++ b/src/src/SourceGeneration/Helpers/DiagnosticLibrary.cs @@ -250,6 +250,16 @@ static class DiagnosticLibrary isEnabledByDefault: true ); + public static readonly DiagnosticDescriptor ResourceKitChildGroupDiffersFromParent = new( + id: "SG0039", + title: "Child resource kit uses a different primary group than its parent", + messageFormat: "The '{0}' resource kit uses primary group '{1}', but its parent '{2}' uses '{3}'. The child renders in its own group unless an Aspire parent relationship places it under the parent.", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "ResourceKit parentage is lifecycle ownership; group membership is visual placement. A child with no [InGroup] inherits the parent's primary group. An explicit child group overrides that, so a mismatch is easy to miss." + ); + public static readonly DiagnosticDescriptor AssignSetsMultiplePropertyPaths = new( id: "SG0020", title: "OptionsHelper.Assign action must set exactly one property path", diff --git a/src/src/SourceGeneration/Helpers/ResourceKitRules.cs b/src/src/SourceGeneration/Helpers/ResourceKitRules.cs index a51bc7f..bf3706b 100644 --- a/src/src/SourceGeneration/Helpers/ResourceKitRules.cs +++ b/src/src/SourceGeneration/Helpers/ResourceKitRules.cs @@ -1035,6 +1035,11 @@ string resourceName var diagnostics = ImmutableArray.CreateBuilder(); var parent = GetParent(symbol); + // These checks only describe generated members on a *child* kit. A root kit may legitimately be named + // ParentKit (property "Parent") and has neither a generated Parent member nor a parent property slot. + if (parent is null) + return diagnostics.ToImmutable(); + if (GeneratedKitMemberNames.Contains(propertyName) || GeneratedOptionsMemberNames.Contains(propertyName)) { diagnostics.Add( @@ -1042,16 +1047,11 @@ string resourceName DiagnosticLibrary.ResourceKitChildPropertyCollision, IsBlocking: true, GetLocation(symbol), - [propertyName, parent?.Name ?? symbol.Name] + [propertyName, parent.Name] ) ); } - else if ( - parent is not null - && parent - .GetMembers(propertyName) - .Any(member => !member.IsImplicitlyDeclared && GeneratedUserMember(member)) - ) + else if (parent.GetMembers(propertyName).Any(static member => !member.IsImplicitlyDeclared)) { diagnostics.Add( new( @@ -1078,11 +1078,6 @@ parent is not null return diagnostics.ToImmutable(); } - static bool GeneratedUserMember(ISymbol member) => - member.DeclaringSyntaxReferences.Any(reference => - reference.SyntaxTree.FilePath.EndsWith(".cs", StringComparison.Ordinal) - ); - /// /// Evaluates SG0017 for every IResourceBuilder<T> property that is never assigned in /// BuildResource or ConfigureResource. diff --git a/src/src/SourceGeneration/Helpers/SourceGenLibrary.cs b/src/src/SourceGeneration/Helpers/SourceGenLibrary.cs index 2920331..a7e673b 100644 --- a/src/src/SourceGeneration/Helpers/SourceGenLibrary.cs +++ b/src/src/SourceGeneration/Helpers/SourceGenLibrary.cs @@ -114,6 +114,7 @@ static Func< var hierarchyDiagnostics = ResourceKitRules .EvaluateHierarchy(hierarchyNodes) .Select(finding => ToHierarchyDiagnostic(finding, models)) + .Concat(EvaluateChildGroupDiagnostics(models)) .ToImmutableArray(); var groupedResourceKits = allKits @@ -156,6 +157,44 @@ ImmutableArray models return ReportableDiagnostic.Create(finding.Descriptor, isBlocking: true, location, [.. finding.MessageArgs]); } + // A warning, not an error: an explicit child group is allowed, but it silently changes visual placement. + // A child with no [InGroup] inherits the parent's primary group and never reaches this check. + static ImmutableArray EvaluateChildGroupDiagnostics(ImmutableArray models) + { + var byType = models + .GroupBy(static model => model.ResourceKitType.MetadataFullName, StringComparer.Ordinal) + .ToDictionary(static group => group.Key, static group => group.First(), StringComparer.Ordinal); + + List diagnostics = []; + foreach (var child in models) + { + if (!child.HasParent || child.Groups.IsEmpty) + continue; + + if (!byType.TryGetValue(child.ParentType.MetadataFullName, out var parent) || parent.Groups.IsEmpty) + continue; + + var childGroup = child.Groups[0]; + var parentGroup = parent.Groups[0]; + if (childGroup == parentGroup) + continue; + + diagnostics.Add( + ReportableDiagnostic.Create( + DiagnosticLibrary.ResourceKitChildGroupDiffersFromParent, + isBlocking: false, + child.Location.IsEmpty ? Location.None : child.Location.ToLocation(), + child.ResourceKitType.Name, + childGroup.Name, + parent.ResourceKitType.Name, + parentGroup.Name + ) + ); + } + + return [.. diagnostics]; + } + static IncrementalValueProvider< EquatableArray> > GetCombineResourceDefinitionsPipeline( diff --git a/src/tests/ResourceKit.UnitTests/ResourceKitHierarchyGroupTests.cs b/src/tests/ResourceKit.UnitTests/ResourceKitHierarchyGroupTests.cs index dc09dbc..7358f35 100644 --- a/src/tests/ResourceKit.UnitTests/ResourceKitHierarchyGroupTests.cs +++ b/src/tests/ResourceKit.UnitTests/ResourceKitHierarchyGroupTests.cs @@ -58,6 +58,34 @@ public async Task Build_GivenChildWithAnExplicitGroup_DoesNotInheritTheParentsGr await Assert.That(services.Resources).Contains(child); await Assert.That(parent.IsEnabled).IsTrue(); await Assert.That(child.IsEnabled).IsTrue(); + + // The child renders in its own group, not under the parent's group: ResourceKit parentage is not an + // Aspire visual parent relationship. + await Assert.That(ParentOf(parent.Resource)).IsSameReferenceAs(data.DashboardResource!.Resource); + await Assert.That(ParentOf(child.Resource)).IsSameReferenceAs(services.DashboardResource!.Resource); + } + + [Test] + public async Task Configure_GivenChildWithAnAspireParentAndAnotherGroup_RendersUnderParentAndLeavesGroupNode() + { + // Arrange + var builder = DistributedApplication.CreateBuilder(); + HierarchyHostKit host = new(); + ResourceKitGroup data = new("data"); + ResourceKitGroup services = new("services"); + HierarchyTestKit parent = new(host, "parent", []); + HierarchyTestKit child = new(host, parent, "child", [], visualParent: true); + host.AddResource(parent, data); + host.AddResource(child, services); + + // Act + host.Build(builder); + host.Configure(); + + // Assert — Aspire has one visual parent, so the existing chain wins over the child's group. + await Assert.That(ParentOf(child.Resource)).IsSameReferenceAs(parent.Resource); + await Assert.That(services.DashboardResource).IsNotNull(); + await Assert.That(services.Resources).Contains(child); } [Test] @@ -165,11 +193,19 @@ sealed class HierarchyHostKit : HostKitBase; sealed class HierarchyTestKit : ResourceKitBase { readonly List _events; + readonly bool _visualParent; - public HierarchyTestKit(HierarchyHostKit hostKit, string name, List events, bool enabled = true) + public HierarchyTestKit( + HierarchyHostKit hostKit, + string name, + List events, + bool enabled = true, + bool visualParent = false + ) : base(hostKit, name) { _events = events; + _visualParent = visualParent; IsEnabled = enabled; } @@ -178,9 +214,10 @@ public HierarchyTestKit( HierarchyTestKit parent, string name, List events, - bool enabled = true + bool enabled = true, + bool visualParent = false ) - : this(hostKit, name, events, enabled) + : this(hostKit, name, events, enabled, visualParent) { ParentKit = parent; } @@ -191,7 +228,16 @@ protected override IResourceBuilder BuildResource(IDistribute { BuildCalled = true; _events.Add($"Build:{Name}"); - return builder.AddResource(new HierarchyResource(Name)); + + var resource = builder.AddResource(new HierarchyResource(Name)); + if (_visualParent) + { + resource.Resource.Annotations.Add( + new ResourceRelationshipAnnotation(((HierarchyTestKit)ParentKit!).Resource, "Parent") + ); + } + + return resource; } } diff --git a/src/tests/SourceGeneration.IntegrationTests/ResourceKitHierarchyGenerationTests.cs b/src/tests/SourceGeneration.IntegrationTests/ResourceKitHierarchyGenerationTests.cs index f94e960..bcbc1ee 100644 --- a/src/tests/SourceGeneration.IntegrationTests/ResourceKitHierarchyGenerationTests.cs +++ b/src/tests/SourceGeneration.IntegrationTests/ResourceKitHierarchyGenerationTests.cs @@ -443,6 +443,114 @@ sealed partial class DatabaseKit await Assert.That(forward.GetSource()).IsEqualTo(reverse.GetSource()); } + [Test] + public async Task Generate_GivenChildInADifferentPrimaryGroup_WarnsAboutVisualPlacement() + { + // Arrange + var source = $$""" + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceGroup] + sealed partial class DataGroup; + + [ResourceGroup] + sealed partial class ServicesGroup; + + [ResourceDefinition("parent")] + [InGroup] + sealed partial class ParentKit + { + {{Kit}} + } + + [ResourceDefinition("child", Parent = typeof(ParentKit))] + [InGroup] + sealed partial class ChildKit + { + {{Kit}} + } + """; + + // Act + using var result = await GenerateAsync(source, CancellationToken.None); + + // Assert — a warning, so generation still succeeds. + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ResourceKitChildGroupDiffersFromParent); + await Assert.That(result).HasNoErrorDiagnostics(); + } + + [Test] + public async Task Generate_GivenChildInTheParentsPrimaryGroup_DoesNotWarnAboutVisualPlacement() + { + // Arrange + var source = $$""" + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceGroup] + sealed partial class DataGroup; + + [ResourceDefinition("parent")] + [InGroup] + sealed partial class ParentKit + { + {{Kit}} + } + + [ResourceDefinition("child", Parent = typeof(ParentKit))] + [InGroup] + sealed partial class ChildKit + { + {{Kit}} + } + """; + + // Act + using var result = await GenerateAsync(source, CancellationToken.None); + + // Assert + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.ResourceKitChildGroupDiffersFromParent); + } + + [Test] + public async Task Generate_GivenChildWithoutAGroup_DoesNotWarnBecauseItInheritsTheParentsGroup() + { + // Arrange + var source = $$""" + namespace Testing; + + [HostKit] + partial class TestingHostKit; + + [ResourceGroup] + sealed partial class DataGroup; + + [ResourceDefinition("parent")] + [InGroup] + sealed partial class ParentKit + { + {{Kit}} + } + + [ResourceDefinition("child", Parent = typeof(ParentKit))] + sealed partial class ChildKit + { + {{Kit}} + } + """; + + // Act + using var result = await GenerateAsync(source, CancellationToken.None); + + // Assert + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.ResourceKitChildGroupDiffersFromParent); + } + [Test] public async Task Generate_GivenSelfParent_ReportsCannotParentItself() { From 8d5bb99331a46d86a2fdd372bb15c73954a5a909 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 12 Nov 2026 14:38:47 +0000 Subject: [PATCH 08/12] feat: offer a code fix for redundant ResourceBuilder access (#57) A resource kit already implements IResourceBuilder, so Parent.ResourceBuilder.AddBlobs(...) simplifies to Parent.AddBlobs(...). Adds SG0040 (Info), a safe code fix for receiver-position use, analyzer/code-fix tests, docs and example adoption. --- CHANGELOG.md | 3 + docs/wiki/Diagnostics.md | 19 +++ docs/wiki/Examples.md | 6 +- docs/wiki/Hierarchies.md | 18 +-- docs/wiki/Lifecycle-Build-Configure.md | 6 +- docs/wiki/Release-Notes.md | 2 + docs/wiki/Source-Generator-Behaviors.md | 14 +++ skills/purview-resourcekit-authoring/SKILL.md | 7 +- .../purview-resourcekit-diagnostics/SKILL.md | 6 + skills/purview-resourcekit-migration/SKILL.md | 5 +- .../Resources/AzureStorageBlobKit.cs | 2 +- .../Resources/PostgresDatabaseKit.cs | 2 +- .../AppModels/Resources/SubnetDataKit.cs | 2 +- .../Resources/SubnetPrivateEndpointKit.cs | 2 +- .../AppModels/Resources/SubnetWebKit.cs | 2 +- .../Resources/PostgresDatabaseKit.cs | 2 +- src/src/ResourceKit/HostKitBase.cs | 12 +- src/src/ResourceKit/ResourceKitBase.cs | 5 + src/src/ResourceKit/Sdk/README.md | 4 +- ...antResourceBuilderAccessCodeFixProvider.cs | 61 ++++++++++ .../AnalyzerReleases.Unshipped.md | 1 + .../Helpers/CodeGenEmiiter.ResourceKit.cs | 8 +- .../Helpers/CodeGenEmiiter.cs | 1 + .../Helpers/DiagnosticLibrary.cs | 10 ++ .../Helpers/ResourceKitHierarchyOrdering.cs | 2 +- .../Helpers/ResourceKitRules.cs | 7 +- .../RedundantResourceBuilderAccessAnalyzer.cs | 77 ++++++++++++ .../ResourceKitDiagnosticAnalyzer.cs | 2 +- .../ResourceKitHierarchyEnablementTests.cs | 26 ++-- .../ResourceKitHierarchyTests.cs | 55 ++++----- ...ndantResourceBuilderAccessAnalyzerTests.cs | 113 ++++++++++++++++++ ...undantResourceBuilderAccessCodeFixTests.cs | 91 ++++++++++++++ 32 files changed, 481 insertions(+), 92 deletions(-) create mode 100644 src/src/SourceGeneration.CodeFixes/RedundantResourceBuilderAccessCodeFixProvider.cs create mode 100644 src/src/SourceGeneration/RedundantResourceBuilderAccessAnalyzer.cs create mode 100644 src/tests/SourceGeneration.IntegrationTests/RedundantResourceBuilderAccessAnalyzerTests.cs create mode 100644 src/tests/SourceGeneration.IntegrationTests/RedundantResourceBuilderAccessCodeFixTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a1061d..559ae66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). graphs block generation; manual graphs are rejected at runtime before resource creation. - **SG0039** warns when a child's explicit primary group differs from its parent's. The child still builds, but visual placement follows its own group (or an existing Aspire parent chain), which is easy to miss. +- **SG0040** (Info) and a "Use the resource kit directly" code fix simplify + `Parent.ResourceBuilder.AddDatabase(...)` to `Parent.AddDatabase(...)`; a resource kit already implements + `IResourceBuilder`. ### Changed diff --git a/docs/wiki/Diagnostics.md b/docs/wiki/Diagnostics.md index 7028f71..b4fe251 100644 --- a/docs/wiki/Diagnostics.md +++ b/docs/wiki/Diagnostics.md @@ -52,6 +52,7 @@ exiting the constructor" warning does not apply. | SG0037 | Error | Two children of the same parent use the same generated property name | | SG0038 | Error | The generated `Parent` member collides with a user-authored member | | SG0039 | Warning | A child uses an explicit primary group different from its parent's; visual placement follows the child's group | +| SG0040 | Info | A resource kit is already an `IResourceBuilder`; `.ResourceBuilder` is redundant | See [Attributes Reference](Attributes-Reference.md) for the rules tied to attribute styles, [Project Resources](Project-Resources.md) for SG0018/SG0019 specifics, @@ -97,6 +98,24 @@ OptionsHelper.Assign( Visual Studio offers a **"Split into separate assignments"** code fix that performs this conversion for you. See [OptionsHelper](OptionsHelper.md) and [Source Generator Behaviors](Source-Generator-Behaviors.md). +## Redundant `ResourceBuilder` access (SG0040) + +A resource kit derives from `ResourceKitBase`, which implements +`IResourceBuilder`. When the kit is the receiver of the next call, the `.ResourceBuilder` hop is +redundant: + +```csharp +// Before (SG0040, Info) +Parent.ResourceBuilder.AddDatabase(Name); + +// After — "Use the resource kit directly" +Parent.AddDatabase(Name); +``` + +The code fix only fires when the hop is the receiver of a following member access/invocation. It deliberately +does not fire in argument position, where the expected parameter type might be `IResourceBuilder` +and the kit's own `IResourceBuilder` would not satisfy it. + ## Fast troubleshooting checklist 1. Ensure host/resource classes are marked `partial`. diff --git a/docs/wiki/Examples.md b/docs/wiki/Examples.md index 40f2067..f03a71f 100644 --- a/docs/wiki/Examples.md +++ b/docs/wiki/Examples.md @@ -31,7 +31,7 @@ sealed partial class PostgresDatabaseKit { protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => - Parent.ResourceBuilder.AddDatabase(Name); + Parent.AddDatabase(Name); } ``` @@ -58,7 +58,7 @@ sealed partial class SubnetWebKit { protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => - Parent.ResourceBuilder.AddSubnet(Name, "10.0.1.0/24"); + Parent.AddSubnet(Name, "10.0.1.0/24"); } ``` @@ -245,7 +245,7 @@ sealed partial class PostgresDatabaseKit(ExampleHostKit hostKit, PostgresKit par protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => - Parent.ResourceBuilder.AddDatabase(Name); + Parent.AddDatabase(Name); } ``` diff --git a/docs/wiki/Hierarchies.md b/docs/wiki/Hierarchies.md index c910749..581e394 100644 --- a/docs/wiki/Hierarchies.md +++ b/docs/wiki/Hierarchies.md @@ -28,7 +28,7 @@ sealed partial class PostgresDatabaseKit { protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => - Parent.ResourceBuilder.AddDatabase(Name); + Parent.AddDatabase(Name); } ``` @@ -39,8 +39,10 @@ hostKit.Postgres.Database // strongly typed child property on the paren PostgresDatabaseKit.Parent // strongly typed parent instance on the child ``` -`Parent.ResourceBuilder` is available inside `BuildResource`, because the parent has finished building first. -The child returns the resource it created; ResourceKit never creates a second copy. +`Parent` is available inside `BuildResource`, because the parent has finished building first. A resource kit +implements `IResourceBuilder` itself, so `Parent.AddDatabase(Name)` is the short form; +`Parent.ResourceBuilder.AddDatabase(Name)` works too but is redundant and raises **SG0040** (Info). The +child returns the resource it created; ResourceKit never creates a second copy. `PropertyName` is still the child property name, and the default is derived from the class name exactly as for a root kit. Set it explicitly when the class name is not the property name you want @@ -68,7 +70,7 @@ sealed partial class SubnetWebKit { protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => - Parent.ResourceBuilder.AddSubnet(Name, "10.0.1.0/24"); + Parent.AddSubnet(Name, "10.0.1.0/24"); } [ResourceDefinition( @@ -79,7 +81,7 @@ sealed partial class SubnetDataKit { protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => - Parent.ResourceBuilder.AddSubnet(Name, "10.0.2.0/24"); + Parent.AddSubnet(Name, "10.0.2.0/24"); } ``` @@ -109,7 +111,7 @@ sealed partial class PostgresDatabaseKit(ShopHostKit hostKit, PostgresKit parent protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => - Parent.ResourceBuilder.AddDatabase(Name); + Parent.AddDatabase(Name); } ``` @@ -252,7 +254,7 @@ ResourceKit does **not** add an Aspire visual parent relationship automatically. resource to render under the parent's resource, add it explicitly in `BuildResource`: ```csharp -var database = Parent.ResourceBuilder.AddDatabase(Name); +var database = Parent.AddDatabase(Name); database.WithParentRelationship(Parent.ResourceBuilder); return database; ``` @@ -271,6 +273,8 @@ builds its own resource tree. See [Resource groups and dashboard URLs](Groups-an | SG0036 | The generated child property collides with a member on the parent | | SG0037 | Two children of one parent use the same property name | | SG0038 | The generated `Parent` member collides with a user-authored member | +| SG0039 | A child's primary group differs from its parent's; visual placement follows the child's group | +| SG0040 | `.ResourceBuilder` is redundant when the kit is the next call's receiver; a code fix uses the kit directly | Invalid graphs block generation; the runtime validates manual graphs before creating any Aspire resource. See [Diagnostics](Diagnostics.md). diff --git a/docs/wiki/Lifecycle-Build-Configure.md b/docs/wiki/Lifecycle-Build-Configure.md index 94cd592..2c5d459 100644 --- a/docs/wiki/Lifecycle-Build-Configure.md +++ b/docs/wiki/Lifecycle-Build-Configure.md @@ -74,9 +74,9 @@ protected override void ConfigureResource() Because `ConfigureResource()` runs after every resource is built, you can safely reach other resources through the host kit's generated properties. -A child can use `Parent.ResourceBuilder` inside its own `BuildResource`, because the parent has completed its -build first. The relationship is fixed at construction; a child never triggers its parent's or its own -`Build` recursively. +A child can use `Parent` directly inside its own `BuildResource` (`Parent.ResourceBuilder` also works but is +redundant; SG0040), because the parent has completed its build first. The relationship is fixed at +construction; a child never triggers its parent's or its own `Build` recursively. ## Failure policy diff --git a/docs/wiki/Release-Notes.md b/docs/wiki/Release-Notes.md index 3e57959..f9a4c52 100644 --- a/docs/wiki/Release-Notes.md +++ b/docs/wiki/Release-Notes.md @@ -34,6 +34,8 @@ the [GitHub releases page](https://github.com/purview-dev/aspire-resourcekit/rel runtime before any Aspire resource is created. - **SG0039** warns when a child's explicit primary group differs from its parent's, because visual placement follows the child's group (or an existing Aspire parent chain), not the ResourceKit hierarchy. +- **SG0040** (Info) and its code fix remove a redundant `.ResourceBuilder` hop from a kit receiver: + `Parent.ResourceBuilder.AddDatabase(...)` becomes `Parent.AddDatabase(...)`. ## 1.0.0 diff --git a/docs/wiki/Source-Generator-Behaviors.md b/docs/wiki/Source-Generator-Behaviors.md index d9c623e..316e71b 100644 --- a/docs/wiki/Source-Generator-Behaviors.md +++ b/docs/wiki/Source-Generator-Behaviors.md @@ -79,6 +79,20 @@ The fix lives in a separate code-fix assembly and uses a stable equivalence key. never acquires a `Microsoft.CodeAnalysis.Workspaces` dependency; the compiler loads the code-fix assembly without instantiating its Workspaces-dependent types, and the IDE activates them for fixes. +## `RedundantResourceBuilderAccessAnalyzer` (SG0040) + +A resource kit implements `IResourceBuilder` through `ResourceKitBase`. SG0040 +therefore reports a redundant `.ResourceBuilder` hop when the kit is the receiver of the next call: + +```csharp +Parent.ResourceBuilder.AddDatabase(Name); // SG0040 +Parent.AddDatabase(Name); // preferred +``` + +The **"Use the resource kit directly"** code fix removes the hop. It intentionally does not fire when the +builder value is passed as an argument, because the parameter may be typed as `IResourceBuilder` +rather than the kit's own `IResourceBuilder`. + ## `ResourceKitDiagnosticSuppressor` (SGSUP0001) The suppressor removes `CS8618` ("Non-nullable property must contain a non-null value when exiting diff --git a/skills/purview-resourcekit-authoring/SKILL.md b/skills/purview-resourcekit-authoring/SKILL.md index ee51c7f..30fcb1b 100644 --- a/skills/purview-resourcekit-authoring/SKILL.md +++ b/skills/purview-resourcekit-authoring/SKILL.md @@ -148,11 +148,12 @@ sealed partial class PostgresDatabaseKit { protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => - Parent.ResourceBuilder.AddDatabase(Name); + Parent.AddDatabase(Name); } ``` -- The child reads `Parent.ResourceBuilder`; the parent has already finished `BuildResource`. +- The child uses `Parent` directly; the parent has already finished `BuildResource`, and a resource kit is + itself an `IResourceBuilder` (`Parent.ResourceBuilder` is redundant; SG0040). - `hostKit.Postgres.Database` is the strongly typed child; `Parent` is the strongly typed parent. - One parent per kit, any number of children, any depth. - A disabled parent disables every descendant, and a disabled ancestor never invokes a descendant's @@ -166,7 +167,7 @@ sealed partial class PostgresDatabaseKit validates the graph before creating resources. The VNET/subnet shape from issue #57 is the same: a `VnetKit` root and one `SubnetKit` per subnet with -`Parent = typeof(VnetKit)`, each building with `Parent.ResourceBuilder.AddSubnet(Name, prefix)`. Invalid +`Parent = typeof(VnetKit)`, each building with `Parent.AddSubnet(Name, prefix)`. Invalid graphs report `SG0032`–`SG0038`. ## Build versus Configure diff --git a/skills/purview-resourcekit-diagnostics/SKILL.md b/skills/purview-resourcekit-diagnostics/SKILL.md index c9cbbd4..f9a934a 100644 --- a/skills/purview-resourcekit-diagnostics/SKILL.md +++ b/skills/purview-resourcekit-diagnostics/SKILL.md @@ -63,6 +63,7 @@ assigned. | `SG0037` | Error | Two children of one parent use the same generated property name. | | `SG0038` | Error | The generated `Parent` member collides with a user-authored member. | | `SG0039` | Warning | A child's explicit primary group differs from its parent's; visual placement follows the child's group. | +| `SG0040` | Info | A resource kit is already an `IResourceBuilder`; `.ResourceBuilder` is redundant. | ## Execution-only versus generation-blocking @@ -157,6 +158,11 @@ an explicit child group overrides that. ResourceKit parentage does not add an As child then renders in its own group; if an Aspire parent relationship exists, it renders under the parent and the child's group is not where it appears. Use the same primary group or accept the placement deliberately. +`SG0040` reports `Parent.ResourceBuilder.AddDatabase(...)` because a kit is already an `IResourceBuilder`. +Use `Parent.AddDatabase(...)`. The "Use the resource kit directly" code fix removes the hop. It only offers +the fix when the kit is the receiver of the next call, not when the builder is passed as an argument and the +expected parameter type could differ. + ### `SG0020` — one property path per `Assign` action ```csharp diff --git a/skills/purview-resourcekit-migration/SKILL.md b/skills/purview-resourcekit-migration/SKILL.md index 344fc6f..ac3ed8a 100644 --- a/skills/purview-resourcekit-migration/SKILL.md +++ b/skills/purview-resourcekit-migration/SKILL.md @@ -78,7 +78,8 @@ instead of keeping both responsibilities in one class: 2. Remove the child creation from the parent. 3. Add `Parent = typeof(TParentKit)` (and `PropertyName` when the class name is not the property name you want) to the child `[ResourceDefinition]`. -4. Build the child with `Parent.ResourceBuilder.Add...`, and return that builder. +4. Build the child with `Parent.Add...` (a resource kit is already an `IResourceBuilder`; SG0040 + reports the redundant `Parent.ResourceBuilder` form), and return that builder. 5. Use `Parent` or the generated `hostKit.Parent.Child` from `ConfigureResource`. 6. Guard cross-resource wiring with `HostKit.Parent.IsEnabled && HostKit.Parent.Child.IsEnabled`, because a disabled ancestor disables the child. @@ -92,7 +93,7 @@ sealed partial class SubnetWebKit { protected override IResourceBuilder BuildResource( IDistributedApplicationBuilder builder) => - Parent.ResourceBuilder.AddSubnet(Name, "10.0.1.0/24"); + Parent.AddSubnet(Name, "10.0.1.0/24"); } ``` diff --git a/src/src/Example.AppHost/AppModels/Resources/AzureStorageBlobKit.cs b/src/src/Example.AppHost/AppModels/Resources/AzureStorageBlobKit.cs index deb4d9f..b51457f 100644 --- a/src/src/Example.AppHost/AppModels/Resources/AzureStorageBlobKit.cs +++ b/src/src/Example.AppHost/AppModels/Resources/AzureStorageBlobKit.cs @@ -11,5 +11,5 @@ sealed partial class AzureStorageBlobKit { protected override IResourceBuilder BuildResource( IDistributedApplicationBuilder builder - ) => Parent.ResourceBuilder.AddBlobs(Name); + ) => Parent.AddBlobs(Name); } diff --git a/src/src/Example.AppHost/AppModels/Resources/PostgresDatabaseKit.cs b/src/src/Example.AppHost/AppModels/Resources/PostgresDatabaseKit.cs index c4e60d1..85f5542 100644 --- a/src/src/Example.AppHost/AppModels/Resources/PostgresDatabaseKit.cs +++ b/src/src/Example.AppHost/AppModels/Resources/PostgresDatabaseKit.cs @@ -11,5 +11,5 @@ sealed partial class PostgresDatabaseKit { protected override IResourceBuilder BuildResource( IDistributedApplicationBuilder builder - ) => Parent.ResourceBuilder.AddDatabase(Name); + ) => Parent.AddDatabase(Name); } diff --git a/src/src/Example.AppHost/AppModels/Resources/SubnetDataKit.cs b/src/src/Example.AppHost/AppModels/Resources/SubnetDataKit.cs index 1708d16..b5e9b2a 100644 --- a/src/src/Example.AppHost/AppModels/Resources/SubnetDataKit.cs +++ b/src/src/Example.AppHost/AppModels/Resources/SubnetDataKit.cs @@ -10,5 +10,5 @@ namespace Purview.Aspire.ResourceKit.Example.AppHost.AppModels.Resources; sealed partial class SubnetDataKit { protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => - Parent.ResourceBuilder.AddSubnet(Name, "10.0.2.0/24"); + Parent.AddSubnet(Name, "10.0.2.0/24"); } diff --git a/src/src/Example.AppHost/AppModels/Resources/SubnetPrivateEndpointKit.cs b/src/src/Example.AppHost/AppModels/Resources/SubnetPrivateEndpointKit.cs index 907f622..d879601 100644 --- a/src/src/Example.AppHost/AppModels/Resources/SubnetPrivateEndpointKit.cs +++ b/src/src/Example.AppHost/AppModels/Resources/SubnetPrivateEndpointKit.cs @@ -10,5 +10,5 @@ namespace Purview.Aspire.ResourceKit.Example.AppHost.AppModels.Resources; sealed partial class SubnetPrivateEndpointKit { protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => - Parent.ResourceBuilder.AddSubnet(Name, "10.0.3.0/24"); + Parent.AddSubnet(Name, "10.0.3.0/24"); } diff --git a/src/src/Example.AppHost/AppModels/Resources/SubnetWebKit.cs b/src/src/Example.AppHost/AppModels/Resources/SubnetWebKit.cs index e04db18..e117be8 100644 --- a/src/src/Example.AppHost/AppModels/Resources/SubnetWebKit.cs +++ b/src/src/Example.AppHost/AppModels/Resources/SubnetWebKit.cs @@ -10,5 +10,5 @@ namespace Purview.Aspire.ResourceKit.Example.AppHost.AppModels.Resources; sealed partial class SubnetWebKit { protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => - Parent.ResourceBuilder.AddSubnet(Name, "10.0.1.0/24"); + Parent.AddSubnet(Name, "10.0.1.0/24"); } diff --git a/src/src/Example.ManualAppHost/AppModels/Resources/PostgresDatabaseKit.cs b/src/src/Example.ManualAppHost/AppModels/Resources/PostgresDatabaseKit.cs index 81acebb..3ab1bae 100644 --- a/src/src/Example.ManualAppHost/AppModels/Resources/PostgresDatabaseKit.cs +++ b/src/src/Example.ManualAppHost/AppModels/Resources/PostgresDatabaseKit.cs @@ -13,5 +13,5 @@ sealed partial class PostgresDatabaseKit(ExampleHostKit hostKit, PostgresKit par protected override IResourceBuilder BuildResource( IDistributedApplicationBuilder builder - ) => Parent.ResourceBuilder.AddDatabase(Name); + ) => Parent.AddDatabase(Name); } diff --git a/src/src/ResourceKit/HostKitBase.cs b/src/src/ResourceKit/HostKitBase.cs index 040a348..f864f71 100644 --- a/src/src/ResourceKit/HostKitBase.cs +++ b/src/src/ResourceKit/HostKitBase.cs @@ -22,7 +22,7 @@ public abstract class HostKitBase : IHostKit with(ReferenceEqualityComparer.Instance), ]; - Dictionary, IResourceKit> _parents = new(ReferenceEqualityComparer.Instance); + Dictionary, IResourceKit> _parents = [with(ReferenceEqualityComparer.Instance)]; ImmutableArray> _orderedResources = []; bool _resourcesSealed; @@ -216,7 +216,7 @@ Dictionary, IResourceKit> Parents } ValidateNoCycles(resources, parents); - var ordered = OrderParentsFirst(resources, parents); + var ordered = HostKitBase.OrderParentsFirst(resources, parents); return (ordered, parents); } @@ -257,7 +257,7 @@ Dictionary, IResourceKit> parents } } - ImmutableArray> OrderParentsFirst( + static ImmutableArray> OrderParentsFirst( ImmutableArray> resources, Dictionary, IResourceKit> parents ) @@ -302,6 +302,8 @@ Dictionary, IResourceKit> parents if (ordered.Count != resources.Length) throw new InvalidOperationException("The resource kit ownership graph is not acyclic."); + // The queue is ordered by registration index, so a parent always precedes its children, and siblings are + // ordered by registration index too. return ordered.ToImmutable(); } @@ -453,7 +455,7 @@ void AddGroupNodes(IDistributedApplicationBuilder builder) void PlaceResourcesInGroupNodes() { List<(IResource Resource, IResourceBuilder Node)> placements = []; - HashSet kitResources = new(ReferenceEqualityComparer.Instance); + HashSet kitResources = [with(ReferenceEqualityComparer.Instance)]; foreach (var resource in _orderedResources) { if (!resource.IsEnabled || resource is not IResourceBuilder resourceBuilder) @@ -515,7 +517,7 @@ void PlaceResourcesInGroupNodes() // resource seen twice is a cycle, and a cycle has no top to nest, so null is returned. static IResource? TopOfParentChain(IResource resource) { - HashSet seen = new(ReferenceEqualityComparer.Instance) { resource }; + HashSet seen = [with(ReferenceEqualityComparer.Instance), resource]; var current = resource; while (ParentOf(current) is { } parent) { diff --git a/src/src/ResourceKit/ResourceKitBase.cs b/src/src/ResourceKit/ResourceKitBase.cs index c9f0c25..ba14f74 100644 --- a/src/src/ResourceKit/ResourceKitBase.cs +++ b/src/src/ResourceKit/ResourceKitBase.cs @@ -284,6 +284,10 @@ static bool ParentAllowsBuild(IResourceKit parent) if (parent is not IResourceKitGating gating) return parent.IsEnabled; + // The parent is a gating kit, so its effective enablement is the only thing that matters. If the parent + // is unbuilt or building, the child cannot build yet. If the parent failed to build, the child cannot build. If the parent is disabled, the child cannot build. Otherwise, the + // parent is built and enabled, so the child can build. +#pragma warning disable IDE0072 // Add missing cases return gating.State switch { ResourceKitLifecycleState.Unbuilt or ResourceKitLifecycleState.Building => @@ -296,6 +300,7 @@ static bool ParentAllowsBuild(IResourceKit parent) ResourceKitLifecycleState.Disabled => false, _ => gating.EffectiveIsEnabled, }; +#pragma warning restore IDE0072 // Add missing cases } [DebuggerHidden] diff --git a/src/src/ResourceKit/Sdk/README.md b/src/src/ResourceKit/Sdk/README.md index 7ef2a7a..9c99851 100644 --- a/src/src/ResourceKit/Sdk/README.md +++ b/src/src/ResourceKit/Sdk/README.md @@ -88,7 +88,7 @@ Do not mix both attribute styles on the same class. ### Parent and child kits `Parent = typeof(TParentKit)` gives the child a typed `Parent` property and the parent a typed child -property. Parents are constructed and built before children, so `Parent.ResourceBuilder` is available inside +property. Parents are constructed and built before children, so `Parent` is available inside the child's `BuildResource`: ```csharp @@ -98,7 +98,7 @@ sealed partial class PostgresDatabaseKit { protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => - Parent.ResourceBuilder.AddDatabase(Name); + Parent.AddDatabase(Name); } ``` diff --git a/src/src/SourceGeneration.CodeFixes/RedundantResourceBuilderAccessCodeFixProvider.cs b/src/src/SourceGeneration.CodeFixes/RedundantResourceBuilderAccessCodeFixProvider.cs new file mode 100644 index 0000000..5591a64 --- /dev/null +++ b/src/src/SourceGeneration.CodeFixes/RedundantResourceBuilderAccessCodeFixProvider.cs @@ -0,0 +1,61 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Purview.Aspire.ResourceKit.SourceGeneration.CodeFixes; + +/// +/// Fixes SG0040 by removing a redundant .ResourceBuilder hop from a resource kit expression: +/// Parent.ResourceBuilder.AddBlobs(...) becomes Parent.AddBlobs(...). +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(RedundantResourceBuilderAccessCodeFixProvider))] +public sealed class RedundantResourceBuilderAccessCodeFixProvider : CodeFixProvider +{ + const string RedundantResourceBuilderRuleId = "SG0040"; + + public override ImmutableArray FixableDiagnosticIds => [RedundantResourceBuilderRuleId]; + + public override FixAllProvider? GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + return; + + var diagnostic = context.Diagnostics[0]; + var node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true); + var memberAccess = node.FirstAncestorOrSelf(); + if (memberAccess is null || memberAccess.Name.Identifier.ValueText != "ResourceBuilder") + return; + + context.RegisterCodeFix( + CodeAction.Create( + title: "Use the resource kit directly", + createChangedDocument: cancellationToken => + RemoveResourceBuilderAsync(context.Document, memberAccess, cancellationToken), + equivalenceKey: "UseResourceKitDirectly" + ), + diagnostic + ); + } + + static async Task RemoveResourceBuilderAsync( + Document document, + MemberAccessExpressionSyntax memberAccess, + CancellationToken cancellationToken + ) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) + return document; + + // Keep the original trivia (indentation, trailing comment) on the receiver that replaces the hop. + var replacement = memberAccess.Expression.WithTriviaFrom(memberAccess); + var newRoot = root.ReplaceNode(memberAccess, replacement); + + return document.WithSyntaxRoot(newRoot); + } +} diff --git a/src/src/SourceGeneration/AnalyzerReleases.Unshipped.md b/src/src/SourceGeneration/AnalyzerReleases.Unshipped.md index 34cdd68..fbd0d32 100644 --- a/src/src/SourceGeneration/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGeneration/AnalyzerReleases.Unshipped.md @@ -21,3 +21,4 @@ SG0036 | Purview.Aspire.ResourceKit.SourceGenerator | Error | Generated child pr SG0037 | Purview.Aspire.ResourceKit.SourceGenerator | Error | Duplicate child property name SG0038 | Purview.Aspire.ResourceKit.SourceGenerator | Error | Generated Parent member collides with a user-authored member SG0039 | Purview.Aspire.ResourceKit.SourceGenerator | Warning | Child resource kit uses a different primary group than its parent +SG0040 | Purview.Aspire.ResourceKit.SourceGenerator | Info | Resource kit is already a resource builder diff --git a/src/src/SourceGeneration/Helpers/CodeGenEmiiter.ResourceKit.cs b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.ResourceKit.cs index e369311..427f578 100644 --- a/src/src/SourceGeneration/Helpers/CodeGenEmiiter.ResourceKit.cs +++ b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.ResourceKit.cs @@ -107,11 +107,9 @@ static void EmitResourceKits(OutputContext context, CancellationToken cancellati $"Gets the resource kit that owns this kit." ) .Property( - new( - "Parent", - parentKit.ResourceKitType, - LeastAccessible(resourceKit.Accessibility, parentKit.Accessibility) - ) + "Parent", + parentKit.ResourceKitType, + LeastAccessible(resourceKit.Accessibility, parentKit.Accessibility) ); } diff --git a/src/src/SourceGeneration/Helpers/CodeGenEmiiter.cs b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.cs index 46166db..b73c805 100644 --- a/src/src/SourceGeneration/Helpers/CodeGenEmiiter.cs +++ b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.cs @@ -19,6 +19,7 @@ static string DisplayLocationExpression(string? location) if (location is null || location.Trim().Length == 0) return $"{TypeLibrary.Aspire.Hosting.ApplicationModel.UrlDisplayLocation}.SummaryAndDetails"; + // Ensure the location is fully-qualified. return location.StartsWith("global::", StringComparison.Ordinal) ? location : $"global::{location}"; } diff --git a/src/src/SourceGeneration/Helpers/DiagnosticLibrary.cs b/src/src/SourceGeneration/Helpers/DiagnosticLibrary.cs index b3982dc..33a0a4a 100644 --- a/src/src/SourceGeneration/Helpers/DiagnosticLibrary.cs +++ b/src/src/SourceGeneration/Helpers/DiagnosticLibrary.cs @@ -260,6 +260,16 @@ static class DiagnosticLibrary description: "ResourceKit parentage is lifecycle ownership; group membership is visual placement. A child with no [InGroup] inherits the parent's primary group. An explicit child group overrides that, so a mismatch is easy to miss." ); + public static readonly DiagnosticDescriptor RedundantResourceBuilderAccess = new( + id: "SG0040", + title: "Resource kit is already a resource builder", + messageFormat: "Use the resource kit directly; it implements IResourceBuilder, so '.ResourceBuilder' is redundant", + category: Category, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "A resource kit derives from ResourceKitBase, which implements IResourceBuilder. Calling an extension method on the kit and on kit.ResourceBuilder is equivalent." + ); + public static readonly DiagnosticDescriptor AssignSetsMultiplePropertyPaths = new( id: "SG0020", title: "OptionsHelper.Assign action must set exactly one property path", diff --git a/src/src/SourceGeneration/Helpers/ResourceKitHierarchyOrdering.cs b/src/src/SourceGeneration/Helpers/ResourceKitHierarchyOrdering.cs index 1166dd1..34d087c 100644 --- a/src/src/SourceGeneration/Helpers/ResourceKitHierarchyOrdering.cs +++ b/src/src/SourceGeneration/Helpers/ResourceKitHierarchyOrdering.cs @@ -23,7 +23,7 @@ CancellationToken cancellationToken if (ordered.Length == 0) return EquatableArray.Empty; - Dictionary indexByType = new(StringComparer.Ordinal); + Dictionary indexByType = [with(StringComparer.Ordinal)]; for (var index = 0; index < ordered.Length; index++) indexByType[ordered[index].ResourceKitType.MetadataFullName] = index; diff --git a/src/src/SourceGeneration/Helpers/ResourceKitRules.cs b/src/src/SourceGeneration/Helpers/ResourceKitRules.cs index bf3706b..ee567c0 100644 --- a/src/src/SourceGeneration/Helpers/ResourceKitRules.cs +++ b/src/src/SourceGeneration/Helpers/ResourceKitRules.cs @@ -602,7 +602,7 @@ internal static ImmutableArray EvaluateHierarchy(ImmutableArra if (nodes.IsEmpty) return findings.ToImmutable(); - Dictionary byType = new(StringComparer.Ordinal); + Dictionary byType = [with(StringComparer.Ordinal)]; for (var index = 0; index < nodes.Length; index++) byType[nodes[index].KitType.MetadataFullName] = index; @@ -755,7 +755,7 @@ public static string GroupName(INamedTypeSymbol symbol) => internal static bool IsInParentCycle(INamedTypeSymbol symbol) { var start = symbol.OriginalDefinition; - HashSet seen = new(SymbolEqualityComparer.Default) { start }; + HashSet seen = [with(SymbolEqualityComparer.Default), start]; for ( var parent = GetGroupParent(start)?.OriginalDefinition; parent is not null; @@ -886,7 +886,7 @@ static void EvaluateUrls( CancellationToken cancellationToken ) { - HashSet seen = new(StringComparer.Ordinal); + HashSet seen = [with(StringComparer.Ordinal)]; foreach (var (url, attribute) in GetUrls(symbol)) { cancellationToken.ThrowIfCancellationRequested(); @@ -953,6 +953,7 @@ INamedTypeSymbol kitSymbol if (TypeHelpers.Implements(resourceType, TypeLibrary.Aspire.Hosting.IProjectMetadata)) return true; + // The resource type is not a project, so it must be a resource with endpoints to be valid. return TypeHelpers.Implements(resourceType, TypeLibrary.Aspire.Hosting.ApplicationModel.IResourceWithEndpoints); } diff --git a/src/src/SourceGeneration/RedundantResourceBuilderAccessAnalyzer.cs b/src/src/SourceGeneration/RedundantResourceBuilderAccessAnalyzer.cs new file mode 100644 index 0000000..0f4c35a --- /dev/null +++ b/src/src/SourceGeneration/RedundantResourceBuilderAccessAnalyzer.cs @@ -0,0 +1,77 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using Purview.Aspire.ResourceKit.SourceGeneration.Helpers; + +namespace Purview.Aspire.ResourceKit.SourceGeneration; + +/// +/// Reports SG0040 when a resource kit is routed through its ResourceBuilder property even though +/// the kit itself implements IResourceBuilder<T>. The code fix +/// removes the redundant .ResourceBuilder hop. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class RedundantResourceBuilderAccessAnalyzer : DiagnosticAnalyzer +{ + public override ImmutableArray SupportedDiagnostics => + [DiagnosticLibrary.RedundantResourceBuilderAccess]; + + public override void Initialize(AnalysisContext context) + { + if (context is null) + throw new ArgumentNullException(nameof(context)); + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + + context.RegisterSyntaxNodeAction(AnalyzeMemberAccess, SyntaxKind.SimpleMemberAccessExpression); + } + + static void AnalyzeMemberAccess(SyntaxNodeAnalysisContext context) + { + var node = (MemberAccessExpressionSyntax)context.Node; + if (node.Name.Identifier.ValueText != "ResourceBuilder") + return; + + if (node.Ancestors().OfType().Any(IsNameOf)) + return; + + // Only simplify when the kit is the receiver of the next member access/invocation, for example + // Parent.ResourceBuilder.AddBlobs(...) -> Parent.AddBlobs(...). In an argument position the expected + // type might be IResourceBuilder, which the kit's own IResourceBuilder does not + // satisfy, so replacing it could break compilation. + if (node.Parent is not MemberAccessExpressionSyntax outer || !ReferenceEquals(outer.Expression, node)) + return; + + if ( + context.SemanticModel.GetSymbolInfo(node, context.CancellationToken).Symbol is not IPropertySymbol property + || property.Name != "ResourceBuilder" + ) + return; + + if ( + context.SemanticModel.GetTypeInfo(node.Expression, context.CancellationToken).Type + is not INamedTypeSymbol receiver + ) + return; + + if ( + !receiver.AllInterfaces.Any(interfaceType => + TypeLibrary.Purview.Aspire.ResourceKit.IResourceKit.Matches(interfaceType) + ) + || !receiver.AllInterfaces.Any(interfaceType => + TypeLibrary.Aspire.Hosting.ApplicationModel.IResourceBuilder.Matches(interfaceType) + ) + ) + return; + + context.ReportDiagnostic( + Diagnostic.Create(DiagnosticLibrary.RedundantResourceBuilderAccess, node.Name.GetLocation()) + ); + } + + static bool IsNameOf(InvocationExpressionSyntax invocation) => + invocation.Expression is IdentifierNameSyntax { Identifier.ValueText: "nameof" }; +} diff --git a/src/src/SourceGeneration/ResourceKitDiagnosticAnalyzer.cs b/src/src/SourceGeneration/ResourceKitDiagnosticAnalyzer.cs index 1b52b47..3da639a 100644 --- a/src/src/SourceGeneration/ResourceKitDiagnosticAnalyzer.cs +++ b/src/src/SourceGeneration/ResourceKitDiagnosticAnalyzer.cs @@ -260,7 +260,7 @@ ConcurrentBag resourceGroups .Where(static g => g.Count() > 1) ); - HashSet reported = new(SymbolEqualityComparer.Default); + HashSet reported = [with(SymbolEqualityComparer.Default)]; foreach (var duplicate in duplicates) { foreach (var group in duplicate.Where(reported.Add)) diff --git a/src/tests/ResourceKit.UnitTests/ResourceKitHierarchyEnablementTests.cs b/src/tests/ResourceKit.UnitTests/ResourceKitHierarchyEnablementTests.cs index 5ab2e6b..cb38e95 100644 --- a/src/tests/ResourceKit.UnitTests/ResourceKitHierarchyEnablementTests.cs +++ b/src/tests/ResourceKit.UnitTests/ResourceKitHierarchyEnablementTests.cs @@ -100,23 +100,13 @@ sealed class EnablementHostKit(ResourceKitMode mode) : HostKitBase mode; } - sealed class EnablementKit : ResourceKitBase + sealed class EnablementKit( + EnablementHostKit hostKit, + string name, + bool hook = true, + ResourceKitMode mode = ResourceKitMode.All + ) : ResourceKitBase(hostKit, name) { - readonly bool _hook; - readonly ResourceKitMode _mode; - - public EnablementKit( - EnablementHostKit hostKit, - string name, - bool hook = true, - ResourceKitMode mode = ResourceKitMode.All - ) - : base(hostKit, name) - { - _hook = hook; - _mode = mode; - } - public EnablementKit( EnablementHostKit hostKit, EnablementKit parent, @@ -135,12 +125,12 @@ public EnablementKit( public bool ConfigureCalled { get; private set; } - protected override ResourceKitMode Mode => _mode; + protected override ResourceKitMode Mode => mode; protected override bool IsResourceEnabled(IDistributedApplicationBuilder builder) { HookCalled = true; - return _hook; + return hook; } protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) diff --git a/src/tests/ResourceKit.UnitTests/ResourceKitHierarchyTests.cs b/src/tests/ResourceKit.UnitTests/ResourceKitHierarchyTests.cs index 46bc335..05fc881 100644 --- a/src/tests/ResourceKit.UnitTests/ResourceKitHierarchyTests.cs +++ b/src/tests/ResourceKit.UnitTests/ResourceKitHierarchyTests.cs @@ -289,8 +289,12 @@ public async Task Build_GivenUnregisteredParent_ThrowsBeforeCreatingResources() var builder = DistributedApplication.CreateBuilder(); TestHostKit host = new([]); MutableKit parent = new() { HostKit = host, Name = "parent" }; - MutableKit child = new() { HostKit = host, Name = "child" }; - child.ParentKit = parent; + MutableKit child = new() + { + HostKit = host, + Name = "child", + ParentKit = parent, + }; host.AddResource(child); // Act @@ -435,32 +439,16 @@ sealed class TestHostKit(List events) : HostKitBase public List Events { get; } = events; } - sealed class InstrumentedKit : ResourceKitBase + sealed class InstrumentedKit( + TestHostKit hostKit, + string name, + List events, + bool hook = true, + ResourceKitMode mode = ResourceKitMode.All, + bool throwOnBuild = false, + bool throwOnConfigure = false + ) : ResourceKitBase(hostKit, name) { - readonly bool _hook; - readonly ResourceKitMode _mode; - readonly bool _throwOnBuild; - readonly bool _throwOnConfigure; - readonly List _events; - - public InstrumentedKit( - TestHostKit hostKit, - string name, - List events, - bool hook = true, - ResourceKitMode mode = ResourceKitMode.All, - bool throwOnBuild = false, - bool throwOnConfigure = false - ) - : base(hostKit, name) - { - _hook = hook; - _mode = mode; - _throwOnBuild = throwOnBuild; - _throwOnConfigure = throwOnConfigure; - _events = events; - } - public InstrumentedKit( TestHostKit hostKit, InstrumentedKit parent, @@ -486,23 +474,24 @@ public InstrumentedKit( public bool HookCalled { get; private set; } - protected override ResourceKitMode Mode => _mode; + protected override ResourceKitMode Mode => mode; protected override bool IsResourceEnabled(IDistributedApplicationBuilder builder) { HookCalled = true; - return _hook; + return hook; } protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) { BuildCalled = true; BuildCount++; - _events.Add($"Build:{Name}"); + events.Add($"Build:{Name}"); - if (_throwOnBuild) + if (throwOnBuild) throw new InvalidOperationException($"The '{Name}' parent failed to build."); + // The resource builder is a simple wrapper that creates a new TestResource with the kit's name. The builder is added to the application builder's resources collection. return builder.AddResource(new TestResource(Name)); } @@ -510,9 +499,9 @@ protected override void ConfigureResource() { ConfigureCalled = true; ConfigureCount++; - _events.Add($"Configure:{Name}"); + events.Add($"Configure:{Name}"); - if (_throwOnConfigure) + if (throwOnConfigure) throw new InvalidOperationException($"The '{Name}' parent failed to configure."); } } diff --git a/src/tests/SourceGeneration.IntegrationTests/RedundantResourceBuilderAccessAnalyzerTests.cs b/src/tests/SourceGeneration.IntegrationTests/RedundantResourceBuilderAccessAnalyzerTests.cs new file mode 100644 index 0000000..6678e49 --- /dev/null +++ b/src/tests/SourceGeneration.IntegrationTests/RedundantResourceBuilderAccessAnalyzerTests.cs @@ -0,0 +1,113 @@ +using Purview.Aspire.ResourceKit.SourceGeneration.Helpers; + +namespace Purview.Aspire.ResourceKit.SourceGeneration; + +/// +/// Verifies SG0040: a resource kit is already an IResourceBuilder<TResource>, so routing through +/// its ResourceBuilder property is redundant. +/// +public sealed class RedundantResourceBuilderAccessAnalyzerTests : ResourceKitSourceGeneratorTestBase +{ + const string Kits = """ + using Aspire.Hosting; + using Aspire.Hosting.ApplicationModel; + using Purview.Aspire.ResourceKit; + + namespace Testing; + + public sealed class FakeHost : IHostKit + { + public void Build(IDistributedApplicationBuilder builder) { } + public void Configure() { } + } + + public sealed class ParentKit : ResourceKitBase + { + public ParentKit(FakeHost host) : base(host, "parent") { } + + protected override IResourceBuilder BuildResource( + IDistributedApplicationBuilder builder) => throw new global::System.NotImplementedException(); + } + + public sealed class TestAnnotation : IResourceAnnotation; + """; + + static ResourceKitSourceGeneratorTestOptions CreateOptions() + { + ResourceKitSourceGeneratorTestOptions baseOptions = new(); + return baseOptions with + { + AnalyzerTypes = [typeof(RedundantResourceBuilderAccessAnalyzer)], + AdditionalAssemblyTypes = baseOptions.AdditionalAssemblyTypes.Add(typeof(DefaultAspireResource)), + }; + } + + [Test] + public async Task Generate_GivenKitResourceBuilderCall_ReportsRedundantAccess(CancellationToken cancellationToken) + { + // Arrange + var source = + Kits + + """ + + public sealed class Caller + { + public IResourceBuilder Build(ParentKit parent) => + parent.ResourceBuilder.WithAnnotation(new TestAnnotation()); + } + """; + + // Act + using var result = await GenerateAsync(source, CreateOptions(), cancellationToken); + + // Assert + await Assert.That(result).HasDiagnostic(DiagnosticLibrary.RedundantResourceBuilderAccess); + } + + [Test] + public async Task Generate_GivenResourceBuilderPassedAsArgument_DoesNotReportRedundantAccess( + CancellationToken cancellationToken + ) + { + // Arrange — replacing this with parent could change the target type and break compilation. + var source = + Kits + + """ + + public sealed class Caller + { + public void Consume(ParentKit parent) => Accept(parent.ResourceBuilder); + + static void Accept(IResourceBuilder builder) { } + } + """; + + // Act + using var result = await GenerateAsync(source, CreateOptions(), cancellationToken); + + // Assert + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.RedundantResourceBuilderAccess); + } + + [Test] + public async Task Generate_GivenKitUsedDirectly_DoesNotReportRedundantAccess(CancellationToken cancellationToken) + { + // Arrange + var source = + Kits + + """ + + public sealed class Caller + { + public IResourceBuilder Build(ParentKit parent) => + parent.WithAnnotation(new TestAnnotation()); + } + """; + + // Act + using var result = await GenerateAsync(source, CreateOptions(), cancellationToken); + + // Assert + await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.RedundantResourceBuilderAccess); + } +} diff --git a/src/tests/SourceGeneration.IntegrationTests/RedundantResourceBuilderAccessCodeFixTests.cs b/src/tests/SourceGeneration.IntegrationTests/RedundantResourceBuilderAccessCodeFixTests.cs new file mode 100644 index 0000000..ca76e03 --- /dev/null +++ b/src/tests/SourceGeneration.IntegrationTests/RedundantResourceBuilderAccessCodeFixTests.cs @@ -0,0 +1,91 @@ +using Purview.Aspire.ResourceKit.SourceGeneration.CodeFixes; + +namespace Purview.Aspire.ResourceKit.SourceGeneration; + +/// +/// Verifies that the SG0040 code fix removes the redundant .ResourceBuilder hop while preserving the +/// rest of the fluent chain. +/// +public sealed record RedundantResourceBuilderCodeFixTestOptions : CodeFixTestOptions +{ + public RedundantResourceBuilderCodeFixTestOptions() + { + List additionalTypes = + [ + typeof(ResourceKitBase<,>), + typeof(IHostKit), + typeof(DefaultAspireResource), + typeof(global::Aspire.Hosting.ApplicationModel.IResource), + typeof(global::Aspire.Hosting.IDistributedApplicationBuilder), + ]; + + AdditionalAssemblyTypes = [.. additionalTypes]; + AdditionalNamespaces = + [ + typeof(DefaultAspireResource).Namespace!, + typeof(ResourceKitBase<,>).Namespace!, + typeof(global::Aspire.Hosting.ApplicationModel.IResource).Namespace!, + typeof(global::Aspire.Hosting.IDistributedApplicationBuilder).Namespace!, + ]; + } +} + +public sealed class RedundantResourceBuilderAccessCodeFixTests + : TUnitCodeFixTestBase< + RedundantResourceBuilderAccessAnalyzer, + RedundantResourceBuilderAccessCodeFixProvider, + RedundantResourceBuilderCodeFixTestOptions + > +{ + const string Kits = """ + using Aspire.Hosting; + using Aspire.Hosting.ApplicationModel; + using Purview.Aspire.ResourceKit; + + namespace Testing; + + public sealed class FakeHost : IHostKit + { + public void Build(IDistributedApplicationBuilder builder) { } + public void Configure() { } + } + + public sealed class ParentKit : ResourceKitBase + { + public ParentKit(FakeHost host) : base(host, "parent") { } + + protected override IResourceBuilder BuildResource( + IDistributedApplicationBuilder builder) => throw new global::System.NotImplementedException(); + } + + public sealed class TestAnnotation : IResourceAnnotation; + """; + + [Test] + public async Task ApplyCodeFix_GivenKitResourceBuilderAccess_RemovesTheHop(CancellationToken cancellationToken) + { + // Arrange + var source = + Kits + + """ + + public sealed class Caller + { + public IResourceBuilder Build(ParentKit parent) => + parent.ResourceBuilder.WithAnnotation(new TestAnnotation()); + } + """; + + // Act + var result = await ApplyCodeFixAsync( + source, + new RedundantResourceBuilderCodeFixTestOptions(), + cancellationToken + ); + + // Assert + var fixedCode = result.FixedCode().Trees.First().GetText().ToString(); + await Assert.That(fixedCode).Contains("parent.WithAnnotation(new TestAnnotation())"); + await Assert.That(fixedCode).DoesNotContain("parent.ResourceBuilder.WithAnnotation"); + } +} From 3ee2947f10660aba51d925c51073e541632d06f6 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 12 Nov 2026 14:44:13 +0000 Subject: [PATCH 09/12] docs: cover hierarchies, group placement and SG0040 in docs and skills --- AGENTS.md | 10 +++++----- README.md | 7 +++++-- docs/wiki/Agent-Skills.md | 10 +++++----- docs/wiki/Configuration-and-Options.md | 1 + docs/wiki/Diagnostics.md | 8 ++++++-- docs/wiki/Generated-Output.md | 5 +++-- docs/wiki/OptionsHelper.md | 11 +++++++++++ docs/wiki/Source-Generator-Behaviors.md | 12 +++++++----- skills/purview-resourcekit-authoring/SKILL.md | 2 ++ skills/purview-resourcekit-diagnostics/SKILL.md | 2 +- skills/purview-resourcekit-migration/SKILL.md | 2 +- skills/purview-resourcekit-options/SKILL.md | 8 +++++++- skills/purview-resourcekit-testing/SKILL.md | 4 ++-- src/src/ResourceKit/Sdk/README.md | 9 +++++++++ 14 files changed, 65 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 001aae5..d33d1d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,11 +124,11 @@ npx skills add purview-dev/aspire-resourcekit | Skill | Answers | | --- | --- | -| `skills/purview-resourcekit-authoring/` | declaring a `[HostKit]` and resource kits, attribute styles, the Build/Configure lifecycle, project resources | -| `skills/purview-resourcekit-options/` | generated options, extending them, run modes (`Mode`), `IsEnabled` versus `IsResourceEnabled`, `OptionsHelper` | -| `skills/purview-resourcekit-testing/` | TUnit.Aspire fixtures, overriding options from a test, running the tiers | -| `skills/purview-resourcekit-diagnostics/` | what an `SG00xx` means and how to fix it | -| `skills/purview-resourcekit-migration/` | converting an existing inline AppHost into ResourceKit composition | +| `skills/purview-resourcekit-authoring/` | declaring a `[HostKit]` and resource kits, attribute styles, parent/child hierarchies, the Build/Configure lifecycle, project resources, groups and URLs | +| `skills/purview-resourcekit-options/` | generated options including nested child options, extending them, run modes (`Mode`), inherited `IsEnabled` versus `IsResourceEnabled`, `OptionsHelper` | +| `skills/purview-resourcekit-testing/` | TUnit.Aspire fixtures, overriding options from a test, hierarchy model tests, running the tiers | +| `skills/purview-resourcekit-diagnostics/` | what an `SG00xx` means, including hierarchy, group-placement and redundant-`ResourceBuilder` rules, and how to fix it | +| `skills/purview-resourcekit-migration/` | converting an existing inline AppHost into ResourceKit composition, including extracting child resource kits | Every skill carries the `purview-resourcekit` prefix. Skills install into one flat directory per agent, shared with every skill a developer has installed from anywhere, so an unprefixed name diff --git a/README.md b/README.md index 70eb857..5fa05e7 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ If your AppHost is getting bigger, this helps you keep resource setup maintainab > **Lifecycle quick links** > > - Build vs Configure guidance: [`docs/wiki/Lifecycle-Build-Configure.md`](docs/wiki/Lifecycle-Build-Configure.md) +> - Parent/child resource kits: [`docs/wiki/Hierarchies.md`](docs/wiki/Hierarchies.md) > - Runtime enablement and run modes (`Mode` + `IsEnabled` + `IsResourceEnabled(...)`): [`docs/wiki/Enablement.md`](docs/wiki/Enablement.md) ## Why teams use it @@ -86,6 +87,8 @@ Enablement is evaluated *before* resource construction: - `IsEnabled` is the persisted toggle (typically from generated options) and the single member consumers need to check. - `IsResourceEnabled(builder)` is the runtime hook used by `Build` to decide whether this resource should run now. - If any gate excludes the resource, both `BuildResource(...)` and `ConfigureResource()` are skipped for that resource. +- A child kit is also disabled when any ancestor is disabled; an ancestor-disabled kit never runs a + descendant's `IsResourceEnabled` hook. Use `Mode` for publish-only or local-development-only resources, and `IsResourceEnabled(builder)` when enablement depends on runtime state beyond an execution mode (environment probes, dynamic configuration). @@ -94,8 +97,8 @@ Use `Mode` for publish-only or local-development-only resources, and `IsResource From your `[HostKit]` and `[ResourceDefinition]` declarations, ResourceKit generates: - a host resource base class, -- resource properties on the host, -- host + per-resource options (when enabled), +- typed parent/child properties and constructors for hierarchical kits, +- host + per-resource options (when enabled), including nested child options, - typed resource groups (`[ResourceGroup]`, `[InGroup]`) that switch sets of resources on or off and nest them in the dashboard, - named dashboard URLs (`[ResourceUrl]`, `[EndpointUrl]`) that options can override, - an AppHost extension method to build/configure/register your host kit. diff --git a/docs/wiki/Agent-Skills.md b/docs/wiki/Agent-Skills.md index 3d6a6f5..14f5a30 100644 --- a/docs/wiki/Agent-Skills.md +++ b/docs/wiki/Agent-Skills.md @@ -26,11 +26,11 @@ records what it installed in `skills-lock.json`, so the set can be restored late | Skill | Answers | | --- | --- | -| `purview-resourcekit-authoring` | declaring a `[HostKit]` and resource kits, attribute styles, the Build/Configure lifecycle, project resources | -| `purview-resourcekit-options` | generated options, extending them, run modes (`Mode`), `IsEnabled` versus `IsResourceEnabled`, `OptionsHelper` | -| `purview-resourcekit-testing` | TUnit.Aspire fixtures, overriding options from a test, running the tiers | -| `purview-resourcekit-diagnostics` | what an `SG00xx` means and how to fix it | -| `purview-resourcekit-migration` | converting an existing inline AppHost into ResourceKit composition | +| `purview-resourcekit-authoring` | declaring a `[HostKit]` and resource kits, attribute styles, parent/child hierarchies, the Build/Configure lifecycle, project resources, groups and URLs | +| `purview-resourcekit-options` | generated options including nested child options, extending them, run modes (`Mode`), inherited `IsEnabled` versus `IsResourceEnabled`, `OptionsHelper` | +| `purview-resourcekit-testing` | TUnit.Aspire fixtures, overriding options from a test, hierarchy model tests, running the tiers | +| `purview-resourcekit-diagnostics` | what an `SG00xx` means, including hierarchy, group-placement and redundant-`ResourceBuilder` rules, and how to fix it | +| `purview-resourcekit-migration` | converting an existing inline AppHost into ResourceKit composition, including extracting child resource kits | Each is authored at `skills//SKILL.md` in this repository, and validated on every test run against the Agent Skills specification by `src/tests/Repository.UnitTests/SkillTests.cs`. diff --git a/docs/wiki/Configuration-and-Options.md b/docs/wiki/Configuration-and-Options.md index a32ded7..955e5de 100644 --- a/docs/wiki/Configuration-and-Options.md +++ b/docs/wiki/Configuration-and-Options.md @@ -126,6 +126,7 @@ Typical keys (example): - `ShopHostKit:API:IsEnabled` - `ShopHostKit:API:Mode` - `ShopHostKit:API:Urls:Health:IsEnabled` (a declared `[ResourceUrl("Health", ...)]`) +- `ShopHostKit:Postgres:Database:IsEnabled` (a child resource kit) - `ShopHostKit:Mode` - `ShopHostKit:Groups:Data:IsEnabled`, `:Mode`, `:ShowInDashboard` (a `[ResourceGroup]`) diff --git a/docs/wiki/Diagnostics.md b/docs/wiki/Diagnostics.md index b4fe251..3286102 100644 --- a/docs/wiki/Diagnostics.md +++ b/docs/wiki/Diagnostics.md @@ -57,7 +57,7 @@ exiting the constructor" warning does not apply. See [Attributes Reference](Attributes-Reference.md) for the rules tied to attribute styles, [Project Resources](Project-Resources.md) for SG0018/SG0019 specifics, [Groups and URLs](Groups-and-URLs.md) for SG0021–SG0031, and -[Parent and child resource kits](Hierarchies.md) for SG0032–SG0038. +[Parent and child resource kits](Hierarchies.md) for SG0032–SG0040. A parent cycle is reported on one kit in the cycle and includes the full path, for example `PostgresKit -> DatabaseKit -> MigrationKit -> PostgresKit`. Invalid hierarchies block generation; manual @@ -120,9 +120,13 @@ and the kit's own `IResourceBuilder` would not satisfy it. 1. Ensure host/resource classes are marked `partial`. 2. Ensure exactly one `[HostKit]` class exists in the compilation. -3. Ensure resource property names are unique (explicit `PropertyName` can help). +3. Ensure root property names are unique, and child property names are unique within each parent + (explicit `PropertyName` can help). 4. Ensure each resource uses a compatible base and definition attribute style. 5. Set explicit `Name` when inference cannot determine resource name. +6. If the kit declares `Parent = typeof(...)`, ensure that type is a resource kit in the same composition; + a child in a different primary group than its parent gets SG0039, and a redundant + `Parent.ResourceBuilder` gets SG0040. ## Common fixes diff --git a/docs/wiki/Generated-Output.md b/docs/wiki/Generated-Output.md index 7b80f4b..9c4b5c2 100644 --- a/docs/wiki/Generated-Output.md +++ b/docs/wiki/Generated-Output.md @@ -53,8 +53,9 @@ Root kits are unchanged. See [Parent and child resource kits](Hierarchies.md). ## Generated host Build/Configure -`Build` instantiates each resource kit from its options, registers it with the base class, invokes the -optional `onBuilt` callback, then calls `base.Build(builder)`: +`Build` instantiates roots first and then each child with its parent instance, registers them in +parent-before-child order, invokes the optional `onBuilt` callback, then calls `base.Build(builder)` +(which validates the ownership graph before creating anything): ```csharp public override void Build(global::Aspire.Hosting.IDistributedApplicationBuilder builder) diff --git a/docs/wiki/OptionsHelper.md b/docs/wiki/OptionsHelper.md index 7c1bbb5..b8d6995 100644 --- a/docs/wiki/OptionsHelper.md +++ b/docs/wiki/OptionsHelper.md @@ -30,6 +30,17 @@ You can also pass an explicit root section name: OptionsHelper.Assign("MySection", c => c.API.IsEnabled = false); ``` +Child ResourceKit options are nested under their parent, so the same selector works at any depth: + +```csharp +var args = OptionsHelper.Assign( + c => c.Postgres.Database.IsEnabled = false +).Build(); +// --ShopHostKit:Postgres:Database:IsEnabled=false +``` + +See [Parent and child resource kits](Hierarchies.md) for the ownership model and configuration shape. + ## AsEnvironmentVariables Call `AsEnvironmentVariables()` before `Build()` to produce environment variables instead: diff --git a/docs/wiki/Source-Generator-Behaviors.md b/docs/wiki/Source-Generator-Behaviors.md index 316e71b..61237af 100644 --- a/docs/wiki/Source-Generator-Behaviors.md +++ b/docs/wiki/Source-Generator-Behaviors.md @@ -40,11 +40,13 @@ the two never drift apart. - **Analyzer-owned rules** are reported only by the analyzer; the generator computes them for its `ShouldProcess` gating but does not re-report them (avoiding duplicate diagnostics). -- **Generation-blocking rules** (Error severity, SG0001–SG0016) halt generation via the - `GeneratorResult.ShouldProcess` gate. -- **Execution-only rules** (SG0017–SG0019, warnings) never block generation, so a resource kit with - incomplete `BuildResource`/`ConfigureResource` wiring is still generated and the host kit output is - still emitted. +- **Generation-blocking rules** (Error severity, including the group and hierarchy rules SG0021–SG0038) + halt generation via the `GeneratorResult.ShouldProcess` gate. +- **Execution-only rules** (SG0017–SG0019, SG0024 and SG0028, warnings) never block generation, so a + resource kit with incomplete `BuildResource`/`ConfigureResource` wiring is still generated and the host + kit output is still emitted. +- **Suggestion rules** (SG0040, Info) and the group-placement warning SG0039 never block generation; they + surface a simplification or a visual-placement mismatch without changing the build result. See [Diagnostics](Diagnostics.md) for the full rule reference. diff --git a/skills/purview-resourcekit-authoring/SKILL.md b/skills/purview-resourcekit-authoring/SKILL.md index 30fcb1b..7feb19a 100644 --- a/skills/purview-resourcekit-authoring/SKILL.md +++ b/skills/purview-resourcekit-authoring/SKILL.md @@ -160,6 +160,8 @@ sealed partial class PostgresDatabaseKit `IsResourceEnabled` hook. Siblings are independent. - Child options nest under the parent (`ShopHostKit:Postgres:Database:IsEnabled`). - A child with no `[InGroup]` inherits its parent's **primary** group. Grouping never changes build order. + An explicit child group whose primary group differs from the parent's warns `SG0039`; the child renders in + its own group unless an Aspire visual parent places it under the parent. - ResourceKit parentage is composition ownership, not a general dependency, and it does **not** create an Aspire visual parent relationship. Add `WithParentRelationship(Parent.ResourceBuilder)` yourself when you want that. diff --git a/skills/purview-resourcekit-diagnostics/SKILL.md b/skills/purview-resourcekit-diagnostics/SKILL.md index f9a934a..900ab6c 100644 --- a/skills/purview-resourcekit-diagnostics/SKILL.md +++ b/skills/purview-resourcekit-diagnostics/SKILL.md @@ -2,7 +2,7 @@ name: purview-resourcekit-diagnostics description: >- Use when a Purview.Aspire.ResourceKit build reports an SG00xx diagnostic - what triggered it, how to - fix it, which rules block source generation and which are execution-only warnings, and why CS8618 is + fix it, which rules cover parent/child hierarchies, group placement and redundant ResourceBuilder access, which rules block source generation and which are execution-only warnings, and why CS8618 is suppressed on IResourceBuilder properties. metadata: product: Purview.Aspire.ResourceKit diff --git a/skills/purview-resourcekit-migration/SKILL.md b/skills/purview-resourcekit-migration/SKILL.md index ac3ed8a..9f308d5 100644 --- a/skills/purview-resourcekit-migration/SKILL.md +++ b/skills/purview-resourcekit-migration/SKILL.md @@ -1,6 +1,6 @@ --- name: purview-resourcekit-migration -description: "Use when converting an Aspire AppHost (typically Program.cs/AppHost.cs) into Purview.Aspire.ResourceKit patterns: generate a HostKit, split resources into ResourceKits with BuildResource/ConfigureResource, map existing config into generated options (IsEnabled/Name), replace direct wiring with AddAspireResourceKit, and update tests/fixtures/configuration to use OptionsHelper and ResourceKit semantics." +description: "Use when converting an Aspire AppHost (typically Program.cs/AppHost.cs) into Purview.Aspire.ResourceKit patterns: generate a HostKit, split resources into ResourceKits with BuildResource/ConfigureResource, extract parent/child kits with Parent = typeof(TParentKit), map existing config into generated options (IsEnabled/Name), replace direct wiring with AddAspireResourceKit, and update tests/fixtures/configuration to use OptionsHelper and ResourceKit semantics." --- # Aspire AppHost → ResourceKit Migration Skill diff --git a/skills/purview-resourcekit-options/SKILL.md b/skills/purview-resourcekit-options/SKILL.md index 12599dc..f3464b4 100644 --- a/skills/purview-resourcekit-options/SKILL.md +++ b/skills/purview-resourcekit-options/SKILL.md @@ -2,7 +2,7 @@ name: purview-resourcekit-options description: >- Use when configuring Purview.Aspire.ResourceKit through options - the generated host and resource - options shape, extending them with custom properties, selecting a run mode with Mode, section-name + options shape including nested child-kit options, extending them with custom properties, selecting a run mode with Mode, inherited enablement, section-name resolution, disabling a resource with IsEnabled, disabling a resource group, overriding a declared dashboard URL, overriding enablement with IsResourceEnabled, and building args or environment variables with OptionsHelper. @@ -62,6 +62,12 @@ public sealed partial class PostgresKitOptions `--ShopHostKit:Postgres:Database:IsEnabled=false`. A nested child can be disabled independently, but never re-enabled while an ancestor is disabled. See `purview-resourcekit-authoring` for the hierarchy rules. +```csharp +var args = OptionsHelper.Assign( + c => c.Postgres.Database.IsEnabled = false +).Build(); +``` + Two further option families appear when they are declared: - **Resource groups.** `[ResourceGroup]` adds `ShopHostKit:Groups::{IsEnabled|Mode|ShowInDashboard}`. diff --git a/skills/purview-resourcekit-testing/SKILL.md b/skills/purview-resourcekit-testing/SKILL.md index 2ec87eb..1d12696 100644 --- a/skills/purview-resourcekit-testing/SKILL.md +++ b/skills/purview-resourcekit-testing/SKILL.md @@ -2,7 +2,7 @@ name: purview-resourcekit-testing description: >- Use when testing a Purview.Aspire.ResourceKit AppHost - the TUnit.Aspire fixture pattern, - integration-testing a running AppHost, overriding generated options from a test with OptionsHelper + integration-testing a running AppHost, overriding generated options from a test with OptionsHelper, testing parent/child hierarchies and nested child options or raw args, unit-testing the manual kit pattern, and the treenode filter for running the tiers. metadata: product: Purview.Aspire.ResourceKit @@ -23,7 +23,7 @@ under `src/tests`: | Project | Covers | | --- | --- | -| `ResourceKit.UnitTests` | runtime behaviour of the kit base types, run modes and `OptionsHelper` | +| `ResourceKit.UnitTests` | runtime behaviour of the kit base types, hierarchies, graph validation, run modes, real Aspire models and `OptionsHelper` | | `ResourceKit.IntegrationTests` | starts the example AppHosts and asserts against real resources | | `SourceGeneration.UnitTests` / `SourceGeneration.IntegrationTests` | generator output, diagnostics, suppression, code fixes, caching | | `Repository.UnitTests` | the repository itself: authored skills, the architecture policy and its `Purview.BuildSdk` opt-ins | diff --git a/src/src/ResourceKit/Sdk/README.md b/src/src/ResourceKit/Sdk/README.md index 9c99851..e47fe3d 100644 --- a/src/src/ResourceKit/Sdk/README.md +++ b/src/src/ResourceKit/Sdk/README.md @@ -306,6 +306,15 @@ variables. | SG0029 | Error | Resource group declares its parent more than once | | SG0030 | Error | Resource group is generic | | SG0031 | Error | Resource group is also a resource kit or a group member | +| SG0032 | Error | A resource kit declares itself as its parent | +| SG0033 | Error | Resource kit parent cycle; the message prints the path | +| SG0034 | Error | Parent type is not a resource kit | +| SG0035 | Error | Parent is not part of this host kit composition | +| SG0036 | Error | Generated child property collides with a member on the parent | +| SG0037 | Error | Duplicate child property name under one parent | +| SG0038 | Error | Generated `Parent` member collides with a user-authored member | +| SG0039 | Warning | Child primary group differs from its parent's; visual placement follows the child's group | +| SG0040 | Info | A resource kit is already an `IResourceBuilder`; `.ResourceBuilder` is redundant | SG0017–SG0019, SG0024 and SG0028 are execution-only warnings: they indicate the resource will fail at runtime but never block generation, so a resource kit with incomplete wiring (for example a project not yet added via `AddProject()`) is still generated and the host kit output is still emitted. From 4171d66783232fdbbe5ec37b39922ff976ef113d Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 12 Nov 2026 14:49:48 +0000 Subject: [PATCH 10/12] chore: linting around async/ await + ctp --- .../RedundantResourceBuilderAccessAnalyzer.cs | 8 ++------ .../RedundantResourceBuilderAccessCodeFixTests.cs | 4 +++- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/src/SourceGeneration/RedundantResourceBuilderAccessAnalyzer.cs b/src/src/SourceGeneration/RedundantResourceBuilderAccessAnalyzer.cs index 0f4c35a..1cdfa2f 100644 --- a/src/src/SourceGeneration/RedundantResourceBuilderAccessAnalyzer.cs +++ b/src/src/SourceGeneration/RedundantResourceBuilderAccessAnalyzer.cs @@ -58,12 +58,8 @@ is not INamedTypeSymbol receiver return; if ( - !receiver.AllInterfaces.Any(interfaceType => - TypeLibrary.Purview.Aspire.ResourceKit.IResourceKit.Matches(interfaceType) - ) - || !receiver.AllInterfaces.Any(interfaceType => - TypeLibrary.Aspire.Hosting.ApplicationModel.IResourceBuilder.Matches(interfaceType) - ) + !receiver.AllInterfaces.Any(TypeLibrary.Purview.Aspire.ResourceKit.IResourceKit.Matches) + || !receiver.AllInterfaces.Any(TypeLibrary.Aspire.Hosting.ApplicationModel.IResourceBuilder.Matches) ) return; diff --git a/src/tests/SourceGeneration.IntegrationTests/RedundantResourceBuilderAccessCodeFixTests.cs b/src/tests/SourceGeneration.IntegrationTests/RedundantResourceBuilderAccessCodeFixTests.cs index ca76e03..03037f2 100644 --- a/src/tests/SourceGeneration.IntegrationTests/RedundantResourceBuilderAccessCodeFixTests.cs +++ b/src/tests/SourceGeneration.IntegrationTests/RedundantResourceBuilderAccessCodeFixTests.cs @@ -84,7 +84,9 @@ public IResourceBuilder Build(ParentKit parent) => ); // Assert - var fixedCode = result.FixedCode().Trees.First().GetText().ToString(); + var fixedCodeSource = await result.FixedCode().Trees.First().GetTextAsync(cancellationToken); + var fixedCode = fixedCodeSource.ToString(); + await Assert.That(fixedCode).Contains("parent.WithAnnotation(new TestAnnotation())"); await Assert.That(fixedCode).DoesNotContain("parent.ResourceBuilder.WithAnnotation"); } From b5b71da0f2bf948b4aac53a1e2deb0562b2d62cb Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 12 Nov 2026 14:58:04 +0000 Subject: [PATCH 11/12] chore: removed unrequired tests --- .config/architecture-policy.json | 33 ------------------- .github/workflows/pr.yml | 2 -- .github/workflows/release.yml | 1 - Directory.Packages.props | 3 +- .../Repository.UnitTests/ArchitectureTests.cs | 21 ------------ .../Repository.UnitTests.csproj | 3 -- 6 files changed, 1 insertion(+), 62 deletions(-) delete mode 100644 .config/architecture-policy.json delete mode 100644 src/tests/Repository.UnitTests/ArchitectureTests.cs diff --git a/.config/architecture-policy.json b/.config/architecture-policy.json deleted file mode 100644 index 4b3810f..0000000 --- a/.config/architecture-policy.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/purview-dev/architests/main/architecture-policy.schema.json", - "version": 3, - "description": "Purview.Aspire.ResourceKit composes an Aspire AppHost from strongly typed kits at runtime, and generates the wiring that makes that possible at build time. The direction that matters is build-time-tooling-may-not-know-the-runtime: the Roslyn components are loaded into a consumer's compiler, so a reference to the runtime from one of them would drag Aspire.Hosting into every consumer's compiler host.", - "sourceRoots": ["src"], - "responsibilities": [ - { - "name": "ResourceKit", - "category": "composition", - "purpose": "The runtime model an AppHost composes: host and resource kit base types, generated options, run modes and OptionsHelper. This is the composition root's own library, and the only project packed for consumers.", - "mayDependOn": ["SourceGeneration"], - "mustNotDependOn": ["Example"], - "forbiddenPackagePrefixes": ["Microsoft.CodeAnalysis"], - "mayRemainCombinedWhen": "Its reference to SourceGeneration is analyzer-only (ReferenceOutputAssembly=false); the generator and its code fixes are what make the kit classes strongly typed, and they must never appear as an assembly dependency." - }, - { - "name": "SourceGeneration", - "category": "infrastructure", - "purpose": "Build-time tooling: the incremental generator that emits kit bases and options, the diagnostics and suppressor, the OptionsHelper code fix, and the attribute sources. Loaded into a consumer's compiler, never into a running application.", - "mayDependOn": [], - "mustNotDependOn": ["ResourceKit", "Example"], - "forbiddenPackagePrefixes": ["Aspire.Hosting", "Microsoft.Extensions.Options"] - }, - { - "name": "Example", - "category": "host", - "purpose": "Sample Aspire applications that consume the toolkit the way a real AppHost does - one composed through the generated pattern, one wired by hand - plus the service and service-defaults projects they start.", - "mayDependOn": ["ResourceKit", "SourceGeneration"], - "mustNotDependOn": [] - } - ], - "waivers": [] -} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 381b6cf..4541be0 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -12,6 +12,4 @@ jobs: build: name: Build and test uses: purview-dev/build/.github/workflows/purview-build.yml@main - with: - test-filter: "/*/*/*/*[Category=Unit]" secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 639e0f8..01283a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,4 +39,3 @@ jobs: github-packages: ${{ vars.PURVIEW_PUBLISH_GITHUB_PACKAGES == 'true' }} github-packages-keep-versions: ${{ vars.PURVIEW_GITHUB_PACKAGES_KEEP_VERSIONS || '1' }} github-packages-retention-hours: ${{ vars.PURVIEW_GITHUB_PACKAGES_RETENTION_HOURS || '24' }} - test-filter: "/*/*/*/*[Category=Unit]" diff --git a/Directory.Packages.props b/Directory.Packages.props index a768794..ec11b68 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -65,8 +65,7 @@ - - + diff --git a/src/tests/Repository.UnitTests/ArchitectureTests.cs b/src/tests/Repository.UnitTests/ArchitectureTests.cs deleted file mode 100644 index 20d5f69..0000000 --- a/src/tests/Repository.UnitTests/ArchitectureTests.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Purview.Architecture.Testing; - -namespace Purview.Aspire.ResourceKit.Repository; - -/// -/// Enforces .config/architecture-policy.json - the dependency direction between the runtime -/// model, the build-time Roslyn components and the sample hosts - as a failing build rather than a -/// convention a reviewer repeats. -/// -/// -/// -/// [InheritsTests] is load-bearing. TUnit does not walk base types when it discovers -/// tests, so without it this class compiles, the run is green, and no rule is checked. -/// -/// -/// There is no RepositoryRoot override: the default probe walks up from this test binary for the -/// policy and stops at the .git boundary, which is the same resolution a contributor gets. -/// -/// -[InheritsTests] -public sealed class ArchitectureTests : PurviewArchitectureTests; diff --git a/src/tests/Repository.UnitTests/Repository.UnitTests.csproj b/src/tests/Repository.UnitTests/Repository.UnitTests.csproj index c45108d..ade4cc0 100644 --- a/src/tests/Repository.UnitTests/Repository.UnitTests.csproj +++ b/src/tests/Repository.UnitTests/Repository.UnitTests.csproj @@ -3,8 +3,5 @@ - - From 1495988659f597c5d81a23f618c0ea4395d0236d Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 12 Nov 2026 15:08:21 +0000 Subject: [PATCH 12/12] fix: use the API HTTP endpoint in integration health tests Linux CI agents do not trust the ASP.NET Core development certificate. The health test needs the service, not TLS, so use the http endpoint; the AppHost still starts the https endpoint. --- .../Extensions/TUnit/Aspire/AspireFixtureExtensions.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/tests/ResourceKit.IntegrationTests/Extensions/TUnit/Aspire/AspireFixtureExtensions.cs b/src/tests/ResourceKit.IntegrationTests/Extensions/TUnit/Aspire/AspireFixtureExtensions.cs index 12a0d9c..e35376a 100644 --- a/src/tests/ResourceKit.IntegrationTests/Extensions/TUnit/Aspire/AspireFixtureExtensions.cs +++ b/src/tests/ResourceKit.IntegrationTests/Extensions/TUnit/Aspire/AspireFixtureExtensions.cs @@ -16,7 +16,10 @@ static class AspireFixtureExtensions { public async Task APIIsHealthyAsync(CancellationToken cancellationToken) { - var client = fixture.CreateHttpClient(Platform.ResourceKits.API); + // Use the HTTP endpoint: CI agents (Linux) do not trust the ASP.NET Core development certificate, + // and this test is about the service being healthy, not TLS. The HTTPS endpoint is still covered + // by the AppHost starting it successfully. + var client = fixture.CreateHttpClient(Platform.ResourceKits.API, "http"); var response = await client.GetAsync(new Uri("/health", UriKind.Relative), cancellationToken); await Assert.That(response.StatusCode).IsEqualTo(System.Net.HttpStatusCode.OK);