# Aspire ResourceKit
> Strongly typed, source-generated resource kits for .NET Aspire.
A source-generator-powered framework for structuring .NET Aspire AppHost resource composition as strongly typed, test-friendly classes. Generated host wiring, typed options, and enablement toggles keep large AppHosts maintainable and discoverable.
- Repository: https://github.com/purview-dev/aspire-resourcekit
- Package: https://www.nuget.org/packages/Purview.Aspire.ResourceKit
- Project page: https://purview.dev/projects/aspire-resourcekit/
- Documentation: https://purview.dev/docs/aspire-resourcekit/
- Full machine-readable content: https://purview.dev/projects/aspire-resourcekit/llms-full.txt
# Getting started
This guide walks through a minimal host + resource setup using source generation. It assumes you have
an Aspire AppHost project and a service project ready to compose.
:::tip
For a quick jump across lifecycle concepts, see [Lifecycle: Build vs Configure](lifecycle-build-configure/)
and [Enablement](enablement/).
:::
## Install the package
Install the `Purview.Aspire.ResourceKit` package in your AppHost project.
```bash
dotnet add package Purview.Aspire.ResourceKit
```
:::tip
This package can include one or more bundled [Agent Skills](https://agentskills.io/).
On build, `skills/**/SKILL.md` entries are copied to `.agents/skills/**` in the consuming repository
(for example: `skills/aspire-apphost-to-resourcekit/SKILL.md` →
`.agents/skills/aspire-apphost-to-resourcekit/SKILL.md`), along with a local `.gitignore` in each
generated skill folder to keep updates out of source control noise.
To opt out, set `false` in your project
(or `Directory.Build.props`). See [Bundled Agent Skills](agent-skills/).
:::
## Define a host kit
Create a partial class and annotate it with `[HostKit]`.
```csharp
using Purview.Aspire.ResourceKit;
[HostKit]
partial class ShopHostKit;
```
## Define one or more resources
Create a partial resource class per resource and annotate it with `[ResourceDefinition]`.
```csharp
using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
using Purview.Aspire.ResourceKit;
[ResourceDefinition("api")]
partial class ApiResourceKit
{
protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder)
=> builder.AddProject(Name);
}
```
:::note
For a project resource you can use the Aspire-generated project reference type
(`Projects.Example_Service`, the type accepted by `AddProject()`) instead of the concrete
`ProjectResource`. The generator maps it to `ProjectResource` for the generated base class, and the
analyzer validates that `BuildResource` adds the same project via `AddProject()` (SG0018) and that an
explicit base, when used, resolves to `ProjectResource` (SG0019). See
[Project Resources](project-resources/).
:::
### Choose an attribute style
You have two valid styles:
- **Generic style**: `[ResourceDefinition(...)]`
- Preferred for most cases.
- Do not declare an explicit base type.
- **Non-generic style**: `[ResourceDefinition(...)]`
- Requires an explicit compatible base type that provides the resource type.
Example non-generic style:
```csharp
[ResourceDefinition("api")]
partial class ApiResourceKit : ResourceKitBase
{
protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder)
=> builder.AddProject(Name);
}
```
Avoid mixing both styles on the same class. See [Attributes Reference](attributes-reference/) for
the full rules.
## Register generated wiring in AppHost
Call the generated extension method from your AppHost entry point.
```csharp
var builder = DistributedApplication.CreateBuilder(args);
builder.AddAspireResourceKit();
```
The method name can be customized with `HostKitAttribute.ExtensionMethodName`.
## Extend generated typed options
ResourceKit generates typed options as nested `sealed partial` classes, so you can extend them with
your own settings. Host options are extended on the host kit; resource options are extended on each
resource kit.
```csharp
[ResourceDefinition("api")]
sealed partial class ApiResourceKit
{
partial class ApiResourceKitOptions
{
public string PublishEnvironmentVariableName { get; set; } = "PUBLISH_MARKER";
}
}
```
See [Configuration and Options](configuration-and-options/) for the full pattern.
## Understand Build vs Configure
ResourceKit has two lifecycle pairs:
- `Build` / `BuildResource(...)` builds the resource itself.
- `Configure` / `ConfigureResource()` wires resources together after build.
Think of it as:
- **Build phase**: create each resource.
- **Configure phase**: connect the created resources.
See [Lifecycle: Build vs Configure](lifecycle-build-configure/) and
[Enablement](enablement/) before adding cross-resource dependencies.
## Expand incrementally
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.
For configuration details, continue with [Configuration and Options](configuration-and-options/).
---
# Attributes reference
ResourceKit ships two public attributes in the `Purview.Aspire.ResourceKit` namespace. Both are
generated into the consuming compilation by the bundled source generator, so you do not reference a
separate attribute package.
## `HostKitAttribute` (`[HostKit]`)
Marks the single host kit class per compilation. Exactly one host kit may exist per compilation
(SG0004).
```csharp
[HostKit]
sealed partial class ShopHostKit;
```
Optional named arguments:
| Argument | Type | Default | Purpose |
| --- | --- | --- | --- |
| `Name` | `string?` | `null` | Controls generated naming. |
| `ExtensionMethodName` | `string?` | Derived from host kit name | Overrides the generated builder extension name (for example `AddAspireResourceKit()`). |
| `GenerateOptions` | `bool` | `true` | Enables/disables generated host options. |
## `ResourceDefinitionAttribute` (`[ResourceDefinition]`)
Marks a resource kit class that participates in generation.
```csharp
[ResourceDefinition("api", PropertyName = "API")]
partial class APIResourceKit;
```
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). |
| `AspireResourceType` | generic type argument | `null` | The Aspire `IResource` type (generic style only). |
## `ResourceDefinition` vs `ResourceDefinition`
ResourceKit supports two declaration styles with different base-type behavior.
### Generic attribute (recommended)
Use `[ResourceDefinition]` when you want the resource type declared directly on the
attribute.
```csharp
[ResourceDefinition("api")]
partial class APIResourceKit
{
protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder)
=> builder.AddProject(Name);
}
```
- Do **not** declare an explicit base type on the class (SG0015).
- The generator supplies the host-specific base in generated partial code.
The type argument may be:
- an Aspire resource type implementing `IResource` (used as-is), or
- an `IProjectMetadata` type (the type accepted by `AddProject()`), which is mapped to
`ProjectResource` for the generated base class.
Anything else is rejected (SG0016).
### Non-generic attribute
Use `[ResourceDefinition]` when you prefer (or need) to specify the resource type through an explicit
base type.
```csharp
[ResourceDefinition("api")]
partial class APIResourceKit : ResourceKitBase
{
protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder)
=> builder.AddProject(Name);
}
```
- You **must** declare an explicit valid base type (SG0014).
- The base must derive from the generated `ResourceKitBase` or the runtime
`ResourceKitBase` (SG0006).
### Rules summary
| Situation | Diagnostic | Severity |
| --- | --- | --- |
| Both styles on one class | SG0013 | Error |
| Generic style with explicit base | SG0015 | Error |
| Non-generic style without explicit base | SG0014 | Error |
| Explicit base not derived from a valid Resource Kit base | SG0006 | Error |
| Resource type cannot be inferred/found | SG0016 | Error |
## Constructors
Attributed classes must not declare constructors with parameters or executable constructor bodies
(SG0012). The generator supplies the constructor wiring in generated partial code; the primary
constructor of a resource kit receives the host kit and options. In the manual (non-generated) pattern
you declare it yourself — see [Examples](../examples/).
## See also
- [Generated Output](../generated-output/) — what the generator emits from these attributes.
- [Diagnostics](../diagnostics/) — full diagnostic reference.
---
# Generated output
From your `[HostKit]` and `[ResourceDefinition]` declarations, the generator emits all of the
boilerplate that wires your resource kits together. The generated code is marked with
`[CompilerGenerated]` and `[GeneratedCode("HostKitGenerator", ...)]` and is excluded from code coverage.
## What gets generated
- a host resource base class (the concrete host kit deriving from `HostKitBase`),
- resource properties on the host (one per resource definition),
- host + per-resource options (when `GenerateOptions` is enabled),
- a typed `ResourceKitBase` base per host kit,
- each resource kit's `sealed partial` skeleton with a constructor and `Options` property,
- an AppHost extension method to build/configure/register the host kit.
## Host kit members
For every `[ResourceDefinition]`, the host kit gets a lazily initialized property. Accessing a
property before `Build` throws, and setting it twice throws:
```csharp
public Purview.Aspire.ResourceKit.Example.AppHost.AppModels.Resources.ExampleAPIKit ExampleAPI
{
get
{
return field ?? throw new global::System.InvalidOperationException(
"The 'ExampleAPI' resource has not been initialized. Call Build first.");
}
private set { ... }
}
```
## 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)`:
```csharp
public override void Build(global::Aspire.Hosting.IDistributedApplicationBuilder builder)
{
// Creating ExampleAPIKit Resource Kit.
ExampleAPI = new(this, Options.ExampleAPI);
// Register the discovered app resources with the base class.
AddResource(ExampleAPI);
// Now the additional post-build func builder
onBuilt?.Invoke(this, builder);
base.Build(builder);
}
public override void Configure()
{
base.Configure();
onConfigured?.Invoke(this);
}
```
## Generated resource kit skeleton
Each resource kit derives from the generated `ResourceKitBase` and gains a constructor that
accepts the host kit and its options:
```csharp
internal sealed partial class ExampleAPIKit : global::Purview.Aspire.ResourceKit.ResourceKitBase
{
public ExampleAPIKit(ExampleHostKit hostKit, ExampleAPIKitOptions options)
: base(hostKit, (options ?? throw new global::System.ArgumentNullException(nameof(options))).Name)
{
Options = options;
IsEnabled = options.IsEnabled;
}
public ExampleAPIKitOptions Options { get; }
}
```
Your `partial class` supplies `BuildResource(...)` and optionally `ConfigureResource()` and
`IsResourceEnabled(builder)`.
## Generated options
Host options are nested under the host kit and expose a `SectionName` constant plus one nested options
object per resource:
```csharp
public sealed partial class ExampleHostKitOptions
{
public const string SectionName = "ExampleHostKit";
public ExampleAPIKitOptions ExampleAPI { get; init; } = new();
}
```
Each resource options type carries `Name` (defaulting to the logical resource name) and `IsEnabled`:
```csharp
public sealed partial class ExampleAPIKitOptions
{
[global::System.ComponentModel.DataAnnotations.Required(AllowEmptyStrings = false)]
public string Name { get; set; } = "api";
public bool IsEnabled { get; set; } = true;
}
```
`Name` is decorated with `[Required(AllowEmptyStrings = false)]`; the generated extension method calls
`ValidateOnStart()`.
## Generated extension method
The extension method binds host options from configuration, creates the host kit, and runs the full
lifecycle:
```csharp
public static global::Aspire.Hosting.IDistributedApplicationBuilder AddAspireResourceKit(
this global::Aspire.Hosting.IDistributedApplicationBuilder builder,
global::System.Action? onBuilt = null,
global::System.Action? onConfigured = null,
global::System.Action>? configureOptions = null
)
{
var optionsBuilder = builder.Services
.AddOptions()
.BindConfiguration(ExampleHostKitOptions.SectionName);
configureOptions?.Invoke(optionsBuilder);
optionsBuilder.ValidateOnStart();
var hostKitOptions = builder.Configuration
.GetSection(ExampleHostKitOptions.SectionName)
.Get() ?? new();
ExampleHostKit hostKit = new(onBuilt, onConfigured, hostKitOptions);
hostKit.Build(builder);
hostKit.Configure();
builder.Services.AddSingleton(hostKit);
return builder;
}
```
The extension method name defaults to `Add()` (for example `AddAspireResourceKit()` for
`ExampleHostKit`) and can be overridden with `HostKitAttribute.ExtensionMethodName`.
## See also
- [Attributes Reference](../attributes-reference/)
- [Lifecycle: Build vs Configure](../lifecycle-build-configure/)
- [Configuration and Options](../configuration-and-options/)
---
# Lifecycle: Build vs Configure
ResourceKit executes resources in a predictable sequence so dependency flow stays explicit and easy to
reason about.
## Runtime order
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.
This happens before `DistributedApplication.Build()` completes.
## The two lifecycle pairs
- `Build` / `BuildResource(...)`: **construct this resource** (`AddProject`, `AddRedis`,
`AddAzureStorage`, and so on).
- `Configure` / `ConfigureResource()`: **attach resources to each other** after construction
(references, bindings, cross-resource wiring).
The separation keeps creation and cross-resource wiring explicit and deterministic.
## How the generated host kit drives the lifecycle
The generated host kit overrides `Build` and `Configure`:
- `Build` creates each discovered resource kit from its options, registers all of them with the base
class via `AddResource`, invokes an optional `onBuilt` callback, and then calls `base.Build(builder)`,
which runs `BuildResource(...)` for each resource.
- `Configure` calls `base.Configure()` first (running each resource's `ConfigureResource()`), then
invokes an optional `onConfigured` callback.
The extension method signature gives you hooks for extra wiring:
```csharp
builder.AddAspireResourceKit(
onBuilt: (hostKit, builder) => { /* after resources are created, before Configure */ },
onConfigured: hostKit => { /* after all resources are configured */ },
configureOptions: optionsBuilder => { /* additional options configuration */ }
);
```
`HostKitBase` (the runtime base of the generated host kit) enforces that resources cannot be
added after `Build` seals the resource list, keeping the lifecycle single-run and deterministic.
## Build vs Configure per resource
Each resource kit implements `IResourceKit`:
- `Build(IDistributedApplicationBuilder builder)` calls your `BuildResource(...)` override to
**construct** the resource and store the resulting `IResourceBuilder` in
`ResourceBuilder`.
- `Configure()` calls your `ConfigureResource()` override to **attach** resources to each other after
construction.
A common Configure example that connects an API to its dependencies:
```csharp
protected override void ConfigureResource()
{
ResourceBuilder.WithReference(HostKit.Postgres.Database).WaitFor(HostKit.Postgres.Database);
ResourceBuilder.WithReference(HostKit.AzureStorage.Blobs).WaitFor(HostKit.AzureStorage.Blobs);
if (HostKit.Redis.IsEnabled)
ResourceBuilder.WithReference(HostKit.Redis).WaitFor(HostKit.Redis);
}
```
Because `ConfigureResource()` runs after every resource is built, you can safely reach other resources
through the host kit's generated properties.
## Enablement gates both phases
Before `BuildResource(...)` runs, ResourceKit checks whether the resource should participate:
- If `IsEnabled` is `false`, both `BuildResource(...)` and `ConfigureResource()` are skipped.
- During `Build`, `IsResourceEnabled(builder)` is evaluated (only when `IsEnabled` is already `true`)
and its result is assigned back to `IsEnabled`.
See [Enablement](../enablement/) for the full model.
## See also
- [Generated Output](../generated-output/)
- [Enablement](../enablement/)
---
# Enablement: IsEnabled vs IsResourceEnabled
ResourceKit supports flexible runtime enablement through two related members.
## The two toggles
- `IsEnabled` — the current enablement flag, usually sourced from generated options. This is the
persisted/configured toggle.
- `IsResourceEnabled(builder)` — a runtime decision hook. Its default implementation returns
`IsEnabled`, and you override it when enablement should react to runtime conditions.
## How they interact
At runtime, `Build` evaluates enablement *before* resource construction:
1. If `IsEnabled` is `true`, ResourceKit calls `IsResourceEnabled(builder)` and assigns the result
back to `IsEnabled`.
2. If the resulting `IsEnabled` is `false`, both `BuildResource(...)` and `ConfigureResource()` are
skipped for that resource.
Two important consequences:
- The hook is only invoked when `IsEnabled` is already `true`. A kit disabled via `IsEnabled=false`
(for example from options) is **never** re-enabled by the hook.
- `ResourceBuilder` cannot be accessed while the resource is disabled — doing so throws an
`InvalidOperationException` (the property guards itself).
## When to override `IsResourceEnabled`
Use the hook when enablement depends on runtime state rather than only static options. Common examples
are environment-specific availability, publish mode, or dynamic configuration checks.
```csharp
protected override bool IsResourceEnabled(IDistributedApplicationBuilder builder)
{
// Example: allow config + environment based behavior.
var isEnabledInConfig = IsEnabled;
var isProd = builder.Configuration["ASPNETCORE_ENVIRONMENT"] == "Production";
return isEnabledInConfig && isProd;
}
```
Publish-only resources are a typical pattern. The example hosts run Key Vault and a publish marker
parameter only when publishing:
```csharp
protected override bool IsResourceEnabled(IDistributedApplicationBuilder builder) =>
builder.ExecutionContext.IsPublishMode;
```
## Disabling a resource from options
Because `IsEnabled` is bound from generated options, a resource can be disabled without code changes
by setting the options section in configuration:
```json
{
"ShopHostKit": {
"Redis": {
"IsEnabled": false
}
}
}
```
See [Configuration and Options](../configuration-and-options/) for the generated options shape.
## See also
- [Lifecycle: Build vs Configure](../lifecycle-build-configure/)
- [Configuration and Options](../configuration-and-options/)
---
# Configuration and options
`Purview.Aspire.ResourceKit` can generate host and resource options types so you can control names and
enablement without changing code.
## Generated options shape
A host options type contains one nested options object per resource.
```csharp
public class ShopHostKitOptions
{
public APIKitOptions Api { get; set; } = new();
public RedisKitOptions Redis { get; set; } = new();
}
public class APIKitOptions
{
public string Name { get; set; } = "api";
public bool IsEnabled { get; set; } = true;
}
...
```
The generated host options type exposes a `SectionName` constant that is used to bind configuration:
```csharp
public const string SectionName = "ShopHostKit";
```
Generated options are emitted as nested `sealed partial` classes. You can extend both host and resource
option types with custom properties.
## Extend host and resource typed options
### Extend host options
```csharp
[HostKit]
partial class ShopHostKit
{
public sealed partial class ShopHostKitOptions
{
public bool EnablePreviewResources { get; set; }
}
}
```
### Extend resource options
```csharp
[ResourceDefinition("api")]
sealed partial class APIKit
{
partial class APIKitOptions
{
public string PublishEnvironmentVariableName { get; set; } = "PUBLISH_MARKER";
}
}
```
> Tip: keep custom option members `public` with `get; set;` so configuration binding can populate them.
> Data annotations such as `[Required]` are honored by the generated `ValidateOnStart()` registration.
### Use extended options at runtime
- Access host-level values through `HostKit.Options`.
- Access resource-level values through `Options` in each resource kit.
Example:
```csharp
protected override void ConfigureResource()
{
if (HostKit.Options.EnablePreviewResources)
{
ResourceBuilder.WithEnvironment(Options.PublishEnvironmentVariableName, "true");
}
}
```
## Common configuration keys
Typical keys (example):
- `ShopHostKit:API:Name`
- `ShopHostKit:API:IsEnabled`
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.
## Disable a resource
Set `IsEnabled=false` for that resource's options section.
```json
{
"ShopHostKit": {
"Redis": {
"IsEnabled": false
}
}
}
```
## `IsEnabled` vs `IsResourceEnabled(...)`
Use both together for flexible control:
- `IsEnabled`: static/configured toggle (usually generated options).
- `IsResourceEnabled(builder)`: runtime decision hook.
At runtime, `Build` only calls `IsResourceEnabled(builder)` when `IsEnabled` is already `true`. If the
hook returns `false`, ResourceKit skips both `BuildResource(...)` and `ConfigureResource()` for that
resource. A kit disabled via `IsEnabled=false` is never re-enabled by the hook.
See [Enablement](../enablement/) for details.
## OptionsHelper for tests and overrides
For tests and scenario toggles, `OptionsHelper` converts typed assignments into command-line arguments
or environment variables. It can also flatten a populated options object into ASP.NET Core binder-style
environment variables:
```csharp
var args = OptionsHelper.Assign(
c => c.API.IsEnabled = false,
c => c.API.Name = "api-test"
).Build();
```
See [OptionsHelper](../optionshelper/) for the complete API, including `PathFor`, `SectionNameFor`, and
the `SG0020` single-property-path rule.
For resource wiring, `IResourceBuilder.WithEnvironment(options)` now accepts a populated options object
and emits environment variables using the same binder-style key convention.
## Section-name resolution
Generated options types carry a `SectionName` constant (for example `"ShopHostKit"`). When you do not
pass a section name explicitly, `OptionsHelper` resolves it as follows:
1. `const string SectionName` on the options type
2. Type name trimmed by one suffix: `Options`, `Settings`, `Configuration`, `Config`
3. Original type name
You can look up the resolved section name with
[`OptionsHelper.SectionNameFor`](../optionshelper/#sectionnamefor).
## Tip
Prefer resource-level toggles over conditional host code. It keeps the composition model declarative
and testable.
---
# OptionsHelper
`OptionsHelper` builds configuration arguments or environment variables for options objects by using
strongly typed assignment expressions or by flattening a populated options object. It is useful for
integration-test fixtures, scenario toggles, and CLI overrides.
## Assign
`Assign(...)` starts building entries from assignment expressions.
```csharp
var args = OptionsHelper.Assign(
c => c.API.IsEnabled = false,
c => c.API.Name = "api-test"
).Build();
```
Resulting args are in this form:
- `--ShopHostKit:API:IsEnabled=false`
- `--ShopHostKit:API:Name=api-test`
Each assignment action must set exactly one property path. To set multiple properties, pass one
assignment per property (as above). The compiler reports `SG0020` if an action assigns more than one
property path, and offers a code fix that splits it into separate assignments.
You can also pass an explicit root section name:
```csharp
OptionsHelper.Assign("MySection", c => c.API.IsEnabled = false);
```
## AsEnvironmentVariables
Call `AsEnvironmentVariables()` before `Build()` to produce environment variables instead:
```csharp
var envVars = OptionsHelper.Assign(
c => c.API.IsEnabled = false,
c => c.API.Name = "api-test"
).AsEnvironmentVariables().Build();
```
This returns a dictionary such as
`{"ShopHostKit__API__IsEnabled": "false", "ShopHostKit__API__Name": "api-test"}`.
## Environment
Call `Environment(...)` to flatten a populated options object into ASP.NET Core binder-style
environment variables:
```csharp
var envVars = OptionsHelper.Environment(new ShopOptions
{
Api = new ApiOptions
{
Name = "api-test",
Enabled = true,
},
ReplicaNames = ["a", "b"],
Labels =
{
["region"] = "west",
},
})
.Override(static o => o.Api.Name = "api-prod")
.Ignore(static o => o.Labels)
.Build();
```
The output uses binder-style keys such as:
- `Shop__Api__Name`
- `Shop__Api__Enabled`
- `Shop__ReplicaNames__0`
- `Shop__ReplicaNames__1`
- `Shop__Labels__region`
Use `Override(...)` to replace selected values before emission, and `Ignore(...)` to drop selected
properties from the flattened output.
The dictionary returned by `Build()` can be passed straight to a resource:
```csharp
ResourceBuilder.WithEnvironment(OptionsHelper.Environment(Options).Build());
```
`IResourceBuilder.WithEnvironment(IReadOnlyDictionary)` applies the entries directly, so
the keys are preserved verbatim (for example `Services__ServiceName`).
## PathFor
If you need a property path as a plain string (for example, to build keys or log config), use
`PathFor` with a member selector:
```csharp
var path = OptionsHelper.PathFor(f => f.API.Name);
// "API.Name"
```
## SectionNameFor
Look up the resolved section name for any options type directly:
```csharp
var sectionName = OptionsHelper.SectionNameFor();
// "ShopHostKit"
var sectionNameFromType = OptionsHelper.SectionNameFor(typeof(ShopHostKit.ShopHostKitOptions));
// "ShopHostKit"
```
`SectionNameFor` accepts both a generic type argument and a `Type`, and applies the same resolution
rules as `Assign`:
1. `const string SectionName` on the options type
2. Type name trimmed by one suffix: `Options`, `Settings`, `Configuration`, `Config`
3. Original type name
Suffix trimming works for generic types by ignoring type arguments.
## TUnit.Aspire integration
`OptionsHelper` pairs well with [TUnit.Aspire](https://www.nuget.org/packages/TUnit.Aspire/) when
passing args to an AppHost under test:
```csharp
protected override string[] Args =>
[
.. base.Args,
.. OptionsHelper.Assign(
c => c.API.IsEnabled = false,
c => c.API.Name = "api-test"
).Build(),
];
```
See [Testing with ResourceKit](../testing-with-resourcekit/).
## The SG0020 rule
An `OptionsHelper.Assign(...)` action must set exactly one property path. A block-bodied
lambda such as the following is rejected at compile time:
```csharp
OptionsHelper.Assign(o =>
{
o.API.IsEnabled = false;
o.API.Name = "api-test";
});
```
Split each property into its own assignment argument instead:
```csharp
OptionsHelper.Assign(
o => o.API.IsEnabled = false,
o => o.API.Name = "api-test"
);
```
Visual Studio offers a **"Split into separate assignments"** code fix that performs this conversion for
you. See [Source Generator Behaviors](../source-generator-behaviors/) for how the fix works.
## See also
- [Configuration and Options](../configuration-and-options/)
- [Testing with ResourceKit](../testing-with-resourcekit/)
---
# Project resources
ResourceKit has first-class support for Aspire project resources (`AddProject()`) with generator
and analyzer validation to catch wiring mistakes.
## Declaring a project resource kit
Use the Aspire-generated project reference type directly in the attribute. The generator maps it to
`ProjectResource` for the generated base class:
```csharp
using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
using Purview.Aspire.ResourceKit;
[ResourceDefinition("api")]
partial class ApiResourceKit
{
protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder)
=> builder.AddProject(Name);
}
```
`Projects.Example_Service` is the type generated by the Aspire SDK into the AppHost. It implements
`IProjectMetadata` (the type accepted by `AddProject()`). When the type argument implements
`IProjectMetadata`, ResourceKit:
- validates the resource type (rather than rejecting it),
- maps it to `ProjectResource` for the generated base class,
- enables the two project-specific execution-only rules below.
## The declared project must be added (SG0018)
`BuildResource` must register the same project it declares via `AddProject()`.
```csharp
// OK
protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder)
=> builder.AddProject(Name);
```
```csharp
// SG0018 (warning): the declared project is never registered at runtime.
[ResourceDefinition("api")]
partial class ApiResourceKit
{
protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder)
=> builder.AddContainer("api", "my-image");
}
```
SG0018 is an **execution-only** warning: generation still proceeds so you can complete the override
without losing the host kit output. At runtime the declared project is not registered, so the resource
fails.
## Explicit base must use ProjectResource (SG0019)
When a project resource kit declares an explicit base class (non-generic attribute style), the base
must resolve to `ProjectResource`.
```csharp
[ResourceDefinition("api")]
partial class ApiResourceKit : ResourceKitBase
{
protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder)
=> builder.AddProject(Name);
}
```
If the explicit base resolves to a different resource type, SG0019 is reported (execution-only
warning) because that base cannot build the declared project.
## Generic vs non-generic styles
- **Generic style** (`[ResourceDefinition(...)]`) is recommended for project
resources: the type is declared on the attribute and the base is supplied by the generator.
- **Non-generic style** (`[ResourceDefinition(...)]` + explicit base) requires the base to use
`ProjectResource` (SG0019).
See [Attributes Reference](../attributes-reference/) for the base-type rules shared by all resources.
## See also
- [Attributes Reference](../attributes-reference/)
- [Diagnostics](../diagnostics/)
---
# Diagnostics
ResourceKit reports diagnostics with `SGxxxx` IDs to help you fix model issues quickly. The rules are
evaluated by the bundled `ResourceKitDiagnosticAnalyzer` (and the source generator uses the same shared
rule set to decide what can be generated).
ResourceKit also ships `ResourceKitDiagnosticSuppressor`, which automatically suppresses `CS8618` for
non-nullable `IResourceBuilder` properties declared on resource kits — these are populated at runtime
during the `BuildResource`/`ConfigureResource` lifecycle, so the "must contain a non-null value when
exiting the constructor" warning does not apply.
## Diagnostic reference
| ID | Severity | What it means |
| - | - | - |
| SG0001 | Error | A participating class must be `partial` |
| SG0002 | Info | Host kit exists but no resources were defined |
| SG0003 | Warning | Resources exist but no host kit was defined |
| SG0004 | Error | More than one `[HostKit]` class was found |
| SG0005 | Error | Two resources map to the same generated property name |
| SG0006 | Error | A resource does not derive from the expected generated base |
| SG0007 | Error | Resource name could not be inferred and `Name` was not set |
| SG0008 | Error | Explicit `PropertyName` is not a valid C# identifier |
| SG0009 | Error | Missing `IServiceCollection` dependency |
| SG0010 | Error | Missing configuration binder dependency |
| SG0011 | Error | Missing options configuration extensions dependency |
| SG0012 | Error | Non-empty constructors are not supported on attributed classes |
| SG0013 | Error | Mixed `ResourceDefinition` and `ResourceDefinition` usage on one class |
| SG0014 | Error | Non-generic `ResourceDefinition` requires explicit compatible base type |
| SG0015 | Error | Generic `ResourceDefinition` cannot declare explicit base type |
| SG0016 | Error | No Aspire resource type could be inferred/found |
| SG0017 | Warning | An `IResourceBuilder` property is never assigned in `BuildResource` or `ConfigureResource` |
| 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/) for the rules tied to attribute styles, and
[Project Resources](../project-resources/) for SG0018/SG0019 specifics.
## Execution-only vs generation-blocking rules
SG0017, SG0018, and SG0019 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.
## `OptionsHelper.Assign` action with multiple property paths (SG0020)
Each `OptionsHelper.Assign(...)` action must set exactly one property path. A block-bodied lambda
such as the following is rejected at compile time:
```csharp
OptionsHelper.Assign(o =>
{
o.API.IsEnabled = false;
o.API.Name = "api-test";
});
```
Split each property into its own assignment argument instead:
```csharp
OptionsHelper.Assign(
o => o.API.IsEnabled = false,
o => o.API.Name = "api-test"
);
```
Visual Studio offers a **"Split into separate assignments"** code fix that performs this conversion for you.
See [OptionsHelper](../optionshelper/) and [Source Generator Behaviors](../source-generator-behaviors/).
## Fast troubleshooting checklist
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).
4. Ensure each resource uses a compatible base and definition attribute style.
5. Set explicit `Name` when inference cannot determine resource name.
## Common fixes
### Duplicate generated property names (SG0005)
Use unique `PropertyName` values:
```csharp
[ResourceDefinition("api", PropertyName = "Api")]
[ResourceDefinition("admin", PropertyName = "AdminApi")]
```
### Invalid `PropertyName` (SG0008)
Use valid C# identifiers only (`Api`, `RedisCache`, `OrderDb`, etc.).
### Multiple host kits (SG0004)
Keep one `[HostKit]` per compilation; split scenarios into separate projects if needed.
### Mixed attribute styles (SG0013)
Use exactly one style per class:
- `[ResourceDefinition("name")]`
- or `[ResourceDefinition("name")]`
Do not apply both to the same class.
### Base-type mismatch by style (SG0014 / SG0015)
- If you use non-generic `[ResourceDefinition("name")]`, declare an explicit compatible base.
- If you use generic `[ResourceDefinition("name")]`, do not declare an explicit base.
### Unassigned `IResourceBuilder` property (SG0017)
Every `IResourceBuilder` property on a resource kit (nullable or not) must be assigned in either
`BuildResource` or `ConfigureResource`. `CS8618` is automatically suppressed for these properties because
they are populated at runtime, so SG0017 is the signal that a property is never set:
```csharp
[ResourceDefinition("sql")]
sealed partial class SqlServerKit
{
public IResourceBuilder Database { get; private set; }
protected override void ConfigureResource()
{
Database = ResourceBuilder.AddDatabase("changeops-db", "ChangeOps");
base.ConfigureResource();
}
}
```
---
# Testing with ResourceKit
ResourceKit is designed to be test-friendly: resource composition is split into focused classes, and
generated options give you a typed way to override names and enablement from tests.
## Test project layout
The repository uses [TUnit](https://thomhurst.github.io/TUnit/) with the
[TUnit.Aspire](https://www.nuget.org/packages/TUnit.Aspire/) integration. Test projects live under
`src/tests`:
- `ResourceKit.UnitTests` — runtime behavior of the kit base types and `OptionsHelper`.
- `ResourceKit.IntegrationTests` — starts the example AppHosts and asserts against real resources.
- `SourceGeneration.UnitTests` / `SourceGeneration.IntegrationTests` — generator output, diagnostics,
suppression, code fixes, and caching.
Reports are written to `TestResults/`.
## Integration-testing an AppHost
Create an `AspireFixture` for the AppHost under test:
```csharp
using TUnit.Aspire;
namespace Purview.Aspire.ResourceKit.Fixtures;
public sealed class ExampleAppHostFixture : AspireFixture
where TAppHost : class;
```
Then drive it from a TUnit test class:
```csharp
using Projects;
[ClassDataSource>(Shared = SharedType.PerTestSession)]
public sealed class ExampleAppHostIntegrationTests(ExampleAppHostFixture fixture)
{
[Test]
public async Task AppHost_WhenServicesStarted_APIIsHealthy(CancellationToken cancellationToken)
{
var client = fixture.CreateHttpClient("api");
var response = await client.GetAsync(new Uri("/health", UriKind.Relative), cancellationToken);
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
}
}
```
`TUnit.Aspire` wires the AppHost lifecycle (start/stop) around the fixture. The example API exposes a
`/health` endpoint for this purpose.
## Overriding options from tests
The generated extension method binds host options from configuration, so you can pass command-line
arguments through the fixture to toggle resources. `OptionsHelper` generates these arguments from
strongly typed assignments:
```csharp
using TUnit.Aspire;
public sealed class CustomOptionsExampleAppHostFixture : AspireFixture
{
public const string AzureStorageName = "custom-options-azure-storage-example";
protected override string[] Args =>
[
"--ExampleHostKit:Redis:IsEnabled=false",
$"--ExampleHostKit:AzureStorage:Name={AzureStorageName}",
];
}
```
The test then asserts the options took effect — Redis is disabled (no connection string), and Azure
Storage is registered under the custom name:
```csharp
[Test]
public async Task AppHost_WithCustomOptions_IsPassedToTheHostKit(CancellationToken cancellationToken)
{
await Helpers.ConnectionStringIsUnavailableAsync(fixture, "redis", cancellationToken);
await Assert
.That(fixture.GetResourceSnapshot(CustomOptionsExampleAppHostFixture.AzureStorageName))
.IsNotNull()
.Because("The custom Azure Storage name should be passed to the host kit.");
}
```
Prefer `OptionsHelper.Assign(...)` for typed, refactor-safe args:
```csharp
protected override string[] Args =>
[
.. base.Args,
.. OptionsHelper.Assign(
c => c.Redis.IsEnabled = false,
c => c.AzureStorage.Name = "custom-options-azure-storage-example"
).Build(),
];
```
See [OptionsHelper](../optionshelper/) for `Assign`, `AsEnvironmentVariables`, and the `SG0020`
single-property-path rule.
If you already have a populated options object, use `OptionsHelper.Environment(...)` to flatten it into
configuration-style environment variables and selectively override or ignore members before emission.
## Unit-testing the manual pattern
The manual host pattern (see [Examples](../examples/)) composes `ResourceKitBase`
without generation, which makes the lifecycle directly unit-testable: instantiate a kit, call
`Build(builder)`/`Configure()`, and assert on `ResourceBuilder` and option-driven behavior.
## Running tests
Unit tests are filtered with the TUnit tree-node filter:
```bash
just test "/*/*/*/*[Category=Unit]"
```
Integration tests (which require Docker/Testcontainers) run locally:
```bash
just test
```
See [Contributing](../contributing/) and [Release Flow](../release-flow/) for the CI filter and local
workflow.
---
# Examples: Generated vs Manual
The repository ships two example AppHosts that compose the same set of resources in two different
styles:
- `src/src/Example.AppHost` — the **source-generated** pattern (attributes, no hand-written wiring).
- `src/src/Example.ManualAppHost` — the **manual** pattern (hand-written `HostKitBase` and
`ResourceKitBase` composition, no generator).
Both hosts build the same logical resources through `Purview.Aspire.ResourceKit.Example` constants in
`Example.ServiceDefaults/Platform.cs`:
| Resource | Name | Aspire type |
| --- | --- | --- |
| Postgres | `postgres` (+ `db` database) | `AzurePostgresFlexibleServerResource` |
| Azure Storage | `azure-storage` (+ `blob`) | `AzureStorageResource` |
| Redis | `redis` | `AzureManagedRedisResource` |
| Key Vault | `kv` | `AzureKeyVaultResource` |
| API | `api` | `ProjectResource` (`Projects.Example_Service`) |
| Publish marker | `publish-marker` | `ParameterResource` |
Key Vault and the publish marker are publish-only (`IsResourceEnabled` returns
`builder.ExecutionContext.IsPublishMode`).
## Generated pattern (`Example.AppHost`)
`AppHost.cs` registers everything with a single generated call:
```csharp
var builder = DistributedApplication.CreateBuilder(args);
builder.AddAspireResourceKit();
var app = builder.Build();
await app.RunAsync();
```
The host kit is a one-line attribute declaration:
```csharp
[HostKit]
sealed partial class ExampleHostKit;
```
Each resource is a `[ResourceDefinition]` partial class that supplies the overrides. For
example, the API kit also wires dependencies in `ConfigureResource()` and can now forward a populated
options object to a destination resource:
```csharp
[ResourceDefinition(Platform.ResourceKits.API)]
sealed partial class ExampleAPIKit
{
protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) =>
builder.AddProject(Name).WithUrl("/health", "Health");
protected override void ConfigureResource()
{
if (HostKit.PublishMarker.IsEnabled)
ResourceBuilder.WithEnvironment(Options.PublishEnvironmentVariableName, HostKit.PublishMarker);
ResourceBuilder.WithEnvironment(
OptionsHelper.Environment(
new DemoServiceEnvironmentOptions
{
Service = new()
{
Name = Name,
Enabled = true,
Labels =
{
["region"] = "west",
},
},
Replicas = [1, 2, 3],
Routes =
{
["health"] = "/health",
},
}
)
.Override(static o => o.Service.Name = "api")
.Ignore(static o => o.Service.Labels)
.Build()
);
ResourceBuilder.WithReference(HostKit.Postgres.Database).WaitFor(HostKit.Postgres.Database);
ResourceBuilder.WithReference(HostKit.AzureStorage.Blobs).WaitFor(HostKit.AzureStorage.Blobs);
if (HostKit.KeyVault.IsEnabled)
ResourceBuilder.WithReference(HostKit.KeyVault).WaitFor(HostKit.KeyVault);
if (HostKit.Redis.IsEnabled)
ResourceBuilder.WithReference(HostKit.Redis).WaitFor(HostKit.Redis);
}
partial class ExampleAPIKitOptions
{
[Required(AllowEmptyStrings = false)]
public string PublishEnvironmentVariableName { get; set; } = "PUBLISH_MARKER";
}
sealed class DemoServiceEnvironmentOptions
{
public DemoServiceOptions Service { get; set; } = new();
public List Replicas { get; set; } = [];
public Dictionary Routes { get; set; } = [];
}
sealed class DemoServiceOptions
{
public string Name { get; set; } = string.Empty;
public bool Enabled { get; set; }
public Dictionary Labels { get; set; } = [];
}
}
```
The generator emits the host kit members, options, `ResourceKitBase` base classes, and the
extension method. See [Generated Output](../generated-output/).
## Manual pattern (`Example.ManualAppHost`)
`AppHost.cs` registers a host kit instance directly via the generic extension:
```csharp
builder.AddAspireResourceKit();
```
The host kit is hand-written and composes the resource kits:
```csharp
sealed class ExampleHostKit : HostKitBase
{
public AzureStorageKit AzureStorage { get; init; }
public ExampleAPIKit ExampleAPI { get; init; }
// ...
public ExampleHostKit()
{
AzureStorage = new(this);
ExampleAPI = new(this);
// ...
AddResource(AzureStorage);
AddResource(ExampleAPI);
// ...
}
}
```
Each resource kit is a hand-written partial class deriving from the runtime base, with a primary
constructor that takes the host kit:
```csharp
sealed partial class ExampleAPIKit(ExampleHostKit hostKit)
: ResourceKitBase(hostKit, Platform.ResourceKits.API)
{
protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) =>
builder.AddProject(Name);
protected override void ConfigureResource()
{
if (HostKit.PublishMarker.IsEnabled)
ResourceBuilder.WithEnvironment("PUBLISH_MARKER", HostKit.PublishMarker);
ResourceBuilder.WithReference(HostKit.Postgres.Database).WaitFor(HostKit.Postgres.Database);
// ...
}
}
```
## Choosing between the two
| Consideration | Generated | Manual |
| --- | --- | --- |
| Boilerplate | Minimal (attributes + overrides) | Explicit (base classes, constructors, `AddResource`) |
| Options | Generated, typed, bindable | You manage options yourself |
| Diagnostics | Analyzer checks model rules | No generator diagnostics |
| Testability | Both test well | Lifecycle is directly unit-testable |
| Best for | Most AppHosts | Learning the model, or when you want full control |
Both patterns produce the same runtime behavior: instantiate resources, `Build` enabled resources,
`Configure` enabled resources.
## See also
- [Getting Started](../)
- [Attributes Reference](../attributes-reference/)
- [Generated Output](../generated-output/)
- [Lifecycle: Build vs Configure](../lifecycle-build-configure/)
---
# Bundled agent skills
`Purview.Aspire.ResourceKit` ships one or more bundled [Agent Skills](https://agentskills.io/) that
are auto-installed into consuming repositories so supported coding agents can discover
ResourceKit-specific guidance automatically.
## How auto-install works
When a consuming project builds, any `skills/**/SKILL.md` files in the package are copied to
`.agents/skills/**` in the consuming repository.
Example mapping:
- `skills/aspire-apphost-to-resourcekit/SKILL.md` →
`.agents/skills/aspire-apphost-to-resourcekit/SKILL.md`
A local `.gitignore` is written into each generated skill folder to keep updates out of source control
noise, so the skills stay fresh on rebuild without polluting the repository history.
## Opting out
To disable this behavior, set the shared opt-out property in your project (or `Directory.Build.props`):
```xml
false
```
## Bundled skill
- `aspire-apphost-to-resourcekit` — guides migrating an inline AppHost into ResourceKit composition,
covering attributes, lifecycle, options, and configuration.
---
# Source generator behaviors
The source generator ships alongside the runtime package and is exposed to consuming projects as an
analyzer. This page covers how the generator, analyzers, and code fixes are organized and how they
behave.
## Assembly layout
| Assembly | Contents |
| --- | --- |
| `Purview.Aspire.ResourceKit` | Runtime abstractions (`HostKitBase`, `ResourceKitBase`, `OptionsHelper`, interfaces) |
| `Purview.Aspire.ResourceKit.SourceGeneration` | The `HostKitGenerator` incremental generator, `ResourceKitDiagnosticAnalyzer`, `OptionsHelperAssignAnalyzer`, `ResourceKitDiagnosticSuppressor`, and the shared rule set |
| `Purview.Aspire.ResourceKit.SourceGeneration.CodeFixes` | The `OptionsHelperAssignCodeFixProvider` (separate assembly so the main generator assembly never needs `Microsoft.CodeAnalysis.Workspaces`) |
The generator and code-fix assemblies are packed under `analyzers/dotnet/cs` of the
`Purview.Aspire.ResourceKit` package, so consumers get generation, diagnostics, and IDE code fixes from
a single package reference.
## Incremental generation
`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.
Generation is skipped when the generator is disabled via settings (`IsSourceGeneratorDisabled`).
## Shared rule set
`ResourceKitRules` is the single source of truth for model rules. Both the
`ResourceKitDiagnosticAnalyzer` (which reports diagnostics in the IDE/build) and the generator (which
uses the same rules to decide whether a kit should be generated) evaluate through this shared helper so
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.
See [Diagnostics](../diagnostics/) for the full rule reference.
## `OptionsHelperAssignAnalyzer` (SG0020)
A dedicated analyzer reports SG0020 when an `OptionsHelper.Assign` (or chained `IOptionsBuilder.Assign`)
action is a block-bodied lambda that assigns more than one property path. It matches `Assign` calls by
method name, containing namespace, and the `params Action[]` parameter shape — including the
`sectionName` overload. See [OptionsHelper](../optionshelper/).
## IDE code fix: "Split into separate assignments"
The `OptionsHelperAssignCodeFixProvider` fixes SG0020 by rewriting a block-bodied lambda that assigns
several properties into one assignment argument per property:
```csharp
// Before (SG0020)
OptionsHelper.Assign(o =>
{
o.API.IsEnabled = false;
o.API.Name = "api-test";
});
// After
OptionsHelper.Assign(
o => o.API.IsEnabled = false,
o => o.API.Name = "api-test"
);
```
The fix lives in a separate code-fix assembly and uses a stable equivalence key. The generator assembly
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.
## `ResourceKitDiagnosticSuppressor` (SGSUP0001)
The suppressor removes `CS8618` ("Non-nullable property must contain a non-null value when exiting
constructor") for **non-nullable** `IResourceBuilder` properties declared on resource kits. These
properties are populated at runtime during `BuildResource`/`ConfigureResource`, so the warning does not
apply. Nullable `IResourceBuilder?` properties are left untouched.
A type qualifies as a resource kit when it carries `[ResourceDefinition]`/`[ResourceDefinition]` or
derives from `Purview.Aspire.ResourceKit.ResourceKitBase<,>` (directly or via the generated
`ResourceKitBase`).
## Incremental caching
The generator uses incremental value providers and equatable models so that unrelated edits do not
re-trigger generation. The `GeneratorCachingTests` integration suite verifies that generation is
cached across incremental runs and only recomputed when inputs change.
## See also
- [Generated Output](../generated-output/)
- [Diagnostics](../diagnostics/)
- [Contributing](../contributing/)
---
# Contributing
This page covers the repository layout and the day-to-day commands for working on
`Purview.Aspire.ResourceKit`. It is aimed at contributors, not consumers.
## Repository layout
- `src/src/ResourceKit` — runtime APIs consumed by AppHost projects (the packable project).
- `src/src/SourceGeneration` — the Roslyn incremental generator, analyzers, suppressor, and attributes.
- `src/src/SourceGeneration.CodeFixes` — the SG0020 IDE code fix provider.
- `src/src/Example.*` — sample Aspire applications (`Example.AppHost` generated pattern,
`Example.ManualAppHost` manual pattern, `Example.Service`, `Example.ServiceDefaults`).
- `src/tests/*` — unit and integration tests for the runtime and the generator.
- `docs/wiki` — this documentation suite.
The NuGet package is produced from `src/src/ResourceKit/ResourceKit.csproj` and includes the runtime
APIs plus the analyzer/code-fix assemblies.
## Building and testing
The repository uses [Just](https://just.systems/) recipes (see `Justfile`). All recipes run from the
repository root.
```bash
just build # build the solution (Debug)
just test # run all tests
just test-unit # run unit tests only (TUnit [Category=Unit] filter)
just restore # restore dependencies
just pack # produce NuGet packages into ./artifacts
just lint-check # CSharpier formatting check
just lint-fix # CSharpier format
just scrub # remove bin/obj, clean, restore, stop build server
just version # show the current version from package.json
```
## Testing conventions
- Tests use **TUnit** (and TUnit.Mocks); do not introduce NUnit, xUnit, or MSTest patterns.
- All tests follow AAA with explicit `// Arrange`, `// Act`, `// Assert` comments.
- Test names mirror production structure: `{ClassUnderTest}Tests` and
`{SubjectOrMethodUnderTest}_{Scenario}_{Expectation}`.
- When a method under test accepts a `CancellationToken`, pass one and make it the final argument.
- Unit tests are tagged `[Category=Unit]` and are the only tests CI runs.
Test projects:
- `src/tests/ResourceKit.UnitTests` — runtime behavior (host app resources, `OptionsHelper`).
- `src/tests/ResourceKit.IntegrationTests` — starts the example AppHosts via TUnit.Aspire.
- `src/tests/SourceGeneration.UnitTests` — generator diagnostics/severity and attributes.
- `src/tests/SourceGeneration.IntegrationTests` — generated source content, caching, diagnostics,
suppression, and the SG0020 code fix.
Reports are written to `TestResults/`.
## Agent skills and workflows
The repository carries agent skills and prompt workflows under `.agents/`. Consult them when working
on source generators, tests, the project SDK, or conventional commits. AGENTS.md is the canonical
agent guidance and references `.agents/` for reusable workflows.
## Releases
Releases are produced by the shared pipeline when a version bump is merged to `main`; see
[Release Flow](../release-flow/). When preparing a release:
1. Update `package.json` (the authoritative version) and align `global.json` and
`Directory.Packages.props` with stable dependency versions.
2. Move new diagnostics from `src/src/SourceGeneration/AnalyzerReleases.Unshipped.md` into
`src/src/SourceGeneration/AnalyzerReleases.Shipped.md` under the released version, leaving the
unshipped file empty.
3. Add a [`CHANGELOG.md`](https://github.com/purview-dev/aspire-resourcekit/blob/main/CHANGELOG.md) entry and update the wiki [release notes](../release-notes/).
4. Validate with `just lint-check` and `just pipeline-pack-validate` before merging.
## Pull requests
Pull requests target `main` and are validated by `.github/workflows/pr.yml`, which delegates to the
shared [Purview.Build](https://github.com/purview-dev/build) pipeline (restore, build, CSharpier lint,
unit tests, pack, package-content validation). See [Release Flow](../release-flow/).
Commits follow [Conventional Commits](https://www.conventionalcommits.org/), enforced by commitlint and
Lefthook hooks.
---
# Release flow
This repository uses the shared [Purview.Build](https://github.com/purview-dev/build) pipeline for both
PR validation and releases. Consuming repositories own configuration (through `purview-build.json`) but
not pipeline source code.
- `.github/workflows/pr.yml` — PR validation
- `.github/workflows/release.yml` — release on push to `main`
- `purview-build.json` — pipeline configuration
## PR validation
`pr.yml` runs on pull requests targeting `main` and delegates to the shared `purview-build.yml`
workflow. It runs:
1. `dotnet restore` of `src/ResourceKit.slnx`
2. `dotnet build --no-restore --configuration Release`
3. CSharpier lint across the repository
4. Unit tests (discovered under `src/tests` matching `*Tests.csproj`, run with the
`/*/*/*/*[Category=Unit]` TUnit tree-node filter)
5. `dotnet pack` and package-content validation
Integration tests are never discovered in CI: `purview-build.json` sets `Build:TestPatterns` to
`*Tests.csproj` and `Build:TestFilter` to `/*/*/*/*[Category=Unit]`, so only unit-test projects (tagged
`[Category=Unit]` by the `Purview.BuildSdk`) are executed; integration tests (which require
Docker/Testcontainers) run locally via `just test`.
The PR workflow does not tag, release, or publish packages.
## Versioning model
`package.json` is the authoritative release version source. The release workflow reads:
```bash
bun -p "require('./package.json').version"
```
This flow assumes version prep already happened before release (for example with `@changesets/cli`
versioning and changelog updates merged to `main`). The release pipeline does not invent or auto-bump
versions.
## Release on push to main
`release.yml` triggers on push to `main` and delegates to the shared `purview-release.yml` workflow
with `release-mode: NuGet`.
The shared workflow:
1. Reads `package.json` `version` and computes the `v` tag.
2. Skips the entire release if `v` already exists (so re-merging to `main`, or merging `main`
into a `release` branch, releases exactly once).
3. Restores, builds, lints, runs unit tests, packs, and validates packages.
4. Pushes every `.nupkg` to nuget.org (`--skip-duplicate`).
5. Creates the `v` GitHub release with generated release notes and attaches the package
artifacts.
A release is therefore produced simply by bumping `package.json` (via changesets) and merging to
`main`. Do not create release tags manually.
## Prerelease support
Prerelease versions (any SemVer containing a hyphen, for example `1.0.0-prerelease.28`) release
through the same push-to-`main` flow. The `v` tag and GitHub release are still created and
packages published; the shared pipeline does not mark the GitHub release with the prerelease flag.
## Stable release preparation
A stable release (for example `1.0.0`) follows the same push-to-`main` flow, but requires version
preparation before the merge:
1. Set `package.json` `version` to the stable version (for example `1.0.0`).
2. Align the build SDK in `global.json` and the centrally-managed package versions in
`Directory.Packages.props` with their stable releases.
3. Move every entry from `src/src/SourceGeneration/AnalyzerReleases.Unshipped.md` into
`src/src/SourceGeneration/AnalyzerReleases.Shipped.md` under the released version, and leave the
unshipped file empty (see below).
4. Add the release to `CHANGELOG.md` and update the [release notes](../release-notes/) page.
5. Validate locally (`just lint-check` and `just pipeline-pack-validate`) before merging to `main`.
The GitHub release is created with automatically generated notes, grouped by the categories in
`.github/release.yml`.
## Analyzer release tracking
`src/src/SourceGeneration` ships public diagnostics (`SG0001`–`SG0020`), so it maintains the Roslyn
release tracking files:
- `AnalyzerReleases.Shipped.md` — rules that have shipped in a released version.
- `AnalyzerReleases.Unshipped.md` — rules added or changed since the last release.
`Microsoft.CodeAnalysis.Analyzers` automatically adds these files as analyzer additional files when
they exist in the project directory, so they are validated during the build (Roslyn components build
with `TreatWarningsAsErrors`). Add new rules to the *unshipped* file while developing, and move them
into the *shipped* file as part of a release. See [Contributing](../contributing/) for the day-to-day
workflow.
## Build-time dependencies
Some centrally-managed packages are build/analysis-time only (consumed with `PrivateAssets`), for
example `Purview.Telemetry.SourceGenerator`, which `Purview.BuildSdk` injects into every non-test C#
project, and `Purview.SourceGeneratorFramework`. They never flow to consumers of the NuGet package,
but the repository cannot restore unless the pinned version exists on a configured feed. A stable
release therefore requires the pinned versions to be resolvable before merging to `main`.
## NuGet publishing
NuGet publishing uses the shared workflow's API-key path with the organization `NUGET__APIKEY` secret
(available through `secrets: inherit`). The pipeline also accepts `NUGET_APIKEY`. No long-lived
repository-level API key secrets are required.
To use NuGet Trusted Publishing (OIDC) instead, the consuming repository would need to mint the
federated credential before the shared pipeline runs; the shared workflow itself does not perform the
`NuGet/login` step.
## Shared pipeline configuration
`purview-build.json` at the repository root drives the pipeline:
| Key | Value | Purpose |
| --- | --- | --- |
| `Build:Solution` | `src/ResourceKit.slnx` | Solution passed to restore/build/pack |
| `Build:TestRoot` | `src/tests` | Test project discovery root |
| `Build:TestPatterns` | `*Tests.csproj` | Test projects discovered for the test step |
| `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 — `lib/net8.0|net9.0|net10.0` runtime assemblies + XML docs, the analyzer assembly, `buildTransitive/Purview.Aspire.ResourceKit.props`, `README.md`, and `purview-logo-light.png` |
| `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
built-in defaults. Nested environment keys use `__`, for example `Release__Mode=NuGet`.
---
# Release notes
Stable release history for `Purview.Aspire.ResourceKit`, with the highlights that matter when you
upgrade from the `1.0.0-prerelease.*` line. The full, machine-readable history lives in
[`CHANGELOG.md`](https://github.com/purview-dev/aspire-resourcekit/blob/main/CHANGELOG.md) and on
the [GitHub releases page](https://github.com/purview-dev/aspire-resourcekit/releases).
## 1.0.0
The first stable release. The public surface that shipped during the prerelease line is now frozen
under [semantic versioning](../versioning/).
### Highlights
- **Attributes drive generation** — `[HostKit]` and `[ResourceDefinition]` /
`[ResourceDefinition]` produce the host kit, resource kit skeletons, typed options, and
the AppHost extension method. See [Attributes Reference](../attributes-reference/) and
[Generated Output](../generated-output/).
- **Deterministic lifecycle** — `Build`/`BuildResource(...)` constructs resources, then
`Configure`/`ConfigureResource()` wires them together. See
[Lifecycle: Build vs Configure](../lifecycle-build-configure/).
- **Declarative enablement** — `IsEnabled` and `IsResourceEnabled(builder)`. See
[Enablement](../enablement/).
- **Typed, extendable options** — generated `sealed partial` options bound from configuration. See
[Configuration and Options](../configuration-and-options/).
- **Test-friendly overrides** — `OptionsHelper` for args, environment variables, and flattening. See
[OptionsHelper](../optionshelper/) and [Testing with ResourceKit](../testing-with-resourcekit/).
- **Project resources** — first-class `AddProject()` support with SG0018/SG0019 validation. See
[Project Resources](../project-resources/).
- **Diagnostics SG0001–SG0020** with an IDE code fix for SG0020. See [Diagnostics](../diagnostics/).
- **Bundled agent skill** — `aspire-apphost-to-resourcekit` auto-installs into `.agents/skills/**`.
See [Bundled Agent Skills](../agent-skills/).
### Upgrade notes
- No public API changes are required when moving from `1.0.0-prerelease.*` to `1.0.0`.
- Pin `Purview.Aspire.ResourceKit` to `1.0.0` (or a later compatible `1.x`).
## See also
- [Versioning and compatibility](../versioning/)
- [Release flow](../release-flow/)
- [Getting Started](../)
---
# Versioning and compatibility
`Purview.Aspire.ResourceKit` follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
(`MAJOR.MINOR.PATCH`). The release version is authoritative in `package.json`; the release pipeline
tags the commit `v` and publishes matching NuGet packages.
## What the version numbers mean
- **MAJOR** — a breaking change to the public surface (see below).
- **MINOR** — additive, backwards-compatible functionality.
- **PATCH** — backwards-compatible fixes and documentation updates.
## Prereleases
Prereleases use a hyphenated suffix (for example `1.0.0-prerelease.41`) and ship through the same
push-to-`main` pipeline. They are published so consumers can validate upcoming changes early; they
carry no stability guarantee. `1.0.0` is the first stable release.
## What counts as a breaking change
Once a version is stable, the following are treated as public contracts:
- The attributes `[HostKit]`, `[ResourceDefinition]`, and `[ResourceDefinition]`,
including their named arguments (`Name`, `PropertyName`, `ExtensionMethodName`, `GenerateOptions`).
- The runtime types and members consumed by AppHost code (`HostKitBase`, `ResourceKitBase`,
`OptionsHelper`, and the `IResourceBuilder`/`WithEnvironment` extensions).
- The names and shapes of generated members (`BuildResource`, `ConfigureResource`,
`IsResourceEnabled`, generated host properties, generated options types, and the generated
`Add()` extension method).
- The diagnostic IDs `SG0001`–`SG0020`, their meanings, and their severities.
Renaming or removing any of these requires a MAJOR version and a documented migration path.
### Not covered
- The exact text of generated code and diagnostic messages.
- Files generated into a consuming repository (for example the copied `.agents/skills/**` folder).
- Build-time-only dependencies consumed through `PrivateAssets` (they never flow to consumers).
## Diagnostics as contracts
Diagnostic IDs are stable identifiers. New rules are introduced in the *unshipped* analyzer release
tracking file and only become part of a released version when they move into the *shipped* file as
part of a release. See [Contributing](../contributing/) and [Release flow](../release-flow/) for the
release-time procedure.
## See also
- [Release notes](../release-notes/)
- [Release flow](../release-flow/)