From 8706d2edadebc2129f01af4588972b3fd0b4fa13 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sun, 8 Nov 2026 16:26:13 +0000 Subject: [PATCH 1/2] feat: added publish mode with docs --- .config/architecture-policy.json | 33 +++ .config/dotnet-tools.json | 4 +- .config/purview-build.json | 1 + .editorconfig | 2 +- .github/copilot-instructions.md | 6 +- .gitignore | 9 + AGENTS.md | 22 +- CHANGELOG.md | 32 +++ Directory.Packages.props | 9 +- Justfile | 8 + README.md | 39 ++-- docs/wiki/Agent-Skills.md | 2 +- docs/wiki/Attributes-Reference.md | 27 +++ docs/wiki/Configuration-and-Options.md | 50 ++++- docs/wiki/Contributing.md | 50 ++++- docs/wiki/Enablement.md | 103 ++++++++-- docs/wiki/Examples.md | 15 +- docs/wiki/Generated-Output.md | 18 +- docs/wiki/Getting-Started.md | 4 + docs/wiki/Home.md | 54 ----- docs/wiki/Lifecycle-Build-Configure.md | 1 + docs/wiki/Release-Flow.md | 2 +- docs/wiki/Release-Notes.md | 11 +- docs/wiki/Testing-with-ResourceKit.md | 30 ++- docs/wiki/Versioning.md | 22 +- docs/wiki/_Sidebar.md | 18 -- docs/wiki/index.md | 6 - global.json | 14 +- mkdocs.yml | 32 --- package.json | 4 +- scripts/verify-consumers.proj | 194 ++++++++++++++++++ scripts/verify-consumers/Kits.cs | 15 ++ scripts/verify-consumers/nuget.config | 9 + skills-lock.json | 173 ++++++++++++++++ skills/purview-resourcekit-authoring/SKILL.md | 24 ++- skills/purview-resourcekit-migration/SKILL.md | 13 +- skills/purview-resourcekit-options/SKILL.md | 71 +++++-- skills/purview-resourcekit-testing/SKILL.md | 32 ++- src/Directory.Build.props | 2 - src/Versions.props | 12 +- .../AppModels/Resources/KeyVaultKit.cs | 5 +- .../AppModels/Resources/PublishMarkerKit.cs | 5 +- .../Example.AppHost/Example.AppHost.csproj | 2 - .../Properties/AssemblyInfo.cs | 3 - .../AppModels/Resources/KeyVaultKit.cs | 3 +- .../AppModels/Resources/PublishMarkerKit.cs | 3 +- .../Example.ManualAppHost.csproj | 2 - .../Properties/AssemblyInfo.cs | 3 - src/src/ResourceKit/HostKitBase.cs | 19 ++ src/src/ResourceKit/ResourceKit.csproj | 2 - src/src/ResourceKit/ResourceKitBase.cs | 20 +- src/src/ResourceKit/ResourceKitMode.cs | 40 ++++ .../ResourceKit/ResourceKitModeExtensions.cs | 37 ++++ src/src/ResourceKit/Sdk/README.md | 18 +- .../SourceGeneration.CodeFixes.csproj | 13 +- .../CompilerServices/IsExternalInit.cs | 15 -- .../Helpers/CodeGenEmiiter.Attributes.cs | 165 +++++++++++++++ .../Helpers/CodeGenEmiiter.HostKit.cs | 23 +++ .../Helpers/CodeGenEmiiter.ResourceKit.cs | 21 ++ .../Helpers/CodeGenEmiiter.cs | 28 +++ .../Helpers/SourceGenLibrary.cs | 6 +- .../Helpers/TypeLibraryGenerator.cs | 3 + .../Models/AttributeDataModels.cs | 8 +- .../Models/KitGenerationModels.cs | 6 +- .../ResourceKitDiagnosticAnalyzer.cs | 2 +- .../SourceGeneration/SourceGeneration.csproj | 1 - .../Repository.UnitTests/ArchitectureTests.cs | 21 ++ .../Repository.UnitTests.csproj | 10 +- src/tests/Repository.UnitTests/SkillTests.cs | 15 +- ...pleAppHostCustomOptionsIntegrationTests.cs | 6 + .../ExampleAppHostIntegrationTests.cs | 7 + .../ExampleManualAppHostIntegrationTests.cs | 7 + .../ResourceKit.IntegrationTests.csproj | 5 - .../OptionsHelperTests.cs | 42 ++++ .../ResourceKit.UnitTests.csproj | 5 - ...eTests.cs => ResourceKitLifecycleTests.cs} | 179 +++++++++++++++- .../ResourceKitModeTests.cs | 151 ++++++++++++++ .../TestApplicationBuilderFactory.cs | 38 ++++ .../ResourceKitSourceGeneratorTestOptions.cs | 2 + .../SharedTestingInfra.csproj | 3 + .../GeneratedSourceContentTests.cs | 153 ++++++++++++++ .../SourceGeneration.IntegrationTests.csproj | 5 - .../GeneratedAttributesTests.cs | 94 ++++++--- .../SourceGeneration.UnitTests.csproj | 5 - 84 files changed, 2022 insertions(+), 352 deletions(-) create mode 100644 .config/architecture-policy.json delete mode 100644 docs/wiki/Home.md delete mode 100644 docs/wiki/_Sidebar.md delete mode 100644 docs/wiki/index.md delete mode 100644 mkdocs.yml create mode 100644 scripts/verify-consumers.proj create mode 100644 scripts/verify-consumers/Kits.cs create mode 100644 scripts/verify-consumers/nuget.config create mode 100644 skills-lock.json delete mode 100644 src/src/Example.AppHost/Properties/AssemblyInfo.cs delete mode 100644 src/src/Example.ManualAppHost/Properties/AssemblyInfo.cs create mode 100644 src/src/ResourceKit/ResourceKitMode.cs create mode 100644 src/src/ResourceKit/ResourceKitModeExtensions.cs delete mode 100644 src/src/SourceGeneration/Extensions/System/Runtime/CompilerServices/IsExternalInit.cs create mode 100644 src/tests/Repository.UnitTests/ArchitectureTests.cs rename src/tests/ResourceKit.UnitTests/{HostAppResourceTests.cs => ResourceKitLifecycleTests.cs} (56%) create mode 100644 src/tests/ResourceKit.UnitTests/ResourceKitModeTests.cs create mode 100644 src/tests/ResourceKit.UnitTests/TestApplicationBuilderFactory.cs diff --git a/.config/architecture-policy.json b/.config/architecture-policy.json new file mode 100644 index 0000000..4b3810f --- /dev/null +++ b/.config/architecture-policy.json @@ -0,0 +1,33 @@ +{ + "$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/.config/dotnet-tools.json b/.config/dotnet-tools.json index f8a4785..076bd1b 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -10,14 +10,14 @@ "rollForward": false }, "dotnet-inspect": { - "version": "0.26.0", + "version": "0.27.0", "commands": [ "dotnet-inspect" ], "rollForward": false }, "purview.build": { - "version": "0.7.1", + "version": "0.8.1", "commands": [ "purview-build" ], diff --git a/.config/purview-build.json b/.config/purview-build.json index b9daa61..6062685 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -17,6 +17,7 @@ "RequiredContent": { "purview.aspire.resourcekit": [ "analyzers/dotnet/cs/Purview.Aspire.ResourceKit.SourceGeneration.dll", + "analyzers/dotnet/cs/Purview.Aspire.ResourceKit.SourceGeneration.CodeFixes.dll", "buildTransitive/Purview.Aspire.ResourceKit.props", "lib/$(TFM)/Purview.Aspire.ResourceKit.dll", "lib/$(TFM)/Purview.Aspire.ResourceKit.xml", diff --git a/.editorconfig b/.editorconfig index b511789..ea03058 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,4 @@ -# Purview.BuildSdk 1.1.0 - managed file, do not edit +# Purview.BuildSdk 1.2.0 - managed file, do not edit # # This file is written by Purview.BuildSdk and is replaced whenever the SDK version changes. The version # on the first line is how the SDK recognises a file it owns: a .editorconfig without that marker is diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6aac13e..25e2b54 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -15,5 +15,9 @@ Keep product, architecture, and general engineering standards centralized in `AG - Apply the `AGENTS.md` testing bar strictly (TUnit/TUnit.Mocks, AAA comments, naming, cancellation token rule). - Treat work as incomplete until relevant tests pass. -- Consult the repository `.agents/` folder for additional skills/workflows that may improve execution quality. +- Product, architecture and architecture-policy rules are enforced by tests; read them before changing a + project reference (`src/tests/Repository.UnitTests/ArchitectureTests.cs`). +- Skills this repository authors live in `skills//SKILL.md`. `.agents/skills/` and `.claude/skills/` + are the skills CLI's install locations and are gitignored, so a fresh clone has none — do not read + guidance from there or write to it. - Keep edits minimal, focused, and aligned with existing SDK and repository conventions. diff --git a/.gitignore b/.gitignore index 0354322..3adbc69 100644 --- a/.gitignore +++ b/.gitignore @@ -660,6 +660,15 @@ 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/* !src/src/ResourceKit/build/ !src/src/ResourceKit/build/** diff --git a/AGENTS.md b/AGENTS.md index 002b6b4..7bfb2bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,13 +14,19 @@ 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. +- 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. - Uses centrally-managed package versions (`Directory.Packages.props`). - Uses **TUnit** and **TUnit.Mocks** for testing. - Uses `Microsoft.Testing.Platform` via package-managed tooling. -For additional agent capabilities, conventions, and reusable workflows, use the repository’s `.agents/` folder. -Do not assume this file contains every available helper; consult `.agents/` as part of planning and execution. +The skills this repository **authors** live in the root `skills/` folder — read them there, at +`skills//SKILL.md`. Skills a *package* ships are installed into `.agents/skills/` by the skills +CLI (which is gitignored here, so a fresh clone has none until you run `npx skills add`). ## Purview DotNet Project SDK guidance @@ -56,6 +62,9 @@ Use naming that mirrors production structure: - Test namespace should match the target namespace shape (same hierarchy intent). - Test class naming: `{ClassUnderTest}Tests` +- A suite whose subject is a contract spanning several types is named for its scope instead — for + example `ResourceKitLifecycleTests` covers the `Build`/`Configure` contract of both `HostKitBase` and + `ResourceKitBase`. Forcing a `{ClassUnderTest}Tests` name onto one of those is less truthful, not more. - Test method naming: `{SubjectOrMethodUnderTest}_{Scenario}_{Expectation}` ### Cancellation token rule @@ -71,7 +80,12 @@ Before considering a change done: 1. Relevant tests are updated/added using the conventions above. 2. Test runs pass for the impacted scope. -3. New/updated docs stay consistent with this file and the `.agents/` guidance. +3. New/updated docs stay consistent with this file and the authored skills in `skills/`. +4. A change to a project reference is reflected in `.config/architecture-policy.json` — the policy is + enforced by `src/tests/Repository.UnitTests/ArchitectureTests.cs`, and widening `mayDependOn` is a + reviewed policy change rather than a convenient edit. +5. A change to what the package ships, or to a claim the README/diagnostics pages make about it, is + verified with `just verify-consumers` — it packs the package and builds a consumer that installs it. ## Agent skills @@ -92,7 +106,7 @@ 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, `IsEnabled` versus `IsResourceEnabled`, `OptionsHelper` | +| `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 | diff --git a/CHANGELOG.md b/CHANGELOG.md index 3aa3e9a..e49fced 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,38 @@ All notable changes to `Purview.Aspire.ResourceKit` are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Run modes** — `ResourceKitMode` (`All`, `LocalDevelopment`, `Run`, `Publish`) selects which AppHost + execution a host kit or resource kit participates in. Declare the default on `[HostKit]` / + `[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. + +### 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. +- **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). +- **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. + +### Removed + +- **mkdocs site configuration** — `mkdocs.yml` and the mkdocs-only navigation pages (`index.md`, + `Home.md`, `_Sidebar.md`) are removed. The `docs/wiki` topic pages remain the source consumed by + . + ## [1.0.0] - 2026-09-29 The first stable release of `Purview.Aspire.ResourceKit`. diff --git a/Directory.Packages.props b/Directory.Packages.props index a39d7c7..44ad6a1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,8 +3,8 @@ true 5.9.0 1.76.0 - 1.2.1 - 5.0.6 + 1.3.0 + 5.2.0 10.0.13 10.11.0 1.20.1 @@ -34,7 +34,7 @@ Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="$(DotNetRuntimeVersion)" /> - + @@ -52,7 +52,8 @@ - + + diff --git a/Justfile b/Justfile index a4ca8bb..7f9053d 100644 --- a/Justfile +++ b/Justfile @@ -104,6 +104,14 @@ pipeline-pack-validate *args: echo "Running pack + validate pipeline..." {{ pipeline_tool }} --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None {{ args }} +# Verify the package against a real consumer: pack, assert the package shape, then build a +# throwaway consumer that installs it. Restores from nuget.org, so it is not part of the unit tier. +[group('Pipeline')] +verify-consumers *args: + just ensure-pipeline-tool + echo "Verifying packaged consumers..." + dotnet msbuild scripts/verify-consumers.proj {{ args }} + # Open the solution in Visual Studio/ Registered application [group('Utilities')] vs: diff --git a/README.md b/README.md index e0655d5..2cd6520 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,17 @@ 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. -> [!NOTE] -> `Purview.Aspire.ResourceKit` 1.0.0 is the first stable release. See the [changelog](CHANGELOG.md) and [release notes](docs/wiki/Release-Notes.md). +> [!IMPORTANT] +> Requires **.NET 10**. The package targets `net10.0` only — there is no `net8.0` or `net9.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). > [!TIP] > **Lifecycle quick links** > > - Build vs Configure guidance: [`docs/wiki/Lifecycle-Build-Configure.md`](docs/wiki/Lifecycle-Build-Configure.md) -> - Runtime enablement (`IsEnabled` + `IsResourceEnabled(...)`): [`docs/wiki/Enablement.md`](docs/wiki/Enablement.md) +> - Runtime enablement and run modes (`Mode` + `IsEnabled` + `IsResourceEnabled(...)`): [`docs/wiki/Enablement.md`](docs/wiki/Enablement.md) ## Why teams use it @@ -48,22 +51,21 @@ builder.AddAspireResourceKit(); The extension method name is generated from your host metadata; in this repository sample it is `AddAspireResourceKit()`. > [!TIP] -> **Built-in AI agent skills (auto-installed via NuGet)** -> -> This package can ship one or more bundled [Agent Skills](https://agentskills.io/). -> When a consuming project builds, any `skills/**/SKILL.md` files in the package are copied to: -> -> - `.agents/skills/**` -> -> Example: +> **Agent skills for your coding agent** > -> - `skills/aspire-apphost-to-resourcekit/SKILL.md` → `.agents/skills/aspire-apphost-to-resourcekit/SKILL.md` +> This repository authors [Agent Skills](https://agentskills.io/) describing ResourceKit — declaring a +> `[HostKit]` and resource kits, the generated options and run modes, test fixtures, and what each +> `SG00xx` diagnostic means. > -> It also writes a local `.gitignore` into each generated skill folder to keep updates out of source control noise. +> They are **not** shipped in the NuGet package, and nothing copies them into your repository on build. +> The skills CLI clones the git repository and installs them into your agent's own skill directory: > -> To disable this behavior, set the shared opt-out property in your project (or `Directory.Build.props`): +> ```bash +> npx skills add purview-dev/aspire-resourcekit +> ``` > -> `false` +> Append `#` to pin a commit or tag. The CLI records the installed set in `skills-lock.json`, so it +> can be restored later with `npx skills experimental_install`. ## Lifecycle mental model @@ -74,11 +76,12 @@ ResourceKit has two layers of lifecycle methods: Enablement is evaluated *before* resource construction: -- `IsEnabled` is the persisted toggle (typically from generated options). +- `Mode` selects which AppHost execution a kit participates in (`All`, `LocalDevelopment`, `Run`, `Publish`). It is configured through the generated options and can be declared on `[HostKit]`/`[ResourceDefinition]`. +- `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 it evaluates to `false`, both `BuildResource(...)` and `ConfigureResource()` are skipped for that resource. +- If any gate excludes the resource, both `BuildResource(...)` and `ConfigureResource()` are skipped for that resource. -Use `IsResourceEnabled(builder)` when enablement depends on runtime state (environment, config, publish mode, etc.), not just static options. +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). ## What gets generated diff --git a/docs/wiki/Agent-Skills.md b/docs/wiki/Agent-Skills.md index ee7a45f..3d6a6f5 100644 --- a/docs/wiki/Agent-Skills.md +++ b/docs/wiki/Agent-Skills.md @@ -27,7 +27,7 @@ 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, `IsEnabled` versus `IsResourceEnabled`, `OptionsHelper` | +| `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 | diff --git a/docs/wiki/Attributes-Reference.md b/docs/wiki/Attributes-Reference.md index 1694936..e4d7b37 100644 --- a/docs/wiki/Attributes-Reference.md +++ b/docs/wiki/Attributes-Reference.md @@ -21,6 +21,20 @@ Optional named arguments: | `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. | +| `Mode` | `ResourceKitMode` | `All` | Execution mode in which the host kit, and therefore every resource, participates. | + +`Mode` is available as both a named property and a constructor argument, and configuration can override +the generated default: + +```csharp +[HostKit(Mode = ResourceKitMode.LocalDevelopment)] +sealed partial class ShopHostKit; + +[HostKit(ResourceKitMode.Publish)] +sealed partial class PublishHostKit; +``` + +See [Enablement](Enablement.md) for the mode list and evaluation order. ## `ResourceDefinitionAttribute` (`[ResourceDefinition]`) @@ -37,8 +51,21 @@ Options: | --- | --- | --- | --- | | `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). | +| `Mode` | `ResourceKitMode` | `All` | Execution mode in which this resource participates. | | `AspireResourceType` | generic type argument | `null` | The Aspire `IResource` type (generic style only). | +`Mode` accepts a named property or a constructor argument, and configuration can override it: + +```csharp +// Publish-only, for example Azure Key Vault or Application Insights. +[ResourceDefinition("kv", ResourceKitMode.Publish)] +partial class KeyVaultKit; + +// Local-development-only, for example a documentation or diagram site. +[ResourceDefinition(Mode = ResourceKitMode.LocalDevelopment)] +partial class DocsSiteKit; +``` + ## `ResourceDefinition` vs `ResourceDefinition` ResourceKit supports two declaration styles with different base-type behavior. diff --git a/docs/wiki/Configuration-and-Options.md b/docs/wiki/Configuration-and-Options.md index 385b7ce..5f9cb3f 100644 --- a/docs/wiki/Configuration-and-Options.md +++ b/docs/wiki/Configuration-and-Options.md @@ -10,6 +10,10 @@ A host options type contains one nested options object per resource. ```csharp public class ShopHostKitOptions { + public const string SectionName = "ShopHostKit"; + + public ResourceKitMode Mode { get; set; } = ResourceKitMode.All; + public APIKitOptions Api { get; set; } = new(); public RedisKitOptions Redis { get; set; } = new(); } @@ -19,6 +23,8 @@ public class APIKitOptions public string Name { get; set; } = "api"; public bool IsEnabled { get; set; } = true; + + public ResourceKitMode Mode { get; set; } = ResourceKitMode.All; } ... @@ -87,6 +93,8 @@ Typical keys (example): - `ShopHostKit:API:Name` - `ShopHostKit:API:IsEnabled` +- `ShopHostKit:API:Mode` +- `ShopHostKit:Mode` Configuration is bound by the generated extension method: @@ -108,16 +116,50 @@ Set `IsEnabled=false` for that resource's options section. } ``` +## Select a run mode + +Resource kits (and their host kit) expose a `Mode` option that selects which AppHost execution they +participate in. It defaults to `All`, so nothing changes unless you opt in. + +| Mode | Participates when | +| --- | --- | +| `All` (default) | always | +| `LocalDevelopment` | run mode and the `Development` environment | +| `Run` | run mode, any environment | +| `Publish` | publish mode (`aspire publish` / `azd`) | + +The attribute sets the default, and configuration can override it: + +```csharp +[ResourceDefinition("kv", ResourceKitMode.Publish)] +sealed partial class KeyVaultKit; +``` + +```json +{ + "ShopHostKit": { + "Mode": "All", + "KeyVault": { "Mode": "Publish" }, + "DocsSite": { "Mode": "LocalDevelopment" } + } +} +``` + +The host `Mode` gates every resource in the host kit; a resource `Mode` gates only that resource. +See [Enablement](Enablement.md) for the evaluation order. + ## `IsEnabled` vs `IsResourceEnabled(...)` Use both together for flexible control: -- `IsEnabled`: static/configured toggle (usually generated options). +- `IsEnabled`: static/configured toggle (usually generated options), and the only member consumers + need to check. - `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. +At runtime, `Build` evaluates the configured `Mode` first, then calls `IsResourceEnabled(builder)` when +`IsEnabled` is already `true`. If either gate excludes the kit, 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.md) for details. diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md index a8d0573..3311988 100644 --- a/docs/wiki/Contributing.md +++ b/docs/wiki/Contributing.md @@ -27,6 +27,7 @@ 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 verify-consumers # pack, assert the package shape, build a real consumer against it just lint-check # CSharpier formatting check just lint-fix # CSharpier format just scrub # remove bin/obj, clean, restore, stop build server @@ -40,23 +41,60 @@ just version # show the current version from package.json - 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. +- A unit test runs in CI because of the **project it lives in**, not an attribute: `Purview.BuildSdk` + derives the tier from the project name (`*.UnitTests` → `Category=Unit`), and the pipeline filters on + that. Splitting a suite into a second project is how a test leaves the fast tier. Test projects: -- `src/tests/ResourceKit.UnitTests` — runtime behavior (host app resources, `OptionsHelper`). +- `src/tests/ResourceKit.UnitTests` — runtime behavior (host app resources, `OptionsHelper`, run modes). - `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. +- `src/tests/Repository.UnitTests` — asserts things about this repository itself: the conformance of the + authored skills in `skills/`, and the architecture policy in `.config/architecture-policy.json`. +- `src/tests/SharedTestingInfra` — a shared-testing library, referenced rather than executed. Reports are written to `TestResults/`. +## Documentation + +`docs/wiki` holds the plain-Markdown topic pages and nothing else. There is deliberately no `Home.md` +hub, no `_Sidebar.md` navigation and no site configuration in this repository: the site at + generates its own navigation from the pages that are +here. Do not "restore" the mkdocs scaffolding — it was removed on purpose (see `CHANGELOG.md`), and a +page's discoverability is decided by the site, not by a file in this repository. + +Two rules that still apply: a page added or renamed here must be linked from at least one other page +or from `README.md`, and anything a consumer is told about the package is a claim +`just verify-consumers` or the package's own tests should be able to falsify. + ## 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. +Skills this repository **authors** live in `skills//SKILL.md`, are named with the +`purview-resourcekit` prefix, and are validated on every test run by +`src/tests/Repository.UnitTests/SkillTests.cs`. Add or change one there, not in an agent directory. + +`.agents/skills/` and `.claude/skills/` are the skills CLI's install locations for skills from **other** +repositories. They are gitignored, and this repository's own skills are never installed into them — two +copies would drift. `skills-lock.json` is committed: it pins what this repository resolved. + +```bash +npx skills add purview-dev/aspire-resourcekit +``` + +## Architecture + +The dependency direction between the three layers is a policy, not a diagram: +`.config/architecture-policy.json` declares each project's responsibility and what it may reference, and +`src/tests/Repository.UnitTests/ArchitectureTests.cs` turns that into a failing build. The rule worth +knowing before you touch a project file: `ResourceKit` reaches the Roslyn components through +`OutputItemType="Analyzer"` / `ReferenceOutputAssembly="false"`, and no component may reference the +runtime — a consumer's compiler loads these assemblies, so dragging `Aspire.Hosting` into one would +break every consumer's build. + +Widen `mayDependOn` only as a reviewed policy change; that is the whole point of the file. ## Releases @@ -68,7 +106,7 @@ Releases are produced by the shared pipeline when a version bump is merged to `m 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`](../../CHANGELOG.md) entry and update the wiki [release notes](Release-Notes.md). +3. Add a [`CHANGELOG.md`](../../CHANGELOG.md) entry and update the [release notes](Release-Notes.md). 4. Validate with `just lint-check` and `just pipeline-pack-validate` before merging. ## Pull requests diff --git a/docs/wiki/Enablement.md b/docs/wiki/Enablement.md index e393063..c5f8004 100644 --- a/docs/wiki/Enablement.md +++ b/docs/wiki/Enablement.md @@ -1,52 +1,117 @@ -# Enablement: IsEnabled vs IsResourceEnabled +# Enablement: mode, IsEnabled and IsResourceEnabled -ResourceKit supports flexible runtime enablement through two related members. +ResourceKit decides participation in three ordered steps: the configured **mode**, the configured +`IsEnabled` flag, and an optional runtime hook. + +## Run modes + +`ResourceKitMode` selects which AppHost execution a host kit or resource kit participates in. It is a +*kit option*, so participation is selected from configuration instead of coded into an +`IsResourceEnabled` override. + +| Mode | Participates when | Typical use | +| --- | --- | --- | +| `All` (default) | always | most resources | +| `LocalDevelopment` | run mode **and** the `Development` environment | a documentation site, a diagram site | +| `Run` | run mode, any environment | tooling that must not be published | +| `Publish` | publish mode (`aspire publish` / `azd`) | Azure Application Insights, a container apps environment, Key Vault, publish parameters | + +The mode can be declared on the attribute, which sets the default for the generated options: + +```csharp +[HostKit] +sealed partial class ShopHostKit; + +// Constructor argument. +[ResourceDefinition("kv", ResourceKitMode.Publish)] +sealed partial class KeyVaultKit; + +// Named property. +[ResourceDefinition(Mode = ResourceKitMode.Publish)] +sealed partial class PublishMarkerKit; +``` + +Because it is an option, configuration can override either the host-wide mode or a single resource: + +```json +{ + "ShopHostKit": { + "Mode": "All", + "KeyVault": { + "Mode": "Publish" + }, + "DocsSite": { + "Mode": "LocalDevelopment" + } + } +} +``` + +- The host `Mode` gates the whole host kit: every resource is disabled before it is built. +- A resource `Mode` gates that resource only. + +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` +access throws `InvalidOperationException` — always check `IsEnabled` first, exactly as for a kit +disabled through options. + +Manual (non-generated) kits express the same behaviour by overriding the `Mode` property: + +```csharp +sealed partial class KeyVaultKit(ShopHostKit hostKit) + : ResourceKitBase(hostKit, "kv") +{ + protected override ResourceKitMode Mode => ResourceKitMode.Publish; +} +``` ## The two toggles - `IsEnabled` — the current enablement flag, usually sourced from generated options. This is the - persisted/configured toggle. + persisted/configured toggle, and the only member consumers need to check. - `IsResourceEnabled(builder)` — a runtime decision hook. Its default implementation returns - `IsEnabled`, and you override it when enablement should react to runtime conditions. + `IsEnabled`, and you override it when enablement should react to runtime conditions that are not a + mode (for example a feature flag read from configuration). ## 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 +1. The configured `Mode` is checked first. If it does not participate in this run, `IsEnabled` becomes + `false` and the hook is never invoked. +2. If `IsEnabled` is still `true`, ResourceKit calls `IsResourceEnabled(builder)` and assigns the + result back to `IsEnabled`. +3. 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. +- The hook is only invoked when `IsEnabled` is already `true` and the mode participates. 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. +Use the hook when enablement depends on runtime state that is not an execution mode. Common examples +are environment-specific feature flags or dynamic configuration checks. ```csharp protected override bool IsResourceEnabled(IDistributedApplicationBuilder builder) { - // Example: allow config + environment based behavior. + // Example: allow config + feature flag based behavior. var isEnabledInConfig = IsEnabled; - var isProd = builder.Configuration["ASPNETCORE_ENVIRONMENT"] == "Production"; + var isFeatureEnabled = builder.Configuration["Features:KeyVault"] == "true"; - return isEnabledInConfig && isProd; + return isEnabledInConfig && isFeatureEnabled; } ``` -Publish-only resources are a typical pattern. The example hosts run Key Vault and a publish marker -parameter only when publishing: +Publish-only resources no longer need a hook; declare the mode instead: ```csharp -protected override bool IsResourceEnabled(IDistributedApplicationBuilder builder) => - builder.ExecutionContext.IsPublishMode; +[ResourceDefinition("kv", ResourceKitMode.Publish)] +sealed partial class KeyVaultKit; ``` ## Disabling a resource from options @@ -69,4 +134,4 @@ See [Configuration and Options](Configuration-and-Options.md) for the generated ## See also - [Lifecycle: Build vs Configure](Lifecycle-Build-Configure.md) -- [Configuration and Options](Configuration-and-Options.md) \ No newline at end of file +- [Configuration and Options](Configuration-and-Options.md) diff --git a/docs/wiki/Examples.md b/docs/wiki/Examples.md index f306dde..b9317fc 100644 --- a/docs/wiki/Examples.md +++ b/docs/wiki/Examples.md @@ -19,8 +19,19 @@ Both hosts build the same logical resources through `Purview.Aspire.ResourceKit. | 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`). +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: + +```csharp +// Example.AppHost (generated) +[ResourceDefinition(Platform.ResourceKits.KeyVault, ResourceKitMode.Publish)] +sealed partial class KeyVaultKit; + +// Example.ManualAppHost (manual) +protected override ResourceKitMode Mode => ResourceKitMode.Publish; +``` + +See [Enablement](Enablement.md) for the full mode list. ## Generated pattern (`Example.AppHost`) diff --git a/docs/wiki/Generated-Output.md b/docs/wiki/Generated-Output.md index 38c25ab..eae6273 100644 --- a/docs/wiki/Generated-Output.md +++ b/docs/wiki/Generated-Output.md @@ -73,11 +73,15 @@ internal sealed partial class ExampleAPIKit : global::Purview.Aspire.ResourceKit } public ExampleAPIKitOptions Options { get; } + + protected override global::Purview.Aspire.ResourceKit.ResourceKitMode Mode => Options.Mode; } ``` Your `partial class` supplies `BuildResource(...)` and optionally `ConfigureResource()` and -`IsResourceEnabled(builder)`. +`IsResourceEnabled(builder)`. `Mode` is generated (sourced from the options, or from the attribute when +options are disabled), so publish-only and local-development-only resources are configuration rather +than code. See [Enablement](Enablement.md). ## Generated options @@ -89,11 +93,15 @@ public sealed partial class ExampleHostKitOptions { public const string SectionName = "ExampleHostKit"; + public global::Purview.Aspire.ResourceKit.ResourceKitMode Mode { get; set; } = + global::Purview.Aspire.ResourceKit.ResourceKitMode.All; + public ExampleAPIKitOptions ExampleAPI { get; init; } = new(); } ``` -Each resource options type carries `Name` (defaulting to the logical resource name) and `IsEnabled`: +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): ```csharp public sealed partial class ExampleAPIKitOptions @@ -102,9 +110,15 @@ public sealed partial class ExampleAPIKitOptions public string Name { get; set; } = "api"; public bool IsEnabled { get; set; } = true; + + public global::Purview.Aspire.ResourceKit.ResourceKitMode Mode { get; set; } = + global::Purview.Aspire.ResourceKit.ResourceKitMode.Publish; } ``` +The generated host kit and every generated resource kit override `Mode` from those options, so +`Build` gates on the configured mode before `IsResourceEnabled(builder)` and `BuildResource(...)` run. + `Name` is decorated with `[Required(AllowEmptyStrings = false)]`; the generated extension method calls `ValidateOnStart()`. diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index 2514ffd..f727d33 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -9,6 +9,10 @@ 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`. +> See [Versioning and compatibility](Versioning.md#supported-target-frameworks). + Install the `Purview.Aspire.ResourceKit` package in your AppHost project. ```bash diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md deleted file mode 100644 index 022bd25..0000000 --- a/docs/wiki/Home.md +++ /dev/null @@ -1,54 +0,0 @@ -# Purview Aspire ResourceKit Wiki - -This wiki is the project documentation hub for `Purview.Aspire.ResourceKit`, a -source-generator-powered framework for structuring .NET Aspire AppHost resource composition as -strongly typed, test-friendly classes. - -If your AppHost is getting bigger, ResourceKit keeps resource setup maintainable and discoverable by -moving composition into focused resource classes and generating the plumbing for you. - -## Start here - -- [Getting Started](Getting-Started.md) -- [Attributes Reference](Attributes-Reference.md) -- [Generated Output](Generated-Output.md) -- [Lifecycle: Build vs Configure](Lifecycle-Build-Configure.md) -- [Enablement: IsEnabled vs IsResourceEnabled](Enablement.md) -- [Configuration and Options](Configuration-and-Options.md) -- [OptionsHelper](OptionsHelper.md) -- [Project Resources](Project-Resources.md) -- [Diagnostics](Diagnostics.md) -- [Testing with ResourceKit](Testing-with-ResourceKit.md) -- [Examples: Generated vs Manual](Examples.md) -- [Bundled Agent Skills](Agent-Skills.md) -- [Source Generator Behaviors](Source-Generator-Behaviors.md) -- [Contributing](Contributing.md) -- [Release Flow](Release-Flow.md) -- [Release Notes](Release-Notes.md) -- [Versioning](Versioning.md) - -## Why teams use it - -- **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. -- **Predictable lifecycle** (`Build` then `Configure`) for inter-resource dependencies. -- **Testability** with options overrides and isolated resource composition. - -## Feature highlights - -- **`[HostKit]`** marks the single host kit class per compilation and generates the host wiring and - an AppHost extension method (for example `builder.AddAspireResourceKit()`). -- **`[ResourceDefinition]`** marks a resource kit class; generic and non-generic styles are both - supported. -- **Generated typed options** are emitted as nested `sealed partial` classes you can extend with your - own settings. -- **Build vs Configure** gives a deterministic lifecycle: build each resource, then connect the - created resources. -- **`IsEnabled` + `IsResourceEnabled(builder)`** lets enablement react to runtime state (environment, - publish mode, dynamic configuration). -- **`OptionsHelper`** converts typed assignment expressions into command-line args or environment - variables for tests and scenario toggles. -- **Diagnostics** (`SG0001`–`SG0020`) report model problems early, with an IDE code fix for - `OptionsHelper.Assign` multi-assignment actions. -- **Bundled agent skills** auto-install into consuming repositories unless opted out. \ No newline at end of file diff --git a/docs/wiki/Lifecycle-Build-Configure.md b/docs/wiki/Lifecycle-Build-Configure.md index 62185c3..b478498 100644 --- a/docs/wiki/Lifecycle-Build-Configure.md +++ b/docs/wiki/Lifecycle-Build-Configure.md @@ -75,6 +75,7 @@ through the host kit's generated properties. Before `BuildResource(...)` runs, ResourceKit checks whether the resource should participate: +- The configured `Mode` (`All`, `LocalDevelopment`, `Run`, `Publish`) must participate in the current AppHost execution. - 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`. diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index 89a5d8b..885805e 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 — `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` | +| `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 | | `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 48b628b..170dd18 100644 --- a/docs/wiki/Release-Notes.md +++ b/docs/wiki/Release-Notes.md @@ -5,6 +5,12 @@ upgrade from the `1.0.0-prerelease.*` line. The full, machine-readable history l [`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). +## 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). + ## 1.0.0 The first stable release. The public surface that shipped during the prerelease line is now frozen @@ -28,8 +34,9 @@ under [semantic versioning](Versioning.md). - **Project resources** — first-class `AddProject()` support with SG0018/SG0019 validation. See [Project Resources](Project-Resources.md). - **Diagnostics SG0001–SG0020** with an IDE code fix for SG0020. See [Diagnostics](Diagnostics.md). -- **Bundled agent skill** — `aspire-apphost-to-resourcekit` auto-installs into `.agents/skills/**`. - See [Bundled Agent Skills](Agent-Skills.md). +- **Agent skills** — `1.0.0` bundled `aspire-apphost-to-resourcekit` and copied it into + `.agents/skills/**` on build. That delivery is withdrawn: skills now install through the skills CLI, + and the authored set has changed. See [Agent Skills](Agent-Skills.md). ### Upgrade notes diff --git a/docs/wiki/Testing-with-ResourceKit.md b/docs/wiki/Testing-with-ResourceKit.md index 8d3830e..50efad6 100644 --- a/docs/wiki/Testing-with-ResourceKit.md +++ b/docs/wiki/Testing-with-ResourceKit.md @@ -9,10 +9,12 @@ 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.UnitTests` — runtime behavior of the kit base types, run modes 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. +- `Repository.UnitTests` — the repository itself: authored skills and the architecture policy. +- `SharedTestingInfra` — a shared-testing library, referenced rather than executed. Reports are written to `TestResults/`. @@ -40,16 +42,22 @@ public sealed class ExampleAppHostIntegrationTests(ExampleAppHostFixture]`, - including their named arguments (`Name`, `PropertyName`, `ExtensionMethodName`, `GenerateOptions`). + including their named arguments (`Name`, `PropertyName`, `ExtensionMethodName`, `GenerateOptions`, + `Mode`). - The runtime types and members consumed by AppHost code (`HostKitBase`, `ResourceKitBase`, - `OptionsHelper`, and the `IResourceBuilder`/`WithEnvironment` extensions). + `ResourceKitMode`, `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 + `IsResourceEnabled`, `Mode`, generated host properties, generated options types, and the generated `Add()` extension method). - The diagnostic IDs `SG0001`–`SG0020`, their meanings, and their severities. @@ -34,7 +49,6 @@ Renaming or removing any of these requires a MAJOR version and a documented migr ### 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 diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md deleted file mode 100644 index af72751..0000000 --- a/docs/wiki/_Sidebar.md +++ /dev/null @@ -1,18 +0,0 @@ -- [Home](Home.md) -- [Getting Started](Getting-Started.md) -- [Attributes Reference](Attributes-Reference.md) -- [Generated Output](Generated-Output.md) -- [Lifecycle: Build vs Configure](Lifecycle-Build-Configure.md) -- [Enablement: IsEnabled vs IsResourceEnabled](Enablement.md) -- [Configuration and Options](Configuration-and-Options.md) -- [OptionsHelper](OptionsHelper.md) -- [Project Resources](Project-Resources.md) -- [Diagnostics](Diagnostics.md) -- [Testing with ResourceKit](Testing-with-ResourceKit.md) -- [Examples: Generated vs Manual](Examples.md) -- [Bundled Agent Skills](Agent-Skills.md) -- [Source Generator Behaviors](Source-Generator-Behaviors.md) -- [Contributing](Contributing.md) -- [Release Flow](Release-Flow.md) -- [Release Notes](Release-Notes.md) -- [Versioning](Versioning.md) diff --git a/docs/wiki/index.md b/docs/wiki/index.md deleted file mode 100644 index 45e6375..0000000 --- a/docs/wiki/index.md +++ /dev/null @@ -1,6 +0,0 @@ -# Aspire ResourceKit - -Build strongly typed .NET Aspire resources with source-generated configuration and lifecycle support. - -[Documentation overview](Home.md){ .md-button .md-button--primary } -[Get started](Getting-Started.md){ .md-button } diff --git a/global.json b/global.json index 06b7ae2..a04962d 100644 --- a/global.json +++ b/global.json @@ -1,14 +1,14 @@ { - "sdk": { - "version": "11.0.100-rc.2.26504.105", - "rollForward": "latestMajor", - "allowPrerelease": true - }, + "sdk": { + "version": "11.0.100-rc.3.26522.148", + "rollForward": "latestMajor", + "allowPrerelease": true + }, "msbuild-sdks": { - "Purview.BuildSdk": "1.1.0", + "Purview.BuildSdk": "1.2.0", "Aspire.AppHost.Sdk": "13.6.4" }, "test": { "runner": "Microsoft.Testing.Platform" } -} \ No newline at end of file +} diff --git a/mkdocs.yml b/mkdocs.yml deleted file mode 100644 index 3c6d5d8..0000000 --- a/mkdocs.yml +++ /dev/null @@ -1,32 +0,0 @@ -site_name: Aspire ResourceKit -site_description: Developer documentation for Aspire ResourceKit -repo_url: https://github.com/purview-dev/aspire-resourcekit -edit_uri: edit/main/docs/wiki/ -docs_dir: docs/wiki -theme: - name: material - palette: - - media: '(prefers-color-scheme: light)' - scheme: default - primary: indigo - accent: cyan - - media: '(prefers-color-scheme: dark)' - scheme: slate - primary: indigo - accent: cyan - features: - - navigation.instant - - navigation.sections - - navigation.top - - search.highlight - - search.suggest - - content.code.copy -plugins: - - search - - techdocs-core -markdown_extensions: - - admonition - - attr_list - - md_in_html - - pymdownx.details - - pymdownx.superfences diff --git a/package.json b/package.json index 4710886..7a9fbb9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aspire-resourcekit", - "version": "1.0.3", + "version": "1.1.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 new file mode 100644 index 0000000..7b3575c --- /dev/null +++ b/scripts/verify-consumers.proj @@ -0,0 +1,194 @@ + + + + + + $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..')) + $(RepoRoot)/src/src/ResourceKit/ResourceKit.csproj + + $(RepoRoot)/artifacts/verify-consumers + $(VerifyRoot)/feed + $(VerifyRoot)/package + $(VerifyRoot)/consumer + + Purview.Aspire.ResourceKit + analyzers/dotnet/cs/Purview.Aspire.ResourceKit.SourceGeneration.dll + analyzers/dotnet/cs/Purview.Aspire.ResourceKit.SourceGeneration.CodeFixes.dll + + + + + + + + + + + + + + + <_PackedNupkg Include="$(FeedDir)/*.nupkg" /> + + + + + + %(_PackedNupkg.Filename) + + + $(PackedVersion.Replace('$(PackageId).', '')) + + + + + + + + + + + + + + + <_PackagedFile Include="$(UnzipDir)/**/*" /> + <_LooseFrameworkDll Include="$(UnzipDir)/**/Purview.SourceGeneratorFramework.dll" /> + <_ShippedPdb Include="$(UnzipDir)/**/*.pdb" /> + <_ShippedGenerator Include="$(UnzipDir)/$(MergedGenerator)" /> + <_ShippedCodeFixes Include="$(UnzipDir)/$(MergedCodeFixes)" /> + <_ShippedRuntime Include="$(UnzipDir)/lib/**/$(PackageId).dll" /> + <_ShippedFloorRuntime Include="$(UnzipDir)/lib/$(MinimumTargetFramework)/$(PackageId).dll" /> + <_ShippedBuildTransitive Include="$(UnzipDir)/buildTransitive/$(PackageId).props" /> + <_ShippedReadme Include="$(UnzipDir)/README.md" /> + <_ShippedLogo Include="$(UnzipDir)/purview-logo-light.png" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <_ConsumerGenerated Include="$(ConsumerDir)/obj/**/*.g.cs" /> + <_ConsumerHostKit Include="$(ConsumerDir)/obj/**/*ConsumerHostKit*.g.cs" /> + + + + + + + + + diff --git a/scripts/verify-consumers/Kits.cs b/scripts/verify-consumers/Kits.cs new file mode 100644 index 0000000..5cc4358 --- /dev/null +++ b/scripts/verify-consumers/Kits.cs @@ -0,0 +1,15 @@ +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Purview.Aspire.ResourceKit; + +// Deliberately carries no base types and no members beyond the two overrides the generator's own +// contracts demand: if the packaged analyzer did not run, this file does not compile. +[HostKit] +partial class ConsumerHostKit; + +[ResourceDefinition("p")] +partial class ParameterKit +{ + protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => + builder.AddParameter(Name, "value"); +} diff --git a/scripts/verify-consumers/nuget.config b/scripts/verify-consumers/nuget.config new file mode 100644 index 0000000..7f63836 --- /dev/null +++ b/scripts/verify-consumers/nuget.config @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..b2eddc3 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,173 @@ +{ + "version": 1, + "skills": { + "purview-architecture": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-architecture/SKILL.md", + "computedHash": "2a0f1110ffee2d437e954fa30a66be0bef38f3e8dcea81f3183b7e0a1e842a4e" + }, + "purview-architecture-testing": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-architecture-testing/SKILL.md", + "computedHash": "04c5dd8e8e091917c1b5ac609596fb0a1b8e212717d1909b33a77f4c9755dea8" + }, + "purview-buildsdk-adoption": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-adoption/SKILL.md", + "computedHash": "ca2e13fc8074281f8ca2b510b742ae99db28f97d78f5fb36a6f27936a91941fc" + }, + "purview-buildsdk-configuration": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-configuration/SKILL.md", + "computedHash": "88b4a655efdc058b9a10adb1b5cbcd44e89bff6ed8c858e066092e5be0ca3e0b" + }, + "purview-buildsdk-project-detection": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-project-detection/SKILL.md", + "computedHash": "7c30062fe592105b7a2539384843759521c5a3f90ace8f7c60f0e6ae2405b0b1" + }, + "purview-buildsdk-project-layout": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-project-layout/SKILL.md", + "computedHash": "101f09c92cf97e93387b926c48adab9598a0cf0a64a4932d41085ab0a0078cfa" + }, + "purview-buildsdk-repository-review": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-repository-review/SKILL.md", + "computedHash": "d366821995ff410caa436ccb996eaa6429f1ba51ba985b210d74ec3963f6c06f" + }, + "purview-buildsdk-skill-installation": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-skill-installation/SKILL.md", + "computedHash": "b61cce9f0f213469c3acab4dd5ebaec450ee2be730680b166c9718c901aedffe" + }, + "purview-buildsdk-skill-tests": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-skill-tests/SKILL.md", + "computedHash": "807b64466de0a871e5f8e678e7686c48a06d8bb9d42a03448f604a35d40d3935" + }, + "purview-buildsdk-testing": { + "source": "purview-dev/build-sdk", + "sourceType": "github", + "skillPath": "skills/purview-buildsdk-testing/SKILL.md", + "computedHash": "d6a2ac1167faf68e8c27493de81c2e5b0d8183e523de4411aca5f5b9249c8a9c" + }, + "purview-component-compatibility": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-component-compatibility/SKILL.md", + "computedHash": "6493828fa51cb32c7c5a2dbaf12368ba8ddbee19f3fa9d9ef0a032f497ee7f85" + }, + "purview-engineering-principles": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-engineering-principles/SKILL.md", + "computedHash": "1dfd7a0d68dac6c032b5c51f44c5f6720069c409b11a267a6330e3f392ea636d" + }, + "purview-org-harvest": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-org-harvest/SKILL.md", + "computedHash": "db7e6b34f205fd2d327b20d0f6cadb8fc0a8945be0ec134290326436b8427644" + }, + "purview-persistence": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-persistence/SKILL.md", + "computedHash": "ebf1a8ed780ed79a6c976762dead934a331dc3ae4b4871809e878a0196a5b4a1" + }, + "purview-sourcegenerator-attribute-data-models": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-attribute-data-models/SKILL.md", + "computedHash": "84c309a0094a44a8ca76f0078c1fccee1f3c99b96b13a90b5b01260274c0cda6" + }, + "purview-sourcegenerator-codewriter": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-codewriter/SKILL.md", + "computedHash": "f24ae680340784e62236d044cbc3658d5ca7bdc3cea3fc44f2bb5cf75a84f5e4" + }, + "purview-sourcegenerator-diagnostics": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-diagnostics/SKILL.md", + "computedHash": "c3d035e053ebcde99de95399c46e9d822d01c9837154ecff5792f5f0dfafa0ca" + }, + "purview-sourcegenerator-packaging": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-packaging/SKILL.md", + "computedHash": "ee2440f1e0aa0cea731eb3eae2ea3c4e9ea77502867abc21e9f0a3c61f20c640" + }, + "purview-sourcegenerator-pipeline": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-pipeline/SKILL.md", + "computedHash": "7d48394d7e3890f288949c79f040646207f34af9ae85827a63d056bae1b1b985" + }, + "purview-sourcegenerator-testing": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-testing/SKILL.md", + "computedHash": "8ea0b5a51f045721c730a3bc57e3a14e79195b9b99738430fbb056263785b75c" + }, + "purview-sourcegenerator-tunit-tests": { + "source": "purview-dev/sourcegenerator-framework", + "sourceType": "github", + "skillPath": "skills/purview-sourcegenerator-tunit-tests/SKILL.md", + "computedHash": "5b801f9bf792f2ae0baeb2a3835680b9f95dbaed2e57ced43815449e64e19132" + }, + "purview-telemetry": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-telemetry/SKILL.md", + "computedHash": "2f36efe5ab26fb04ec3043000999066d70272849429a0055d08d5e8c8a0acae5" + }, + "purview-telemetry-design": { + "source": "purview-dev/telemetry-sourcegenerator", + "sourceType": "github", + "skillPath": "skills/purview-telemetry-design/SKILL.md", + "computedHash": "72a7718dfb7810540e4cc5f4e42526773c81fdfed137989408ac57a7e6c54f36" + }, + "purview-telemetry-diagnostics": { + "source": "purview-dev/telemetry-sourcegenerator", + "sourceType": "github", + "skillPath": "skills/purview-telemetry-diagnostics/SKILL.md", + "computedHash": "72a5e78ed6d67358403fb3948189276d955a8a901c857c4914d689a16e3acc00" + }, + "purview-telemetry-migration": { + "source": "purview-dev/telemetry-sourcegenerator", + "sourceType": "github", + "skillPath": "skills/purview-telemetry-migration/SKILL.md", + "computedHash": "a84e619fee300177767af57fb90fd1423b912d2b0f6337b2c39b2ff9aa8409bc" + }, + "purview-telemetry-multi-targeting": { + "source": "purview-dev/telemetry-sourcegenerator", + "sourceType": "github", + "skillPath": "skills/purview-telemetry-multi-targeting/SKILL.md", + "computedHash": "f220ef392d37139a9870a49e9d5be41bf8374e27b42f2808878daa968c666793" + }, + "purview-telemetry-quickstart": { + "source": "purview-dev/telemetry-sourcegenerator", + "sourceType": "github", + "skillPath": "skills/purview-telemetry-quickstart/SKILL.md", + "computedHash": "c71a9979048ff0beb1b0c538eff2fb84707992cb9bb222646bb3786f7988425b" + }, + "purview-testing-conventions": { + "source": "purview-dev/engineering-playbook", + "sourceType": "github", + "skillPath": "skills/purview-testing-conventions/SKILL.md", + "computedHash": "388b0a79b565ed45a8513d77e8ed6e3d4491f96b43328cb09933cec7811c10f6" + } + } +} diff --git a/skills/purview-resourcekit-authoring/SKILL.md b/skills/purview-resourcekit-authoring/SKILL.md index 5169115..f77fefc 100644 --- a/skills/purview-resourcekit-authoring/SKILL.md +++ b/skills/purview-resourcekit-authoring/SKILL.md @@ -19,8 +19,9 @@ one resource kit per resource, and a generated extension method that wires them dotnet add package Purview.Aspire.ResourceKit ``` -Both public attributes are **generated into the consuming compilation** by the bundled source -generator — there is no separate attribute package to reference. +Requires **.NET 10**: the package ships a single `lib/net10.0` asset, and the two attributes below are +generated into the consuming compilation by the bundled source generator — there is no separate +attribute package to reference. ## The shape @@ -59,6 +60,7 @@ need more. Every participating class must be `partial` (`SG0001`). | `Name` | `string?` | `null` | controls generated naming | | `ExtensionMethodName` | `string?` | derived from the host kit name | renames the generated builder extension | | `GenerateOptions` | `bool` | `true` | enables generated host options | +| `Mode` | `ResourceKitMode` | `All` | execution mode for the host and every resource it composes | ## `[ResourceDefinition]` @@ -66,8 +68,17 @@ need more. Every participating class must be `partial` (`SG0001`). | --- | --- | --- | --- | | `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`) | +| `Mode` | `ResourceKitMode` | `All` | execution mode for this resource | | `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 +override the generated default: + +```csharp +[ResourceDefinition("kv", ResourceKitMode.Publish)] // publish only +[ResourceDefinition(Mode = ResourceKitMode.LocalDevelopment)] // local dev only +``` + ### Two styles, and you pick exactly one per class **Generic (recommended)** — the resource type is on the attribute, and the generator supplies the @@ -177,10 +188,11 @@ override instead of losing the host kit output. ## Enablement gates both phases -Before `BuildResource(...)` runs, `IsResourceEnabled(builder)` is evaluated — **only when `IsEnabled` -is already `true`** — and its result is assigned back to `IsEnabled`. A `false` result skips both -`BuildResource(...)` 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. +Before `BuildResource(...)` runs, the configured `Mode` (`All`, `LocalDevelopment`, `Run`, `Publish`) is +evaluated first, then `IsResourceEnabled(builder)` — **only when `IsEnabled` is already `true`** — and +the combined result is assigned back to `IsEnabled`. A `false` result skips both `BuildResource(...)` +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. ## Growing a composition diff --git a/skills/purview-resourcekit-migration/SKILL.md b/skills/purview-resourcekit-migration/SKILL.md index 0778792..e23db7a 100644 --- a/skills/purview-resourcekit-migration/SKILL.md +++ b/skills/purview-resourcekit-migration/SKILL.md @@ -26,8 +26,9 @@ By the end of migration: - Prefer incremental commits/patches and verify after each phase. - Keep `BuildResource(...)` pure to resource construction; place cross-resource references in `ConfigureResource()`. - Use `IsEnabled` for static/config-driven toggles. -- Use `IsResourceEnabled(builder)` for runtime-state decisions (environment, publish mode, feature probes). -- If `IsResourceEnabled(builder)` returns `false`, both build and configure paths are skipped for that resource. +- Use `Mode` (`ResourceKitMode`) for publish-only, run-only or local-development-only resources. +- Use `IsResourceEnabled(builder)` for runtime-state decisions that are not a mode (feature probes, dynamic configuration). +- If `Mode`, `IsEnabled` or `IsResourceEnabled(builder)` excludes the resource, both build and configure paths are skipped for it. ## Discovery checklist @@ -140,7 +141,8 @@ Use these properties via generated `Options` surfaces: Translate existing conditional logic as follows: - Config-only conditions → default to `IsEnabled` option values. -- Runtime conditions → override `IsResourceEnabled(builder)`. +- Publish/run/local-development conditions → `ResourceKitMode` on the attribute (or a `Mode` config override). +- Other runtime conditions → override `IsResourceEnabled(builder)`. Pattern: @@ -156,8 +158,9 @@ protected override bool IsResourceEnabled(IDistributedApplicationBuilder builder When AppHost has multiple mode branches, map each branch into targeted resource kits with branch-aware enablement: -- Publish-only resources: override `IsResourceEnabled(builder)` with `builder.ExecutionContext.IsPublishMode`. -- Run-only resources: override `IsResourceEnabled(builder)` with `builder.ExecutionContext.IsRunMode`. +- Publish-only resources: `[ResourceDefinition(ResourceKitMode.Publish)]`. +- Run-only resources: `[ResourceDefinition(ResourceKitMode.Run)]`. +- Local-development-only resources (docs sites, diagram sites): `[ResourceDefinition(ResourceKitMode.LocalDevelopment)]`. - Test-mode-only behavior: include config predicates such as `builder.Configuration.GetValue("Testing:IsTestMode")`. ### 6) Update AppHost entrypoint diff --git a/skills/purview-resourcekit-options/SKILL.md b/skills/purview-resourcekit-options/SKILL.md index dd3e574..acf38a1 100644 --- a/skills/purview-resourcekit-options/SKILL.md +++ b/skills/purview-resourcekit-options/SKILL.md @@ -2,9 +2,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, section-name resolution, disabling a resource - with IsEnabled, overriding enablement with IsResourceEnabled, and building args or environment - variables with OptionsHelper. + 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. metadata: product: Purview.Aspire.ResourceKit repository: purview-dev/aspire-resourcekit @@ -12,8 +12,8 @@ metadata: # Configuration, enablement and OptionsHelper -ResourceKit generates typed options for the host and for each resource, so names and enablement are -configuration rather than code. +ResourceKit generates typed options for the host and for each resource, so names, enablement and the +run mode are configuration rather than code. ## The generated shape @@ -22,6 +22,8 @@ public class ShopHostKitOptions { public const string SectionName = "ShopHostKit"; + public ResourceKitMode Mode { get; set; } = ResourceKitMode.All; + public APIKitOptions Api { get; set; } = new(); public RedisKitOptions Redis { get; set; } = new(); } @@ -30,11 +32,13 @@ public class APIKitOptions { public string Name { get; set; } = "api"; public bool IsEnabled { get; set; } = true; + public ResourceKitMode Mode { get; set; } = ResourceKitMode.All; } ``` Host options bind to `SectionName` and `ValidateOnStart()` is called; resource options nest under the -generated resource property name. Typical keys: `ShopHostKit:API:Name`, `ShopHostKit:API:IsEnabled`. +generated resource property name. Typical keys: `ShopHostKit:API:Name`, `ShopHostKit:API:IsEnabled`, +`ShopHostKit:API:Mode`, `ShopHostKit:Mode`. ## Extending them @@ -85,32 +89,71 @@ When a section name is not passed explicitly, it resolves in this order: Suffix trimming ignores generic type arguments. `OptionsHelper.SectionNameFor()` (or the `Type` overload) returns the resolved name. +## Run modes + +`Mode` selects which AppHost execution a kit participates in, so publish-only and local-only resources +are configuration instead of branching code: + +| Mode | Participates when | +| --- | --- | +| `All` (default) | always | +| `LocalDevelopment` | run mode and the `Development` environment | +| `Run` | run mode, any environment | +| `Publish` | publish mode (`aspire publish` / `azd`) | + +Declare the default on the attribute (named property or constructor argument): + +```csharp +[ResourceDefinition("kv", ResourceKitMode.Publish)] +[ResourceDefinition(Mode = ResourceKitMode.Publish)] +``` + +Override it from configuration, host-wide or per resource: + +```json +{ "ShopHostKit": { "Mode": "All", "KeyVault": { "Mode": "Publish" } } } +``` + +The host `Mode` gates every resource in the host kit; a resource `Mode` gates that resource only. Manual +kits override the `Mode` property instead. `ResourceKitModeExtensions.IsEnabledFor(mode, builder)` +evaluates a mode yourself if you need to. + ## `IsEnabled` versus `IsResourceEnabled` Two toggles, and the interaction is the part people get wrong: -- **`IsEnabled`** — the persisted/configured flag, usually bound from generated options. +- **`IsEnabled`** — the persisted/configured flag, usually bound from generated options, and the only + member consumers need to check. - **`IsResourceEnabled(builder)`** — the runtime decision hook. Its default returns `IsEnabled`. During `Build`: -1. if `IsEnabled` is `true`, `IsResourceEnabled(builder)` is called and its result assigned back to +1. the configured `Mode` is evaluated first; if it does not participate, `IsEnabled` becomes `false` and + the hook never runs; +2. if `IsEnabled` is `true`, `IsResourceEnabled(builder)` is called and its result assigned back to `IsEnabled`; -2. if the result is `false`, both `BuildResource(...)` and `ConfigureResource()` are skipped. +3. if the result is `false`, both `BuildResource(...)` and `ConfigureResource()` are skipped. Two consequences worth stating plainly: -- **The hook only runs when `IsEnabled` is already `true`.** A kit disabled through options is - *never* re-enabled by the hook. That is deliberate: configuration is the outer gate. +- **The hook only runs when `IsEnabled` is already `true` and the mode participates.** A kit disabled + through options is *never* re-enabled by the hook. That is deliberate: configuration is the outer gate. - **`ResourceBuilder` cannot be accessed while the resource is disabled** — the property guards itself and throws `InvalidOperationException`. -Override the hook when enablement depends on runtime state: +Override the hook when enablement depends on runtime state that is not a mode: ```csharp -// Publish-only resources - a Key Vault, a publish marker parameter. +// A feature flag, not an execution mode. protected override bool IsResourceEnabled(IDistributedApplicationBuilder builder) => - builder.ExecutionContext.IsPublishMode; + builder.Configuration["Features:KeyVault"] == "true"; +``` + +Publish-only resources need no hook — use the mode: + +```csharp +[ResourceDefinition("kv", ResourceKitMode.Publish)] +sealed partial class KeyVaultKit; ``` Disabling from configuration needs no code change: diff --git a/skills/purview-resourcekit-testing/SKILL.md b/skills/purview-resourcekit-testing/SKILL.md index 57b4b34..0428c76 100644 --- a/skills/purview-resourcekit-testing/SKILL.md +++ b/skills/purview-resourcekit-testing/SKILL.md @@ -17,16 +17,19 @@ AppHost. ## Test tiers -This repository uses [TUnit](https://thomhurst.github.io/TUnit/) with -[TUnit.Aspire](https://www.nuget.org/packages/TUnit.Aspire/), under `src/tests`: +ResourceKit requires **.NET 10** (the package targets `net10.0` only). This repository uses +[TUnit](https://thomhurst.github.io/TUnit/) with [TUnit.Aspire](https://www.nuget.org/packages/TUnit.Aspire/), +under `src/tests`: | Project | Covers | | --- | --- | -| `ResourceKit.UnitTests` | runtime behaviour of the kit base types and `OptionsHelper` | +| `ResourceKit.UnitTests` | runtime behaviour of the kit base types, run modes 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 and the architecture policy | -Reports land in `TestResults/`. +Reports land in `TestResults/`. The project **name** sets the tier (`*.UnitTests` → `Category=Unit`); +nothing is tagged per test. ## Integration-testing an AppHost @@ -51,9 +54,14 @@ public sealed class ExampleAppHostIntegrationTests(ExampleAppHostFixture( + c => c.KeyVault.Mode = ResourceKitMode.LocalDevelopment +).Build(), +``` + Assert that the override actually took effect, rather than that the host started: ```csharp [Test] public async Task AppHost_WithCustomOptions_IsPassedToTheHostKit(CancellationToken cancellationToken) { - await Helpers.ConnectionStringIsUnavailableAsync(fixture, "redis", cancellationToken); + // Arrange + // The fixture starts the AppHost with the options above as command-line arguments. + + // Act + await fixture.ConnectionStringIsUnavailableAsync(Platform.ResourceKits.Redis, cancellationToken); + // Assert await Assert .That(fixture.GetResourceSnapshot(CustomOptionsExampleAppHostFixture.AzureStorageName)) .IsNotNull() diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 6f76bf7..98b5a0b 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -4,7 +4,6 @@ $(DefaultTargetFramework) Purview.Aspire.ResourceKit - false https://github.com/purview-dev/aspire-resourcekit @@ -20,7 +19,6 @@ Purview Contributors Purview.dev $(PurviewProjectUrl) - $(RepositoryUrl) git true purview-logo-light.png diff --git a/src/Versions.props b/src/Versions.props index 9850ca6..fba8f18 100644 --- a/src/Versions.props +++ b/src/Versions.props @@ -1,6 +1,14 @@ - net8.0 - $(DefaultTargetFramework);net9.0;net10.0 + + net10.0 diff --git a/src/src/Example.AppHost/AppModels/Resources/KeyVaultKit.cs b/src/src/Example.AppHost/AppModels/Resources/KeyVaultKit.cs index 7b8137b..b3acf1d 100644 --- a/src/src/Example.AppHost/AppModels/Resources/KeyVaultKit.cs +++ b/src/src/Example.AppHost/AppModels/Resources/KeyVaultKit.cs @@ -2,12 +2,9 @@ namespace Purview.Aspire.ResourceKit.Example.AppHost.AppModels.Resources; -[ResourceDefinition(Platform.ResourceKits.KeyVault)] +[ResourceDefinition(Platform.ResourceKits.KeyVault, ResourceKitMode.Publish)] sealed partial class KeyVaultKit { - protected override bool IsResourceEnabled(IDistributedApplicationBuilder builder) => - builder.ExecutionContext.IsPublishMode; - protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => builder.AddAzureKeyVault(Name); } diff --git a/src/src/Example.AppHost/AppModels/Resources/PublishMarkerKit.cs b/src/src/Example.AppHost/AppModels/Resources/PublishMarkerKit.cs index 7f8c4a3..2d2712c 100644 --- a/src/src/Example.AppHost/AppModels/Resources/PublishMarkerKit.cs +++ b/src/src/Example.AppHost/AppModels/Resources/PublishMarkerKit.cs @@ -1,11 +1,8 @@ namespace Purview.Aspire.ResourceKit.Example.AppHost.AppModels.Resources; -[ResourceDefinition(Platform.ResourceKits.PublishMarker)] +[ResourceDefinition(Platform.ResourceKits.PublishMarker, ResourceKitMode.Publish)] sealed partial class PublishMarkerKit { - protected override bool IsResourceEnabled(IDistributedApplicationBuilder builder) => - builder.ExecutionContext.IsPublishMode; - protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) => builder.AddParameter(Name, "enabled", secret: false); } diff --git a/src/src/Example.AppHost/Example.AppHost.csproj b/src/src/Example.AppHost/Example.AppHost.csproj index a08aaf2..fb93571 100644 --- a/src/src/Example.AppHost/Example.AppHost.csproj +++ b/src/src/Example.AppHost/Example.AppHost.csproj @@ -1,9 +1,7 @@  true - true true - false $(NoWarn);CA1034;CA1062; true - true true - false diff --git a/src/src/Example.ManualAppHost/Properties/AssemblyInfo.cs b/src/src/Example.ManualAppHost/Properties/AssemblyInfo.cs deleted file mode 100644 index 7f9c96f..0000000 --- a/src/src/Example.ManualAppHost/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,3 +0,0 @@ -using System.Runtime.CompilerServices; - -[assembly: InternalsVisibleTo("ResourceKit.IntegrationTests")] diff --git a/src/src/ResourceKit/HostKitBase.cs b/src/src/ResourceKit/HostKitBase.cs index a9667cd..b4580e9 100644 --- a/src/src/ResourceKit/HostKitBase.cs +++ b/src/src/ResourceKit/HostKitBase.cs @@ -19,6 +19,16 @@ public abstract class HostKitBase : IHostKit /// The resources will be empty until is called. protected ImmutableArray> Resources { get; private set; } = []; + /// + /// Gets the execution mode in which this host kit, and therefore every resource it composes, participates. + /// + /// + /// Defaults to . Generated host kits override this member to surface the + /// configured host Mode option. When the mode does not participate in the current run, every resource + /// is disabled before it is built, so and both skip them. + /// + protected virtual ResourceKitMode Mode => ResourceKitMode.All; + /// /// Adds a resource to the host kit. /// @@ -48,11 +58,20 @@ bool SealResources() /// public virtual void Build(IDistributedApplicationBuilder builder) { + ArgumentNullException.ThrowIfNull(builder); + if (!SealResources()) return; + var modeEnabled = Mode.IsEnabledFor(builder); + foreach (var resource in Resources) + { + if (!modeEnabled) + resource.IsEnabled = false; + resource.Build(builder); + } } /// diff --git a/src/src/ResourceKit/ResourceKit.csproj b/src/src/ResourceKit/ResourceKit.csproj index e00fd72..f803b9f 100644 --- a/src/src/ResourceKit/ResourceKit.csproj +++ b/src/src/ResourceKit/ResourceKit.csproj @@ -1,7 +1,5 @@ - - $(AllTargetFrameworks) true Purview Aspire ResourceKit Composable and test-friendly Aspire resource isolation model for local, running, and publishing modes. diff --git a/src/src/ResourceKit/ResourceKitBase.cs b/src/src/ResourceKit/ResourceKitBase.cs index 0313b91..476e510 100644 --- a/src/src/ResourceKit/ResourceKitBase.cs +++ b/src/src/ResourceKit/ResourceKitBase.cs @@ -44,6 +44,21 @@ public string Name /// public bool IsEnabled { get; set; } = true; + /// + /// Gets the execution mode in which this resource participates. + /// + /// + /// + /// Defaults to . Generated resource kits override this member to surface the + /// configured Mode option; manual resource kits can override it directly. + /// + /// + /// The mode is evaluated by before is invoked, so a + /// resource excluded by its mode never runs that hook and never builds. + /// + /// + protected virtual ResourceKitMode Mode => ResourceKitMode.All; + /// /// Gets the underlying Aspire resource builder for this resource. /// @@ -71,7 +86,8 @@ private set /// when the resource should be built; otherwise . /// /// The default implementation returns . This hook is only invoked when - /// is already ; a disabled kit is never re-enabled here. + /// is already and participates in the + /// current run; a disabled or mode-excluded kit is never re-enabled here. /// protected virtual bool IsResourceEnabled(IDistributedApplicationBuilder builder) => IsEnabled; @@ -94,7 +110,7 @@ public void Build(IDistributedApplicationBuilder builder) ArgumentException.ThrowIfNullOrWhiteSpace(Name, nameof(Name)); if (IsEnabled) - IsEnabled = IsResourceEnabled(builder); + IsEnabled = Mode.IsEnabledFor(builder) && IsResourceEnabled(builder); if (!IsEnabled) return; diff --git a/src/src/ResourceKit/ResourceKitMode.cs b/src/src/ResourceKit/ResourceKitMode.cs new file mode 100644 index 0000000..83ac85d --- /dev/null +++ b/src/src/ResourceKit/ResourceKitMode.cs @@ -0,0 +1,40 @@ +namespace Purview.Aspire.ResourceKit; + +/// +/// Selects the AppHost execution mode(s) in which a host kit or resource kit participates. +/// +/// +/// +/// The mode is a configured option, so participation is selected from configuration rather than coded into +/// IsResourceEnabled overrides. The default is . +/// +/// +/// Enablement is still observed through : when a mode excludes a +/// kit, sets IsEnabled to +/// so both BuildResource(...) and ConfigureResource() are skipped. +/// +/// +public enum ResourceKitMode +{ + /// + /// Participates in every mode. This is the default. + /// + All = 0, + + /// + /// Participates only when the AppHost runs locally in the development environment, for example a + /// documentation site or a diagram site that only helps while developing. + /// + LocalDevelopment = 1, + + /// + /// Participates only in run mode, regardless of environment. + /// + Run = 2, + + /// + /// Participates only in publish mode, for example Azure Application Insights or an Azure Container Apps + /// environment that only exists once deployed. + /// + Publish = 3, +} diff --git a/src/src/ResourceKit/ResourceKitModeExtensions.cs b/src/src/ResourceKit/ResourceKitModeExtensions.cs new file mode 100644 index 0000000..8c2cd2f --- /dev/null +++ b/src/src/ResourceKit/ResourceKitModeExtensions.cs @@ -0,0 +1,37 @@ +using Aspire.Hosting; +using Microsoft.Extensions.Hosting; + +namespace Purview.Aspire.ResourceKit; + +/// +/// Provides helpers for evaluating a against the current AppHost execution. +/// +public static class ResourceKitModeExtensions +{ + /// + /// Determines whether the specified participates in the current AppHost execution. + /// + /// The configured . + /// + /// The distributed application builder supplying the execution context and hosting environment. + /// + /// when the mode participates; otherwise . + /// + /// requires run mode and the development environment, + /// while requires run mode only and therefore also covers local development. + /// + public static bool IsEnabledFor(this ResourceKitMode mode, IDistributedApplicationBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + + return mode switch + { + ResourceKitMode.All => true, + ResourceKitMode.LocalDevelopment => builder.ExecutionContext.IsRunMode + && builder.Environment.IsDevelopment(), + ResourceKitMode.Run => builder.ExecutionContext.IsRunMode, + ResourceKitMode.Publish => builder.ExecutionContext.IsPublishMode, + _ => false, + }; + } +} diff --git a/src/src/ResourceKit/Sdk/README.md b/src/src/ResourceKit/Sdk/README.md index af18698..4f4d6a9 100644 --- a/src/src/ResourceKit/Sdk/README.md +++ b/src/src/ResourceKit/Sdk/README.md @@ -4,6 +4,10 @@ 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. + ## Package goals - Keep AppHost resource composition explicit and testable. @@ -126,13 +130,18 @@ This happens before `DistributedApplication.Build()` completes. This separation keeps creation and cross-resource wiring explicit and deterministic. -### How `IsEnabled` and `IsResourceEnabled(...)` interact +### How `Mode`, `IsEnabled` and `IsResourceEnabled(...)` interact -- `IsEnabled` is the current enablement flag (usually sourced from generated options). -- During `Build`, ResourceKit evaluates `IsResourceEnabled(builder)` and assigns that result to `IsEnabled`. +- `Mode` selects which AppHost execution the kit participates in: `All` (default), `LocalDevelopment`, + `Run`, or `Publish`. It is bound from the generated options (`Mode`) and declared on the attribute. +- `IsEnabled` is the current enablement flag (usually sourced from generated options) and the single + member consumers need to check. +- During `Build`, ResourceKit evaluates the `Mode` first, then `IsResourceEnabled(builder)`, and assigns + the combined result to `IsEnabled`. - If disabled, both `BuildResource(...)` and `ConfigureResource()` are skipped for that resource. -Override `IsResourceEnabled(builder)` when enablement should react to runtime state rather than only static options. +Use `Mode` for publish-only or local-development-only resources. Override `IsResourceEnabled(builder)` +when enablement should react to runtime state beyond an execution mode. ## Options and configuration @@ -141,6 +150,7 @@ When options are generated: - Host options root section is the generated host options type name (or configured section). - Resource options are nested by generated resource property name. - `IsEnabled` can be used to skip a resource at runtime. +- `Mode` can be used to restrict a resource (or the whole host) to `LocalDevelopment`, `Run`, or `Publish`. See detailed [configuration and options](https://purview.dev/docs/aspire-resourcekit/configuration/) patterns. diff --git a/src/src/SourceGeneration.CodeFixes/SourceGeneration.CodeFixes.csproj b/src/src/SourceGeneration.CodeFixes/SourceGeneration.CodeFixes.csproj index 05d5a59..09acdc9 100644 --- a/src/src/SourceGeneration.CodeFixes/SourceGeneration.CodeFixes.csproj +++ b/src/src/SourceGeneration.CodeFixes/SourceGeneration.CodeFixes.csproj @@ -1,10 +1,13 @@ - netstandard2.0 - latest - enable - true - true + + true diff --git a/src/src/SourceGeneration/Extensions/System/Runtime/CompilerServices/IsExternalInit.cs b/src/src/SourceGeneration/Extensions/System/Runtime/CompilerServices/IsExternalInit.cs deleted file mode 100644 index ae5a598..0000000 --- a/src/src/SourceGeneration/Extensions/System/Runtime/CompilerServices/IsExternalInit.cs +++ /dev/null @@ -1,15 +0,0 @@ -#if NETSTANDARD2_0 || NETSTANDARD2_1_OR_GREATER || NETCOREAPP2_0 || NETCOREAPP2_1 || NETCOREAPP2_2 || NETCOREAPP3_0 || NETCOREAPP3_1 || NET45 || NET451 || NET452 || NET6 || NET461 || NET462 || NET47 || NET471 || NET472 || NET48 - -using System.ComponentModel; - -// Compilation error of CS0518 IsExternalInit is not defined when using .NET Standard. -// re: https://mking.net/blog/error-cs0518-isexternalinit-not-defined -#pragma warning disable IDE0130 // Namespace does not match folder structure -namespace System.Runtime.CompilerServices; - -#pragma warning restore IDE0130 // Namespace does not match folder structure - -[EditorBrowsable(EditorBrowsableState.Never)] -static class IsExternalInit; - -#endif diff --git a/src/src/SourceGeneration/Helpers/CodeGenEmiiter.Attributes.cs b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.Attributes.cs index 709c1b1..bf9e495 100644 --- a/src/src/SourceGeneration/Helpers/CodeGenEmiiter.Attributes.cs +++ b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.Attributes.cs @@ -50,6 +50,55 @@ static SourceText HostKitAttribute() static ctor => ctor.Assignment("GenerateOptions", "generateOptions") ); + attributeBody + .XmlSummary("Initializes a new instance of the HostKitAttribute class.") + .XmlParam("mode", "The execution mode in which the host kit participates.") + .Constructor( + new(TypeLibrary.Purview.Aspire.ResourceKit.HostKitAttribute) + { + Parameters = [new("mode", TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitMode)], + }, + static ctor => ctor.Assignment("Mode", "mode") + ); + + attributeBody + .XmlSummary("Initializes a new instance of the HostKitAttribute class.") + .XmlParam("generateOptions", "Whether the Host App generates IOptions support.") + .XmlParam("mode", "The execution mode in which the host kit participates.") + .Constructor( + new(TypeLibrary.Purview.Aspire.ResourceKit.HostKitAttribute) + { + Parameters = + [ + new("generateOptions", TypeLibrary.System.Boolean), + new("mode", TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitMode), + ], + }, + static ctor => + ctor.Assignment("GenerateOptions", "generateOptions").Assignment("Mode", "mode") + ); + + attributeBody + .XmlSummary("Initializes a new instance of the HostKitAttribute class.") + .XmlParam("name", "Optional logical host app name used for generated type naming.") + .XmlParam("generateOptions", "Whether the Host App generates IOptions support.") + .XmlParam("mode", "The execution mode in which the host kit participates.") + .Constructor( + new(TypeLibrary.Purview.Aspire.ResourceKit.HostKitAttribute) + { + Parameters = + [ + new("name", TypeLibrary.System.String), + new("generateOptions", TypeLibrary.System.Boolean), + new("mode", TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitMode), + ], + }, + static ctor => + ctor.Assignment("Name", "name") + .Assignment("GenerateOptions", "generateOptions") + .Assignment("Mode", "mode") + ); + attributeBody .XmlSummary("Gets or sets an optional logical host app name used in generated type names.") .Property( @@ -89,6 +138,8 @@ static SourceText HostKitAttribute() IsInitOnly = true, } ); + + AddModeProperty(attributeBody); } ); } @@ -137,6 +188,54 @@ static SourceText ResourceKitDefinitionAttribute() static ctor => ctor.Assignment("Name", "name") ); + attributeBody + .XmlSummary("Initializes a new instance of the ResourceDefinitionAttribute class.") + .XmlParam("mode", "The execution mode in which this resource participates.") + .Constructor( + new(TypeLibrary.Purview.Aspire.ResourceKit.ResourceDefinitionAttribute) + { + Parameters = [new("mode", TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitMode)], + }, + static ctor => ctor.Assignment("Mode", "mode") + ); + + attributeBody + .XmlSummary("Initializes a new instance of the ResourceDefinitionAttribute class.") + .XmlParam("name", "Logical resource name used in Aspire resource registration.") + .XmlParam("mode", "The execution mode in which this resource participates.") + .Constructor( + new(TypeLibrary.Purview.Aspire.ResourceKit.ResourceDefinitionAttribute) + { + Parameters = + [ + new("name", TypeLibrary.System.String), + new("mode", TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitMode), + ], + }, + static ctor => ctor.Assignment("Name", "name").Assignment("Mode", "mode") + ); + + attributeBody + .XmlSummary("Initializes a new instance of the ResourceDefinitionAttribute class.") + .XmlParam("name", "Logical resource name used in Aspire resource registration.") + .XmlParam("propertyName", "Generated host-app property name for this resource.") + .XmlParam("mode", "The execution mode in which this resource participates.") + .Constructor( + new(TypeLibrary.Purview.Aspire.ResourceKit.ResourceDefinitionAttribute) + { + Parameters = + [ + new("name", TypeLibrary.System.String), + new("propertyName", TypeLibrary.System.String), + new("mode", TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitMode), + ], + }, + static ctor => + ctor.Assignment("Name", "name") + .Assignment("PropertyName", "propertyName") + .Assignment("Mode", "mode") + ); + attributeBody .XmlComment("Gets or sets the logical resource name used in Aspire resource registration.") .Property( @@ -162,6 +261,8 @@ static SourceText ResourceKitDefinitionAttribute() IsInitOnly = true, } ); + + AddModeProperty(attributeBody); } ); @@ -207,6 +308,54 @@ static SourceText ResourceKitDefinitionAttribute() }, static ctor => ctor.Comment("Empty") ); + + attributeBody + .XmlSummary("Initializes a new instance of the ResourceDefinitionAttribute class.") + .XmlParam("mode", "The execution mode in which this resource participates.") + .Constructor( + new(TypeLibrary.Purview.Aspire.ResourceKit.ResourceDefinitionAttribute) + { + Parameters = [new("mode", TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitMode)], + Initializer = "base(mode)", + }, + static ctor => ctor.Comment("Empty") + ); + + attributeBody + .XmlSummary("Initializes a new instance of the ResourceDefinitionAttribute class.") + .XmlParam("name", "Logical resource name used in Aspire resource registration.") + .XmlParam("mode", "The execution mode in which this resource participates.") + .Constructor( + new(TypeLibrary.Purview.Aspire.ResourceKit.ResourceDefinitionAttribute) + { + Parameters = + [ + new("name", TypeLibrary.System.String), + new("mode", TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitMode), + ], + Initializer = "base(name, mode)", + }, + static ctor => ctor.Comment("Empty") + ); + + attributeBody + .XmlSummary("Initializes a new instance of the ResourceDefinitionAttribute class.") + .XmlParam("name", "Logical resource name used in Aspire resource registration.") + .XmlParam("propertyName", "Generated host-app property name for this resource.") + .XmlParam("mode", "The execution mode in which this resource participates.") + .Constructor( + new(TypeLibrary.Purview.Aspire.ResourceKit.ResourceDefinitionAttribute) + { + Parameters = + [ + new("name", TypeLibrary.System.String), + new("propertyName", TypeLibrary.System.String), + new("mode", TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitMode), + ], + Initializer = "base(name, propertyName, mode)", + }, + static ctor => ctor.Comment("Empty") + ); } ); @@ -219,4 +368,20 @@ static CodeWriter CreateWriter() return writer.AutoGeneratedHeader().FileScopedNamespace(TypeLibrary.Purview.Aspire.ResourceKit.Namespace); } + + static void AddModeProperty(CodeWriter attributeBody) + { + attributeBody + .XmlSummary( + "Gets or sets the execution mode in which the host kit or resource kit participates.", + $"Defaults to ." + ) + .Property( + new("Mode", TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitMode, TypeDeclarationAccessibility.Public) + { + Initializer = $"{TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitMode}.All", + IsInitOnly = true, + } + ); + } } diff --git a/src/src/SourceGeneration/Helpers/CodeGenEmiiter.HostKit.cs b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.HostKit.cs index 8a4bfbb..2c3f804 100644 --- a/src/src/SourceGeneration/Helpers/CodeGenEmiiter.HostKit.cs +++ b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.HostKit.cs @@ -64,6 +64,12 @@ static void EmitHostKit(OutputContext context, CancellationToken cancellationTok ); } + // Surface the execution mode from the bound options (or the attribute) so the base class gates Build. + ModeOverride( + context.Writer, + context.HostKit.ShouldGenerateOptions ? "Options.Mode" : ModeExpression(context.HostKit.Mode) + ); + // Write the Resource Kit properties if (context.Model.HasResourceKits) { @@ -379,6 +385,23 @@ static void GenerateHostKitOptionsClass(OutputContext context, CancellationToken } ); + context + .Writer.NewLine() + .XmlSummary( + "Gets or sets the execution mode in which this host kit and all of its resources participate." + ) + .Property( + new( + "Mode", + TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitMode, + TypeDeclarationAccessibility.Public + ) + { + HasSetter = true, + Initializer = ModeExpression(context.HostKit.Mode), + } + ); + if (context.HasResourceKits) { foreach ( diff --git a/src/src/SourceGeneration/Helpers/CodeGenEmiiter.ResourceKit.cs b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.ResourceKit.cs index ab55bdf..675d9a7 100644 --- a/src/src/SourceGeneration/Helpers/CodeGenEmiiter.ResourceKit.cs +++ b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.ResourceKit.cs @@ -92,6 +92,12 @@ static void EmitResourceKits(OutputContext context, CancellationToken cancellati { GenerateResourceKitOptionsClass(context, resourceKit, cancellationToken); } + + // Surface the execution mode from the bound options (or the attribute) so the base class gates Build. + ModeOverride( + context.Writer, + context.HostKit.ShouldGenerateOptions ? "Options.Mode" : ModeExpression(resourceKit.Mode) + ); } } @@ -146,6 +152,21 @@ CancellationToken cancellationToken Initializer = "true", } ); + + context + .Writer.NewLine() + .XmlSummary("Gets or sets the execution mode in which this resource participates.") + .Property( + new( + "Mode", + TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitMode, + TypeDeclarationAccessibility.Public + ) + { + HasSetter = true, + Initializer = ModeExpression(resourceKit.Mode), + } + ); } } } diff --git a/src/src/SourceGeneration/Helpers/CodeGenEmiiter.cs b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.cs index 1f7f1de..c6e9894 100644 --- a/src/src/SourceGeneration/Helpers/CodeGenEmiiter.cs +++ b/src/src/SourceGeneration/Helpers/CodeGenEmiiter.cs @@ -30,4 +30,32 @@ public static CodeWriter Emit(OutputContext outputContext, CancellationToken can return writer; } + + // Attribute-supplied modes arrive as a fully-qualified enum member name (for example + // "global::Purview.Aspire.ResourceKit.ResourceKitMode.Publish"). Normalise them so the value can be used + // as generated code, and fall back to the default when no mode was supplied. + static string ModeExpression(string? mode) + { + if (mode is null || mode.Trim().Length == 0) + return $"{TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitMode}.All"; + + // Ensure the mode is fully-qualified. + return mode.StartsWith("global::", StringComparison.Ordinal) ? mode : $"global::{mode}"; + } + + static CodeWriter ModeOverride(CodeWriter writer, string expression) => + writer + .NewLine() + .XmlInheritDoc() + .Property( + new( + "Mode", + TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitMode, + TypeDeclarationAccessibility.Protected + ) + { + IsOverride = true, + ExpressionBody = expression, + } + ); } diff --git a/src/src/SourceGeneration/Helpers/SourceGenLibrary.cs b/src/src/SourceGeneration/Helpers/SourceGenLibrary.cs index 2b07f92..5b90500 100644 --- a/src/src/SourceGeneration/Helpers/SourceGenLibrary.cs +++ b/src/src/SourceGeneration/Helpers/SourceGenLibrary.cs @@ -162,7 +162,8 @@ CancellationToken cancellationToken OptionsType: optionsType, ResourceKitBaseType: TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitBase, Accessibility: symbol.DeclaredAccessibility.ToTypeDeclarationAccessibility(), - ExtensionMethodName: data.ExtensionMethodName ?? PropertyLibrary.DefaultExtensionMethodName + ExtensionMethodName: data.ExtensionMethodName ?? PropertyLibrary.DefaultExtensionMethodName, + Mode: data.Mode ), diagnostics ); @@ -202,7 +203,8 @@ CancellationToken cancellationToken Accessibility: symbol.DeclaredAccessibility.ToTypeDeclarationAccessibility(), PropertyName: propertyName, ResourceName: resourceName ?? "", - HasExplicitBaseType: hasExplicitBaseType + HasExplicitBaseType: hasExplicitBaseType, + Mode: matchedAttribute.Instance.Mode ), diagnostics ); diff --git a/src/src/SourceGeneration/Helpers/TypeLibraryGenerator.cs b/src/src/SourceGeneration/Helpers/TypeLibraryGenerator.cs index 68d9825..1543d50 100644 --- a/src/src/SourceGeneration/Helpers/TypeLibraryGenerator.cs +++ b/src/src/SourceGeneration/Helpers/TypeLibraryGenerator.cs @@ -32,6 +32,9 @@ static partial class TypeLibraryGenerator [TypeRef(PurviewAspireResourceKitNamespace, 1, true)] static readonly TypeIdentity IResourceKit = default; + [TypeRef(PurviewAspireResourceKitNamespace, IncludeInGetTypes = true)] + static readonly TypeIdentity ResourceKitMode = default; + // Other required types [TypeRef("Microsoft.Extensions.Configuration")] static readonly TypeIdentity ConfigurationBinder = default; diff --git a/src/src/SourceGeneration/Models/AttributeDataModels.cs b/src/src/SourceGeneration/Models/AttributeDataModels.cs index 998a6c0..673023a 100644 --- a/src/src/SourceGeneration/Models/AttributeDataModels.cs +++ b/src/src/SourceGeneration/Models/AttributeDataModels.cs @@ -9,7 +9,10 @@ readonly partial record struct HostKitAttributeData( string? Name, string? ExtensionMethodName, #pragma warning disable format - [Property] [Argument("generateOptions", DefaultValue = true)] bool GenerateOptions + [Property] [Argument("generateOptions", DefaultValue = true)] bool GenerateOptions, + [Property(IsEnum = true, DefaultValue = "Purview.Aspire.ResourceKit.ResourceKitMode.All")] + [Argument("mode", IsEnum = true, DefaultValue = "Purview.Aspire.ResourceKit.ResourceKitMode.All")] + string? Mode #pragma warning restore format ); @@ -25,5 +28,8 @@ readonly partial record struct ResourceDefinitionAttributeData( // For the ctor [Argument("propertyName")] string? PropertyName, + [Property(IsEnum = true, DefaultValue = "Purview.Aspire.ResourceKit.ResourceKitMode.All")] + [Argument("mode", IsEnum = true, DefaultValue = "Purview.Aspire.ResourceKit.ResourceKitMode.All")] + string? Mode, [GenericTypeArgument] TypeIdentity AspireResourceType ); diff --git a/src/src/SourceGeneration/Models/KitGenerationModels.cs b/src/src/SourceGeneration/Models/KitGenerationModels.cs index 2da0028..085c9ba 100644 --- a/src/src/SourceGeneration/Models/KitGenerationModels.cs +++ b/src/src/SourceGeneration/Models/KitGenerationModels.cs @@ -76,7 +76,8 @@ readonly record struct HostKitModel( TypeIdentity OptionsType, TypeIdentity ResourceKitBaseType, TypeDeclarationAccessibility? Accessibility, - string ExtensionMethodName + string ExtensionMethodName, + string? Mode ) { public bool ShouldGenerateOptions => OptionsType != TypeIdentity.Empty; @@ -89,5 +90,6 @@ readonly record struct ResourceKitModel( TypeDeclarationAccessibility? Accessibility, string PropertyName, string ResourceName, - bool HasExplicitBaseType + bool HasExplicitBaseType, + string? Mode ); diff --git a/src/src/SourceGeneration/ResourceKitDiagnosticAnalyzer.cs b/src/src/SourceGeneration/ResourceKitDiagnosticAnalyzer.cs index a7f0fb6..bfb3436 100644 --- a/src/src/SourceGeneration/ResourceKitDiagnosticAnalyzer.cs +++ b/src/src/SourceGeneration/ResourceKitDiagnosticAnalyzer.cs @@ -136,7 +136,7 @@ ConcurrentBag resourceKits ); } - Dictionary seenPropertyNames = new(StringComparer.Ordinal); + Dictionary seenPropertyNames = [with(StringComparer.Ordinal)]; foreach (var resourceKit in resourceKitArray) { var allAttributes = ResourceDefinitionAttributeData.AllAttributeData(resourceKit).ToArray(); diff --git a/src/src/SourceGeneration/SourceGeneration.csproj b/src/src/SourceGeneration/SourceGeneration.csproj index 648377c..53c618f 100644 --- a/src/src/SourceGeneration/SourceGeneration.csproj +++ b/src/src/SourceGeneration/SourceGeneration.csproj @@ -1,7 +1,6 @@  true - diff --git a/src/tests/Repository.UnitTests/ArchitectureTests.cs b/src/tests/Repository.UnitTests/ArchitectureTests.cs new file mode 100644 index 0000000..20d5f69 --- /dev/null +++ b/src/tests/Repository.UnitTests/ArchitectureTests.cs @@ -0,0 +1,21 @@ +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 0b2a2d5..c45108d 100644 --- a/src/tests/Repository.UnitTests/Repository.UnitTests.csproj +++ b/src/tests/Repository.UnitTests/Repository.UnitTests.csproj @@ -1,14 +1,10 @@ - - - net10.0 - false - - + + diff --git a/src/tests/Repository.UnitTests/SkillTests.cs b/src/tests/Repository.UnitTests/SkillTests.cs index aac7cd3..8dd4def 100644 --- a/src/tests/Repository.UnitTests/SkillTests.cs +++ b/src/tests/Repository.UnitTests/SkillTests.cs @@ -24,8 +24,14 @@ public sealed class SkillTests : PurviewSkillTests [Test] public async Task SkillsFolder_ResolvesToThisRepository() { + // Arrange + // No RepositoryRoot override: the catalogue resolves the repository root with the SDK's own + // marker walk, which is the resolution a contributor gets. + + // Act var catalogue = Evaluator.Catalogue; + // Assert await Assert .That(catalogue.FolderExists) .IsTrue() @@ -42,14 +48,19 @@ await Assert [Test] public async Task EverySkill_IsNamedForThisProduct() { + // Arrange // Skills install into one flat directory per agent, shared with every skill a developer has // installed from anywhere. An unprefixed name - 'dotnet-tunit', 'git-conventional-commits' - // collides with somebody else's, and the loser is whichever installed second. - var unprefixed = Evaluator - .Catalogue.Skills.Select(skill => skill.FolderName) + var skills = Evaluator.Catalogue.Skills; + + // Act + var unprefixed = skills + .Select(skill => skill.FolderName) .Where(name => !name.StartsWith("purview-resourcekit", StringComparison.Ordinal)) .ToArray(); + // Assert await Assert .That(unprefixed) .IsEmpty() diff --git a/src/tests/ResourceKit.IntegrationTests/ExampleAppHostCustomOptionsIntegrationTests.cs b/src/tests/ResourceKit.IntegrationTests/ExampleAppHostCustomOptionsIntegrationTests.cs index f62f938..47fa54b 100644 --- a/src/tests/ResourceKit.IntegrationTests/ExampleAppHostCustomOptionsIntegrationTests.cs +++ b/src/tests/ResourceKit.IntegrationTests/ExampleAppHostCustomOptionsIntegrationTests.cs @@ -10,8 +10,14 @@ public sealed class ExampleAppHostCustomOptionsIntegrationTests(CustomOptionsExa [Test] public async Task AppHost_WithCustomOptions_IsPassedToTheHostKit(CancellationToken cancellationToken) { + // Arrange + // CustomOptionsExampleAppHostFixture starts the AppHost with the options below as command-line + // arguments, so ResourceKit should bind the Azure Storage name it is given and disable Redis. + + // Act await fixture.ConnectionStringIsUnavailableAsync(Platform.ResourceKits.Redis, cancellationToken); + // Assert await Assert .That(fixture.GetResourceSnapshot(CustomOptionsExampleAppHostFixture.AzureStorageName)) .IsNotNull() diff --git a/src/tests/ResourceKit.IntegrationTests/ExampleAppHostIntegrationTests.cs b/src/tests/ResourceKit.IntegrationTests/ExampleAppHostIntegrationTests.cs index 6dfb7b6..b02e50b 100644 --- a/src/tests/ResourceKit.IntegrationTests/ExampleAppHostIntegrationTests.cs +++ b/src/tests/ResourceKit.IntegrationTests/ExampleAppHostIntegrationTests.cs @@ -9,6 +9,13 @@ public sealed class ExampleAppHostIntegrationTests(ExampleAppHostFixture - - - $(AllTargetFrameworks) - - diff --git a/src/tests/ResourceKit.UnitTests/OptionsHelperTests.cs b/src/tests/ResourceKit.UnitTests/OptionsHelperTests.cs index a8160ac..9475cb0 100644 --- a/src/tests/ResourceKit.UnitTests/OptionsHelperTests.cs +++ b/src/tests/ResourceKit.UnitTests/OptionsHelperTests.cs @@ -30,6 +30,8 @@ public async Task Assign_GivenExplicitSectionName_UsesSectionOverride() [Test] public async Task Assign_WithNestedClasses_GeneratesCorrectSet() { + // Arrange + // Act var args = OptionsHelper .Assign( @@ -84,6 +86,8 @@ public async Task SectionNameFor_GivenSectionNameConst_ReturnsConstValue() [Test] public async Task SectionNameFor_GivenNoConstSection_TrimsKnownSuffix() { + // Arrange + // Act var fromOptions = OptionsHelper.SectionNameFor(); var fromSettings = OptionsHelper.SectionNameFor(); @@ -100,6 +104,8 @@ public async Task SectionNameFor_GivenNoConstSection_TrimsKnownSuffix() [Test] public async Task SectionNameFor_GivenTypeNameOnlySuffix_UsesOriginalTypeName() { + // Arrange + // Act var sectionName = OptionsHelper.SectionNameFor(); @@ -111,6 +117,8 @@ public async Task SectionNameFor_GivenTypeNameOnlySuffix_UsesOriginalTypeName() public async Task SectionNameFor_GivenTypeOverload_ReturnsSameAsGeneric() { #pragma warning disable CA2263 // Prefer generic overload; this test intentionally exercises the Type overload. + // Arrange + // Act var fromType = OptionsHelper.SectionNameFor(typeof(ServiceOptions)); var fromGeneric = OptionsHelper.SectionNameFor(); @@ -124,6 +132,8 @@ public async Task SectionNameFor_GivenTypeOverload_ReturnsSameAsGeneric() [Test] public async Task SectionNameFor_GivenGenericType_TrimsKnownSuffix() { + // Arrange + // Act var fromOpenGeneric = OptionsHelper.SectionNameFor(typeof(GenericServiceOptions<>)); var fromClosedGeneric = OptionsHelper.SectionNameFor>(); @@ -485,4 +495,36 @@ await Assert .That(envVars.Keys) .DoesNotContain(key => key.StartsWith("IReadOnlyDictionary", StringComparison.Ordinal)); } + + [Test] + public async Task Assign_GivenResourceKitMode_EmitsEnumMemberName() + { + // Arrange + const string expected = "--Mode:Mode=Publish"; + + // Act + var args = OptionsHelper.Assign(static c => c.Mode = ResourceKitMode.Publish).Build(); + + // Assert + await Assert.That(args.Length).IsEqualTo(1); + await Assert.That(args[0]).IsEqualTo(expected); + } + + [Test] + public async Task Environment_GivenResourceKitMode_EmitsEnumMemberName() + { + // Arrange + const string expectedKey = "Mode__Mode"; + + // Act + var envVars = OptionsHelper.Environment(new ModeOptions { Mode = ResourceKitMode.LocalDevelopment }).Build(); + + // Assert + await Assert.That(envVars[expectedKey]).IsEqualTo("LocalDevelopment"); + } + + sealed class ModeOptions + { + public ResourceKitMode Mode { get; set; } + } } diff --git a/src/tests/ResourceKit.UnitTests/ResourceKit.UnitTests.csproj b/src/tests/ResourceKit.UnitTests/ResourceKit.UnitTests.csproj index 88eace9..09a4735 100644 --- a/src/tests/ResourceKit.UnitTests/ResourceKit.UnitTests.csproj +++ b/src/tests/ResourceKit.UnitTests/ResourceKit.UnitTests.csproj @@ -1,9 +1,4 @@ - - - $(AllTargetFrameworks) - - diff --git a/src/tests/ResourceKit.UnitTests/HostAppResourceTests.cs b/src/tests/ResourceKit.UnitTests/ResourceKitLifecycleTests.cs similarity index 56% rename from src/tests/ResourceKit.UnitTests/HostAppResourceTests.cs rename to src/tests/ResourceKit.UnitTests/ResourceKitLifecycleTests.cs index 3d3570c..c0ea3e0 100644 --- a/src/tests/ResourceKit.UnitTests/HostAppResourceTests.cs +++ b/src/tests/ResourceKit.UnitTests/ResourceKitLifecycleTests.cs @@ -4,17 +4,30 @@ namespace Purview.Aspire.ResourceKit; -public sealed class HostResourceKitTests +/// +/// Covers the Build/Configure lifecycle contract shared by HostKitBase<THostKit> +/// and ResourceKitBase<THostKit, TResource>: enablement gating, the order the hooks run in, +/// and how excludes a kit from an execution. +/// +/// +/// A scope-named suite rather than a {ClassUnderTest}Tests one, because no single type owns the +/// contract under test - the resource kit decides, and the host kit fans the call out over its +/// resources. +/// +public sealed class ResourceKitLifecycleTests { [Test] public async Task Build_WhenDisabledByServices_DoesNotCallBuild() { + // Arrange var builder = DistributedApplication.CreateBuilder(); TestHostKit hostApp = new(); TestResourceKit resource = new(hostApp, enabled: false); + // Act resource.Build(builder); + // Assert await Assert.That(resource.IsEnabled).IsFalse(); await Assert.That(resource.BuildCalled).IsFalse(); } @@ -90,12 +103,15 @@ public async Task Build_GivenOptionsSetIsEnabledToTrue_StillCallsIsResourceEnabl [Test] public async Task Build_WhenEnabled_CallsBuildAndSetsResourceBuilder() { + // Arrange var builder = DistributedApplication.CreateBuilder(); TestHostKit hostApp = new(); TestResourceKit resource = new(hostApp, enabled: true); + // Act resource.Build(builder); + // Assert await Assert.That(resource.IsEnabled).IsTrue(); await Assert.That(resource.BuildCalled).IsTrue(); await Assert.That(resource.ResourceBuilder).IsNotNull(); @@ -104,25 +120,31 @@ public async Task Build_WhenEnabled_CallsBuildAndSetsResourceBuilder() [Test] public async Task Configure_WhenDisabled_DoesNotCallConfigure() { + // Arrange var builder = DistributedApplication.CreateBuilder(); TestHostKit hostApp = new(); TestResourceKit resource = new(hostApp, enabled: false); + // Act resource.Build(builder); resource.Configure(); + // Assert await Assert.That(resource.ConfigureCalled).IsFalse(); } [Test] public async Task IsResourceEnabled_WithServices_DelegatesToBuilderOnlyOverloadByDefault() { + // Arrange var builder = DistributedApplication.CreateBuilder(); TestHostKit hostApp = new(); DelegatingTestResourceKit resource = new(hostApp); + // Act resource.Build(builder); + // Assert await Assert.That(resource.IsEnabled).IsFalse(); } @@ -144,20 +166,169 @@ public async Task Configure_WhenResourceAddedAfterBuild_CallsConfigureOnAddedRes await Assert.That(resource.ConfigureCalled).IsTrue(); } + [Test] + public async Task Build_GivenModeExcludedByExecutionOperation_DoesNotInvokeHookOrBuild() + { + // Arrange + var builder = TestApplicationBuilderFactory.Create(DistributedApplicationOperation.Run); + TestHostKit hostApp = new(); + ModeTrackingResourceKit resource = new(hostApp, ResourceKitMode.Publish); + + // Act + resource.Build(builder); + + // Assert + await Assert.That(resource.IsEnabled).IsFalse(); + await Assert.That(resource.IsResourceEnabledCalled).IsFalse(); + await Assert.That(resource.BuildCalled).IsFalse(); + } + + [Test] + public async Task Build_GivenModeIncludedByExecutionOperation_InvokesHookAndBuilds() + { + // Arrange + var builder = TestApplicationBuilderFactory.Create( + DistributedApplicationOperation.Run, + environmentName: "Development" + ); + TestHostKit hostApp = new(); + ModeTrackingResourceKit resource = new(hostApp, ResourceKitMode.LocalDevelopment); + + // Act + resource.Build(builder); + + // Assert + await Assert.That(resource.IsEnabled).IsTrue(); + await Assert.That(resource.IsResourceEnabledCalled).IsTrue(); + await Assert.That(resource.BuildCalled).IsTrue(); + } + + [Test] + public async Task Configure_GivenModeExcludedByExecutionOperation_DoesNotCallConfigure() + { + // Arrange + var builder = TestApplicationBuilderFactory.Create(DistributedApplicationOperation.Publish); + TestHostKit hostApp = new(); + ModeTrackingResourceKit resource = new(hostApp, ResourceKitMode.LocalDevelopment); + + resource.Build(builder); + + // Act + resource.Configure(); + + // Assert + await Assert.That(resource.ConfigureCalled).IsFalse(); + } + + [Test] + public async Task Build_GivenHostKitModeExcludedByExecutionOperation_DisablesAndSkipsEveryResource() + { + // Arrange + var builder = TestApplicationBuilderFactory.Create(DistributedApplicationOperation.Run); + ModeHostKit hostApp = new(ResourceKitMode.Publish); + ModeHostResourceKit resource = new(hostApp); + hostApp.AddResource(resource); + + // Act + hostApp.Build(builder); + hostApp.Configure(); + + // Assert + await Assert.That(resource.IsEnabled).IsFalse(); + await Assert.That(resource.BuildCalled).IsFalse(); + await Assert.That(resource.ConfigureCalled).IsFalse(); + } + + [Test] + public async Task Build_GivenHostKitModeIncludedByExecutionOperation_BuildsEveryResource() + { + // Arrange + var builder = TestApplicationBuilderFactory.Create(DistributedApplicationOperation.Run); + ModeHostKit hostApp = new(ResourceKitMode.Run); + ModeHostResourceKit resource = new(hostApp); + hostApp.AddResource(resource); + + // Act + hostApp.Build(builder); + hostApp.Configure(); + + // Assert + await Assert.That(resource.IsEnabled).IsTrue(); + await Assert.That(resource.BuildCalled).IsTrue(); + await Assert.That(resource.ConfigureCalled).IsTrue(); + } + [Test] public async Task AddResource_WhenSealed_ThrowsInvalidOperationException() { // Arrange + var builder = DistributedApplication.CreateBuilder(); TestHostKit hostApp = new(); TestResourceKit resource = new(hostApp); - hostApp.Build(IDistributedApplicationBuilder.Mock()); + hostApp.Build(builder); + + // Act + var exception = await Assert + .That(() => hostApp.AddResource(resource)) + .ThrowsExactly(); - // Act/Assert - await Assert.That(() => hostApp.AddResource(resource)).ThrowsExactly(); + // Assert + await Assert.That(exception).IsNotNull(); } sealed class TestHostKit : HostKitBase; + sealed class ModeHostKit(ResourceKitMode mode) : HostKitBase + { + protected override ResourceKitMode Mode => mode; + } + + sealed class ModeHostResourceKit(ModeHostKit hostKit) + : ResourceKitBase(hostKit, "test") + { + public bool BuildCalled { get; private set; } + + public bool ConfigureCalled { get; private set; } + + protected override IResourceBuilder BuildResource( + [NotNull] IDistributedApplicationBuilder builder + ) + { + BuildCalled = true; + return builder.AddParameter(Name, "value", secret: false); + } + + protected override void ConfigureResource() => ConfigureCalled = true; + } + + sealed class ModeTrackingResourceKit(TestHostKit hostKit, ResourceKitMode mode) + : ResourceKitBase(hostKit, "test") + { + public bool IsResourceEnabledCalled { get; private set; } + + public bool BuildCalled { get; private set; } + + public bool ConfigureCalled { get; private set; } + + protected override ResourceKitMode Mode => mode; + + protected override bool IsResourceEnabled([NotNull] IDistributedApplicationBuilder builder) + { + IsResourceEnabledCalled = true; + return true; + } + + protected override IResourceBuilder BuildResource( + [NotNull] IDistributedApplicationBuilder builder + ) + { + BuildCalled = true; + return builder.AddParameter(Name, "value", secret: false); + } + + protected override void ConfigureResource() => ConfigureCalled = true; + } + sealed class TestResourceKit(TestHostKit hostKit, bool enabled = true) : ResourceKitBase(hostKit, "test") { diff --git a/src/tests/ResourceKit.UnitTests/ResourceKitModeTests.cs b/src/tests/ResourceKit.UnitTests/ResourceKitModeTests.cs new file mode 100644 index 0000000..5e4eb22 --- /dev/null +++ b/src/tests/ResourceKit.UnitTests/ResourceKitModeTests.cs @@ -0,0 +1,151 @@ +using Aspire.Hosting; + +namespace Purview.Aspire.ResourceKit; + +public sealed class ResourceKitModeTests +{ + [Test] + public async Task IsEnabledFor_GivenAll_WhenPublishing_ReturnsTrue() + { + // Arrange + var builder = TestApplicationBuilderFactory.Create(DistributedApplicationOperation.Publish); + + // Act + var isEnabled = ResourceKitMode.All.IsEnabledFor(builder); + + // Assert + await Assert.That(isEnabled).IsTrue(); + } + + [Test] + public async Task IsEnabledFor_GivenAll_WhenRunningInProduction_ReturnsTrue() + { + // Arrange + var builder = TestApplicationBuilderFactory.Create( + DistributedApplicationOperation.Run, + environmentName: "Production" + ); + + // Act + var isEnabled = ResourceKitMode.All.IsEnabledFor(builder); + + // Assert + await Assert.That(isEnabled).IsTrue(); + } + + [Test] + public async Task IsEnabledFor_GivenPublish_WhenPublishing_ReturnsTrue() + { + // Arrange + var builder = TestApplicationBuilderFactory.Create(DistributedApplicationOperation.Publish); + + // Act + var isEnabled = ResourceKitMode.Publish.IsEnabledFor(builder); + + // Assert + await Assert.That(isEnabled).IsTrue(); + } + + [Test] + public async Task IsEnabledFor_GivenPublish_WhenRunningLocally_ReturnsFalse() + { + // Arrange + var builder = TestApplicationBuilderFactory.Create(DistributedApplicationOperation.Run); + + // Act + var isEnabled = ResourceKitMode.Publish.IsEnabledFor(builder); + + // Assert + await Assert.That(isEnabled).IsFalse(); + } + + [Test] + public async Task IsEnabledFor_GivenRun_WhenRunningInProduction_ReturnsTrue() + { + // Arrange + var builder = TestApplicationBuilderFactory.Create( + DistributedApplicationOperation.Run, + environmentName: "Production" + ); + + // Act + var isEnabled = ResourceKitMode.Run.IsEnabledFor(builder); + + // Assert + await Assert.That(isEnabled).IsTrue(); + } + + [Test] + public async Task IsEnabledFor_GivenRun_WhenPublishing_ReturnsFalse() + { + // Arrange + var builder = TestApplicationBuilderFactory.Create(DistributedApplicationOperation.Publish); + + // Act + var isEnabled = ResourceKitMode.Run.IsEnabledFor(builder); + + // Assert + await Assert.That(isEnabled).IsFalse(); + } + + [Test] + public async Task IsEnabledFor_GivenLocalDevelopment_WhenRunningInDevelopment_ReturnsTrue() + { + // Arrange + var builder = TestApplicationBuilderFactory.Create( + DistributedApplicationOperation.Run, + environmentName: "Development" + ); + + // Act + var isEnabled = ResourceKitMode.LocalDevelopment.IsEnabledFor(builder); + + // Assert + await Assert.That(isEnabled).IsTrue(); + } + + [Test] + public async Task IsEnabledFor_GivenLocalDevelopment_WhenRunningInProduction_ReturnsFalse() + { + // Arrange + var builder = TestApplicationBuilderFactory.Create( + DistributedApplicationOperation.Run, + environmentName: "Production" + ); + + // Act + var isEnabled = ResourceKitMode.LocalDevelopment.IsEnabledFor(builder); + + // Assert + await Assert.That(isEnabled).IsFalse(); + } + + [Test] + public async Task IsEnabledFor_GivenLocalDevelopment_WhenPublishing_ReturnsFalse() + { + // Arrange + var builder = TestApplicationBuilderFactory.Create(DistributedApplicationOperation.Publish); + + // Act + var isEnabled = ResourceKitMode.LocalDevelopment.IsEnabledFor(builder); + + // Assert + await Assert.That(isEnabled).IsFalse(); + } + + [Test] + public async Task IsEnabledFor_GivenNullBuilder_ThrowsArgumentNullException() + { + // Arrange + IDistributedApplicationBuilder builder = null!; + + // Act + var exception = await Assert + .That(() => ResourceKitMode.All.IsEnabledFor(builder)) + .ThrowsExactly(); + + // Assert + var argumentNullException = await Assert.That(exception).IsNotNull(); + await Assert.That(argumentNullException.ParamName).IsEqualTo("builder"); + } +} diff --git a/src/tests/ResourceKit.UnitTests/TestApplicationBuilderFactory.cs b/src/tests/ResourceKit.UnitTests/TestApplicationBuilderFactory.cs new file mode 100644 index 0000000..e1ae114 --- /dev/null +++ b/src/tests/ResourceKit.UnitTests/TestApplicationBuilderFactory.cs @@ -0,0 +1,38 @@ +using Aspire.Hosting; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Hosting; + +namespace Purview.Aspire.ResourceKit; + +/// +/// Creates test doubles that expose a controlled execution +/// context and hosting environment so gating can be exercised. +/// +static class TestApplicationBuilderFactory +{ + /// + /// Creates a builder double for the specified operation and environment name. + /// + public static IDistributedApplicationBuilder Create( + DistributedApplicationOperation operation, + string environmentName = "Development" + ) + { + var builder = IDistributedApplicationBuilder.Mock(); + builder.ExecutionContext.Returns(new DistributedApplicationExecutionContext(operation)); + builder.Environment.Returns(new TestHostEnvironment(environmentName)); + + return builder; + } + + sealed class TestHostEnvironment(string environmentName) : IHostEnvironment + { + public string EnvironmentName { get; set; } = environmentName; + + public string ApplicationName { get; set; } = "Purview.Aspire.ResourceKit.Tests"; + + public string ContentRootPath { get; set; } = AppContext.BaseDirectory; + + public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); + } +} diff --git a/src/tests/SharedTestingInfra/ResourceKitSourceGeneratorTestOptions.cs b/src/tests/SharedTestingInfra/ResourceKitSourceGeneratorTestOptions.cs index 885554f..6b29974 100644 --- a/src/tests/SharedTestingInfra/ResourceKitSourceGeneratorTestOptions.cs +++ b/src/tests/SharedTestingInfra/ResourceKitSourceGeneratorTestOptions.cs @@ -25,6 +25,8 @@ public ResourceKitSourceGeneratorTestOptions() additionalTypes.Add(typeof(Microsoft.Extensions.Options.IOptions<>)); additionalTypes.Add(typeof(Microsoft.Extensions.DependencyInjection.OptionsBuilderConfigurationExtensions)); additionalTypes.Add(typeof(Microsoft.Extensions.Configuration.ConfigurationBinder)); + // Emitted attributes expose ResourceKitMode, so every test compilation needs the runtime assembly. + additionalTypes.Add(typeof(ResourceKitMode)); AdditionalNamespaces = [.. additionalNamespaces]; AdditionalAssemblyTypes = [.. additionalTypes]; diff --git a/src/tests/SharedTestingInfra/SharedTestingInfra.csproj b/src/tests/SharedTestingInfra/SharedTestingInfra.csproj index 03cf67c..62fc81a 100644 --- a/src/tests/SharedTestingInfra/SharedTestingInfra.csproj +++ b/src/tests/SharedTestingInfra/SharedTestingInfra.csproj @@ -6,5 +6,8 @@ + + diff --git a/src/tests/SourceGeneration.IntegrationTests/GeneratedSourceContentTests.cs b/src/tests/SourceGeneration.IntegrationTests/GeneratedSourceContentTests.cs index ae0f25d..78690c9 100644 --- a/src/tests/SourceGeneration.IntegrationTests/GeneratedSourceContentTests.cs +++ b/src/tests/SourceGeneration.IntegrationTests/GeneratedSourceContentTests.cs @@ -49,6 +49,135 @@ await Assert ); } + [Test] + public async Task Generate_GivenResourceDefinedKits_EmitsModeOptionDefaultingToAll( + CancellationToken cancellationToken + ) + { + // Arrange + var source = TestHelper.GenerateSources(); + + // Act + using var result = await GenerateAsync(source, cancellationToken); + + // Assert + var generated = result.GetSource(); + await Assert + .That(generated) + .Contains( + $"public {TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitMode} Mode {{ get; set; }} = {TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitMode}.All;" + ); + } + + [Test] + public async Task Generate_GivenResourceDefinedKits_EmitsModeOverrideSourcedFromOptions( + CancellationToken cancellationToken + ) + { + // Arrange + var source = TestHelper.GenerateSources(); + + // Act + using var result = await GenerateAsync(source, cancellationToken); + + // Assert + var generated = result.GetSource(); + await Assert + .That(generated) + .Contains( + $"protected override {TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitMode} Mode => Options.Mode;" + ); + } + + [Test] + public async Task Generate_GivenHostKitWithModeConstructorArgument_EmitsHostOptionsModeDefault( + CancellationToken cancellationToken + ) + { + // Arrange + var source = + @$" +namespace Testing +{{ + [HostKit({TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitMode}.LocalDevelopment)] + partial class TestingHostKit; +}} +"; + + // 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.ResourceKitMode} Mode {{ get; set; }} = {TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitMode}.LocalDevelopment;" + ); + } + + [Test] + public async Task Generate_GivenGenerateOptionsDisabledWithModeProperty_EmitsModeOverrideFromAttribute( + CancellationToken cancellationToken + ) + { + // Arrange + var source = + @$" +namespace Testing +{{ + [HostKit(GenerateOptions = false, Mode = {TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitMode}.Publish)] + partial class TestingHostKit; +}} +"; + + // Act + using var result = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasNoErrorDiagnostics(); + + var generated = result.GetSource(); + await Assert + .That(generated) + .Contains( + $"protected override {TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitMode} Mode => {TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitMode}.Publish;" + ); + } + + [Test] + public async Task Generate_GivenResourceDefinitionWithModeConstructorArgument_EmitsConfiguredModeDefault( + CancellationToken cancellationToken + ) + { + // Arrange + var source = + @$" +namespace Testing +{{ + [HostKit] + partial class TestingHostKit; + + [ResourceDefinition<{TestingTypeLibrary.Purview.Aspire.ResourceKit.DefaultAspireResource}>({TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitMode}.Publish)] + partial class RedisResourceKit + {{ + {TestHelper.GenerateBuildResourceMethod()} + }} +}} +"; + + // Act + using var result = await GenerateAsync(source, cancellationToken); + + // Assert + await Assert.That(result).HasNoErrorDiagnostics(); + + var generated = result.GetSource(); + await Assert.That(generated).Contains($"= {TypeLibrary.Purview.Aspire.ResourceKit.ResourceKitMode}.Publish;"); + } + [Test] public async Task Generate_GivenHostKit_EmitsLifecyclePartialMethods(CancellationToken cancellationToken) { @@ -177,6 +306,7 @@ public async Task Generate_GivenHostKitWithMultipleResources_GeneratesExpectedRe CancellationToken cancellationToken ) { + // Arrange var buildResourceMethod = TestHelper.GenerateBuildResourceMethod(); var source = @$" @@ -202,8 +332,10 @@ public partial class SqlServerResourceKit : {TypeLibrary.Purview.Aspire.Resource }} "; + // Act using var result = await GenerateAsync(source, cancellationToken); + // Assert await Assert.That(result).HasNoErrorDiagnostics(); var generated = result.GetSource(); @@ -220,6 +352,7 @@ public async Task Generate_GivenResourceWithNameOverride_UsesNameOverrideWithTyp CancellationToken cancellationToken ) { + // Arrange var source = @$" namespace Testing @@ -235,8 +368,10 @@ public partial class RedisResourceKit : {TypeLibrary.Purview.Aspire.ResourceKit. }} "; + // Act using var result = await GenerateAsync(source, cancellationToken); + // Assert await Assert.That(result).HasNoErrorDiagnostics(); var generated = result.GetSource(); @@ -250,6 +385,7 @@ public async Task Generate_GivenResourceWithPropertyNameOverride_GeneratesCustom CancellationToken cancellationToken ) { + // Arrange var source = @$" namespace Testing @@ -265,8 +401,10 @@ public partial class RedisResourceKit : {TypeLibrary.Purview.Aspire.ResourceKit. }} "; + // Act using var result = await GenerateAsync(source, cancellationToken); + // Assert await Assert.That(result).HasNoErrorDiagnostics(); var generated = result.GetSource(); @@ -282,6 +420,7 @@ public async Task Generate_GivenResourceWithoutName_InitializesEmptyNameButTypeB CancellationToken cancellationToken ) { + // Arrange var source = @$" namespace Testing @@ -303,8 +442,10 @@ public partial class SqlServerResource : {TypeLibrary.Purview.Aspire.ResourceKit }} "; + // Act using var result = await GenerateAsync(source, cancellationToken); + // Assert await Assert.That(result).HasNoErrorDiagnostics(); var generated = result.GetSource(); @@ -321,6 +462,7 @@ public async Task Generate_GivenResourceNameWithSeparators_DerivesTypeBasedPrope CancellationToken cancellationToken ) { + // Arrange var source = @$" namespace Testing @@ -336,8 +478,10 @@ public partial class AzureStorageResourceKit : {TypeLibrary.Purview.Aspire.Resou }} "; + // Act using var result = await GenerateAsync(source, cancellationToken); + // Assert await Assert.That(result).HasNoErrorDiagnostics(); var generated = result.GetSource(); @@ -394,6 +538,7 @@ public async Task Generate_GivenLowercaseResourceName_AutoPropertyNameUsesResour CancellationToken cancellationToken ) { + // Arrange var source = @$" namespace Testing @@ -409,8 +554,10 @@ public partial class KeyVaultResourceKit : {TypeLibrary.Purview.Aspire.ResourceK }} "; + // Act using var result = await GenerateAsync(source, cancellationToken); + // Assert await Assert.That(result).HasNoErrorDiagnostics(); var generated = result.GetSource(); @@ -423,6 +570,7 @@ public async Task Generate_GivenResourcesInDifferentNamespace_GeneratesHostNames CancellationToken cancellationToken ) { + // Arrange var source = @$" namespace Testing.Host @@ -441,8 +589,10 @@ public partial class RedisResourceKit : {TypeLibrary.Purview.Aspire.ResourceKit. }} "; + // Act using var result = await GenerateAsync(source, cancellationToken); + // Assert await Assert.That(result).HasNoErrorDiagnostics(); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); @@ -461,6 +611,7 @@ public partial class RedisResourceKit : {TypeLibrary.Purview.Aspire.ResourceKit. )] public async Task Generate_GivenHostKitWithNameOverride_UsesCustomBaseClassName(CancellationToken cancellationToken) { + // Arrange var source = @$" namespace Testing @@ -484,8 +635,10 @@ protected CustomResourceBase(TestingHostKit hostKit, string? name) : base(hostKi }} "; + // Act using var result = await GenerateAsync(source, cancellationToken); + // Assert await Assert.That(result).HasNoErrorDiagnostics(); var generated = result.GetSource(); diff --git a/src/tests/SourceGeneration.IntegrationTests/SourceGeneration.IntegrationTests.csproj b/src/tests/SourceGeneration.IntegrationTests/SourceGeneration.IntegrationTests.csproj index 111d55a..c25c714 100644 --- a/src/tests/SourceGeneration.IntegrationTests/SourceGeneration.IntegrationTests.csproj +++ b/src/tests/SourceGeneration.IntegrationTests/SourceGeneration.IntegrationTests.csproj @@ -1,9 +1,4 @@  - - - $(AllTargetFrameworks) - - diff --git a/src/tests/SourceGeneration.UnitTests/GeneratedAttributesTests.cs b/src/tests/SourceGeneration.UnitTests/GeneratedAttributesTests.cs index 7e7e922..514269c 100644 --- a/src/tests/SourceGeneration.UnitTests/GeneratedAttributesTests.cs +++ b/src/tests/SourceGeneration.UnitTests/GeneratedAttributesTests.cs @@ -1,3 +1,5 @@ +using System.Reflection; + namespace Purview.Aspire.ResourceKit.SourceGeneration; /// @@ -17,19 +19,18 @@ public class Empty { } [Test] public async Task Generate_GivenEmptySource_GeneratesHostKitAttributeSource(CancellationToken cancellationToken) { - using var result = await GenerateAsync(EmptySource, cancellationToken); + // Arrange + // Nothing to arrange: the attribute sources come from post-initialization output, so an empty + // compilation is enough to produce them. - var tree = result.GetGeneratedTree("HostKitAttribute.g.cs"); - tree = await Assert.That(tree).IsNotNull(); - - var syntaxTree = await tree.GetTextAsync(cancellationToken); - var text = syntaxTree.ToString(); + // Act + using var result = await GenerateAsync(EmptySource, cancellationToken); - await Assert.That(text).Contains("class HostKitAttribute"); - await Assert.That(text).Contains("string? Name"); - await Assert.That(text).Contains("AttributeTargets.Class"); - await Assert.That(text).Contains("AllowMultiple = false"); - await Assert.That(text).Contains("Inherited = false"); + // Assert + await Assert.That(result.GetGeneratedTree("HostKitAttribute.g.cs")).IsNotNull(); + await Assert + .That(result.Generated()) + .HasGeneratedClass(TypeLibrary.Purview.Aspire.ResourceKit.HostKitAttribute); } [Test] @@ -37,7 +38,13 @@ public async Task Generate_GivenEmptySource_GeneratesResourceDefinitionAttribute CancellationToken cancellationToken ) { + // Arrange + // Nothing to arrange: an empty compilation is enough for the generator's attribute output. + + // Act using var result = await GenerateAsync(EmptySource, cancellationToken); + + // Assert var query = result.Generated(); await Assert @@ -57,7 +64,13 @@ public async Task Generate_GivenEmptySource_AttributesAreEmittedIntoResourceKitN CancellationToken cancellationToken ) { + // Arrange + // Nothing to arrange: an empty compilation is enough for the generator's attribute output. + + // Act using var result = await GenerateAsync(EmptySource, cancellationToken); + + // Assert var query = result.Generated(); await Assert.That(query).HasGeneratedClass(TypeLibrary.Purview.Aspire.ResourceKit.HostKitAttribute); @@ -72,7 +85,13 @@ public async Task Compile_GivenEmptySource_ProducesAssemblyWithGeneratedAttribut CancellationToken cancellationToken ) { + // Arrange + // Nothing to arrange: the option requests a compiled assembly, which is the artefact under test. + + // Act using var result = await GenerateAsync(EmptySource, cancellationToken); + + // Assert var query = result.Generated(); foreach ( @@ -91,7 +110,13 @@ var attributeType in new[] [Test] public async Task Compile_GivenEmptySource_HostKitAttributeHasExpectedMembers(CancellationToken cancellationToken) { + // Arrange + // Nothing to arrange: an empty compilation is enough for the generator's attribute output. + + // Act using var result = await GenerateAsync(EmptySource, cancellationToken); + + // Assert var query = result.Generated(); var attributeClass = await Assert @@ -106,11 +131,17 @@ public async Task Compile_GivenEmptySource_ResourceDefinitionAttributeHasExpecte CancellationToken cancellationToken ) { + // Arrange + // Nothing to arrange: the option requests a compiled assembly, so the attributes can be reflected over. + + // Act using var result = await GenerateAsync( EmptySource, ResourceKitSourceGeneratorTestOptions.Compile, cancellationToken ); + + // Assert var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var type = assembly.GetType( TypeLibrary.Purview.Aspire.ResourceKit.ResourceDefinitionAttribute.MetadataFullName @@ -131,35 +162,34 @@ await Assert [Test] public async Task Compile_GivenEmptySource_AttributesHaveExpectedAttributeUsage(CancellationToken cancellationToken) { + // Arrange + var attributeTypes = new[] + { + TypeLibrary.Purview.Aspire.ResourceKit.HostKitAttribute, + TypeLibrary.Purview.Aspire.ResourceKit.ResourceDefinitionAttribute, + TypeLibrary.Purview.Aspire.ResourceKit.GenericResourceDefinitionAttribute, + }; + + // Act using var result = await GenerateAsync( EmptySource, ResourceKitSourceGeneratorTestOptions.Compile, cancellationToken ); - //var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); - var query = result.Generated(); + // Assert + var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); - foreach ( - var attributeType in new[] - { - TypeLibrary.Purview.Aspire.ResourceKit.HostKitAttribute, - TypeLibrary.Purview.Aspire.ResourceKit.ResourceDefinitionAttribute, - TypeLibrary.Purview.Aspire.ResourceKit.GenericResourceDefinitionAttribute, - } - ) + foreach (var attributeType in attributeTypes) { - var classNode = query.GetClass(attributeType); - - await Assert.That(classNode).IsNotNull(); - - //var type = assembly.GetType(attributeType)!; - //var usage = type.GetCustomAttribute(); - - //await Assert.That(usage).IsNotNull(); - //await Assert.That(usage.ValidOn.HasFlag(AttributeTargets.Class)).IsTrue(); - //await Assert.That(usage.AllowMultiple).IsFalse(); - //await Assert.That(usage.Inherited).IsFalse(); + var type = assembly.GetType(attributeType.MetadataFullName); + await Assert.That(type).IsNotNull(); + + var usage = type!.GetCustomAttribute(); + await Assert.That(usage).IsNotNull(); + await Assert.That(usage!.ValidOn.HasFlag(AttributeTargets.Class)).IsTrue(); + await Assert.That(usage.AllowMultiple).IsFalse(); + await Assert.That(usage.Inherited).IsFalse(); } } } diff --git a/src/tests/SourceGeneration.UnitTests/SourceGeneration.UnitTests.csproj b/src/tests/SourceGeneration.UnitTests/SourceGeneration.UnitTests.csproj index 9f261f5..12e01c2 100644 --- a/src/tests/SourceGeneration.UnitTests/SourceGeneration.UnitTests.csproj +++ b/src/tests/SourceGeneration.UnitTests/SourceGeneration.UnitTests.csproj @@ -1,9 +1,4 @@  - - - $(AllTargetFrameworks) - - From 8b0ddef3b63b8b31643ba758b975d3399ceffeb7 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sun, 8 Nov 2026 18:10:08 +0000 Subject: [PATCH 2/2] chore: bumped build-sdk --- .editorconfig | 2 +- global.json | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.editorconfig b/.editorconfig index ea03058..f8b3050 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,4 +1,4 @@ -# Purview.BuildSdk 1.2.0 - managed file, do not edit +# Purview.BuildSdk 1.3.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/global.json b/global.json index a04962d..4d9bb41 100644 --- a/global.json +++ b/global.json @@ -1,14 +1,14 @@ { - "sdk": { - "version": "11.0.100-rc.3.26522.148", - "rollForward": "latestMajor", - "allowPrerelease": true - }, + "sdk": { + "version": "11.0.100-rc.3.26522.148", + "rollForward": "latestMajor", + "allowPrerelease": true + }, "msbuild-sdks": { - "Purview.BuildSdk": "1.2.0", + "Purview.BuildSdk": "1.3.0", "Aspire.AppHost.Sdk": "13.6.4" }, "test": { "runner": "Microsoft.Testing.Platform" } -} +} \ No newline at end of file