From bdc3291c09a1e0e8594bc593ed847c266f1670ae Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 14 Oct 2026 23:46:59 +0100 Subject: [PATCH 1/4] chore: move authored skills to repository-root skills folder The skills CLI only discovers skills at a root skills/ folder, and .agents/ is gitignored here, so it is absent from a git clone. Only aspire-apphost-to-resourcekit is packed; the repository-local skills are installable from git but ship in no package. --- AGENTS.md | 32 ++++++++ .../aspire-apphost-to-resourcekit/SKILL.md | 0 .../changesets-prerelease/SKILL.md | 0 .../skills => skills}/dotnet-tunit/SKILL.md | 0 .../git-conventional-commits/SKILL.md | 0 .../lefthook-integration/SKILL.md | 0 .../tunit-filtering/SKILL.md | 0 .../tunit-test-runner/SKILL.md | 0 src/Directory.Build.targets | 74 +++++++++++++++++++ src/src/ResourceKit/ResourceKit.csproj | 9 ++- 10 files changed, 114 insertions(+), 1 deletion(-) rename {src/src/ResourceKit/Sdk/.agents/skills => skills}/aspire-apphost-to-resourcekit/SKILL.md (100%) rename {.agents/skills => skills}/changesets-prerelease/SKILL.md (100%) rename {.agents/skills => skills}/dotnet-tunit/SKILL.md (100%) rename {.agents/skills => skills}/git-conventional-commits/SKILL.md (100%) rename {.agents/skills => skills}/lefthook-integration/SKILL.md (100%) rename {.agents/skills => skills}/tunit-filtering/SKILL.md (100%) rename {.agents/skills => skills}/tunit-test-runner/SKILL.md (100%) diff --git a/AGENTS.md b/AGENTS.md index 7327f60..bb626c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,3 +72,35 @@ 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. + +## Agent skills + +Authored skills live in the repository-root `skills/` folder — **not** in `.agents/skills/`, which is the +gitignored mirror that NuGet packages write into on build. The skills CLI (`npx skills`) only discovers +skills at a root `skills/` folder, and a gitignored folder is absent from a git clone, so the authored copy +has to be tracked at the root. One copy serves both routes: the git clone for the CLI, and the package for +the existing mirror. + +Install them into your agent's own directories with: + +```bash +npx skills add purview-dev/aspire-resourcekit +``` + +### Skills shipped with a package + +| Skill | Package | Declared in | +| --- | --- | --- | +| `aspire-apphost-to-resourcekit` | `Purview.Aspire.ResourceKit` | `src/src/ResourceKit/ResourceKit.csproj` | + +The repository's other authored skills (`changesets-prerelease`, `dotnet-tunit`, `git-conventional-commits`, +`lefthook-integration`, `tunit-filtering`, `tunit-test-runner`) describe working *in this repository*. They +live in `skills/` and are installable from git, but are not packed into any package. + +Packing is performed by the `IncludeRepositorySkillsInPackage` target in `src/Directory.Build.targets`. That +is an **interim shim**: `global.json` pins `Purview.BuildSdk` 1.0.5, which has no SDK-side equivalent yet. It +implements the same `PurviewSkillsToPack` property the newer SDK uses, so adopting it means setting +`PurviewPackRepositorySkills=true` on the same projects and deleting the shim. + +To add a skill: create `skills//SKILL.md`, then add the name to the owning project's +`PurviewSkillsToPack`. A skill not named there is installable from git but ships in no package. diff --git a/src/src/ResourceKit/Sdk/.agents/skills/aspire-apphost-to-resourcekit/SKILL.md b/skills/aspire-apphost-to-resourcekit/SKILL.md similarity index 100% rename from src/src/ResourceKit/Sdk/.agents/skills/aspire-apphost-to-resourcekit/SKILL.md rename to skills/aspire-apphost-to-resourcekit/SKILL.md diff --git a/.agents/skills/changesets-prerelease/SKILL.md b/skills/changesets-prerelease/SKILL.md similarity index 100% rename from .agents/skills/changesets-prerelease/SKILL.md rename to skills/changesets-prerelease/SKILL.md diff --git a/.agents/skills/dotnet-tunit/SKILL.md b/skills/dotnet-tunit/SKILL.md similarity index 100% rename from .agents/skills/dotnet-tunit/SKILL.md rename to skills/dotnet-tunit/SKILL.md diff --git a/.agents/skills/git-conventional-commits/SKILL.md b/skills/git-conventional-commits/SKILL.md similarity index 100% rename from .agents/skills/git-conventional-commits/SKILL.md rename to skills/git-conventional-commits/SKILL.md diff --git a/.agents/skills/lefthook-integration/SKILL.md b/skills/lefthook-integration/SKILL.md similarity index 100% rename from .agents/skills/lefthook-integration/SKILL.md rename to skills/lefthook-integration/SKILL.md diff --git a/.agents/skills/tunit-filtering/SKILL.md b/skills/tunit-filtering/SKILL.md similarity index 100% rename from .agents/skills/tunit-filtering/SKILL.md rename to skills/tunit-filtering/SKILL.md diff --git a/.agents/skills/tunit-test-runner/SKILL.md b/skills/tunit-test-runner/SKILL.md similarity index 100% rename from .agents/skills/tunit-test-runner/SKILL.md rename to skills/tunit-test-runner/SKILL.md diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index ab68169..77afe53 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -1,3 +1,77 @@ + + + $([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)', '..', 'skills')) + $(TargetsForTfmSpecificContentInPackage);IncludeRepositorySkillsInPackage + + + + + <_RepositorySkillToPack Include="$(PurviewSkillsToPack)" /> + <_RepositorySkillFile Include="$(PurviewSkillsSourceFolder)%(_RepositorySkillToPack.Identity)/**/*"> + %(_RepositorySkillToPack.Identity) + + + + + + + + <_RepositorySkillPackageFile Include="@(_RepositorySkillFile)"> + .agents/skills/%(SkillName)/$([System.String]::Copy('%(RecursiveDir)').Replace("\", "/"))%(Filename)%(Extension) + + + + + + + <_RepositorySkillGitIgnoreRoot>$([MSBuild]::NormalizeDirectory('$(IntermediateOutputPath)', '_PurviewSkillGitIgnores')) + + + + <_RepositorySkillGitIgnoreFile Include="$(_RepositorySkillGitIgnoreRoot)%(_RepositorySkillToPack.Identity)/.gitignore"> + .agents/skills/%(_RepositorySkillToPack.Identity)/.gitignore + + + + diff --git a/src/src/ResourceKit/ResourceKit.csproj b/src/src/ResourceKit/ResourceKit.csproj index 79d2f38..190406b 100644 --- a/src/src/ResourceKit/ResourceKit.csproj +++ b/src/src/ResourceKit/ResourceKit.csproj @@ -1,4 +1,10 @@ - + + + + aspire-apphost-to-resourcekit + + $(AllTargetFrameworks) @@ -21,6 +27,7 @@ ReferenceOutputAssembly="false" OutputItemType="Analyzer" /> + Date: Thu, 15 Oct 2026 16:12:45 +0100 Subject: [PATCH 2/4] feat: migrate agent skills to the root skills/ folder and the skills CLI Purview.BuildSdk 1.0.6 removes skill packaging and the build-time .agents/ mirror, so the skills CLI cloning the git repository is the only delivery route. Skills are authored at skills//SKILL.md and nothing packs them. - rename every skill to a purview--* prefix; skills install into one flat directory per agent, so an unprefixed name collides with somebody else's and the loser is whichever installed second - validate them on every test run with Purview.Skills.Testing 1.0.6, through a Repository.UnitTests project whose [InheritsTests] attribute is load-bearing - raise the TUnit floor to 1.75.0, which Purview.Skills.Testing depends on and central transitive pinning would otherwise pin back down --- .config/purview-build.json | 1 - AGENTS.md | 62 +++-- Directory.Packages.props | 6 +- docs/wiki/Agent-Skills.md | 49 ++-- docs/wiki/Getting-Started.md | 11 +- global.json | 2 +- skills/changesets-prerelease/SKILL.md | 36 --- skills/dotnet-tunit/SKILL.md | 56 ----- skills/git-conventional-commits/SKILL.md | 16 -- skills/lefthook-integration/SKILL.md | 16 -- skills/purview-resourcekit-authoring/SKILL.md | 195 ++++++++++++++++ .../purview-resourcekit-diagnostics/SKILL.md | 130 +++++++++++ .../SKILL.md | 2 +- skills/purview-resourcekit-options/SKILL.md | 215 ++++++++++++++++++ skills/purview-resourcekit-testing/SKILL.md | 148 ++++++++++++ skills/tunit-filtering/SKILL.md | 146 ------------ skills/tunit-test-runner/SKILL.md | 112 --------- src/Directory.Build.targets | 74 ------ src/ResourceKit.slnx | 1 + src/src/ResourceKit/ResourceKit.csproj | 6 - .../Repository.UnitTests.csproj | 14 ++ src/tests/Repository.UnitTests/SkillTests.cs | 60 +++++ 22 files changed, 839 insertions(+), 519 deletions(-) delete mode 100644 skills/changesets-prerelease/SKILL.md delete mode 100644 skills/dotnet-tunit/SKILL.md delete mode 100644 skills/git-conventional-commits/SKILL.md delete mode 100644 skills/lefthook-integration/SKILL.md create mode 100644 skills/purview-resourcekit-authoring/SKILL.md create mode 100644 skills/purview-resourcekit-diagnostics/SKILL.md rename skills/{aspire-apphost-to-resourcekit => purview-resourcekit-migration}/SKILL.md (99%) create mode 100644 skills/purview-resourcekit-options/SKILL.md create mode 100644 skills/purview-resourcekit-testing/SKILL.md delete mode 100644 skills/tunit-filtering/SKILL.md delete mode 100644 skills/tunit-test-runner/SKILL.md create mode 100644 src/tests/Repository.UnitTests/Repository.UnitTests.csproj create mode 100644 src/tests/Repository.UnitTests/SkillTests.cs diff --git a/.config/purview-build.json b/.config/purview-build.json index 4fd8deb..4101b1e 100644 --- a/.config/purview-build.json +++ b/.config/purview-build.json @@ -14,7 +14,6 @@ "RequireSymbolFiles": true, "RequiredContent": { "purview.aspire.resourcekit": [ - ".agents/**.*", "analyzers/dotnet/cs/Purview.Aspire.ResourceKit.SourceGeneration.dll", "buildTransitive/Purview.Aspire.ResourceKit.props", "lib/$(TFM)/Purview.Aspire.ResourceKit.dll", diff --git a/AGENTS.md b/AGENTS.md index bb626c9..002b6b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,32 +75,48 @@ Before considering a change done: ## Agent skills -Authored skills live in the repository-root `skills/` folder — **not** in `.agents/skills/`, which is the -gitignored mirror that NuGet packages write into on build. The skills CLI (`npx skills`) only discovers -skills at a root `skills/` folder, and a gitignored folder is absent from a git clone, so the authored copy -has to be tracked at the root. One copy serves both routes: the git clone for the CLI, and the package for -the existing mirror. +Authored skills live in the repository-root `skills/` folder. **Nothing packs them into a NuGet +package.** `Purview.BuildSdk` 1.0.6 removed skill packaging outright — the skills CLI clones the +*git* repository and only discovers a root `skills/` folder, so that is the one delivery route. +`PurviewSkillsToPack`, the `IncludeRepositorySkillsInPackage` shim in `src/Directory.Build.targets`, +and the `.agents/**.*` entry in `.config/purview-build.json` went with it. -Install them into your agent's own directories with: +`.agents/skills/` is the CLI's install location for skills from **other** repositories, and is +gitignored. This repository's own skills are never installed into it — that would leave two copies to +drift apart. Read them at their authored path, `skills//SKILL.md`. ```bash npx skills add purview-dev/aspire-resourcekit ``` -### Skills shipped with a package - -| Skill | Package | Declared in | -| --- | --- | --- | -| `aspire-apphost-to-resourcekit` | `Purview.Aspire.ResourceKit` | `src/src/ResourceKit/ResourceKit.csproj` | - -The repository's other authored skills (`changesets-prerelease`, `dotnet-tunit`, `git-conventional-commits`, -`lefthook-integration`, `tunit-filtering`, `tunit-test-runner`) describe working *in this repository*. They -live in `skills/` and are installable from git, but are not packed into any package. - -Packing is performed by the `IncludeRepositorySkillsInPackage` target in `src/Directory.Build.targets`. That -is an **interim shim**: `global.json` pins `Purview.BuildSdk` 1.0.5, which has no SDK-side equivalent yet. It -implements the same `PurviewSkillsToPack` property the newer SDK uses, so adopting it means setting -`PurviewPackRepositorySkills=true` on the same projects and deleting the shim. - -To add a skill: create `skills//SKILL.md`, then add the name to the owning project's -`PurviewSkillsToPack`. A skill not named there is installable from git but ships in no package. +| 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-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 | + +Every skill carries the `purview-resourcekit` prefix. Skills install into one flat directory per +agent, shared with every skill a developer has installed from anywhere, so an unprefixed name +collides and the loser is whichever installed second. + +Six skills were **removed** in the 1.0.6 migration: `changesets-prerelease`, `dotnet-tunit`, +`git-conventional-commits`, `lefthook-integration`, `tunit-filtering` and `tunit-test-runner`. None +of them described ResourceKit, all of them carried exactly the kind of generic name that collides in +a shared install directory, and the TUnit ones duplicate `purview-buildsdk-testing` +(`npx skills add purview-dev/build-sdk`). Guidance about working *in this repository* belongs in this +file, not in a skill — a skill here ships to somebody else. + +To add a skill: create `skills//SKILL.md` with spec-legal frontmatter. There is no per-project +registration any more. + +`src/tests/Repository.UnitTests/SkillTests.cs` validates every skill on every test run, through +`Purview.Skills.Testing`'s rule set (the Agent Skills specification: frontmatter fields, name and +description constraints, bundled links, body size). The `[InheritsTests]` attribute on that class is +load-bearing — TUnit does not walk base types during discovery, so without it the run is green and +nothing is checked. + +A relative link from one `SKILL.md` into another skill's folder is a rule violation: only a skill's +own folder is installed with it, so such a link works here and nowhere else. Name the other skill in +prose instead, and keep deep detail in the skill's own `references/`. diff --git a/Directory.Packages.props b/Directory.Packages.props index 4e3c837..607242b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,7 +2,9 @@ true 5.9.0 - 1.68.17 + + 1.75.0 1.1.0 5.0.3 10.0.12 @@ -52,6 +54,8 @@ + + diff --git a/docs/wiki/Agent-Skills.md b/docs/wiki/Agent-Skills.md index b28b23f..ee7a45f 100644 --- a/docs/wiki/Agent-Skills.md +++ b/docs/wiki/Agent-Skills.md @@ -1,31 +1,36 @@ -# Bundled agent skills +# Agent skills -`Purview.Aspire.ResourceKit` ships one or more bundled [Agent Skills](https://agentskills.io/) that -are auto-installed into consuming repositories so supported coding agents can discover -ResourceKit-specific guidance automatically. +This repository authors a set of [Agent Skills](https://agentskills.io/) for +`Purview.Aspire.ResourceKit`, so a coding agent can discover ResourceKit-specific guidance instead of +inferring it. -## How auto-install works +## How to install them -When a consuming project builds, any `skills/**/SKILL.md` files in the package are copied to -`.agents/skills/**` in the consuming repository. +They are **not** shipped inside the NuGet package, and nothing copies them into your repository on +build. Install them with the skills CLI, which clones this git repository: -Example mapping: - -- `skills/aspire-apphost-to-resourcekit/SKILL.md` → - `.agents/skills/aspire-apphost-to-resourcekit/SKILL.md` - -A local `.gitignore` is written into each generated skill folder to keep updates out of source control -noise, so the skills stay fresh on rebuild without polluting the repository history. +```bash +npx skills add purview-dev/aspire-resourcekit +``` -## Opting out +Append `#` to pin a specific commit or tag. The CLI writes into your agent's own directories and +records what it installed in `skills-lock.json`, so the set can be restored later with +`npx skills experimental_install`. -To disable this behavior, set the shared opt-out property in your project (or `Directory.Build.props`): +> The earlier behaviour — the package writing `skills/**/SKILL.md` into `.agents/skills/**` during a +> build, with a generated `.gitignore` per folder, controlled by `EnableAgentFolderInPackage` — was +> removed in `Purview.BuildSdk` 1.0.6. It duplicated the CLI and wrote to the same paths, so a build +> could silently overwrite a pinned install. The CLI is now the only installer. -```xml -false -``` +## The skills -## Bundled skill +| 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-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 | -- `aspire-apphost-to-resourcekit` — guides migrating an inline AppHost into ResourceKit composition, - covering attributes, lifecycle, options, and configuration. \ No newline at end of file +Each is authored at `skills//SKILL.md` in this repository, and validated on every test run +against the Agent Skills specification by `src/tests/Repository.UnitTests/SkillTests.cs`. diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index 190b4b3..2514ffd 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -16,14 +16,9 @@ dotnet add package Purview.Aspire.ResourceKit ``` > [!TIP] -> This package can include one or more bundled [Agent Skills](https://agentskills.io/). -> On build, `skills/**/SKILL.md` entries are copied to `.agents/skills/**` in the consuming repository -> (for example: `skills/aspire-apphost-to-resourcekit/SKILL.md` → -> `.agents/skills/aspire-apphost-to-resourcekit/SKILL.md`), along with a local `.gitignore` in each -> generated skill folder to keep updates out of source control noise. -> -> To opt out, set `false` in your project -> (or `Directory.Build.props`). See [Bundled Agent Skills](Agent-Skills.md). +> ResourceKit publishes [Agent Skills](https://agentskills.io/) so a coding agent knows these +> patterns. They are not shipped in the package - install them explicitly with +> `npx skills add purview-dev/aspire-resourcekit`. See [Agent Skills](Agent-Skills.md). ## Define a host kit diff --git a/global.json b/global.json index 5ad4318..32f1a20 100644 --- a/global.json +++ b/global.json @@ -3,7 +3,7 @@ "allowPrerelease": false }, "msbuild-sdks": { - "Purview.BuildSdk": "1.0.5" + "Purview.BuildSdk": "1.0.6" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/skills/changesets-prerelease/SKILL.md b/skills/changesets-prerelease/SKILL.md deleted file mode 100644 index d3bb879..0000000 --- a/skills/changesets-prerelease/SKILL.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -name: changesets-prerelease -description: Create and apply a prerelease bump using Changesets CLI, with the new changeset markdown summarizing the actual changes since the last shipped release. ---- - -# Changesets Prerelease Skill - -Use this skill when preparing the next prerelease version in a project that uses Changesets. - -## Steps - -1. Add a new changeset: - - `npx @changesets/cli add --empty --message ""` - - update the generated `.changeset/*.md` frontmatter with the correct target package and bump type used by the repository. - - replace the placeholder body with a concise summary of the **actual user-facing changes since the last shipped release**, not just a generic "prepare prerelease" note. - - if the immediately previous prerelease number (for example `.24`) has **not** been released yet and you are preparing `.25`, the new markdown should still describe the cumulative changes that matter for the next published prerelease. - -2. Bump versions/changelog: - - `npx @changesets/cli version` -3. Commit the resulting changes (`package.json`, `CHANGELOG.md`, and consumed `.changeset` files). - -## Writing the new `.changeset/*.md` body - -- Summarize the functional changes that should appear in the changelog for the next published prerelease. -- Prefer short release-note language such as: - - fixed SQL snapshot translation for directly mapped complex mirror properties - - clarified provider documentation for scalar value object query behaviour - - aligned repo-local agent skills and instructions -- Do **not** leave the body as a procedural placeholder such as `Prepare next prerelease` unless there were truly no meaningful changes. -- If multiple unreleased prerelease bumps exist locally, write the new file so the generated changelog entry remains useful to an external consumer reading the next shipped release. - -## Notes - -- If the repository uses prerelease mode, check `.changeset/pre.json` for the active prerelease tag. -- Ensure release notes/changelog are generated from actual changes, not placeholder text. -- In prerelease mode, `changeset version` can record a changeset in `.changeset/pre.json` even if the new file was created with placeholder/empty content first. If that happens, correct the `.md` frontmatter/body before finalizing the release notes so the next shipped prerelease entry is accurate. diff --git a/skills/dotnet-tunit/SKILL.md b/skills/dotnet-tunit/SKILL.md deleted file mode 100644 index 274d53c..0000000 --- a/skills/dotnet-tunit/SKILL.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -name: dotnet-tunit -description: Use when creating or refactoring .NET tests with TUnit conventions (AAA pattern, naming, async assertions, and CancellationToken usage). -category: dotnet -roles: - - dotnet - - dotnet-tunit - - coding -tags: - - dotnet - - csharp - - tunit - - tests ---- - -# dotnet-tunit Skill - -Use this skill when creating or refactoring tests in a .NET codebase that uses TUnit. - -## Authoring and refactoring rules (mandatory) - -- Always use TUnit (`TUnit.Core`, `TUnit.Assertions`). -- Do not introduce xUnit, NUnit, MSTest, or FluentAssertions unless explicitly requested. -- Test methods must have `[Test]`. -- Assertion calls must be awaited. -- Prefer `await Assert.That(actual).IsEqualTo(expected);`. - -### Arrange / Act / Assert structure - -- Always use Arrange / Act / Assert. -- Always include explicit section comments in the test body: - - `// Arrange` - - `// Act` - - `// Assert` - -### Naming conventions - -- Test classes must be named `{ModuleOrClassBeingTested}Tests`. -- Test classes must be in the same namespace as the module/class being tested (identical namespace). -- Test methods must follow `{SubjectUnderTest}_{Scenario}_{Expectation}`. - - `SubjectUnderTest` is usually the method name. - - Use `ctor` for constructor-focused tests when appropriate. - -### CancellationToken rule - -- Always include `CancellationToken cancellationToken` as the last parameter when any called method in the test body supports a `CancellationToken` parameter. -- Pass that token through in the call under test (for example: `var result = await sut.ProcessAsync(cancellationToken);`). - -## Refactoring expectation - -- When refactoring existing tests, bring them into compliance with all rules above (structure, naming, and cancellation-token usage), while preserving test intent. - -## Execution reminder - -- Run relevant tests before completion. -- For execution/filtering details, use `../tunit-test-runner/SKILL.md`. diff --git a/skills/git-conventional-commits/SKILL.md b/skills/git-conventional-commits/SKILL.md deleted file mode 100644 index 6896ae6..0000000 --- a/skills/git-conventional-commits/SKILL.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -name: git-conventional-commits -description: Git workflow and conventional commit guidance. ---- - -# Git Conventional Commits Skill - -Use this skill when applying Git workflow and commit hygiene practices. - -## Rules - -- Use Conventional Commits. -- Prefer small, reviewable commits. -- Commit types include `feat`, `fix`, `docs`, `test`, `refactor`, `chore`, `build`, and `ci`. -- Do not mix unrelated changes. -- Explain validation commands before completion. diff --git a/skills/lefthook-integration/SKILL.md b/skills/lefthook-integration/SKILL.md deleted file mode 100644 index 42320ec..0000000 --- a/skills/lefthook-integration/SKILL.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -name: lefthook-integration -description: Lefthook Git hook integration. ---- - -# Lefthook Integration Skill - -Use this skill when configuring repository-local Git hook setup. - -## Rules - -- Prefer small, fast checks in pre-commit. -- Put slower checks in pre-push. -- Do not block commits with network-dependent checks. -- Keep commands cross-platform where possible. -- Document how to install and run hooks locally. diff --git a/skills/purview-resourcekit-authoring/SKILL.md b/skills/purview-resourcekit-authoring/SKILL.md new file mode 100644 index 0000000..5169115 --- /dev/null +++ b/skills/purview-resourcekit-authoring/SKILL.md @@ -0,0 +1,195 @@ +--- +name: purview-resourcekit-authoring +description: >- + Use when composing an Aspire AppHost with Purview.Aspire.ResourceKit - declaring a [HostKit] and + [ResourceDefinition] resource kits, choosing the generic or non-generic attribute style, the + Build/BuildResource and Configure/ConfigureResource lifecycle, project resources through + AddProject, and registering the generated AddAspireResourceKit extension. +metadata: + product: Purview.Aspire.ResourceKit + repository: purview-dev/aspire-resourcekit +--- + +# Composing an AppHost with ResourceKit + +`Purview.Aspire.ResourceKit` turns an inline Aspire AppHost into a declarative model: one host kit, +one resource kit per resource, and a generated extension method that wires them together. + +```bash +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. + +## The shape + +```csharp +using Purview.Aspire.ResourceKit; + +[HostKit] +partial class ShopHostKit; +``` + +```csharp +using Aspire.Hosting; +using Aspire.Hosting.ApplicationModel; +using Purview.Aspire.ResourceKit; + +[ResourceDefinition("api")] +partial class ApiResourceKit +{ + protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) + => builder.AddProject(Name); +} +``` + +```csharp +var builder = DistributedApplication.CreateBuilder(args); +builder.AddAspireResourceKit(); +``` + +Exactly **one** `[HostKit]` per compilation (`SG0004`); split scenarios into separate projects if you +need more. Every participating class must be `partial` (`SG0001`). + +## `[HostKit]` + +| Argument | Type | Default | Purpose | +| --- | --- | --- | --- | +| `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 | + +## `[ResourceDefinition]` + +| Argument | Type | Default | Purpose | +| --- | --- | --- | --- | +| `Name` | `string?` | derived from the class name, suffix trimmed | the logical Aspire resource name | +| `PropertyName` | `string?` | derived from the class name | the generated host property name; must be a valid C# identifier (`SG0008`) | +| `AspireResourceType` | generic type argument | `null` | the Aspire `IResource` type — generic style only | + +### Two styles, and you pick exactly one per class + +**Generic (recommended)** — the resource type is on the attribute, and the generator supplies the +base: + +```csharp +[ResourceDefinition("api")] +partial class ApiResourceKit +{ + protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) + => builder.AddProject(Name); +} +``` + +Do **not** declare an explicit base (`SG0015`). The type argument may be an Aspire resource type +implementing `IResource` (used as-is) or an `IProjectMetadata` type — the type `AddProject()` +accepts — which is mapped to `ProjectResource`. Anything else is `SG0016`. + +**Non-generic** — the resource type comes from an explicit base: + +```csharp +[ResourceDefinition("api")] +partial class ApiResourceKit : ResourceKitBase +{ + protected override IResourceBuilder BuildResource(IDistributedApplicationBuilder builder) + => builder.AddProject(Name); +} +``` + +You **must** declare a valid explicit base (`SG0014`), deriving from the generated +`ResourceKitBase` or the runtime `ResourceKitBase` (`SG0006`). + +| Situation | Diagnostic | +| --- | --- | +| both styles on one class | `SG0013` | +| generic style with an explicit base | `SG0015` | +| non-generic style without one | `SG0014` | +| explicit base not a valid resource-kit base | `SG0006` | +| resource type cannot be inferred | `SG0016` | + +### Constructors + +An attributed class must not declare a parameterised constructor or an executable constructor body +(`SG0012`). The generator supplies the wiring — a resource kit's primary constructor receives the +host kit and its options. + +## Build versus Configure + +Two lifecycle pairs, and keeping them separate is what makes the composition deterministic: + +- **`Build` / `BuildResource(...)`** — *construct* this resource: `AddProject`, `AddRedis`, + `AddAzureStorage`. +- **`Configure` / `ConfigureResource()`** — *attach* resources to each other after construction: + references, bindings, cross-resource wiring. + +When the generated extension runs, before `DistributedApplication.Build()` completes: + +1. resource kits are instantiated from options; +2. every enabled resource is **built**; +3. every enabled resource is **configured**. + +So by the time `ConfigureResource()` runs, every resource exists and is reachable through the host +kit's generated properties: + +```csharp +protected override void ConfigureResource() +{ + ResourceBuilder.WithReference(HostKit.Postgres.Database).WaitFor(HostKit.Postgres.Database); + + if (HostKit.Redis.IsEnabled) + ResourceBuilder.WithReference(HostKit.Redis).WaitFor(HostKit.Redis); +} +``` + +`HostKitBase` enforces that resources cannot be added after `Build` seals the list, so the +lifecycle is single-run. + +`Build` stores the result of your `BuildResource(...)` in `ResourceBuilder`. Every +`IResourceBuilder` property on a resource kit — nullable or not — must be assigned in +`BuildResource` or `ConfigureResource`, or `SG0017` reports it. (`CS8618` is suppressed for these +properties automatically, since they are populated during the lifecycle, which is why `SG0017` is the +signal that matters.) + +### Hooks on the extension method + +```csharp +builder.AddAspireResourceKit( + onBuilt: (hostKit, builder) => { /* after resources are created, before Configure */ }, + onConfigured: hostKit => { /* after all resources are configured */ }, + configureOptions: optionsBuilder => { /* additional options configuration */ } +); +``` + +## Project resources + +Use the Aspire-generated project reference type directly in the attribute. When the type argument +implements `IProjectMetadata`, ResourceKit validates it, maps it to `ProjectResource` for the +generated base, and enables two execution-only rules: + +- **`SG0018`** — `BuildResource` must register the **declared** project via `AddProject()`. + Building something else (`builder.AddContainer("api", "my-image")`) is a warning, because at runtime + the declared project is never registered and the resource fails. +- **`SG0019`** — a project resource kit with an explicit base must resolve to `ProjectResource`. + +Both are warnings rather than errors on purpose: generation still proceeds, so you can finish the +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. + +## Growing a composition + +Start with one resource kit, add one class per resource, and use the generated options to toggle +resources for local, dev and test scenarios. Prefer a resource-level toggle to conditional host code +— it keeps the model declarative and testable. + +## Related skills + +`purview-resourcekit-options` for generated options, enablement and `OptionsHelper`; +`purview-resourcekit-diagnostics` for the `SG00xx` catalogue; `purview-resourcekit-testing` for test +hosts; and `purview-resourcekit-migration` for converting an existing inline AppHost. diff --git a/skills/purview-resourcekit-diagnostics/SKILL.md b/skills/purview-resourcekit-diagnostics/SKILL.md new file mode 100644 index 0000000..8f46254 --- /dev/null +++ b/skills/purview-resourcekit-diagnostics/SKILL.md @@ -0,0 +1,130 @@ +--- +name: purview-resourcekit-diagnostics +description: >- + Use when a Purview.Aspire.ResourceKit build reports an SG00xx diagnostic - what triggered it, how to + fix it, which rules block source generation and which are execution-only warnings, and why CS8618 is + suppressed on IResourceBuilder properties. +metadata: + product: Purview.Aspire.ResourceKit + repository: purview-dev/aspire-resourcekit +--- + +# ResourceKit diagnostics + +`ResourceKitDiagnosticAnalyzer` evaluates the `SGxxxx` rules, and the source generator uses the same +shared rule set to decide what it can generate. + +ResourceKit also ships `ResourceKitDiagnosticSuppressor`, which suppresses **`CS8618`** for +non-nullable `IResourceBuilder` properties on resource kits. Those are populated at runtime during +the `BuildResource`/`ConfigureResource` lifecycle, so "must contain a non-null value when exiting the +constructor" does not apply — and `SG0017` is the rule that actually tells you a property is never +assigned. + +## The catalogue + +| ID | Severity | Meaning | +| --- | --- | --- | +| `SG0001` | Error | A participating class is not `partial`. | +| `SG0002` | Info | A host kit exists but no resources were defined. | +| `SG0003` | Warning | Resources exist but no host kit was defined. | +| `SG0004` | Error | More than one `[HostKit]` class. | +| `SG0005` | Error | Two resources map to the same generated property name. | +| `SG0006` | Error | A resource does not derive from the expected generated base. | +| `SG0007` | Error | The resource name could not be inferred and `Name` was not set. | +| `SG0008` | Error | An explicit `PropertyName` is not a valid C# identifier. | +| `SG0009` | Error | Missing `IServiceCollection` dependency. | +| `SG0010` | Error | Missing configuration binder dependency. | +| `SG0011` | Error | Missing options configuration extensions dependency. | +| `SG0012` | Error | A non-empty constructor on an attributed class. | +| `SG0013` | Error | `ResourceDefinition` and `ResourceDefinition` mixed on one class. | +| `SG0014` | Error | Non-generic `ResourceDefinition` without an explicit compatible base. | +| `SG0015` | Error | Generic `ResourceDefinition` with an explicit base. | +| `SG0016` | Error | No Aspire resource type could be inferred or found. | +| `SG0017` | Warning | An `IResourceBuilder` property is never assigned in `BuildResource` or `ConfigureResource`. | +| `SG0018` | Warning | A project resource kit does not add the declared project via `AddProject()`. | +| `SG0019` | Warning | A project resource kit's explicit base does not use `ProjectResource`. | +| `SG0020` | Error | An `OptionsHelper.Assign` action sets more than one property path. | + +## Execution-only versus generation-blocking + +`SG0017`, `SG0018` and `SG0019` are **execution-only**. They report things that break the resource at +runtime — an unset builder property, a project that is never registered, a base that cannot build a +project — but they deliberately do **not** stop generation, and are warnings for that reason. + +The point is practical: a resource kit whose `BuildResource` does not yet register its declared +project is still generated, and the host kit still emitted, so you can finish the override instead of +losing the whole output to a half-written method. + +Only the Error-severity rules, `SG0001`–`SG0016`, block generation. + +## Fast triage + +1. Is every host/resource class `partial`? +2. Is there exactly one `[HostKit]` in the compilation? +3. Are the generated property names unique? +4. Does each resource use one attribute style, with a matching base? +5. Is `Name` set where inference cannot determine it? + +## Common fixes + +### `SG0005` — duplicate generated property names + +```csharp +[ResourceDefinition("api", PropertyName = "Api")] +[ResourceDefinition("admin", PropertyName = "AdminApi")] +``` + +### `SG0008` — invalid `PropertyName` + +Valid C# identifiers only: `Api`, `RedisCache`, `OrderDb`. + +### `SG0004` — multiple host kits + +One `[HostKit]` per compilation. Split scenarios into separate projects. + +### `SG0013` / `SG0014` / `SG0015` — attribute style and base type + +One style per class. Non-generic `[ResourceDefinition("name")]` needs an explicit compatible base; +generic `[ResourceDefinition("name")]` must **not** declare one. + +### `SG0017` — unassigned `IResourceBuilder` + +Every such property, nullable or not, must be assigned in `BuildResource` or `ConfigureResource`: + +```csharp +[ResourceDefinition("sql")] +sealed partial class SqlServerKit +{ + public IResourceBuilder Database { get; private set; } + + protected override void ConfigureResource() + { + Database = ResourceBuilder.AddDatabase("changeops-db", "ChangeOps"); + base.ConfigureResource(); + } +} +``` + +### `SG0020` — one property path per `Assign` action + +```csharp +// SG0020 +OptionsHelper.Assign(o => +{ + o.API.IsEnabled = false; + o.API.Name = "api-test"; +}); + +// Correct +OptionsHelper.Assign( + o => o.API.IsEnabled = false, + o => o.API.Name = "api-test" +); +``` + +Visual Studio offers a **"Split into separate assignments"** code fix. + +## Related skills + +`purview-resourcekit-authoring` for the attribute and lifecycle rules behind most of these, and +`purview-resourcekit-options` for `SG0020` and `OptionsHelper`. diff --git a/skills/aspire-apphost-to-resourcekit/SKILL.md b/skills/purview-resourcekit-migration/SKILL.md similarity index 99% rename from skills/aspire-apphost-to-resourcekit/SKILL.md rename to skills/purview-resourcekit-migration/SKILL.md index 2afee36..0778792 100644 --- a/skills/aspire-apphost-to-resourcekit/SKILL.md +++ b/skills/purview-resourcekit-migration/SKILL.md @@ -1,5 +1,5 @@ --- -name: aspire-apphost-to-resourcekit +name: purview-resourcekit-migration description: "Use when converting an Aspire AppHost (typically Program.cs/AppHost.cs) into Purview.Aspire.ResourceKit patterns: generate a HostKit, split resources into ResourceKits with BuildResource/ConfigureResource, map existing config into generated options (IsEnabled/Name), replace direct wiring with AddAspireResourceKit, and update tests/fixtures/configuration to use OptionsHelper and ResourceKit semantics." --- diff --git a/skills/purview-resourcekit-options/SKILL.md b/skills/purview-resourcekit-options/SKILL.md new file mode 100644 index 0000000..dd3e574 --- /dev/null +++ b/skills/purview-resourcekit-options/SKILL.md @@ -0,0 +1,215 @@ +--- +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. +metadata: + product: Purview.Aspire.ResourceKit + repository: purview-dev/aspire-resourcekit +--- + +# Configuration, enablement and OptionsHelper + +ResourceKit generates typed options for the host and for each resource, so names and enablement are +configuration rather than code. + +## The generated shape + +```csharp +public class ShopHostKitOptions +{ + public const string SectionName = "ShopHostKit"; + + public APIKitOptions Api { get; set; } = new(); + public RedisKitOptions Redis { get; set; } = new(); +} + +public class APIKitOptions +{ + public string Name { get; set; } = "api"; + public bool IsEnabled { get; set; } = true; +} +``` + +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`. + +## Extending them + +Generated options are nested `sealed partial` classes, so you add your own properties on the host kit +or the resource kit: + +```csharp +[HostKit] +partial class ShopHostKit +{ + public sealed partial class ShopHostKitOptions + { + public bool EnablePreviewResources { get; set; } + } +} + +[ResourceDefinition("api")] +sealed partial class APIKit +{ + partial class APIKitOptions + { + public string PublishEnvironmentVariableName { get; set; } = "PUBLISH_MARKER"; + } +} +``` + +Keep custom members `public` with `get; set;` so configuration binding can populate them. Data +annotations such as `[Required]` are honoured by the generated `ValidateOnStart()` registration. + +At runtime, host values come from `HostKit.Options` and resource values from `Options`: + +```csharp +protected override void ConfigureResource() +{ + if (HostKit.Options.EnablePreviewResources) + ResourceBuilder.WithEnvironment(Options.PublishEnvironmentVariableName, "true"); +} +``` + +## Section-name resolution + +When a section name is not passed explicitly, it resolves in this order: + +1. `const string SectionName` on the options type; +2. the type name trimmed by **one** suffix — `Options`, `Settings`, `Configuration`, `Config`; +3. the original type name. + +Suffix trimming ignores generic type arguments. `OptionsHelper.SectionNameFor()` (or the `Type` +overload) returns the resolved name. + +## `IsEnabled` versus `IsResourceEnabled` + +Two toggles, and the interaction is the part people get wrong: + +- **`IsEnabled`** — the persisted/configured flag, usually bound from generated options. +- **`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 + `IsEnabled`; +2. 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. +- **`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: + +```csharp +// Publish-only resources - a Key Vault, a publish marker parameter. +protected override bool IsResourceEnabled(IDistributedApplicationBuilder builder) => + builder.ExecutionContext.IsPublishMode; +``` + +Disabling from configuration needs no code change: + +```json +{ "ShopHostKit": { "Redis": { "IsEnabled": false } } } +``` + +## `OptionsHelper` + +Builds configuration arguments or environment variables from strongly typed assignment expressions, +or by flattening a populated options object. Built for integration-test fixtures, scenario toggles +and CLI overrides. + +### `Assign` + +```csharp +var args = OptionsHelper.Assign( + c => c.API.IsEnabled = false, + c => c.API.Name = "api-test" +).Build(); +// --ShopHostKit:API:IsEnabled=false +// --ShopHostKit:API:Name=api-test +``` + +**Each action must set exactly one property path.** A block-bodied lambda setting several is +`SG0020`, with a "Split into separate assignments" code fix in Visual Studio: + +```csharp +// SG0020 +OptionsHelper.Assign(o => +{ + o.API.IsEnabled = false; + o.API.Name = "api-test"; +}); +``` + +An explicit root section name is accepted too: +`OptionsHelper.Assign<…>("MySection", c => c.API.IsEnabled = false)`. + +### `AsEnvironmentVariables` + +```csharp +var envVars = OptionsHelper.Assign( + c => c.API.IsEnabled = false +).AsEnvironmentVariables().Build(); +// { "ShopHostKit__API__IsEnabled": "false" } +``` + +### `Environment` — flatten a populated object + +```csharp +var envVars = OptionsHelper.Environment(new ShopOptions +{ + Api = new ApiOptions { Name = "api-test", Enabled = true }, + ReplicaNames = ["a", "b"], + Labels = { ["region"] = "west" }, +}) +.Override(static o => o.Api.Name = "api-prod") +.Ignore(static o => o.Labels) +.Build(); +``` + +Binder-style keys: `Shop__Api__Name`, `Shop__Api__Enabled`, `Shop__ReplicaNames__0`, +`Shop__ReplicaNames__1`, `Shop__Labels__region`. `Override(...)` replaces values before emission; +`Ignore(...)` drops properties. + +The dictionary goes straight to a resource — keys are preserved verbatim, which matters for things +like `Services__ServiceName`: + +```csharp +ResourceBuilder.WithEnvironment(OptionsHelper.Environment(Options).Build()); +``` + +### `PathFor` + +```csharp +var path = OptionsHelper.PathFor(f => f.API.Name); // "API.Name" +``` + +### With TUnit.Aspire + +```csharp +protected override string[] Args => +[ + .. base.Args, + .. OptionsHelper.Assign( + c => c.API.IsEnabled = false, + c => c.API.Name = "api-test" + ).Build(), +]; +``` + +## A preference worth keeping + +Prefer a resource-level toggle to conditional host code. It keeps the composition declarative, and it +is the difference between a test that flips a config key and one that needs a different AppHost. + +## Related skills + +`purview-resourcekit-authoring` for the composition model, `purview-resourcekit-testing` for test +hosts, `purview-resourcekit-diagnostics` for `SG0020` and the rest. diff --git a/skills/purview-resourcekit-testing/SKILL.md b/skills/purview-resourcekit-testing/SKILL.md new file mode 100644 index 0000000..57b4b34 --- /dev/null +++ b/skills/purview-resourcekit-testing/SKILL.md @@ -0,0 +1,148 @@ +--- +name: purview-resourcekit-testing +description: >- + Use when testing a Purview.Aspire.ResourceKit AppHost - the TUnit.Aspire fixture pattern, + integration-testing a running AppHost, overriding generated options from a test with OptionsHelper + or raw args, unit-testing the manual kit pattern, and the treenode filter for running the tiers. +metadata: + product: Purview.Aspire.ResourceKit + repository: purview-dev/aspire-resourcekit +--- + +# Testing with ResourceKit + +ResourceKit is test-friendly by construction: composition is split into focused classes, and the +generated options give a typed way to override names and enablement from a test without a second +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`: + +| Project | Covers | +| --- | --- | +| `ResourceKit.UnitTests` | runtime behaviour of the kit base types and `OptionsHelper` | +| `ResourceKit.IntegrationTests` | starts the example AppHosts and asserts against real resources | +| `SourceGeneration.UnitTests` / `SourceGeneration.IntegrationTests` | generator output, diagnostics, suppression, code fixes, caching | + +Reports land in `TestResults/`. + +## Integration-testing an AppHost + +Declare a fixture for the AppHost under test; `TUnit.Aspire` wires its start/stop lifecycle around +the fixture: + +```csharp +using TUnit.Aspire; + +namespace Purview.Aspire.ResourceKit.Fixtures; + +public sealed class ExampleAppHostFixture : AspireFixture + where TAppHost : class; +``` + +```csharp +using Projects; + +[ClassDataSource>(Shared = SharedType.PerTestSession)] +public sealed class ExampleAppHostIntegrationTests(ExampleAppHostFixture fixture) +{ + [Test] + public async Task AppHost_WhenServicesStarted_APIIsHealthy(CancellationToken cancellationToken) + { + var client = fixture.CreateHttpClient("api"); + var response = await client.GetAsync(new Uri("/health", UriKind.Relative), cancellationToken); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + } +} +``` + +`SharedType.PerTestSession` matters: starting an AppHost per test is slow enough to change how you +write the suite. + +## Overriding options from a test + +The generated extension binds host options from configuration, so a fixture can pass command-line +arguments to toggle resources. + +Raw strings work: + +```csharp +public sealed class CustomOptionsExampleAppHostFixture : AspireFixture +{ + public const string AzureStorageName = "custom-options-azure-storage-example"; + + protected override string[] Args => + [ + "--ExampleHostKit:Redis:IsEnabled=false", + $"--ExampleHostKit:AzureStorage:Name={AzureStorageName}", + ]; +} +``` + +But prefer `OptionsHelper.Assign(...)` — typed, and refactor-safe: + +```csharp +protected override string[] Args => +[ + .. base.Args, + .. OptionsHelper.Assign( + c => c.Redis.IsEnabled = false, + c => c.AzureStorage.Name = "custom-options-azure-storage-example" + ).Build(), +]; +``` + +Each `Assign` action must set exactly one property path (`SG0020`). The +`purview-resourcekit-options` skill covers `Assign`, `AsEnvironmentVariables`, `Environment`, +`PathFor` and `SectionNameFor`. + +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); + + await Assert + .That(fixture.GetResourceSnapshot(CustomOptionsExampleAppHostFixture.AzureStorageName)) + .IsNotNull() + .Because("The custom Azure Storage name should be passed to the host kit."); +} +``` + +If you already have a populated options object, `OptionsHelper.Environment(...)` flattens it into +configuration-style environment variables, with `Override(...)` and `Ignore(...)` to adjust before +emission. + +## Unit-testing the manual pattern + +The manual host pattern composes `ResourceKitBase` without generation, which +makes the lifecycle directly unit-testable: instantiate a kit, call `Build(builder)` then +`Configure()`, and assert on `ResourceBuilder` and the option-driven behaviour. Use it when you want +to test lifecycle semantics without starting anything. + +## Running them + +Unit tier only — integration tests need Docker/Testcontainers: + +```bash +just test "/*/*/*/*[Category=Unit]" +``` + +Everything, locally: + +```bash +just test +``` + +Filtering uses the TUnit **tree-node** filter (`--treenode-filter`), not `--filter`: this repository +runs on Microsoft.Testing.Platform, where `dotnet test --filter` reports "Zero tests ran". + +## Related skills + +`purview-resourcekit-options` for `OptionsHelper` and enablement, and +`purview-resourcekit-authoring` for the lifecycle these tests exercise. diff --git a/skills/tunit-filtering/SKILL.md b/skills/tunit-filtering/SKILL.md deleted file mode 100644 index a01143f..0000000 --- a/skills/tunit-filtering/SKILL.md +++ /dev/null @@ -1,146 +0,0 @@ ---- -name: tunit-filtering -description: "Use for quick TUnit --treenode-filter patterns." ---- - -# TUnit Filtering (quick reference) - -Use this skill when you already know you need filtering syntax and want concise examples. - -For full execution and troubleshooting guidance, use `../tunit-test-runner/SKILL.md`. - -## Defaults - -- Test runner is Microsoft.Testing.Platform (MTP), not VSTest. -- Use `--treenode-filter` for narrowing tests (do not use `--filter`). -- Prefer your repository's standard full-suite test command for broad runs. - -## `--treenode-filter` - -Select tests by tree path: - -`////` - -## Tree segments - -Each segment maps to one level: - -- 1st segment: Assembly -- 2nd segment: Namespace -- 3rd segment: Class -- 4th segment: Test name - -Examples: - -- `/*/*/LoginTests/*` → all tests in class `LoginTests` -- `/*/*/*/AcceptCookiesTest` → a single test by name -- `/*/MyProject.Tests.Integration/*/*` → tests in a namespace - -## Operators and filter options - -### `*` wildcard - -Matches any value in a segment, or part of a value. - -Examples: - -- `/*/*/LoginTests*/*` -- `/*/*/MyProject.Tests.Api*/*` - -### `=` equality - -Matches an exact property value. - -Example: - -- `/*/*/*/*[Category=Unit]` - -### `!=` not equal - -Excludes a property value. - -Example: - -- `[Category!=Slow]` - -### `&` AND - -Combines multiple conditions in the same segment or property group. - -Examples: - -- `/**[(Category=Unit)&(Priority=High)]` -- `/*/*/*/*[(Category=Unit)&(Priority=High)]` - -### `|` OR - -Matches either condition, inside a single parenthesized group. - -Examples: - -- `/*/*/(LoginTests)|(SignupTests)/*` -- `/**[(Category=Unit)|(Priority=High)]` - -### `**` match-all - -Matches any path depth, but it must appear at the end of the path. - -Examples: - -- `/**` -- `/MyAssembly/**` - -## Property filtering - -You can filter on custom properties in the last segment using `[...]`. - -Examples: - -- `/*/*/*/*[Category=Unit]` -- `/*/*/*/*[Owner=*Team-Backend*]` -- `/*/*/*/*[Category!=Slow]` - -## Important rules - -- Only one property group `[...]` is allowed per path segment. -- If you need multiple property conditions, combine them inside the same brackets with `&` or `|`. -- Separate brackets like `[Category=Smoke]|[Priority=High]` are not valid. -- `**` must be at the end; `/**/Path` is not allowed. - -## Common examples - -- All tests: `/*/*/*/*` -- All unit tests: `/*/*/*/*[Category=Unit]` -- High-priority unit tests: `/*/*/*/*[(Category=Unit)&(Priority=High)]` -- Integration tests with priority: `/*/MyProject.Tests.Integration/*/*[Priority=Critical]` - -## `dotnet test` note - -TUnit does not use the usual VSTest `--filter` syntax. Use `--treenode-filter` instead. - -Preferred: `dotnet test --treenode-filter "..."` - -Compatibility form (older SDKs): `dotnet test -- --treenode-filter "..."` - -## Quick starts - -Use path-like tree-node filters for common flows: - -- `/*/*/*/*/` — all tests -- `/*/*/*/MyFeatureTests/*` — class-scoped -- `/*/*/*/*/MyScenario*` — name pattern - -## Zero-tests troubleshooting - -If zero tests run: - -1. Verify `--treenode-filter` is used (not `--filter`). -2. Try `dotnet test --treenode-filter "..."` first; if needed for your SDK version, use `dotnet test -- --treenode-filter "..."`. -3. Broaden filter first (`/*/*/*/*/`), then narrow. -4. Confirm target project contains `[Test]` methods. -5. Confirm property predicates are in one bracket group (e.g. `[(A=1)&(B=2)]`). - -## See also - -- `../tunit-test-runner/SKILL.md` -- `../dotnet-tunit/SKILL.md` diff --git a/skills/tunit-test-runner/SKILL.md b/skills/tunit-test-runner/SKILL.md deleted file mode 100644 index d0a2108..0000000 --- a/skills/tunit-test-runner/SKILL.md +++ /dev/null @@ -1,112 +0,0 @@ ---- -name: tunit-test-runner -description: >- - Run, filter, and select TUnit tests through `dotnet test`. Use this whenever - executing .NET tests in a project that depends on TUnit (built on - Microsoft.Testing.Platform / MTP, not VSTest), when `dotnet test --filter` - reports "Zero tests ran", or when tests must be narrowed by assembly, - namespace, class, test name, [Category], or other custom properties. Covers - the `--treenode-filter` path-based query syntax, its operators, the `--` - separator rules across SDK versions, and common 0-tests troubleshooting. -license: MIT ---- - -# Running TUnit tests with `dotnet test` - -Use this skill for execution, filtering, and troubleshooting. - -For test creation/refactoring conventions (AAA pattern, naming, cancellation tokens), use `../dotnet-tunit/SKILL.md`. - -## The one rule that matters most - -TUnit runs on **Microsoft.Testing.Platform (MTP)**, not VSTest. The reflexive -`dotnet test --filter "Category=X"` **does not work**: MTP silently rejects the -`--filter` flag, prints its own help text, and exits with `Zero tests ran`. That -looks like a passing-but-empty run or a config failure, but it is just an -unrecognised flag. **Never reach for `--filter` on a TUnit project.** Use -`--treenode-filter` instead. - -| Other frameworks (VSTest) | TUnit (MTP) | -| ------------------------------------------ | ---------------------------------------------------- | -| `--filter "Category=Integration"` | `--treenode-filter "/*/*/*/*[Category=Integration]"` | -| `--filter "FullyQualifiedName~LoginTests"` | `--treenode-filter "/*/*/LoginTests/*"` | -| `--filter "Name=AcceptCookiesTest"` | `--treenode-filter "/*/*/*/AcceptCookiesTest"` | - -## How to invoke it - -Prefer `dotnet test` over `dotnet run`: `dotnet test` builds and runs every -targeted TFM automatically and works against a `.csproj`, `.sln`, or `.slnx`, -whereas `dotnet run` only runs a single TFM. - -The catch is the `--` separator, which depends on the SDK: - -```bash -# Universal form — works on every SDK. Use this by default. -dotnet test -- --treenode-filter "/*/*/LoginTests/*" - -# .NET 10+ SDK only — the platform flag can be passed directly. -dotnet test --treenode-filter "/*/*/LoginTests/*" -``` - -Anything after `--` is passed through to the TUnit test runner rather than to -the `dotnet test` command itself. Flags from extension packages -(`--coverage`, `--report-trx`, `--results-directory`, etc.) **must** also sit -after the `--`: - -```bash -dotnet test --configuration Release --no-build \ - -- --treenode-filter "/*/*/*/*[Category=Unit]" --coverage --report-trx -``` - -> Run with no filter to execute everything: `dotnet test`. - -## The `--treenode-filter` syntax - -A filter is a path with four segments, optionally annotated with a property -group on any segment: - -``` -////[Property=Value] -``` - -Use `*` as a wildcard in any segment. The classic "run all tests" filter is -`/*/*/*/*` — four wildcards, one per level. - -### Operators - -| Operator | Meaning | Example | -| -------- | --------------------------------------------- | -------------------------------------- | -| `*` | Wildcard within a segment | `/*/*/LoginTests*/*` | -| `=` | Property equals (exact) | `/*/*/*/*[Category=Unit]` | -| `!=` | Property not equal (exclude) | `/*/*/*/*[Category!=Slow]` | -| `&` | AND — within one segment / property group | `/**[(Category=Unit)&(Priority=High)]` | -| `\|` | OR — within one segment / property group | `/*/*/(LoginTests)\|(SignupTests)/*` | -| `**` | Match any path depth (must be at the **end**) | `/MyAssembly/**` | - -### Two grammar rules that are easy to get wrong - -1. **`&` and `|` operate _inside a single segment or property group_, and each - side must be wrapped in parentheses.** They do not join two complete paths. -2. **Only one property group `[...]` is allowed per path segment.** Combine - conditions _inside_ the single bracket — do not chain brackets. -3. **`**` must terminate the path.** `/MyAssembly/**` is valid; `/**/Class/*` - is not. - -## Troubleshooting: "Zero tests ran" / 0 tests discovered - -Check, in order: - -1. **`--filter` was used instead of `--treenode-filter`.** This is the most common cause. -2. **`Microsoft.NET.Test.Sdk` is still referenced.** It conflicts with the TUnit MTP platform. -3. **TUnit package missing.** Ensure ``. -4. **Missing `[Test]` attribute** or unsupported method shape. -5. **Wrong `OutputType`.** A `hostfxr.dll could not be found` error means the project needs `Exe`. -6. **Bad filter shape.** Remember the path has exactly four segments. - -## References - -- TUnit — Test Filters: https://tunit.dev/docs/execution/test-filters/ -- TUnit — Troubleshooting & FAQ: https://tunit.dev/docs/troubleshooting/ -- TUnit — CI/CD pipelines: https://tunit.dev/docs/examples/tunit-ci-pipeline/ -- TUnit — Explicit tests: https://tunit.dev/docs/writing-tests/explicit/ -- MTP graph-query filtering spec: https://github.com/microsoft/testfx/blob/main/docs/mstest-runner-graphqueryfiltering/graph-query-filtering.md diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index 77afe53..ab68169 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -1,77 +1,3 @@ - - - $([MSBuild]::NormalizeDirectory('$(MSBuildThisFileDirectory)', '..', 'skills')) - $(TargetsForTfmSpecificContentInPackage);IncludeRepositorySkillsInPackage - - - - - <_RepositorySkillToPack Include="$(PurviewSkillsToPack)" /> - <_RepositorySkillFile Include="$(PurviewSkillsSourceFolder)%(_RepositorySkillToPack.Identity)/**/*"> - %(_RepositorySkillToPack.Identity) - - - - - - - - <_RepositorySkillPackageFile Include="@(_RepositorySkillFile)"> - .agents/skills/%(SkillName)/$([System.String]::Copy('%(RecursiveDir)').Replace("\", "/"))%(Filename)%(Extension) - - - - - - - <_RepositorySkillGitIgnoreRoot>$([MSBuild]::NormalizeDirectory('$(IntermediateOutputPath)', '_PurviewSkillGitIgnores')) - - - - <_RepositorySkillGitIgnoreFile Include="$(_RepositorySkillGitIgnoreRoot)%(_RepositorySkillToPack.Identity)/.gitignore"> - .agents/skills/%(_RepositorySkillToPack.Identity)/.gitignore - - - - diff --git a/src/ResourceKit.slnx b/src/ResourceKit.slnx index 558d8c3..6e49a59 100644 --- a/src/ResourceKit.slnx +++ b/src/ResourceKit.slnx @@ -28,5 +28,6 @@ + diff --git a/src/src/ResourceKit/ResourceKit.csproj b/src/src/ResourceKit/ResourceKit.csproj index 190406b..e00fd72 100644 --- a/src/src/ResourceKit/ResourceKit.csproj +++ b/src/src/ResourceKit/ResourceKit.csproj @@ -1,10 +1,4 @@ - - - aspire-apphost-to-resourcekit - - $(AllTargetFrameworks) diff --git a/src/tests/Repository.UnitTests/Repository.UnitTests.csproj b/src/tests/Repository.UnitTests/Repository.UnitTests.csproj new file mode 100644 index 0000000..0b2a2d5 --- /dev/null +++ b/src/tests/Repository.UnitTests/Repository.UnitTests.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + false + + + + + + + diff --git a/src/tests/Repository.UnitTests/SkillTests.cs b/src/tests/Repository.UnitTests/SkillTests.cs new file mode 100644 index 0000000..4952454 --- /dev/null +++ b/src/tests/Repository.UnitTests/SkillTests.cs @@ -0,0 +1,60 @@ +using Purview.Skills.Testing; + +namespace Purview.Aspire.ResourceKit; + +/// +/// Runs the Agent Skills conformance rule set against this repository's authored skills in +/// skills/. +/// +/// +/// +/// [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 nothing is validated. +/// +/// +/// There is no RepositoryRoot override: the default is the SDK's repository-root marker walk, +/// the same resolution InstallPurviewSkills uses, so an ordinary test run exercises it. +/// is what catches a walk that landed elsewhere - +/// every inherited rule passes vacuously against a catalogue that was never found. +/// +/// +[InheritsTests] +public sealed class SkillTests : PurviewSkillTests +{ + [Test] + public async Task SkillsFolder_ResolvesToThisRepository() + { + var catalogue = Evaluator.Catalogue; + + await Assert + .That(catalogue.FolderExists) + .IsTrue() + .Because($"the marker walk resolved '{catalogue.RepositoryRoot}', whose skills folder does not exist"); + + await Assert + .That(File.Exists(Path.Combine(catalogue.RepositoryRoot, "AGENTS.md"))) + .IsTrue() + .Because($"'{catalogue.RepositoryRoot}' does not look like this repository"); + + await Assert.That(catalogue.Skills).IsNotEmpty(); + } + + [Test] + public async Task EverySkill_IsNamedForThisProduct() + { + // 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) + .Where(name => !name.StartsWith("purview-resourcekit", StringComparison.Ordinal)) + .ToArray(); + + await Assert + .That(unprefixed) + .IsEmpty() + .Because( + "every skill this repository publishes must carry the product prefix: " + string.Join(", ", unprefixed) + ); + } +} From fef39573e347666354d7ace9c93eeee717b37631 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 15 Oct 2026 16:17:40 +0100 Subject: [PATCH 3/4] chore: run CSharpier on save in Visual Studio A tracked solution-level setting, so format-on-save matches the lint check the pipeline runs rather than being set per developer. --- src/settings.VisualStudio.json | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 src/settings.VisualStudio.json diff --git a/src/settings.VisualStudio.json b/src/settings.VisualStudio.json new file mode 100644 index 0000000..194ba35 --- /dev/null +++ b/src/settings.VisualStudio.json @@ -0,0 +1,4 @@ +/* Visual Studio Settings File */ +{ + "csharpier.general.runonsave": true +} From abddfb899e858797b0084ec2e2ecca913a7643c1 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 15 Oct 2026 23:02:57 +0100 Subject: [PATCH 4/4] refactor: added usings to drive result --- .config/dotnet-tools.json | 4 +- Directory.Packages.props | 17 +++--- package.json | 2 +- src/ResourceKit.slnx | 2 +- .../Example.AppHost/Example.AppHost.csproj | 3 +- .../Example.ManualAppHost.csproj | 3 +- src/tests/Repository.UnitTests/SkillTests.cs | 2 +- .../DiagnosticMessageRenderingTests.cs | 36 ++++++++++--- .../GeneratedSourceContentTests.cs | 34 ++++++------ .../HostAppGeneratorTests.cs | 52 +++++++++++++++---- .../OptionsHelperAssignAnalyzerTests.cs | 14 ++--- .../ProjectResourceDefinitionTests.cs | 24 ++++++--- .../ResourceKitDiagnosticSuppressorTests.cs | 6 +-- .../ResourcePropertyNeverSetTests.cs | 12 ++--- .../GeneratedAttributesTests.cs | 22 +++++--- 15 files changed, 154 insertions(+), 79 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 42b8dea..d51c9f0 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -10,14 +10,14 @@ "rollForward": false }, "dotnet-inspect": { - "version": "0.25.0", + "version": "0.26.0", "commands": [ "dotnet-inspect" ], "rollForward": false }, "purview.build": { - "version": "0.5.3", + "version": "0.5.4", "commands": [ "purview-build" ], diff --git a/Directory.Packages.props b/Directory.Packages.props index 607242b..1430680 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,15 +2,13 @@ true 5.9.0 - 1.75.0 - 1.1.0 - 5.0.3 - 10.0.12 - 10.10.0 - 1.19.0 - 13.4.6 + 1.1.1 + 5.0.4 + 10.0.13 + 10.11.0 + 1.20.0 + 13.6.1 @@ -36,7 +34,7 @@ Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="$(DotNetRuntimeVersion)" /> - + @@ -54,7 +52,6 @@ - diff --git a/package.json b/package.json index 8786bcd..8c13291 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aspire-resourcekit", - "version": "1.0.0.1", + "version": "1.0.1", "description": "Source-generator-powered framework for structuring .NET Aspire AppHost resource composition as strongly typed, test-friendly classes.", "keywords": [], "homepage": "https://purview.dev/projects/aspire-resourcekit/", diff --git a/src/ResourceKit.slnx b/src/ResourceKit.slnx index 6e49a59..d385223 100644 --- a/src/ResourceKit.slnx +++ b/src/ResourceKit.slnx @@ -1,10 +1,10 @@ - + diff --git a/src/src/Example.AppHost/Example.AppHost.csproj b/src/src/Example.AppHost/Example.AppHost.csproj index 0a12ea0..a29757c 100644 --- a/src/src/Example.AppHost/Example.AppHost.csproj +++ b/src/src/Example.AppHost/Example.AppHost.csproj @@ -1,7 +1,8 @@ - + true true + true false $(NoWarn);CA1034;CA1062; true true + true false diff --git a/src/tests/Repository.UnitTests/SkillTests.cs b/src/tests/Repository.UnitTests/SkillTests.cs index 4952454..aac7cd3 100644 --- a/src/tests/Repository.UnitTests/SkillTests.cs +++ b/src/tests/Repository.UnitTests/SkillTests.cs @@ -1,6 +1,6 @@ using Purview.Skills.Testing; -namespace Purview.Aspire.ResourceKit; +namespace Purview.Aspire.ResourceKit.Repository; /// /// Runs the Agent Skills conformance rule set against this repository's authored skills in diff --git a/src/tests/SourceGeneration.IntegrationTests/DiagnosticMessageRenderingTests.cs b/src/tests/SourceGeneration.IntegrationTests/DiagnosticMessageRenderingTests.cs index b96ba7c..3f528b1 100644 --- a/src/tests/SourceGeneration.IntegrationTests/DiagnosticMessageRenderingTests.cs +++ b/src/tests/SourceGeneration.IntegrationTests/DiagnosticMessageRenderingTests.cs @@ -55,7 +55,11 @@ protected override IResourceBuilder BuildResource(IDistri """; // Act - var result = await GenerateAsync(source, ResourceKitSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); // Assert await AssertMessagesRenderAsync(result); @@ -79,7 +83,11 @@ class RedisResourceKit : ResourceKitBase; """; // Act - var result = await GenerateAsync(source, ResourceKitSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); // Assert await AssertMessagesRenderAsync(result); @@ -109,7 +117,11 @@ partial class CacheResourceKit : ResourceKitBase; """; // Act - var result = await GenerateAsync(source, ResourceKitSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); // Assert await AssertMessagesRenderAsync(result); @@ -130,7 +142,11 @@ partial class TestingHostKit; """; // Act - var result = await GenerateAsync(source, ResourceKitSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); // Assert await AssertMessagesRenderAsync(result); @@ -178,7 +194,11 @@ protected override IResourceBuilder BuildResource(IDistri """; // Act - var result = await GenerateAsync(source, ResourceKitSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); // Assert await AssertMessagesRenderAsync(result); @@ -206,7 +226,11 @@ protected override IResourceBuilder BuildResource(IDistri """; // Act - var result = await GenerateAsync(source, ResourceKitSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); // Assert await AssertMessagesRenderAsync(result); diff --git a/src/tests/SourceGeneration.IntegrationTests/GeneratedSourceContentTests.cs b/src/tests/SourceGeneration.IntegrationTests/GeneratedSourceContentTests.cs index 4294af1..ae0f25d 100644 --- a/src/tests/SourceGeneration.IntegrationTests/GeneratedSourceContentTests.cs +++ b/src/tests/SourceGeneration.IntegrationTests/GeneratedSourceContentTests.cs @@ -15,7 +15,7 @@ CancellationToken cancellationToken var source = TestHelper.GenerateSources(generateOptions: false); // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert var generated = result.GetSource(); @@ -36,7 +36,7 @@ CancellationToken cancellationToken var source = TestHelper.GenerateSources(resourceKitBase: null); // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(result).HasNoErrorDiagnostics(); @@ -56,7 +56,7 @@ public async Task Generate_GivenHostKit_EmitsLifecyclePartialMethods(Cancellatio var source = TestHelper.GenerateSources(); // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert var generated = result.GetSource(); @@ -79,7 +79,7 @@ CancellationToken cancellationToken var source = TestHelper.GenerateSources(); // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert var generated = result.GetSource(); @@ -105,7 +105,7 @@ CancellationToken cancellationToken var source = TestHelper.GenerateSources(); // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert var generated = result.GetSource(); @@ -133,7 +133,7 @@ public async Task Generate_GivenExplicitBaseClass_CorrectlyGenerates(Cancellatio ); // Act - var result = await GenerateAsync(sources, cancellationToken); + using var result = await GenerateAsync(sources, cancellationToken); // Assert var generated = result.GetSource(); @@ -159,7 +159,7 @@ public partial class RedisResourceKit "; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(result).HasNoErrorDiagnostics(); @@ -202,7 +202,7 @@ public partial class SqlServerResourceKit : {TypeLibrary.Purview.Aspire.Resource }} "; - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); await Assert.That(result).HasNoErrorDiagnostics(); @@ -235,7 +235,7 @@ public partial class RedisResourceKit : {TypeLibrary.Purview.Aspire.ResourceKit. }} "; - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); await Assert.That(result).HasNoErrorDiagnostics(); @@ -265,7 +265,7 @@ public partial class RedisResourceKit : {TypeLibrary.Purview.Aspire.ResourceKit. }} "; - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); await Assert.That(result).HasNoErrorDiagnostics(); @@ -303,7 +303,7 @@ public partial class SqlServerResource : {TypeLibrary.Purview.Aspire.ResourceKit }} "; - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); await Assert.That(result).HasNoErrorDiagnostics(); @@ -336,7 +336,7 @@ public partial class AzureStorageResourceKit : {TypeLibrary.Purview.Aspire.Resou }} "; - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); await Assert.That(result).HasNoErrorDiagnostics(); @@ -373,7 +373,7 @@ public partial class SecretsKit : {TypeLibrary.Purview.Aspire.ResourceKit.Resour "; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(result).HasNoErrorDiagnostics(); @@ -409,7 +409,7 @@ public partial class KeyVaultResourceKit : {TypeLibrary.Purview.Aspire.ResourceK }} "; - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); await Assert.That(result).HasNoErrorDiagnostics(); @@ -441,7 +441,7 @@ public partial class RedisResourceKit : {TypeLibrary.Purview.Aspire.ResourceKit. }} "; - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); await Assert.That(result).HasNoErrorDiagnostics(); @@ -484,7 +484,7 @@ protected CustomResourceBase(TestingHostKit hostKit, string? name) : base(hostKi }} "; - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); await Assert.That(result).HasNoErrorDiagnostics(); @@ -514,7 +514,7 @@ CancellationToken cancellationToken ); // Act - var result = await GenerateAsync(sources, cancellationToken); + using var result = await GenerateAsync(sources, cancellationToken); var types = result.CompilationResult.Assembly!.GetExportedTypes(); var globalHostKitType = types.SingleOrDefault(static m => m.Name == globalHostKitTypeName); diff --git a/src/tests/SourceGeneration.IntegrationTests/HostAppGeneratorTests.cs b/src/tests/SourceGeneration.IntegrationTests/HostAppGeneratorTests.cs index f2307c6..2f55ada 100644 --- a/src/tests/SourceGeneration.IntegrationTests/HostAppGeneratorTests.cs +++ b/src/tests/SourceGeneration.IntegrationTests/HostAppGeneratorTests.cs @@ -16,7 +16,7 @@ public class Empty { } "; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(result.DriverResult.GeneratedTrees.Length).IsEqualTo(ExpectedGeneratedFileCount); @@ -35,7 +35,7 @@ partial class TestingHostKit; "; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert — attribute files + 1 generated host app await Assert.That(result.DriverResult.GeneratedTrees.Length).IsEqualTo(ExpectedFileCountPlusGen); @@ -59,7 +59,11 @@ partial class TestingHostKit2; "; // Act - var result = await GenerateAsync(source, ResourceKitSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); // Assert await Assert.That(result).HasDiagnostic(DiagnosticLibrary.MultipleHostKitsFoundInfo); @@ -80,7 +84,11 @@ class TestingHostKit; "; // Act - var result = await GenerateAsync(source, ResourceKitSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); // Assert await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ClassMustBePartial); @@ -106,7 +114,11 @@ public TestingHostKit(string value) "; // Act - var result = await GenerateAsync(source, ResourceKitSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); // Assert await Assert.That(result).HasDiagnostic(DiagnosticLibrary.NonEmptyConstructorsNotSupported); @@ -136,7 +148,11 @@ public RedisResourceKit() "; // Act - var result = await GenerateAsync(source, ResourceKitSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); // Assert await Assert.That(result).HasDiagnostic(DiagnosticLibrary.NonEmptyConstructorsNotSupported); @@ -163,7 +179,11 @@ partial class RedisResourceKit : {TypeLibrary.Purview.Aspire.ResourceKit.Resourc "; // Act - var result = await GenerateAsync(source, ResourceKitSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); // Assert await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ResourceMustDeriveFromResourceKitBase); @@ -187,7 +207,11 @@ partial class RedisResourceKit; "; // Act - var result = await GenerateAsync(source, ResourceKitSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); // Assert await Assert.That(result).HasDiagnostic(DiagnosticLibrary.NonGenericResourceDefinitionRequiresExplicitBase); @@ -211,7 +235,11 @@ partial class RedisResourceKit : ResourceKitBase; "; // Act - var result = await GenerateAsync(source, ResourceKitSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); // Assert await Assert.That(result).HasDiagnostic(DiagnosticLibrary.GenericResourceDefinitionCannotHaveExplicitBase); @@ -236,7 +264,11 @@ partial class RedisResourceKit; "; // Act - var result = await GenerateAsync(source, ResourceKitSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); // Assert await Assert.That(result).HasDiagnostic(DiagnosticLibrary.MixedResourceDefinitionAttributesNotSupported); diff --git a/src/tests/SourceGeneration.IntegrationTests/OptionsHelperAssignAnalyzerTests.cs b/src/tests/SourceGeneration.IntegrationTests/OptionsHelperAssignAnalyzerTests.cs index 3e2d195..59fad62 100644 --- a/src/tests/SourceGeneration.IntegrationTests/OptionsHelperAssignAnalyzerTests.cs +++ b/src/tests/SourceGeneration.IntegrationTests/OptionsHelperAssignAnalyzerTests.cs @@ -53,7 +53,7 @@ string[] Build() => """; // Act - var result = await GenerateAsync(BuildSource(callerSource), CreateOptions(), cancellationToken); + using var result = await GenerateAsync(BuildSource(callerSource), CreateOptions(), cancellationToken); // Assert await Assert.That(result).HasDiagnostic(DiagnosticLibrary.AssignSetsMultiplePropertyPaths); @@ -79,7 +79,7 @@ string[] Build() => """; // Act - var result = await GenerateAsync(BuildSource(callerSource), CreateOptions(), cancellationToken); + using var result = await GenerateAsync(BuildSource(callerSource), CreateOptions(), cancellationToken); // Assert await Assert.That(result).HasDiagnostic(DiagnosticLibrary.AssignSetsMultiplePropertyPaths); @@ -100,7 +100,7 @@ string[] Build() => """; // Act - var result = await GenerateAsync(BuildSource(callerSource), CreateOptions(), cancellationToken); + using var result = await GenerateAsync(BuildSource(callerSource), CreateOptions(), cancellationToken); // Assert await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.AssignSetsMultiplePropertyPaths); @@ -124,7 +124,7 @@ string[] Build() => """; // Act - var result = await GenerateAsync(BuildSource(callerSource), CreateOptions(), cancellationToken); + using var result = await GenerateAsync(BuildSource(callerSource), CreateOptions(), cancellationToken); // Assert await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.AssignSetsMultiplePropertyPaths); @@ -149,7 +149,7 @@ string[] Build() => """; // Act - var result = await GenerateAsync(BuildSource(callerSource), CreateOptions(), cancellationToken); + using var result = await GenerateAsync(BuildSource(callerSource), CreateOptions(), cancellationToken); // Assert await Assert.That(result).HasDiagnostic(DiagnosticLibrary.AssignSetsMultiplePropertyPaths); @@ -176,7 +176,7 @@ string[] Build() => """; // Act - var result = await GenerateAsync(BuildSource(callerSource), CreateOptions(), cancellationToken); + using var result = await GenerateAsync(BuildSource(callerSource), CreateOptions(), cancellationToken); // Assert await Assert.That(result).HasDiagnostic(DiagnosticLibrary.AssignSetsMultiplePropertyPaths); @@ -208,7 +208,7 @@ void Run() """; // Act - var result = await GenerateAsync(BuildSource(callerSource), CreateOptions(), cancellationToken); + using var result = await GenerateAsync(BuildSource(callerSource), CreateOptions(), cancellationToken); // Assert await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.AssignSetsMultiplePropertyPaths); diff --git a/src/tests/SourceGeneration.IntegrationTests/ProjectResourceDefinitionTests.cs b/src/tests/SourceGeneration.IntegrationTests/ProjectResourceDefinitionTests.cs index 2b3d843..dd9d5f3 100644 --- a/src/tests/SourceGeneration.IntegrationTests/ProjectResourceDefinitionTests.cs +++ b/src/tests/SourceGeneration.IntegrationTests/ProjectResourceDefinitionTests.cs @@ -41,7 +41,7 @@ protected override IResourceBuilder BuildResource(IDistributedA """; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(result).HasNoErrorDiagnostics(); @@ -91,7 +91,11 @@ protected override IResourceBuilder BuildResource(IDistributedA """; // Act - var result = await GenerateAsync(source, ResourceKitSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); // Assert await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ProjectDefinitionMismatch); @@ -128,7 +132,11 @@ protected override IResourceBuilder BuildResource(IDistri """; // Act - var result = await GenerateAsync(source, ResourceKitSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); // Assert await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ProjectResourceKitBaseMismatch); @@ -154,7 +162,11 @@ sealed partial class ApiKit; """; // Act - var result = await GenerateAsync(source, ResourceKitSourceGeneratorTestOptions.NoValidation, cancellationToken); + using var result = await GenerateAsync( + source, + ResourceKitSourceGeneratorTestOptions.NoValidation, + cancellationToken + ); // Assert await Assert.That(result).HasDiagnostic(DiagnosticLibrary.NoAspireResourceFound); @@ -189,7 +201,7 @@ protected override IResourceBuilder BuildResource(IDistributedA """; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(result).HasNoErrorDiagnostics(); @@ -226,7 +238,7 @@ protected override IResourceBuilder BuildResource(IDistributedA """; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert — SG0018 is an execution-only warning, so it is reported but generation still proceeds. await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ProjectDefinitionMismatch); diff --git a/src/tests/SourceGeneration.IntegrationTests/ResourceKitDiagnosticSuppressorTests.cs b/src/tests/SourceGeneration.IntegrationTests/ResourceKitDiagnosticSuppressorTests.cs index 7e6d6f9..1b8b4dd 100644 --- a/src/tests/SourceGeneration.IntegrationTests/ResourceKitDiagnosticSuppressorTests.cs +++ b/src/tests/SourceGeneration.IntegrationTests/ResourceKitDiagnosticSuppressorTests.cs @@ -40,7 +40,7 @@ public class NotAResourceKit """; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); var diagnostics = await RunSuppressorAsync(result, cancellationToken); // Assert @@ -84,7 +84,7 @@ protected override IResourceBuilder BuildResource(IDistri """; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); var diagnostics = await RunSuppressorAsync(result, cancellationToken); // Assert @@ -129,7 +129,7 @@ protected override IResourceBuilder BuildResource(IDistributedA """; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); var diagnostics = await RunSuppressorAsync(result, cancellationToken); // Assert diff --git a/src/tests/SourceGeneration.IntegrationTests/ResourcePropertyNeverSetTests.cs b/src/tests/SourceGeneration.IntegrationTests/ResourcePropertyNeverSetTests.cs index e3c305e..d0f29a1 100644 --- a/src/tests/SourceGeneration.IntegrationTests/ResourcePropertyNeverSetTests.cs +++ b/src/tests/SourceGeneration.IntegrationTests/ResourcePropertyNeverSetTests.cs @@ -32,7 +32,7 @@ protected override IResourceBuilder BuildResource(IDistri """; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ResourcePropertyNeverSet); @@ -61,7 +61,7 @@ protected override IResourceBuilder BuildResource(IDistri """; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ResourcePropertyNeverSet); @@ -93,7 +93,7 @@ protected override IResourceBuilder BuildResource(IDistri """; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.ResourcePropertyNeverSet); @@ -128,7 +128,7 @@ protected override void ConfigureResource() """; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.ResourcePropertyNeverSet); @@ -157,7 +157,7 @@ protected override IResourceBuilder BuildResource(IDistri """; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert await Assert.That(result).DoesNotHaveDiagnostic(DiagnosticLibrary.ResourcePropertyNeverSet); @@ -186,7 +186,7 @@ protected override IResourceBuilder BuildResource(IDistri """; // Act - var result = await GenerateAsync(source, cancellationToken); + using var result = await GenerateAsync(source, cancellationToken); // Assert — SG0017 is an execution-only warning, so it is reported but generation still proceeds. await Assert.That(result).HasDiagnostic(DiagnosticLibrary.ResourcePropertyNeverSet); diff --git a/src/tests/SourceGeneration.UnitTests/GeneratedAttributesTests.cs b/src/tests/SourceGeneration.UnitTests/GeneratedAttributesTests.cs index 0eb485f..7e7e922 100644 --- a/src/tests/SourceGeneration.UnitTests/GeneratedAttributesTests.cs +++ b/src/tests/SourceGeneration.UnitTests/GeneratedAttributesTests.cs @@ -17,7 +17,7 @@ public class Empty { } [Test] public async Task Generate_GivenEmptySource_GeneratesHostKitAttributeSource(CancellationToken cancellationToken) { - var result = await GenerateAsync(EmptySource, cancellationToken); + using var result = await GenerateAsync(EmptySource, cancellationToken); var tree = result.GetGeneratedTree("HostKitAttribute.g.cs"); tree = await Assert.That(tree).IsNotNull(); @@ -37,7 +37,7 @@ public async Task Generate_GivenEmptySource_GeneratesResourceDefinitionAttribute CancellationToken cancellationToken ) { - var result = await GenerateAsync(EmptySource, cancellationToken); + using var result = await GenerateAsync(EmptySource, cancellationToken); var query = result.Generated(); await Assert @@ -57,7 +57,7 @@ public async Task Generate_GivenEmptySource_AttributesAreEmittedIntoResourceKitN CancellationToken cancellationToken ) { - var result = await GenerateAsync(EmptySource, cancellationToken); + using var result = await GenerateAsync(EmptySource, cancellationToken); var query = result.Generated(); await Assert.That(query).HasGeneratedClass(TypeLibrary.Purview.Aspire.ResourceKit.HostKitAttribute); @@ -72,7 +72,7 @@ public async Task Compile_GivenEmptySource_ProducesAssemblyWithGeneratedAttribut CancellationToken cancellationToken ) { - var result = await GenerateAsync(EmptySource, cancellationToken); + using var result = await GenerateAsync(EmptySource, cancellationToken); var query = result.Generated(); foreach ( @@ -91,7 +91,7 @@ var attributeType in new[] [Test] public async Task Compile_GivenEmptySource_HostKitAttributeHasExpectedMembers(CancellationToken cancellationToken) { - var result = await GenerateAsync(EmptySource, cancellationToken); + using var result = await GenerateAsync(EmptySource, cancellationToken); var query = result.Generated(); var attributeClass = await Assert @@ -106,7 +106,11 @@ public async Task Compile_GivenEmptySource_ResourceDefinitionAttributeHasExpecte CancellationToken cancellationToken ) { - var result = await GenerateAsync(EmptySource, ResourceKitSourceGeneratorTestOptions.Compile, cancellationToken); + using var result = await GenerateAsync( + EmptySource, + ResourceKitSourceGeneratorTestOptions.Compile, + cancellationToken + ); var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var type = assembly.GetType( TypeLibrary.Purview.Aspire.ResourceKit.ResourceDefinitionAttribute.MetadataFullName @@ -127,7 +131,11 @@ await Assert [Test] public async Task Compile_GivenEmptySource_AttributesHaveExpectedAttributeUsage(CancellationToken cancellationToken) { - var result = await GenerateAsync(EmptySource, ResourceKitSourceGeneratorTestOptions.Compile, cancellationToken); + using var result = await GenerateAsync( + EmptySource, + ResourceKitSourceGeneratorTestOptions.Compile, + cancellationToken + ); //var assembly = await Assert.That(result.CompilationResult.Assembly).IsNotNull(); var query = result.Generated();