diff --git a/.agents/agents/sdk-consumer-setup.md b/.agents/agents/sdk-consumer-setup.md
deleted file mode 100644
index 8e5066c3..00000000
--- a/.agents/agents/sdk-consumer-setup.md
+++ /dev/null
@@ -1,34 +0,0 @@
-# sdk-consumer-setup (generic agent spec)
-
-## Goal
-
-Help a consuming repository adopt or troubleshoot `Purview.BuildSdk` correctly, without breaking existing build behaviour.
-
-## Workflow
-
-1. Confirm the SDK is imported in `Directory.Build.props`/`Directory.Build.targets` via
- `` and the matching `Sdk.targets` import.
-2. Check pre-import bootstrap properties are set **before** the `Sdk.props` import when they must affect
- evaluation: `NamespacePrefix`, `UsePackageJsonVersion`, `RootPackageJson`.
-3. If version resolution looks wrong, verify `package.json` discovery: explicit `RootPackageJson`, then CI
- variables, `.git` root, or a nearby `package.json`. `UsePackageJsonVersion=Strict` fails fast instead of
- silently skipping resolution.
-4. If the bundled `.agents/**` content isn't appearing in the repo root, check `EnableAgentFolderInPackage`
- (default `true`) and `AgentPackDestinationFolder` (default `.agents`) — the copy runs before build via
- `EnsureAgentFolderInPackageTarget`.
-5. For test-framework or project-shape questions, confirm the project follows repo naming and placement
- conventions the SDK expects, rather than introducing bespoke structure.
-6. Re-run `dotnet build` (or the repo's canonical build command) after each configuration change to confirm
- the fix.
-
-## Constraints
-
-- Prefer minimal, targeted property changes over broad `Directory.Build.props` rewrites.
-- Do not disable `PurviewAutoSdkPack` or `EnableAgentFolderInPackage` unless the consumer explicitly asks to
- opt out.
-- Do not duplicate SDK-managed properties in individual project files unless the scenario is intentionally
- project-specific.
-
-## Related skill
-
-See `../skills/sdk-configuration-reference/SKILL.md` for the full property reference.
diff --git a/.agents/agents/source-generator-framework-writer.agent.md b/.agents/agents/source-generator-framework-writer.agent.md
deleted file mode 100644
index 65f5bd03..00000000
--- a/.agents/agents/source-generator-framework-writer.agent.md
+++ /dev/null
@@ -1,79 +0,0 @@
----
-name: Source Generator Framework Writer
-description: "Specialist for Purview.SourceGeneratorFramework generation code using CodeWriter and XmlCodeWriter-style XML doc extensions; ideal for creating or refactoring generator emitters."
-tools:
- [
- "search/codebase",
- "edit/editFiles",
- "search",
- "execute/getTerminalOutput",
- "execute/runInTerminal",
- "read/terminalLastCommand",
- "read/terminalSelection",
- "execute/createAndRunTask",
- "execute/runTask",
- "read/getTaskOutput",
- "vscodeTasks/createAndRunTask",
- "vscodeTasks/getTaskOutput",
- "vscodeTasks/runTask",
- ]
----
-
-You are a specialist for `Purview.SourceGeneratorFramework` emitter authoring.
-
-## Primary objective
-
-Produce clear, deterministic, maintainable source-generator emission code using `CodeWriter` and XML extension helpers from `XmlCommentWriter`.
-
-## Background knowledge
-
-Before changing any source generator, analyser, or CodeWriter-related code, load and apply the `source-generator-codewriter-modernization` skill. It contains the full source-generator, analyser, and CodeWriter best-practices guidance for this framework, including incremental pipeline design, value equality, deterministic output, and Roslyn version compatibility.
-
-The most important rules are:
-
-- **Analyser for validation; generator for generation.**
-- **Syntax for syntax, symbols for declarations, operations for executable semantics.**
-- **Use `ForAttributeWithMetadataName` whenever possible.**
-- **Remove `ISymbol`, `Compilation`, `SemanticModel`, `IOperation`, `SyntaxTree`, `SyntaxNode`, and `Location` from incremental pipeline models as early as possible.**
-- **Pipeline models must be immutable and value-equatable; use `EquatableArray` for collections.**
-- **Avoid `Collect()` until global knowledge is genuinely required.**
-- **Never combine `CompilationProvider` into the pipeline merely because it is convenient.**
-- **Generate deterministic output and stable hint names.**
-- **Test incrementally, not just generated text.**
-- **Compile against the oldest Roslyn API version containing the functionality you need.**
-- **Create `CodeWriter` inside the output callback and pass it to helpers within that callback; never create it earlier in the pipeline or store it in incremental provider state or custom contexts.**
-
-## Available resources
-
-- `skills/source-generator-codewriter-modernization/SKILL.md` — source-generator, analyser, and CodeWriter best practices for this framework.
-- `prompts/refactor-source-generator-to-codewriter.prompt.md` — prompt template for legacy-emitter refactor tasks.
-
-## Must-follow rules
-
-1. Load and apply the `source-generator-codewriter-modernization` skill.
-2. Prefer structured declaration APIs over handwritten declaration strings.
-3. Prefer XML helper extensions (`XmlSummary`, `XmlParam`, etc.) over raw `///` output.
-4. Create `CodeWriter` inside each output callback; never create it earlier in the pipeline or cache it in incremental provider state or custom contexts.
-5. Preserve semantic behavior while modernizing implementation style.
-6. Keep edits minimal and localized to emitter concerns.
-
-## Refactoring posture
-
-When modernizing legacy code:
-
-- Replace manual indentation/braces with scope APIs.
-- Replace signature text with declaration option records.
-- Replace ad-hoc XML tags with helper APIs.
-- Preserve diagnostics and emitted symbol names.
-
-## Quality gates
-
-- Build/tests pass for impacted projects.
-- No scope leaks when materializing generated source.
-- Generated artifacts remain deterministic and reviewable.
-
-## Skill routing
-
-When relevant, first load and apply:
-
-- `source-generator-codewriter-modernization`
diff --git a/.agents/prompts/refactor-source-generator-to-codewriter.prompt.md b/.agents/prompts/refactor-source-generator-to-codewriter.prompt.md
deleted file mode 100644
index e230d224..00000000
--- a/.agents/prompts/refactor-source-generator-to-codewriter.prompt.md
+++ /dev/null
@@ -1,52 +0,0 @@
----
-agent: ask
-description: "Refactor a legacy source generator emitter from string/StringBuilder to CodeWriter + XmlCodeWriter-style XML extensions with behavior parity."
----
-
-You are modernizing a source generator implementation in this repository. Apply the guidance from the `source-generator-codewriter-modernization` skill for incremental pipelines, value equality, deterministic output, and CodeWriter scope safety.
-
-## Inputs
-
-- Target file(s): `${input:targetFiles:Path(s) to emitter file(s)}`
-- Generator type name: `${input:generatorName:Generator class name}`
-- Generator version: `${input:generatorVersion:Version string (for generated attributes/header)}`
-- Keep output byte-identical where possible: `${input:preserveFormatting:true|false}`
-
-## Task
-
-Refactor the selected legacy emitter implementation from manual `string` / `StringBuilder` output construction to `CodeWriter` and XML documentation extension helpers from `XmlCommentWriter` (XmlCodeWriter-style API usage).
-
-### Requirements
-
-1. Use structured declaration APIs where applicable:
- - `Class/Struct/RecordClass/Interface/Enum`
- - `Method`, `Property`, `Field`, `Constructor`
-2. Use XML helper extensions instead of raw `///` composition:
- - `XmlSummary`, `XmlParam`, `XmlReturn`, `XmlRemarks`, `XmlCode` or `XmlCodeBlock`
-3. Use `TypeReference` when type text becomes complex (nullability, generics, arrays).
-4. Ensure writer lifetime is output-scoped (`generationContext.CreateCodeWriter()` inside callback).
-5. Preserve behavior, diagnostics, and generated names.
-6. Keep changes minimal and focused; do not reformat unrelated logic.
-
-### Migration strategy
-
-- Identify emitter phases: header, namespace, type declarations, member declarations.
-- Replace indentation/braces with scoped APIs.
-- Replace signature strings with declaration options.
-- Replace XML comments with XmlCommentWriter extension methods.
-- Keep semantic equivalence; call out any intentional deltas.
-
-### Verification
-
-- Run relevant tests.
-- Confirm generated files still compile.
-- Confirm no `CodeWriter` scope leaks (`OpenScopeCount == 0` when materialized).
-
-### Output format
-
-Return:
-
-1. Files changed
-2. Why each change was necessary
-3. Risks/behavior differences (if any)
-4. Verification performed
diff --git a/.agents/prompts/sdk-diagnose-agent-folder-copy.md b/.agents/prompts/sdk-diagnose-agent-folder-copy.md
deleted file mode 100644
index e234df90..00000000
--- a/.agents/prompts/sdk-diagnose-agent-folder-copy.md
+++ /dev/null
@@ -1,37 +0,0 @@
-# sdk-diagnose-agent-folder-copy (generic prompt spec)
-
-Diagnose why the bundled `.agents/**` folder from `Purview.BuildSdk` did not appear at the expected
-destination in a consuming repository.
-
-## Required behaviour
-
-1. Confirm the NuGet package actually contains `.agents/**` content (inspect the `.nupkg` if available).
-2. Confirm the consuming project is packable/buildable and imports the SDK via
- `Sdk.props`/`Sdk.targets`, since the copy runs in `EnsureAgentFolderInPackageTarget` before build.
-3. Check `EnableAgentFolderInPackage` is not set to `false` anywhere in the build (project file,
- `Directory.Build.props`, or command-line `-p:` overrides).
-4. Confirm the destination folder: default is `.agents` at the repo root, overridable per-build with
- `-p:AgentPackDestinationFolder=`, and the source defaults to the package-level `.agents`
- folder (override with `PurviewAgentFolderSourcePath`).
-5. Verify repo-root discovery succeeded: explicit `RepoRoot`, then a nearby `AGENTS.md`, then source-control
- root metadata.
-6. If the destination looks stale or incomplete, inspect the change-detection manifest
- (`/.purview/agent-sync.cache`). It lists the files the SDK believes it already mirrored; a
- matching entry with a present destination file means the sync was skipped as up to date. Delete the
- manifest (or the affected destination file) to force a fresh copy.
-7. Retry notices are demoted to low-importance messages, so a healthy build shows no `MSB3026` warnings.
- A copy that still fails after every retry is reported as an error naming the source, destination and
- OS error - search the build log for `The Purview SDK could not copy`. Temporarily set
- `PurviewAgentFolderCopyRetries` / `PurviewAgentFolderCopyRetryDelayMilliseconds` to retry longer, and
- `PurviewSuppressCopyRetryWarnings=false` to see every retry attempt.
-8. Re-run the build and confirm the destination folder now contains the copied files (including the
- generated `.gitignore` for skill/prompt/agent subfolders).
-
-## Suggested output
-
-- A short root-cause explanation (missing import, disabled flag, wrong destination override, repo-root
- discovery miss, or a destination held open by another process).
-- The exact command used to reproduce/verify the fix (for example
- `dotnet build -p:AgentPackDestinationFolder=`).
-- Confirmation that the expected files exist at the resolved destination path, plus whether the
- manifest skipped the sync (in which case the content was already up to date).
diff --git a/.agents/skills/project-placement-defaults/.gitignore b/.agents/skills/project-placement-defaults/.gitignore
deleted file mode 100644
index 27997545..00000000
--- a/.agents/skills/project-placement-defaults/.gitignore
+++ /dev/null
@@ -1,8 +0,0 @@
-# Ignore all files
-*
-
-# Don't ignore directories, so Git can traverse them
-!*/
-
-# Keep this file
-!.gitignore
\ No newline at end of file
diff --git a/.agents/skills/project-placement-defaults/SKILL.md b/.agents/skills/project-placement-defaults/SKILL.md
deleted file mode 100644
index b650da81..00000000
--- a/.agents/skills/project-placement-defaults/SKILL.md
+++ /dev/null
@@ -1,114 +0,0 @@
----
-name: project-placement-defaults
-description: "Use when creating, moving, or splitting projects in a repository that uses Purview.BuildSdk, especially for src/tests placement, test suffix naming, namespace alignment, and automatic project-reference behavior."
----
-
-# Project placement defaults for Purview.BuildSdk
-
-Use this skill whenever a task asks to add, move, split, or create a project in a repository that uses `Purview.BuildSdk` and you need placement, naming, and reference decisions to remain consistent with the SDK's automatic conventions.
-
-## Core principle
-
-Preserve the host repository's existing layout first; only introduce new structure when no established pattern exists. In Purview-based repos, prefer layouts that let the SDK's naming and auto-reference rules work without extra overrides.
-
-## Placement heuristics
-
-Use the repository's current structure as the source of truth, with these Purview-friendly defaults:
-
-1. Prefer source projects under `src/`.
-2. Prefer test projects under `tests/`.
-3. Place new projects beside similar projects (same language, layer, and test type).
-4. Keep one test type per project by default.
-5. Keep shared helper projects in explicit shared/shared-testing locations when those concepts exist.
-
-When a repo has no clear structure, use these conservative defaults because they align well with the SDK's automatic project-reference search paths:
-
-- Source/library projects under `src/`
-- Test projects under `tests/`
-- Integration/end-to-end tests in explicit sibling projects/folders such as `tests/Api.IntegrationTests/` or `tests/Api.E2ETests/`
-
-## Purview-specific naming rules
-
-The SDK relies heavily on project names.
-
-- Keep the `.csproj` filename equal to its containing directory name unless `DisableProjectFileNamingConventionCheck=true` is explicitly used.
-- Use conventional test suffixes such as `.UnitTests`, `.IntegrationTests`, `.E2ETests`, `.FunctionalTests`, `.ContractTests`, and other supported `*Tests` suffixes.
-- Keep shared helper projects on the SDK's exact recognized names when you want shared behavior:
- - Shared projects: `Shared`, `SharedFramework`, `SharedInfrastructure`, `SharedInfra`, `SharedUtilities`, `SharedUtils`, `SharedLibrary`, `SharedLib`, `SharedHelpers`
- - Shared testing projects: `SharedTestingFramework`, `SharedTestingInfrastructure`, `SharedTestingInfra`, `SharedTestingUtilities`, `SharedTestingUtils`, `SharedTestingLibrary`, `SharedTestingLib`, `SharedTestingHelpers`
-- Do not invent near-miss names if you expect the SDK to classify the project automatically.
-
-## Test-type boundaries
-
-Separate tests by behavior and dependency scope:
-
-- **Unit tests**: isolate logic with minimal external dependencies.
-- **Integration tests**: verify behavior across component boundaries (I/O, framework integration, build/evaluation behavior).
-- **End-to-end/system tests**: verify full workflow behavior across the assembled system.
-
-If specialized test categories exist (for example, analyzer diagnostics vs code-fix integration), keep category-specific tests in distinct projects/folders.
-
-The SDK recognizes many test suffixes, including `Unit`, `Integration`, `E2E`, `EndToEnd`, `Acceptance`, `Functional`, `Performance`, `Load`, `Smoke`, `Stress`, `Regression`, `Security`, `Chaos`, `Scenario`, `System`, `Threat`, `BlackBox`, `WhiteBox`, `Accessibility`, `Interactive`, `Environment`, `Architecture`, and `Contract`.
-
-## Naming and namespace defaults
-
-Align identities with existing repository conventions:
-
-- Project names should follow prevailing patterns in sibling projects.
-- Test project names should clearly indicate scope/type with recognized test suffixes.
-- `NamespacePrefix` should remain the root identity source for the repo.
-- `RootNamespace` usually flows from the logical project identity generated by the SDK; avoid custom namespace overrides unless required.
-- `AssemblyName` and `PackageId` default to the fully evaluated `RootNamespace` — or to the full logical project name when suffix-stripping removed a segment (e.g. `Shared`, `ServiceDefaults`) — so a project's package/assembly identity follows its namespace and stays distinct, unless the repo explicitly overrides `AssemblyName`/`PackageId`/`RootNamespace` or opts out via `EnableAssemblyNameGeneration=false`.
-- When moving files between projects, update namespaces so they match the destination project's conventions.
-
-Do not invent a new naming scheme when an existing one is already in use.
-
-## Project defaults
-
-When creating a new project:
-
-1. Match the SDK/project style used by sibling projects.
-2. Reuse central dependency/version management if present.
-3. Add only dependencies required for the project's scope.
-4. Add the project to the repository solution/workspace entry point.
-5. Keep configuration consistent with neighboring projects (target frameworks, nullable, analyzers, warnings).
-
-When working in a Purview-based repo, also assume:
-
-- `TargetFramework` defaults to `net10.0` if not otherwise set, or `netstandard2.0` when the project explicitly declares `IsRoslynComponent=true`.
-- Test projects receive framework packages and coverage defaults from the SDK.
-- Non-test projects receive SourceLink and telemetry defaults unless explicitly opted out.
-
-## Move/split workflow checklist
-
-When splitting or relocating tests/projects:
-
-1. Create destination project/folder using established layout patterns.
-2. Move files physically.
-3. Update namespaces/imports/references for the destination.
-4. Verify the destination project name still produces the intended `TestingType`, `TargetProjectName`, and `RootNamespace`.
-5. Remove stale dependencies from the source project.
-6. Update solution/workspace membership and project references.
-6. Run build and relevant tests.
-
-## Automatic project-reference behavior to preserve
-
-The SDK automatically searches for project references based on naming and placement.
-
-- Test projects probe for their target project in these relative locations:
- - `../$(TargetProjectName)/$(TargetProjectName).csproj`
- - `../../$(TargetProjectName)/$(TargetProjectName).csproj`
- - `../src/$(TargetProjectName)/$(TargetProjectName).csproj`
- - `../../src/$(TargetProjectName)/$(TargetProjectName).csproj`
-- Non-test projects automatically look for sibling shared projects via `../Shared*/Shared*.csproj`.
-- Test projects automatically look for sibling shared-testing projects via `../SharedTesting*/SharedTesting*.csproj`.
-
-If you move projects away from these conventions, be prepared to add explicit project references.
-
-## Guardrails
-
-- Prefer minimal, targeted diffs.
-- Avoid cross-cutting renames unrelated to the move/split intent.
-- Keep test intent unchanged while relocating.
-- If structure is ambiguous, infer from nearest sibling projects and document the assumption in the change summary.
-- When in doubt, preserve compatibility with the SDK's automatic naming, namespace, and project-reference behavior.
diff --git a/.agents/skills/sdk-configuration-reference/.gitignore b/.agents/skills/sdk-configuration-reference/.gitignore
deleted file mode 100644
index 27997545..00000000
--- a/.agents/skills/sdk-configuration-reference/.gitignore
+++ /dev/null
@@ -1,8 +0,0 @@
-# Ignore all files
-*
-
-# Don't ignore directories, so Git can traverse them
-!*/
-
-# Keep this file
-!.gitignore
\ No newline at end of file
diff --git a/.agents/skills/sdk-configuration-reference/SKILL.md b/.agents/skills/sdk-configuration-reference/SKILL.md
new file mode 100644
index 00000000..5dd68c61
--- /dev/null
+++ b/.agents/skills/sdk-configuration-reference/SKILL.md
@@ -0,0 +1,204 @@
+---
+name: sdk-configuration-reference
+description: "Use when configuring Purview.BuildSdk through Directory.Build.props or a .csproj, especially for NamespacePrefix, version detection, testing framework selection, telemetry, repo bootstrapping, and embedded agent-skill settings."
+---
+
+# Purview.BuildSdk configuration reference
+
+Use this skill when a task asks what can be configured in `Purview.BuildSdk`, where a property must be set, or which defaults the SDK applies automatically.
+
+## First rule: know where a property must be set
+
+Set repo-wide bootstrap properties **before** importing the SDK in `Directory.Build.props` when the value must affect `Sdk.props` evaluation.
+
+Common pre-import properties:
+
+- `NamespacePrefix`
+- `UsePackageJsonVersion`
+- `RootPackageJson`
+- Repo-wide testing framework selection properties when you want every project to inherit them
+
+If a property changes behavior in `Sdk.targets` instead, it can usually be set later (for example in a project file), but prefer repo-wide defaults in `Directory.Build.props` unless the scenario is intentionally project-specific.
+
+## Version detection settings
+
+These properties control package/app version resolution from `package.json`:
+
+- `UsePackageJsonVersion` — default `true`; supported values: `true`, `false`, `Strict`
+- `RootPackageJson` — explicit path to the `package.json` to read
+- `EnableVersionDetectionCache` — default `true`; enables local caching of resolved version data
+- `VersionDetectionCacheFile` — optional explicit cache file path
+- `VersionDetectionLogEnabled` — default `false`; set to `true` to log the detected package version
+
+Behavior rules:
+
+1. If `RootPackageJson` is set, the SDK uses that path.
+2. Otherwise it tries to discover the repo root from CI variables, `.git`, or a nearby `package.json`.
+3. When version detection succeeds, both `Version` and `PackageVersion` are set from the `version` field.
+4. `UsePackageJsonVersion=Strict` should be treated as “fail if discovery/resolution cannot succeed”.
+
+## Core identity and build settings
+
+These are the most important configurable properties exposed by the SDK:
+
+- `NamespacePrefix` — required unless `DisableNamespacePrefixCheck=true`
+- `DisableNamespacePrefixCheck` — default `false`
+- `DisablePurviewStylePolicyValidation` — default `false`; set to `true` to stop the build failing (`PRSGD0006`-`PRSGD0009`) when the repository `.editorconfig` overrides the modifier policy (`dotnet_style_require_accessibility_modifiers` other than `omit_if_default`), hides `IDE0040`/`IDE1006`, disables the Style category in bulk, weakens the `_camelCase` private instance field naming rule, or adds the accessibility rules to `NoWarn`. Entries the SDK injects itself (the test-context rule set for test/shared-testing projects, `CA1515` for Aspire hosts and CLI apps) are ignored
+- `TargetFramework` — defaults to `net10.0` when neither `TargetFramework` nor `TargetFrameworks` is set; projects explicitly declaring `IsRoslynComponent=true` default to `netstandard2.0`
+- `IsRoslynComponent` — when explicitly `true`, applies source-generator defaults: a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, SourceLink with `EmbedUntrackedSources=true`, no dependency file, compiler-generated output under the framework-specific intermediate directory, telemetry exclusion, and `PrivateAssets=all` applied to `Microsoft.CodeAnalysis.*` / `Microsoft.CodeAnalysis.Analyzers` references. Packable Roslyn components automatically pack the built analyzer assembly and PDB into `analyzers/dotnet/cs/`; a pack-time validation (`ValidateRoslynComponentCompilerSettings`) fails the pack if the compiler defaults are missing unless `DisableRoslynCompilerDefaultsValidation=true`
+- `IsRoslynComponentOnly` — defaults to `true` for Roslyn components and creates an analyzer-only package: it sets `IncludeBuildOutput=false`, `IncludeSymbols=false`, and packages the portable PDB alongside the analyzer under `analyzers/dotnet/cs/`. Set it to `false` for a dual-role Roslyn component that uses normal library symbol packaging.
+- `PackProjectReferencedSourceGenerators` — default `true`; packable projects automatically include analyzer `ProjectReference` outputs and runtime dependencies under `analyzers/dotnet/cs/`. Set it to `false` globally or use `Pack="false"` on one analyzer reference to opt out.
+- `EnableAssemblyNameGeneration` — default `true`; when `true`, `AssemblyName` and default `PackageId` follow the fully evaluated `RootNamespace` (or the full logical project name when suffix-stripping removed a segment, e.g. `Shared`/`ServiceDefaults`). Set `false` before the SDK import to use the standard project-name behaviour
+- `PurviewSharedTestingOutputType` — default `Library`; forced onto `IsSharedTestingProject` projects (with `IsTestProject`/`IsTestingPlatformApplication` cleared), because the test packages otherwise flip them into an executable test host. Set it to `Exe` before the SDK import to keep that package-driven shape
+- `PurviewTestContextNoWarn` — default `CA1002;CA1012;CA1034;CA1047;CA1050;CA1051;CA1062;CA1064;CA1515;CA1707`; the production API-surface rules exempted in test and shared-testing projects. Test projects keep the strict style contract (`IDE0040`, field naming, formatting, `IDE1006`), but are context aware: public test classes/fixtures, `Method_Scenario_Expectation` names, exposed fields and unvalidated helper parameters are allowed. Override before the SDK import to narrow or extend the set
+- `DisablePurviewTestContextRuleSet` — default `false`; set to `true` to make test and shared-testing projects enforce the production API-surface rules as well
+- `DisableProjectFileNamingConventionCheck` — default `false`; disables the directory-name/file-name match validation
+- `DisableGenerateAssemblyInfoClass` — default `false`; disables generated `AssemblyInfo`
+- `DisableAutoInternalsVisibleTo` — default `false`; disables automatic friend assembly generation
+- `AutoIncludeUsings` — default `true`; controls SDK-added global usings
+- `SourceLinkPackageName` — default `Microsoft.SourceLink.GitHub`
+- `DisableSourceLink` — default `false`
+
+## Telemetry and package-related settings
+
+- `ExcludePurviewTelemetry` — default `false`; removes `Purview.Telemetry.SourceGenerator`
+- `ExcludeMSTelemetryExtension` — default `false`; removes `Microsoft.Extensions.Telemetry.Abstractions`. Only relevant when `ExcludePurviewTelemetry` is also `false` — when `ExcludePurviewTelemetry=true` the whole telemetry group is skipped anyway
+- `IsPackable` — defaults to `false` if not set elsewhere
+- `PackageTags`, `IncludeSource`, `IncludeSymbols`, `PublishRepositoryUrl`, `SymbolPackageFormat` — standard pack-related settings the SDK participates in for packable projects
+- Packable-project defaults (only applied when the consuming project has not supplied a value): `GenerateDocumentationFile=true`, `IncludeSymbols=true`, `SymbolPackageFormat=snupkg`, `PublishRepositoryUrl=true`, `EmbedUntrackedSources=true`, `DebugType=portable`. Portable PDBs are delivered through the `.snupkg`; the normal `.nupkg` does not receive PDB files unless the project opts in explicitly. Roslyn-component-only packages default `IncludeSymbols=false` and ship their PDB inside `analyzers/dotnet/cs/` instead
+- If the repo root is discoverable, the repository-root `README.md` is packed automatically (and registered via `PackageReadmeFile`) when the file exists and `PackageReadmeFile` was not configured explicitly
+
+## Test framework settings
+
+The SDK supports opinionated testing defaults and validation.
+
+Primary settings:
+
+- `TestingFramework` — default `TUnit`; supported values: `TUnit`, `Xunit`, `None`
+- `SubstituteFramework` — default `TUnitMocks`; supported values: `TUnitMocks`, `NSubstitute`, `None`
+- `TestDataFramework` — default `Bogus`; supported values: `Bogus`, `None`
+
+Default outcome for standard test projects:
+
+- `TUnit`
+- `TUnit.Mocks`
+- `Bogus`
+- Microsoft.Testing.Platform integration
+
+Specialised packages such as `TUnit.Aspire` and `Testcontainers` are not automatic defaults; they remain
+explicit choices based on the project's purpose.
+
+Related toggles and derived settings:
+
+- `CollectCoverage` — defaults to `true` for detected test projects
+- `EnableStaticNativeInstrumentation` — defaults to `false` for test projects
+- `EnableDynamicNativeInstrumentation` — defaults to `false` for test projects
+- `TestingPlatformDotnetTestSupport`, `UseMicrosoftTestingPlatformRunner`, `EnableMicrosoftTestingPlatform` — enabled automatically for TUnit test projects
+
+## Repo bootstrap and developer-experience settings
+
+These settings control the SDK’s repo-level helper file bootstrapping:
+
+- `DisableAutoCopySdkFiles` — default `false`; master switch for SDK-managed repo file copying
+- `BootstrapEditorConfigToRepoRoot` — default `true`
+- `RepositoryEditorConfigFilePath` — optional override for the destination `.editorconfig`
+- `BootstrapGlobalJsonToRepoRoot` — default `true`
+- `RepositoryGlobalJsonFilePath` — optional override for the destination `global.json`
+- `PurviewBuildSdkVersionForGlobalJson` — defaults to detected SDK package version, fallback `1.0.0`
+- `PurviewAutoSdkPack` — default `true`; when `true`, automatically packs the `Sdk/` folder contents into the NuGet package with the correct root-level paths
+- `EnableAgentFolderInPackage` — default `true`; mirrors the bundled `.agents/**` folder from the SDK NuGet package into the consuming repo’s `.agents/`
+- `AgentPackDestinationFolder` — default `.agents`; repo-relative destination folder that receives mirrored agent content as `$(AgentPackDestinationFolder)/**`
+- `PurviewAgentFolderSourcePath` — overrides the folder that provides the bundled `.agents` content (defaults to the package-level `.agents` folder beside `Sdk/`)
+- `PurviewAgentFolderCopyRetries` — default `3`; copy attempts per file before a failure is reported
+- `PurviewAgentFolderCopyRetryDelayMilliseconds` — default `500`; base delay between copy attempts
+- `PurviewAgentFolderCopyFailureAsError` — default `true`; when `false`, a copy that still fails after every retry is a warning instead of an error
+- `PurviewAgentSyncManifestPath` — overrides the change-detection manifest (default `/.purview/agent-sync.cache`) used to skip unchanged agent content
+- `PurviewSuppressCopyRetryWarnings` — default `true`; demotes built-in copy task retry notices (`MSB3026`) to messages. Set to `false` to see every retry attempt
+
+## Shared repository copy behaviour
+
+`.agents` content, `.editorconfig` and `global.json` live in one repository-wide location but are written
+by every project, so parallel builds race for the same destinations. The SDK therefore:
+
+1. Skips unchanged content using the `.purview/agent-sync.cache` manifest (fingerprint + content hash per file), so repeat builds touch nothing. This also detects an in-place package republish that keeps the same version.
+2. Stages every write into a temporary file in the destination folder and renames it into place, so readers never see partial content and writers cannot interleave.
+3. Retries quietly — retry attempts are low-importance messages, `MSB3026` notices are demoted — and only reports a copy that still fails after `Purview*CopyRetries` attempts, as an error by default.
+4. Treats "another project already wrote identical content" as success, so a lost race is a no-op instead of a failure.
+
+`PurviewRepoBootstrapMode` (`IfMissing` default, or `Always`/`WarnOnDrift`/`Never`) controls whether
+existing `.editorconfig`/`global.json` files may be overwritten or reported as drifted.
+
+**Hard requirement:** This SDK must pack the contents of `Sdk/` into the NuGet package so that downstream consumers of `Purview.BuildSdk` receive the same `Sdk/**` files. The `PurviewAutoSdkPack` feature (default `true`) is the mechanism that delivers this for standard consuming projects. When a project is packable, the SDK automatically adds `Sdk/**/*` as package content with the correct root-level paths:
+
+- `Sdk/.agents/**` → `.agents/**`
+- `Sdk/.github/**` → `.github/**`
+- `Sdk/build/**` → `build/**`
+- `Sdk/buildTransitive/**` → `buildTransitive/**`
+- `Sdk/buildMultiTargeting/**` → `buildMultiTargeting/**`
+- `Sdk/*.md`, `Sdk/*.png`, `Sdk/*.jpg`, etc. → package root
+- everything else under `Sdk/` → `Sdk/`
+
+The SDK injects a `.gitignore` file into each second-level folder under `Sdk/.agents` during packaging with the following content:
+
+```text[.gitignore]
+# Ignore all files
+*
+
+# Don't ignore directories, so Git can traverse them
+!*/
+
+# Keep this file
+!.gitignore
+```
+
+This lets consuming repos keep the agent folder structure discoverable while ignoring the copied content in Git.
+
+## Important derived properties you can inspect
+
+When explaining SDK behavior, prefer these derived values over guessing:
+
+- `PurviewLogicalProjectName`
+- `PurviewNamespacePrefix`
+- `PurviewProjectShortName`
+- `PurviewTestType`
+- `PurviewSharedTestingOutputType` (default `Library`; `Exe` keeps the test packages' executable/test-host shape)
+- `PurviewTestContextNoWarn` (production API-surface rules exempted in test/shared-testing projects)
+- `PurviewPolicyExemptNoWarn` (SDK-injected `NoWarn` entries that `ValidatePurviewStylePolicy` accepts)
+- `RootNamespace`
+- `AssemblyName`
+- `PackageVersion`
+- `TestingType`
+- `TargetProjectName`
+- `RepoRoot`
+- `RootPackageJson`
+
+## Compiler-visible properties
+
+The SDK exports many properties for analyzers and source generators through `build_property.`. When authoring analyzers or generators, prefer those exported properties instead of re-deriving SDK behavior manually.
+
+Especially relevant exported properties include:
+
+- `UsePackageJsonVersion`, `RootPackageJson`, `RepoRoot`, `Version`, `PackageVersion`
+- `NamespacePrefix`, `DisableNamespacePrefixCheck`
+- `TestingFramework`, `SubstituteFramework`, `TestDataFramework`
+- `ExcludePurviewTelemetry`, `ExcludeMSTelemetryExtension`
+- `EnableAssemblyNameGeneration`, `DisableAutoInternalsVisibleTo`, `DisableGenerateAssemblyInfoClass`
+- `IsCSharpProject`, `IsTestProject`, `IsSharedTestingProject`, `IsSharedProject`
+- `TestingType`, `TargetProjectName`
+- `IsContainerProject`, `IsSdkProject`, `SdkProjectName`, `IsWebProject`, `IsWebSdkProject`, `IsWorkerSdkProject`, `IsAspireHostProject`, `IsCLIProject`
+- `EditorConfigFilePath`, `RepositoryEditorConfigFilePath`, `BootstrapEditorConfigToRepoRoot`
+- `RepositoryGlobalJsonFilePath`, `BootstrapGlobalJsonToRepoRoot`, `DisableAutoCopySdkFiles`
+- `PurviewRepoBootstrapMode`, `PurviewRepoBootstrapCopyRetries`, `PurviewRepoBootstrapCopyRetryDelayMilliseconds`, `PurviewRepoBootstrapCopyFailureAsError`
+- `PurviewAgentFolderSourcePath`, `PurviewAgentFolderCopyRetries`, `PurviewAgentFolderCopyRetryDelayMilliseconds`, `PurviewAgentFolderCopyFailureAsError`, `PurviewAgentSyncManifestPath`, `PurviewSuppressCopyRetryWarnings`
+- `PurviewBuildSdkVersionForGlobalJson`, `CurrentYear`, `AutoGeneratedAssemblyInfoFile`
+
+## Guidance for edits
+
+When changing SDK configuration:
+
+1. Preserve existing defaults unless the task explicitly changes product behavior.
+2. Keep README, SDK property declarations, validation, and any shipped skills aligned.
+3. If you add a new user-facing property, update both the configuration docs and the bundled skills.
+4. If the property affects import-time behavior, document that it must be set before the SDK import.
+5. Keep repository policy guidance aligned with the engineering-principles documentation, and keep low-level
+ property explanations aligned with the wiki reference pages.
diff --git a/.agents/skills/sdk-project-behavior-and-detection/.gitignore b/.agents/skills/sdk-project-behavior-and-detection/.gitignore
deleted file mode 100644
index 27997545..00000000
--- a/.agents/skills/sdk-project-behavior-and-detection/.gitignore
+++ /dev/null
@@ -1,8 +0,0 @@
-# Ignore all files
-*
-
-# Don't ignore directories, so Git can traverse them
-!*/
-
-# Keep this file
-!.gitignore
\ No newline at end of file
diff --git a/.agents/skills/sdk-project-behavior-and-detection/SKILL.md b/.agents/skills/sdk-project-behavior-and-detection/SKILL.md
new file mode 100644
index 00000000..9961d001
--- /dev/null
+++ b/.agents/skills/sdk-project-behavior-and-detection/SKILL.md
@@ -0,0 +1,214 @@
+---
+name: sdk-project-behavior-and-detection
+description: "Use when explaining why Purview.BuildSdk classified a project as test, shared, CLI, web, Aspire host, or container, or when reasoning about auto-added packages, project references, namespaces, and naming conventions."
+---
+
+# Purview.BuildSdk project behavior and detection
+
+Use this skill when a task asks **why** the SDK applied a behavior automatically, or when adding/moving projects in a repo that relies on the SDK's naming and project-type inference.
+
+## Project-type detection rules
+
+The SDK infers behavior from project names, project contents, and SDK declarations.
+
+### Test detection
+
+A project is treated as a test project when its name ends with `*Test` or `*Tests` and the suffix before `Test(s)` matches a supported testing type such as:
+
+- `Unit`
+- `Integration`
+- `E2E`
+- `EndToEnd`
+- `Acceptance`
+- `Functional`
+- `Performance`
+- `Load`
+- `Smoke`
+- `Stress`
+- `Regression`
+- `Security`
+- `Chaos`
+- `Scenario`
+- `System`
+- `Threat`
+- `BlackBox`
+- `WhiteBox`
+- `Accessibility`
+- `Interactive`
+- `Environment`
+- `Architecture`
+- `Contract`
+
+Derived properties:
+
+- `IsTestProject=true`
+- `TestingType=`
+- `PurviewTestType=Tests`
+- `TargetProjectName=`
+
+### Shared project detection
+
+The SDK recognizes shared project names exactly. These are not generic substring matches.
+
+Shared project names:
+
+- `Shared`
+- `SharedFramework`
+- `SharedInfrastructure`
+- `SharedInfra`
+- `SharedUtilities`
+- `SharedUtils`
+- `SharedLibrary`
+- `SharedLib`
+- `SharedHelpers`
+
+Shared testing project names:
+
+- `SharedTestingFramework`
+- `SharedTestingInfrastructure`
+- `SharedTestingInfra`
+- `SharedTestingUtilities`
+- `SharedTestingUtils`
+- `SharedTestingLibrary`
+- `SharedTestingLib`
+- `SharedTestingHelpers`
+
+Derived flags:
+
+- `IsSharedProject`
+- `IsSharedTestingProject`
+
+### SDK/content-based detection
+
+- `IsSdkProject` / `SdkProjectName` come from parsing the project/import `Sdk="..."` declaration
+- `IsWebSdkProject=true` for `Microsoft.NET.Sdk.Web`
+- `IsWorkerSdkProject=true` for `Microsoft.NET.Sdk.Worker`
+- `IsAspireHostProject=true` when the SDK starts with `Aspire.Sdk.Host` or `Aspire.AppHost.Sdk`
+- `IsContainerProject=true` when `Dockerfile`, `dockerfile`, or `Dockerfile.dev` exists in the project directory
+- `IsCLIProject=true` when the project name ends with `CLI`, `Console`, `CommandLine`, `QuickStart`, or `QuickStarts`
+
+## Namespace and identity behavior
+
+The SDK derives the project identity from `NamespacePrefix` and the project name.
+
+Key behavior:
+
+1. `PurviewLogicalProjectName` is built from `NamespacePrefix` plus the project name, with deduplication when the project name already starts with the namespace tail.
+2. `RootNamespace` defaults to `PurviewLogicalProjectName`.
+3. Known suffixes are stripped from `RootNamespace`, including shared/shared-testing names and common segments like `Core`, `EF`, `Shared`, `ClientShared`, and `ServiceDefaults`.
+4. Test suffixes are removed from `RootNamespace`, so `Acme.Api.UnitTests` still maps back to `Acme.Api`.
+5. `AssemblyName` and `PackageId` default to the fully evaluated `RootNamespace` (the canonical default public name) — except when suffix-stripping removed a segment of the logical project name (e.g. `Shared` or `ServiceDefaults`), in which case they use the full `PurviewLogicalProjectName` so those assemblies/packages stay distinct from their parent. Test/shared-testing projects keep their detected suffix in `AssemblyName`/`PackageId` so test assemblies stay distinct. Explicit `AssemblyName`/`PackageId`/`RootNamespace` values always win.
+6. The naming defaults are applied during `Sdk.props` evaluation (before the Microsoft SDK computes `TargetName`), so the compiled output name always matches `AssemblyName`.
+7. The SDK ships `Purview.BuildSdk.Analyzers` and adds it as an `` item to every C# project, so its rules (PDS0002 Extensions namespace, PDS0003 explicit types with target-typed `new()`, PDS0004 correct acronym capitalization) surface in both command-line builds and Visual Studio. The code-fix assembly ships beside it and is referenced as an `` item inside Visual Studio so the IDE discovers its code fixes. `PDS0004` follows .NET naming guidance for well-known framework spellings (`Sql`, `Guid`, `Uuid`, `Url`, `Dns`, `Tcp`, `Http`, `Xml`, `Db`, ...) and exempts them by default — `Db` is exempt so `DbContext`/`DbConnection`/`DbSet` are never flagged (re-enable via `acronym_map = Db:DB`); a small set — `Api`, `Ai`, `Ui`, `Io`, `Os`, `Cpu`, `Gpu`, `Cli`, `Gui`, `Ram`, `Ssh` — is still renamed to uppercase. Members mandated by a contract (interface implementations, base-class overrides) are never renamed. Customise via `dotnet_analyzer_configuration.pds0004.allowed_words` (exempt segments), `.acronym_map` (segment renames, e.g. `Sql:SQL`), and `.allowed_identifiers` (brand names exempted by exact name or word-boundary prefix, first match wins — e.g. `CosmosDb` covers `CosmosDbServer`). All three merge with and override the shipped defaults. The shipped `.editorconfig` treats generated content (`Migrations/`, `*.g.cs`, `Generated/`, `*.Designer.cs`, `obj/`/`bin/`) as `generated_code = true`, and suppresses namespace-conflict diagnostics under `Extensions/` so no `#pragma` suppressions are needed there. A VS code refactoring (`Split extensions class into one class per receiver type`) is offered on static extensions classes that target multiple receiver types: it splits them into one `Extensions` class per receiver (a generic `this TBuilder where TBuilder : IHostApplicationBuilder` receiver becomes `HostApplicationBuilderExtensions`) and places each under `Extensions//` so the PDS0002 convention stays satisfied. A companion refactoring (`Move extensions class to conventional location`) is offered on single-receiver extensions classes that are misplaced: it re-paths the file to `Extensions//`, fixes the namespace to the receiver's namespace, and updates `using` directives in other referencing documents so the move compiles.
+
+Do not hand-author alternate namespace conventions unless the repository explicitly opts out of the SDK defaults.
+
+## Automatic project references
+
+The SDK adds project references based on layout conventions.
+
+### Non-test projects
+
+For ordinary non-test, non-shared projects, it automatically looks for sibling shared projects:
+
+- `../Shared*/Shared*.csproj`
+
+It also removes accidental self/shared-testing matches.
+
+### Test projects
+
+For detected test projects, it attempts these target-project paths in order when they exist:
+
+- `../$(TargetProjectName)/$(TargetProjectName).csproj`
+- `../../$(TargetProjectName)/$(TargetProjectName).csproj`
+- `../src/$(TargetProjectName)/$(TargetProjectName).csproj`
+- `../../src/$(TargetProjectName)/$(TargetProjectName).csproj`
+
+It also adds sibling shared-testing project references via:
+
+- `../SharedTesting*/SharedTesting*.csproj`
+
+This is why consistent naming and placement matter so much in repos that use the SDK.
+
+## Automatic framework/package behavior
+
+### For non-test C# projects
+
+- Adds SourceLink unless `DisableSourceLink=true`
+- Adds Purview telemetry packages unless `ExcludePurviewTelemetry=true`
+- Generates documentation files (`GenerateDocumentationFile=true`) unless explicitly disabled
+- Generates `InternalsVisibleTo` attributes unless `DisableAutoInternalsVisibleTo=true`
+
+### For packable projects
+
+- Defaults `GenerateDocumentationFile`, `IncludeSymbols`, `SymbolPackageFormat=snupkg`, `PublishRepositoryUrl`, `EmbedUntrackedSources`, `IncludeSource`, and `DebugType=portable` — only when the consuming project has not supplied a value
+- Delivers portable PDBs via the `.snupkg`; the normal `.nupkg` does not receive PDBs unless the project opts in explicitly
+- Packs the repository-root `README.md` (registered via `PackageReadmeFile`) when the file exists and `PackageReadmeFile` is unset; skips when a README is already being packed
+- Non-packable projects (including web apps) default `WarnOnPackingNonPackableProject=false` so solution-wide pack operations skip them silently
+
+### For Roslyn component (analyzer/source-generator) projects
+
+- Defaults a single `netstandard2.0` target, `LangVersion=latest`, `Nullable=enable`, `TreatWarningsAsErrors=true`, `Deterministic=true`, extended analyzer rules, and SourceLink with `EmbedUntrackedSources=true`
+- `IsRoslynComponentOnly` defaults to `true`, excluding normal build output (`IncludeBuildOutput=false`) and setting `IncludeSymbols=false`; no `.symbols.nupkg` or `.snupkg` is produced and the analyzer PDB ships inside the main `.nupkg` under `analyzers/dotnet/cs/` beside the analyzer assembly (`PurviewPackAnalyzerPdb=true`). Set it to `false` for a dual-role component that uses normal library symbol packaging.
+- Packable Roslyn components automatically pack the built analyzer assembly (and PDB) into `analyzers/dotnet/cs/`; `SymbolPackageFormat` defaults to the modern `snupkg` if symbols are explicitly opted into
+- `Microsoft.CodeAnalysis.*` and `Microsoft.CodeAnalysis.Analyzers` references are defaulted to `PrivateAssets=all` (development-only dependencies) so they never leak into the packed nuspec
+- A pack-time validation (`ValidateRoslynComponentCompilerSettings`) fails the pack of a packable Roslyn component if `LangVersion`, `Nullable`, `TreatWarningsAsErrors`, or `EnforceExtendedAnalyzerRules` is missing; opt out with `DisableRoslynCompilerDefaultsValidation=true`
+- `ContinuousIntegrationBuild` is set only by real CI environment variables — packability alone never forces SourceLink's CI-mode dirty-repository checks
+
+### For test and shared-testing projects
+
+- Applies test-friendly `NoWarn` defaults
+- Marks projects as not packable/publishable
+- Adds substitute/test-data/testing packages based on `SubstituteFramework`, `TestDataFramework`, and `TestingFramework`
+- For TUnit test projects, enables Microsoft.Testing.Platform integration properties automatically
+- For shared-testing projects, skips the runnable test package and marks them with a skip/category pattern appropriate to the selected test framework
+
+For the default configuration, standard test projects receive:
+
+- `TUnit`
+- `TUnit.Mocks`
+- `Bogus`
+- Microsoft.Testing.Platform integration
+
+Specialized testing dependencies such as `TUnit.Aspire` and `Testcontainers` are still explicit additions by
+project purpose.
+
+### For special project types
+
+- CLI projects default to `OutputType=Exe` and include `appsettings*.json` as content
+- Container projects enable `InvariantGlobalization`, `PublishAot`, Linux Docker defaults, and container tooling package references
+- Web SDK projects get `Microsoft.AspNetCore.OpenApi.Generated` added to `InterceptorsNamespaces` unless marked as a separate web-project mode
+- Aspire host projects default to `OutputType=Exe`
+
+## How to reason about surprising behavior
+
+If the SDK “did something unexpected”, inspect these values first:
+
+- `MSBuildProjectName`
+- `NamespacePrefix`
+- `PurviewLogicalProjectName`
+- `RootNamespace`
+- `TestingType`
+- `TargetProjectName`
+- `SdkProjectName`
+- `IsTestProject`
+- `IsSharedProject`
+- `IsSharedTestingProject`
+- `IsContainerProject`
+- `IsCLIProject`
+- `IsWebSdkProject`
+- `IsAspireHostProject`
+
+Prefer explaining behavior from these computed properties rather than from assumptions about folder names alone.
+
+## Guidance for structural changes
+
+When adding or moving projects in a repo using this SDK:
+
+1. Keep the `.csproj` filename equal to its containing directory name unless the repo explicitly disables that validation.
+2. Preserve established `src/` and `tests/`-style layouts whenever possible.
+3. Use test project suffixes intentionally so auto-detection and auto-references work.
+4. Keep shared helpers in exact shared/shared-testing names if you want the corresponding SDK behavior.
+5. If you change a naming rule in the SDK, update the README and the shipped skills together.
+6. If the question is really about repository policy rather than one computed property, point the user to the
+ engineering-principles documentation first, then explain the specific SDK mechanics.
diff --git a/.agents/skills/source-generator-codewriter-modernization/.gitignore b/.agents/skills/source-generator-codewriter-modernization/.gitignore
deleted file mode 100644
index 27997545..00000000
--- a/.agents/skills/source-generator-codewriter-modernization/.gitignore
+++ /dev/null
@@ -1,8 +0,0 @@
-# Ignore all files
-*
-
-# Don't ignore directories, so Git can traverse them
-!*/
-
-# Keep this file
-!.gitignore
\ No newline at end of file
diff --git a/.agents/skills/source-generator-codewriter-modernization/SKILL.md b/.agents/skills/source-generator-codewriter-modernization/SKILL.md
new file mode 100644
index 00000000..8501ca40
--- /dev/null
+++ b/.agents/skills/source-generator-codewriter-modernization/SKILL.md
@@ -0,0 +1,324 @@
+---
+name: source-generator-codewriter-modernization
+description: "Use when implementing, reviewing, or refactoring C# source generators and analysers in Purview.SourceGeneratorFramework. Covers CodeWriter/XmlCommentWriter-style emission, incremental pipeline design, value equality, and Roslyn best practices."
+---
+
+# Source generator CodeWriter modernization
+
+Use this skill for any work involving C# source generators, analysers, or generated output in `Purview.SourceGeneratorFramework`. It combines CodeWriter/XmlCommentWriter emission guidance with the incremental-source-generator and analyser best practices that ship with the framework.
+
+## Required implementation pattern
+
+When creating source output, favor this shape:
+
+1. Build immutable, value-equatable pipeline values first.
+2. Register source output.
+3. Inside the callback: `var writer = generationContext.CreateCodeWriter();`
+4. Write header/usings/namespace.
+5. Write structured types and members using declaration options records.
+6. Add source once per output artifact.
+
+### CodeWriter lifetime: created in the output callback, never in pipeline state
+
+The line between "fine" and "forbidden" is the **incremental-cache boundary**, not the act of
+creating a writer or handing it to a helper.
+
+**Fine — output-scoped use inside a `RegisterSourceOutput` callback.** Create the writer inside the
+callback and pass it to any emitter/helper methods called from that same callback. It may be held in
+local variables, passed as a parameter, or wrapped in a short-lived output context:
+
+```csharp
+context.RegisterSourceOutput(
+ targets.CombineWithContext(contextProvider),
+ static (spc, pair) =>
+ {
+ var (model, generationContext) = pair;
+ var writer = generationContext.CreateCodeWriter();
+ EmitHeader(writer, model);
+ EmitType(writer, model);
+ spc.AddSource($"{model.Name}.g.cs", writer.ToString());
+ }
+);
+```
+
+**Forbidden — persisting the writer across the incremental-cache boundary.** Do not create it earlier
+in the pipeline and pass it down, and do not store it anywhere Roslyn caches or another callback can
+observe it:
+
+- Do not create a `CodeWriter` in a provider stage and carry it through the pipeline.
+- Do not store a `CodeWriter` as a property or field on `GenerationContext` or a custom context.
+- Do not return a `CodeWriter` (or an object holding one) from an incremental provider.
+- Do not cache or reuse a writer for a later callback or a different output.
+
+A cached writer can retain previously written source, mix output from concurrently running
+callbacks, and defeat scope tracking. A writer created in the callback and used only within that
+callback is safe and expected.
+
+## Source generator & analyser best practices
+
+Apply the following rules to every generator, analyser, and refactor.
+
+### 1. Core principles
+
+- **Analyser for validation; generator for generation.**
+- **Syntax for syntax, symbols for declarations, operations for executable semantics.**
+- **Use `ForAttributeWithMetadataName` for attribute-driven generators.**
+- **Remove Roslyn objects from the incremental pipeline as early as possible.**
+- **Every value crossing a pipeline boundary must have meaningful value equality.**
+- **Prefer many small incremental stages over one large transform.**
+- **Keep broad inputs such as `Compilation` away from downstream generation.**
+- **Generate deterministic output.**
+- **Compile against the oldest Roslyn API version containing the functionality you need.**
+- **Test caching, not just generated text.**
+
+The guiding principle for an incremental generator is:
+
+> Extract semantic information once, convert it into a small value model, and make everything downstream operate only on that value model.
+
+### 2. Analyser vs source generator
+
+Use a `DiagnosticAnalyser` when the question is:
+
+> Is the source code valid according to this library's rules?
+
+Use an `IIncrementalGenerator` when the question is:
+
+> Given valid source code, what source should be generated?
+
+| Requirement | Prefer |
+| --- | --- |
+| Require a class to be `partial` | Analyser |
+| Require an attribute on a declaration | Analyser |
+| Validate a method signature | Analyser |
+| Reject unsupported property types | Analyser |
+| Detect invalid attribute arguments | Analyser |
+| Detect unsupported API usage | Analyser |
+| Offer an automatic fix | Analyser + `CodeFixProvider` |
+| Generate members for a marked class | Incremental generator |
+| Generate serializers/validators/mappers | Incremental generator |
+| Generate a registry from discovered types | Incremental generator |
+| Read a schema file and generate C# | Incremental generator |
+| Internal generation failure | Generator diagnostic |
+
+### 3. Choosing an analyser action
+
+Use the narrowest API that represents the concept being analysed:
+
+- `RegisterSyntaxNodeAction` — exact source syntax (e.g., modifier presence).
+- `RegisterSymbolAction` — declaration semantics (e.g., attributes, interfaces, accessibility).
+- `RegisterOperationAction` — executable behaviour (e.g., invocation, assignment, object creation).
+- `RegisterOperationBlockStart/EndAction` — stateful method analysis.
+- `RegisterSymbolStart/EndAction` — type-wide analysis across members.
+- `RegisterCompilationStartAction` — resolve known framework symbols once.
+- `RegisterAdditionalFileAction` — analyse `AdditionalFiles`.
+- Avoid `RegisterSyntaxTreeAction`, `RegisterSemanticModelAction`, and compilation-end actions unless genuinely necessary.
+
+### 4. Syntax vs symbol vs operation
+
+Decision tree:
+
+1. Does exact source spelling/structure matter? → **Syntax**
+2. Otherwise, is it a declaration? → **Symbol**
+3. Otherwise, is it executable behaviour? → **Operation**
+
+Use `SymbolEqualityComparer.Default.Equals(...)` when comparing symbols.
+
+### 5. Analyser best practices
+
+- Enable concurrent execution with `context.EnableConcurrentExecution()`.
+- Explicitly configure generated-code analysis with `context.ConfigureGeneratedCodeAnalysis(...)`.
+- Resolve known framework/library symbols once in a `RegisterCompilationStartAction`.
+- Prefer narrow registrations over scanning entire syntax trees or compilations.
+- Treat diagnostic IDs as public contracts and maintain release tracking files when publishing public diagnostics.
+
+### 6. Incremental generator golden rules
+
+Implement `IIncrementalGenerator`. Simply implementing it is not enough; the pipeline must be incremental.
+
+> **Pipeline values must be immutable and value-equatable.**
+
+Never keep these in persistent pipeline models:
+
+| Type | Verdict |
+| --- | --- |
+| `ISymbol` / `INamedTypeSymbol` / `IMethodSymbol` / `IPropertySymbol` | Never retain |
+| `Compilation` | Do not propagate |
+| `SemanticModel` | Do not propagate |
+| `IOperation` | Do not propagate |
+| `SyntaxTree` | Do not propagate |
+| `SyntaxNode` | Remove ASAP |
+| `Location` | Remove ASAP |
+| `AdditionalText` | Project immediately |
+| `T[]` / `List` | Avoid |
+| `ImmutableArray` | Wrap with sequence equality |
+
+Use immutable records and `EquatableArray` (sequence equality) for collection members.
+
+### 7. Designing the pipeline
+
+Pipeline shape:
+
+```text
+Roslyn Input
+ ↓
+Cheap discovery
+ ↓
+Semantic extraction
+ ↓
+Small equatable model
+ ↓
+Validation/transformation
+ ↓
+Generation model
+ ↓
+Source output
+```
+
+Guidelines:
+
+- Project the semantic transform as the boundary where Roslyn objects disappear.
+- Prefer `static` callbacks to avoid capturing generator state.
+- Honour cancellation tokens.
+- Split transformations into many small stages.
+- Keep syntax predicates cheap.
+- Avoid indirect discovery (every interface implementation, every subclass, entire-compilation scans).
+
+### 8. Syntax discovery
+
+- Prefer `context.SyntaxProvider.ForAttributeWithMetadataName(...)` for attribute-driven generators.
+- Use `context.SyntaxProvider.CreateSyntaxProvider(...)` only when syntax itself is the trigger and there is no marker attribute.
+- The predicate must be cheap; do not walk the tree or do semantic work in it.
+
+### 9. `Collect`, `Combine`, and invalidation
+
+- `Collect()` turns per-item outputs into one aggregate. Changing any item invalidates the aggregate.
+- Use `Collect()` only for genuinely global output: registries, lookups, duplicate detection, aggregate switches.
+- Prefer per-item `RegisterSourceOutput`.
+- `Combine()` is correct when the output depends on two providers.
+- Avoid `models.Combine(context.CompilationProvider)` — project the compilation to a tiny capability fact first.
+- Use `.WithComparer(...)` only when logical equality differs from the default.
+
+### 10. Diagnostics
+
+- Prefer a separate `DiagnosticAnalyser` for normal user validation.
+- Use generator diagnostics only for malformed additional files, generator-only configuration, conflicting output, or failures that cannot be expressed by an analyser.
+- Report diagnostics on the most useful user-authored `Location`.
+- Do not keep `Location` in long-lived pipeline models.
+
+### 11. Output generation
+
+- Output must be deterministic: no timestamps, random GUIDs, process IDs, machine paths, culture-dependent output, or unordered dictionary output.
+- Hint names must be deterministic, unique, and stable.
+- Prefer text generation or `CodeWriter` over building Roslyn syntax trees just to stringify them.
+- Use `RegisterPostInitializationOutput` for constant source such as marker attributes.
+- Add generated source once per output artifact.
+
+### 12. Testing incrementally
+
+- Snapshot-testing generated source is not enough.
+- Test first execution, cached second execution, unrelated changes remaining cached, per-target invalidation, deletion, renaming, global options, additional files, and global registry invalidation.
+- Use `GeneratorDriverOptions` with `trackIncrementalGeneratorSteps: true` and inspect reasons: `New`, `Modified`, `Unchanged`, `Cached`, `Removed`.
+
+### 13. Roslyn version compatibility and packaging
+
+- The `Microsoft.CodeAnalysis.*` version used to compile the analyser/generator sets the minimum compiler-host requirement.
+- The consumer's `TargetFramework` does not determine analyser compatibility.
+- Choose the oldest Roslyn version that contains the APIs you need.
+- Common baselines: Roslyn 4.8 for VS 17.8 / .NET 8, 4.12 for VS 17.12 / .NET 9, 5.0 for VS 2026 18.0 / .NET 10.
+- Ship one `netstandard2.0` analyser/generator binary unless you have a deliberate multi-version strategy.
+- Do not mistake multi-targeting for automatic analyser asset selection.
+- Use `PrivateAssets="all"` for Roslyn development dependencies.
+- Enable `EnforceExtendedAnalyzerRules` and investigate `RSxxxx` diagnostics before suppressing them.
+
+### 14. Recommended project configuration
+
+A generator project should normally include:
+
+```xml
+
+ netstandard2.0
+ latest
+ enable
+ true
+ false
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+```
+
+## Preferred API map for CodeWriter
+
+### File and namespace
+
+- `AutoGeneratedHeader(...)`
+- `Using(...)`
+- `FileScopedNamespace(...)` or `BlockNamespace(...)`
+- `OpenPragmasScope(...)` for warning suppression scopes
+
+### Types and members
+
+- Types: `Class`, `Struct`, `RecordClass`, `RecordStruct`, `Interface`, `Enum`, `Delegate`
+- Members: `Method`, `MethodScope`, `Property`, `Field`, `Constructor`
+- Attributes: `AttributeDeclarationOptions`, `AttributeArgumentOptions`
+- Type syntax: `TypeReference` (nullable/generic/array/pointer-safe composition)
+
+### XML documentation
+
+Use `XmlCommentWriter` extension methods on `CodeWriter`:
+
+- `XmlSummary(...)`, `XmlParam(...)`, `XmlTypeParam(...)`, `XmlReturn(...)`, `XmlRemarks(...)`, `XmlExample(...)`
+- `XmlCode(...)` / `XmlCodeBlock(...)`
+- `XmlList(...)`, `XmlSeeAlso(...)`, `XmlException(...)`
+
+Static helpers: `CodeWriter.XmlInlineCode(...)`, `CodeWriter.XmlSee(...)`, `CodeWriter.XmlParamRef(...)`, `CodeWriter.XmlText(...)`.
+
+## Refactoring guide: string/StringBuilder -> CodeWriter
+
+Apply this checklist in order:
+
+1. **Move emission boundaries** — replace giant string assembly with phases: header, namespace, type, members.
+2. **Replace manual braces/indentation** — use `using` scopes (`ClassScope`, `MethodScope`, `OpenBlockScope`, `IndentedScope`).
+3. **Replace handwritten signatures** — use declaration option records.
+4. **Replace raw XML lines** — use XML extension methods (`XmlSummary`, `XmlParam`, etc.).
+5. **Normalize type strings** — use `TypeReference`.
+6. **Preserve semantics and ordering** — generated members and diagnostics must remain equivalent.
+7. **Validate scope safety** — keep or enable `PurviewSourceGeneratorFrameworkValidateCodeWriterScopes` for tests/dev.
+
+## Anti-patterns to remove during refactors
+
+- `StringBuilder.AppendLine("public class ...")` for declarations that can be structured.
+- Manually writing `{` / `}` around methods and types where scope APIs exist.
+- Hard-coded nullable type suffixes and generic syntax in arbitrary strings when `TypeReference` is available.
+- Raw XML tag string composition when XML extension methods can enforce consistency.
+- Creating a `CodeWriter` before the output callback and passing it through the pipeline.
+- Storing a `CodeWriter` on `GenerationContext`, a custom context, or any incremental pipeline model.
+- Sharing one `CodeWriter` across multiple generated outputs or callbacks.
+- Keeping Roslyn objects, `CodeWriter`, or mutable state in incremental pipeline models.
+
+## Review checklist for pull requests
+
+- Generated declarations use structured APIs for types and members.
+- XML docs use XML extension methods rather than raw `///` fragments.
+- `CodeWriter` is created inside each output callback and never persists in pipeline state; passing it
+ to helper methods within that callback is expected.
+- Header and generated attributes are deterministic and consistent.
+- Existing diagnostics, generated member names, and public behavior are preserved.
+- Roslyn objects are removed from pipeline models; `EquatableArray` is used for collections.
+- Per-target output is preferred over collected global output unless global knowledge is required.
+- `CompilationProvider` is not casually combined into output.
+- Deterministic hint names and source text are used.
+- Incremental caching behavior is tested, not just generated text.
+
+## See also
+
+- `agents/source-generator-framework-writer.agent.md` — specialist agent for `Purview.SourceGeneratorFramework` emitter authoring.
+- `prompts/refactor-source-generator-to-codewriter.prompt.md` — prompt template for legacy-emitter refactor tasks.
diff --git a/.agents/skills/source-generator-testing/.gitignore b/.agents/skills/source-generator-testing/.gitignore
deleted file mode 100644
index 27997545..00000000
--- a/.agents/skills/source-generator-testing/.gitignore
+++ /dev/null
@@ -1,8 +0,0 @@
-# Ignore all files
-*
-
-# Don't ignore directories, so Git can traverse them
-!*/
-
-# Keep this file
-!.gitignore
\ No newline at end of file
diff --git a/.agents/skills/source-generator-testing/SKILL.md b/.agents/skills/source-generator-testing/SKILL.md
new file mode 100644
index 00000000..2a565445
--- /dev/null
+++ b/.agents/skills/source-generator-testing/SKILL.md
@@ -0,0 +1,364 @@
+---
+name: source-generator-testing
+description: "Use when writing or fixing tests for source generators, diagnostic analyzers, code fixes, or refactorings in a Purview.SourceGeneratorFramework repository — picking the right runner/base, configuring options, querying produced code with CodeQuery, and asserting incremental caching."
+---
+
+# Testing source generators, analyzers, code fixes and refactorings
+
+Use this skill whenever a task involves authoring, fixing, or modernising tests for Roslyn components
+(generators, diagnostic analyzers, code fix providers, refactoring providers) built with
+`Purview.SourceGeneratorFramework`. It covers the framework-agnostic test runner layer and the
+`CodeQuery` syntax-lookup API. For the TUnit base classes and assertion extensions, also load the
+`sdk` package's `tunit-test-authoring` skill.
+
+## Picking the right runner
+
+| Roslyn type | Runner |
+|---|---|
+| `IIncrementalGenerator` / `ISourceGenerator` | `SourceGeneratorTestRunner` |
+| `DiagnosticAnalyzer` | `DiagnosticAnalyzerTestRunner` |
+| `CodeFixProvider` | `CodeFixTestRunner` (single) |
+| — | `CodeFixTestRunner.RunFixAllAsync` (project-wide) |
+| `CodeRefactoringProvider` | `RefactoringTestRunner` |
+
+TUnit projects should prefer the matching base class instead (see `tunit-test-authoring`):
+`TUnitSourceGeneratorTestBase`, `TUnitDiagnosticAnalyzerTestBase`, `TUnitCodeFixTestBase`,
+`TUnitRefactoringTestBase`.
+
+## Result types
+
+- `DriverRunResult` (generator) — `DriverResult`, `AllSyntaxTrees`/`PrimarySyntaxTrees`,
+ `CompilationResult.Compilation`, `GetGeneratedTree`, `GetSource`, `GetTypeByMetadataName`, `LogEntries`.
+- `AnalyzerTestResult` — `Diagnostics`, `Compilation`.
+- `CodeFixTestResult` — `Diagnostics`, `CodeActions`, `FixedSource`, `Compilation`, `ChangedSolution`.
+- `CodeFixFixAllResult` — `Diagnostics`, `CodeActions`, `FixedSources`, `ChangedSolution`.
+- `RefactorTestResult` — `CodeActions`, `FixedSources`, `ChangedSolution`, `Compilation`.
+
+Use `DriverRunResultExtensions` (`AssertNoCompilationErrors`, `AssertNoGenerationExceptions`,
+`AssertSingleGeneratedSource`, `AssertGeneratedSourceContains`, …) for quick checks, but prefer
+`CodeQuery` for structural assertions.
+
+## Querying produced code with `CodeQuery`
+
+Every result exposes a `CodeQuery` via extensions in `CodeQueryResultExtensions`:
+
+```csharp
+result.Generated() // DriverRunResult: generated trees (default, generated-first)
+result.Output() // DriverRunResult: entire output compilation (user + generated)
+analyzerResult.Code() // AnalyzerTestResult: input compilation
+codeFixResult.Code() // CodeFixTestResult: input compilation
+codeFixResult.FixedCode() // CodeFixTestResult: parsed fixed source (or post-fix solution)
+fixAllResult.FixedCode() // CodeFixFixAllResult: post-fix documents
+refactorResult.FixedCode() // RefactorTestResult: post-refactor documents
+```
+
+Every `Get` has an accompanying `Has` (bool) and `TryGet` (out): `GetMethod`/`HasMethod`/`TryGetMethod`,
+`GetClass`, `GetStruct`, `GetInterface`, `GetEnum`, `GetDelegate`, `GetRecord`, `GetProperty`,
+`GetField`, `GetConstructor`, `GetNamespace`, `GetTypeDeclaration`, plus generic `Get`/`Has`
+and `GetSyntaxTree`/`HasSyntaxTree`. `Get` throws `SyntaxNotFoundException` when nothing matches.
+
+Type lookups accept an optional generic arity — `GetClass(name, arity)` / `HasClass(name, arity)` — and the
+`TypeReference`/`TypeIdentity` overloads match arity automatically from the identity, so
+`new TypeIdentity("ResourceDefinition", ns, arity: 1)` finds `ResourceDefinition` without matching the
+non-generic `ResourceDefinition`.
+
+Every `Get` returns a `CodeQueryResult` — the matched syntax node (`Node`) plus a query scoped to it
+(`Query`), with implicit conversions to both the node and the scoped query. Use `.Node` for direct syntax
+access, or chain member queries (the originating query is carried by the result, so it is not passed again).
+
+Types can be matched against `TypeReference`/`TypeIdentity`, resolved through the compilation's semantic
+model (nullable value types are significant, so `int?` never matches `int`):
+
+```csharp
+result.Generated().HasMethod("DoWork", TypeReference.Create(), TypeReference.Create().Nullable(), complexType);
+result.Generated().HasReturnType("Compute", TypeReference.Create());
+result.Generated().GetMethod("Format").HasParameters(TypeReference.Create(), objectReference);
+```
+
+When a test expects a nullable annotation, prefer the test-only `query.MakeNullable(type)` extension (on a
+`CodeQuery`, accepting a `TypeReference` or `TypeIdentity`). It resolves the annotation against the query's
+compilation and, unlike `TypeReference.Nullable()`/`TypeIdentity.MakeNullable()`, does not trigger the
+`PSGFR16` context-overload suggestion (tests have no generation context to pass).
+
+Member chaining from a type declaration (`MemberQueryExtensions`):
+
+```csharp
+var service = result.Generated().GetClass("ServiceCollectionExtensions"); // or GetClass(name, "Namespace")
+service.HasProperty("Count", intType);
+service.HasIndexer(stringType, intType);
+service.HasMethod("Add", intType, complexType);
+service.HasMethodReturnType("Add", stringType);
+service.HasConstructor(stringType);
+service.HasAttribute("SomeAttribute");
+```
+
+Node-inspection checks on scoped results:
+
+```csharp
+result.Generated().GetClass("Service").HasAccessibility(Accessibility.Public); // resolves C# defaults
+result.Generated().GetClass("Service").GetProperty("Name").HasSetterAccessibility(Accessibility.Private);
+result.Generated().GetClass("ResourceDefinition", 1).HasGenericTypeParameters("TResourceType");
+result.Generated().GetClass("ResourceDefinition", 1).HasBaseType(new TypeIdentity("ResourceDefinition", ns));
+result.Generated().GetClass("Service").HasNestedType("Builder"); // GetNestedType(...) to fetch
+result.Generated().GetClass("Service").IsInNamespace("Example.Models"); // IsInGlobalNamespace() for no namespace
+result.Generated().IsInNamespace(cls.Node, "Example.Models"); // or on the query directly
+```
+
+## Configuring options and a reusable starting point
+
+`SourceGeneratorTestOptions` is the base record. Common knobs:
+
+- `AdditionalNamespaces` / `IncludeDefaultNamespaces` — namespaces prepended to test source.
+- `AdditionalAssemblyTypes` / `AdditionalReferences` — assemblies referenced by the test compilation
+ (use `AdditionalAssemblyTypes = [typeof(SomeType)]` to pull in a whole assembly).
+- `AdditionalSources` — extra source files added to every run.
+- `AnalyzerConfigOptions` — `build_property.*` values; keys without the prefix are also exposed as MSBuild
+ properties.
+- `DisableSourceGeneratorPropertyName` / `DisableSourceGeneratorValue` — generator disable toggle.
+- `NullableContextOptions`, `OutputKind`, `LanguageVersion`, `CompileToAssembly`.
+- `ValidateCodeWriterScopes`, `EnableLogging`.
+- `ExcludeGeneratedSourceHintNames` — hides generated marker trees from `PrimarySyntaxTrees`.
+
+**Easy starting point recipe.** Derive an options record that seeds the namespaces and assemblies your
+generator needs, so every test gets a working compilation with no boilerplate:
+
+```csharp
+public sealed record MyGeneratorTestOptions : SourceGeneratorTestOptions
+{
+ public MyGeneratorTestOptions()
+ {
+ AdditionalNamespaces = AdditionalNamespaces.Add("My.Namespace");
+ AdditionalAssemblyTypes = AdditionalAssemblyTypes.AddRange(
+ typeof(SomeDependencyType),
+ typeof(TypeIdentity) // the framework's Shared assembly, when needed
+ );
+ DisableSourceGeneratorPropertyName = PropertyLibrary.DisableMyGenerator;
+ }
+}
+```
+
+`Compile()` returns a copy with `CompileToAssembly = true`, preserving the derived options type. Use the
+base class hooks `OnBeforeRun`/`OnBeforeRunAsync`/`OnAfterRun` to mutate sources/options per run (for
+example to append a marker attribute source via `WithAdditionalSources`).
+
+When `CompileToAssembly` is enabled, emission is fully in-memory. On .NET 8+ the assembly loads into a
+collectible `AssemblyLoadContext`, so the result is `IDisposable` — `using var result = ...` unloads it and
+keeps repeated runs from polluting the default context. `result.CompilationResult.Assembly` is the runnable
+assembly (generated code can execute); `result.CompilationResult.Metadata` / `.MetadataAssembly` give a
+metadata-only reflection view (types, members, attributes) over the emitted assembly without executing code.
+
+## Best practices
+
+- **Deterministic output**: `WriteAutoGeneratedHeader` is timestamp-free; assert with
+ `ContainsGeneratedCode`/`GeneratesCode` (whitespace-flattened) or `CodeQuery`, never with timestamps.
+- **Generator references**: to use a generated type in the test project AND pass the generator type to a
+ runner, reference the generator project twice — once `OutputItemType="Analyzer"` and once as a normal
+ reference.
+- **Multi-target**: build generators against the Roslyn version that supports the test matrix; the
+ framework is built against Roslyn 5.0 (net8.0/net9.0 assets keep a .NET 8–10 matrix loading); keep
+ `System.Collections.Immutable` version pinned to the shared one.
+- **Scope validation**: keep `PurviewSourceGeneratorFrameworkValidateCodeWriterScopes` enabled; it makes
+ undisposed `CodeWriter` scopes fail tests.
+- **Prefer `CodeQuery` over string matching** for structural assertions (members, signatures, namespaces).
+
+## Incremental cache testing (`RunIncrementalAsync`)
+
+To prove the pipeline caches correctly stage-by-stage, use `SourceGeneratorTestRunner.RunIncrementalAsync`
+(or `GenerateIncrementalAsync` on the TUnit base). It runs a sequence of source sets over a **single shared
+`GeneratorDriver`** and captures each run's `TrackedSteps`, keyed by tracking name. The canonical reference
+implementation is `IncrementalPipelineCacheTests` in `SourceGeneratorShared.UnitTests`; the end-to-end
+generator variant is `ServiceRegistrationCacheTests` in
+`SourceGeneratorFramework.ExampleGenerator.UnitTests`. Copy the pattern into your own test project — do
+not expect the source repo's files locally.
+
+### What is being asserted and why
+
+Roslyn reports one `IncrementalStepRunReason` per step output on each run:
+
+- `New` — the step ran for the first time.
+- `Modified` — the step ran and produced a different value than the previous run.
+- `Unchanged` — the step ran but produced the same value.
+- `Cached` — the step was skipped and its previous result reused from the incremental cache.
+
+A pipeline is "caching correctly" when an unchanged input keeps every stage `Cached`/`Unchanged`, and a
+targeted change marks **only** the stages whose inputs actually changed `Modified` while unrelated stages
+stay `Cached`. If a generator accidentally leaks `Compilation`, `SemanticModel`, `ISymbol`,
+`SyntaxNode`, or `Location` into a pipeline model, unrelated stages will report `Modified`/`New` on rerun —
+these tests fail the build and catch the regression.
+
+### The four scenarios every cache test should cover
+
+1. **First run → all `New`.** Nothing can be cached on the first run; this confirms every stage is tracked
+ under the expected name.
+2. **Identical rerun → all `Cached`/`Unchanged`.** `RunIncrementalAsync(sources, ...)` runs the same source
+ set twice for exactly this case. This is the strongest "it caches" proof.
+3. **Source-only change → only the source/attribute stage `Modified`.** Changing an attributed class must
+ mark `ForAttribute_*` (and downstream output) `Modified` while property/config stages stay `Cached`.
+4. **Property-only change → only the property/configuration stage `Modified`.** Toggling an MSBuild
+ property (via `IncrementalRunInput.AnalyzerConfig`) must mark `GetMSBuildPropertyValue_*` /
+ `GetGenerationConfiguration` / `GetGenerationContext_*` `Modified` while `ForAttribute_*` stays `Cached`.
+
+### How the runner makes this possible
+
+`RunIncrementalAsync` creates **one** driver, enables incremental step tracking, and **reuses the same
+`Compilation` instance for identical source sets** (keyed by prepared source text). Without that reuse,
+Roslyn would see a fresh compilation on the second run and report stages `Modified`/`New` even though the
+sources are byte-identical — the "cached" assertion would fail.
+
+### The `StepReasons` helper
+
+`IncrementalCacheRun.Steps` is `ImmutableDictionary>`
+keyed by tracking name. Flatten each step's `Outputs` into the reasons list so assertions read cleanly:
+
+```csharp
+static ImmutableDictionary> StepReasons(IncrementalCacheRun run)
+{
+ var builder = ImmutableDictionary.CreateBuilder>();
+ foreach (var pair in run.Steps)
+ builder[pair.Key] = [.. pair.Value.SelectMany(step => step.Outputs.Select(static output => output.Reason))];
+ return builder.ToImmutable();
+}
+```
+
+### Framework pipeline stage names
+
+- `GetMSBuildPropertyValue_{Property}` — `IncrementalPipeline.PropertyValueProvider`.
+- `GetGenerationConfiguration` — `IncrementalPipeline.GenerationContextValueProvider`.
+- `GetGenerationContext_{Capabilities}` — e.g. `GetGenerationContext_EmptyCapabilities`.
+- `ForAttribute_{AttributeType}` — `IncrementalPipeline.ForAttributeWithMetadataName`.
+
+The framework reference (`IncrementalPipelineCacheTests`, framework-agnostic runner) shows all four
+scenarios against `TestGenerator`/`DiagnosticTestGenerator`:
+
+```csharp
+using System.Collections.Immutable;
+using Purview.SourceGeneratorFramework.TestGenerators;
+using StepReason = Microsoft.CodeAnalysis.IncrementalStepRunReason;
+
+public class IncrementalPipelineCacheTests
+{
+ const string AttributedSource = """
+ [TestAttribute]
+ public partial class MyClass { }
+ """;
+ const string ChangedAttributedSource = """
+ [TestAttribute]
+ public partial class AnotherClass { }
+ """;
+ const string TestAttributeSource = """
+ [System.AttributeUsage(System.AttributeTargets.Class)]
+ public sealed class TestAttribute : System.Attribute { }
+ """;
+
+ static SourceGeneratorTestOptions CreateOptions() =>
+ new SourceGeneratorTestOptions()
+ .WithAdditionalSources(TestAttributeSource)
+ .WithExcludeGeneratedSourceHintNames("TestAttribute");
+
+ static ImmutableDictionary> StepReasons(IncrementalCacheRun run) { /* as above */ }
+
+ [Test]
+ public async Task FirstRun_AllStagesAreNew(CancellationToken cancellationToken)
+ {
+ var result = await new SourceGeneratorTestRunner().RunIncrementalAsync(
+ [new IncrementalRunInput([AttributedSource])],
+ CreateOptions(),
+ cancellationToken);
+
+ var reasons = StepReasons(result.Runs[0]);
+ await Assert.That(reasons).IsNotEmpty();
+ await Assert.That(reasons.Values.SelectMany(r => r).All(r => r == StepReason.New)).IsTrue();
+ }
+
+ [Test]
+ public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken)
+ {
+ var result = await new SourceGeneratorTestRunner()
+ .RunIncrementalAsync([AttributedSource], CreateOptions(), cancellationToken);
+
+ var second = StepReasons(result.Runs[1]);
+ await Assert.That(second.Values.SelectMany(r => r).All(r => r is StepReason.Cached or StepReason.Unchanged)).IsTrue();
+ }
+
+ [Test]
+ public async Task SourceChange_MarksAttributeStageModified_PropertyStagesStayCached(CancellationToken cancellationToken)
+ {
+ var result = await new SourceGeneratorTestRunner().RunIncrementalAsync(
+ [new IncrementalRunInput([AttributedSource]), new IncrementalRunInput([ChangedAttributedSource])],
+ CreateOptions(),
+ cancellationToken);
+
+ var second = StepReasons(result.Runs[1]);
+ await Assert.That(second["ForAttribute_TestAttribute"]).Contains(StepReason.Modified);
+ await Assert.That(second["GetMSBuildPropertyValue_DisableTestGenerator"].All(r => r == StepReason.Cached)).IsTrue();
+ }
+
+ [Test]
+ public async Task PropertyChange_MarksPropertyStageModified_AttributeStageStaysCached(CancellationToken cancellationToken)
+ {
+ var result = await new SourceGeneratorTestRunner().RunIncrementalAsync(
+ [
+ new IncrementalRunInput([AttributedSource]),
+ new IncrementalRunInput([AttributedSource], [("build_property.DisableTestGenerator", "true")]),
+ ],
+ CreateOptions(),
+ cancellationToken);
+
+ var second = StepReasons(result.Runs[1]);
+ await Assert.That(second["GetMSBuildPropertyValue_DisableTestGenerator"]).Contains(StepReason.Modified);
+ await Assert.That(second["ForAttribute_TestAttribute"].All(r => r == StepReason.Cached)).IsTrue();
+ }
+}
+```
+
+### End-to-end generator variant (`GenerateIncrementalAsync`)
+
+TUnit tests derive from the base class and call `GenerateIncrementalAsync` (which wires the
+`OnBeforeRun`/`OnBeforeRunAsync` hooks and the derived options). `ServiceRegistrationCacheTests` in
+`SourceGeneratorFramework.ExampleGenerator.UnitTests` is the reference:
+
+```csharp
+public class ServiceRegistrationCacheTests
+ : TUnitSourceGeneratorTestBase
+{
+ const string Source = """
+ namespace Test;
+
+ [GenerateService]
+ public class MyService { }
+ """;
+
+ [Test]
+ public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken)
+ {
+ var result = await GenerateIncrementalAsync([Source], cancellationToken: cancellationToken);
+
+ var second = StepReasons(result.Runs[1]);
+ string[] frameworkStages =
+ [
+ "GetMSBuildPropertyValue_EmitServiceRegistrationInfo",
+ "GetGenerationConfiguration",
+ "GetGenerationContext_EmptyCapabilities",
+ "ForAttribute_GenerateServiceAttribute",
+ ];
+ await Assert.That(
+ frameworkStages.All(stage =>
+ second.TryGetValue(stage, out var reasons)
+ && reasons.All(r => r is StepReason.Cached or StepReason.Unchanged))).IsTrue();
+ }
+}
+```
+
+**Why the example filters to `frameworkStages`:** `ServiceRegistrationGenerator` emits its own
+`GenerateServiceAttribute` via post-initialization output, which is regenerated as a new `SyntaxTree` each
+run. That makes Roslyn's *internal* `ForAttributeWithMetadataName` `Compilation` step legitimately report
+`Modified` on an identical rerun. Asserting on the framework-named stages (which stay `Cached`/`Unchanged`)
+is the meaningful check. If your generator does not depend on its own post-init output, the stricter
+"every tracked step is `Cached`/`Unchanged`" assertion (as in the framework `IncrementalPipelineCacheTests`)
+is correct.
+
+Per-run MSBuild-property changes are supplied with `new IncrementalRunInput(sources, [("build_property.X", "value")])`.
+
+## License
+
+This project is licensed under the MIT license.
\ No newline at end of file
diff --git a/.agents/skills/tunit-test-authoring/.gitignore b/.agents/skills/tunit-test-authoring/.gitignore
deleted file mode 100644
index 27997545..00000000
--- a/.agents/skills/tunit-test-authoring/.gitignore
+++ /dev/null
@@ -1,8 +0,0 @@
-# Ignore all files
-*
-
-# Don't ignore directories, so Git can traverse them
-!*/
-
-# Keep this file
-!.gitignore
\ No newline at end of file
diff --git a/.agents/skills/tunit-test-authoring/SKILL.md b/.agents/skills/tunit-test-authoring/SKILL.md
new file mode 100644
index 00000000..fa58cd8f
--- /dev/null
+++ b/.agents/skills/tunit-test-authoring/SKILL.md
@@ -0,0 +1,214 @@
+---
+name: tunit-test-authoring
+description: "Use when writing TUnit tests for source generators, diagnostic analyzers, code fixes, or refactorings in a Purview.SourceGeneratorFramework repository — choosing the correct base class and method, customising options, using the assertion extensions, and modernising existing tests."
+---
+
+# TUnit test authoring for Roslyn components
+
+Use this skill whenever a task involves authoring, fixing, or modernising **TUnit** tests for Roslyn
+components built with `Purview.SourceGeneratorFramework`. It tells you which base class to derive from,
+which method to call, how to customise options with an easy starting point, and how to use the TUnit
+assertion extensions. For the framework-agnostic runner layer and the `CodeQuery` API, also load the
+`sdk` package's `source-generator-testing` skill.
+
+## Base class → method matrix
+
+| Roslyn type | Base class | Method to call |
+|---|---|---|
+| `IIncrementalGenerator` / `ISourceGenerator` | `TUnitSourceGeneratorTestBase` | `GenerateAsync(source, options, ct)` |
+| `DiagnosticAnalyzer` | `TUnitDiagnosticAnalyzerTestBase` | `AnalyzeAsync(source, options, ct)` |
+| `CodeFixProvider` (single fix) | `TUnitCodeFixTestBase` | `ApplyCodeFixAsync(source, options, ct)` |
+| `CodeFixProvider` (fix-all) | `TUnitCodeFixTestBase` | `ApplyFixAllAsync(sources, options, ct)` |
+| `CodeRefactoringProvider` | `TUnitRefactoringTestBase` | `RefactorAsync(source, options, ct)` |
+
+For cache tests, `TUnitSourceGeneratorTestBase` also exposes `GenerateIncrementalAsync(...)`.
+
+Framework-agnostic equivalents (no TUnit): `SourceGeneratorTestRunner`, `DiagnosticAnalyzerTestRunner`,
+`CodeFixTestRunner`, `RefactoringTestRunner`.
+
+## Easy starting point: derive your options record
+
+Create a test-options record that seeds the namespaces and assemblies your component needs, then pass it
+to every test via `new MyTestOptions()`:
+
+```csharp
+public sealed record MyGeneratorTestOptions : SourceGeneratorTestOptions
+{
+ public MyGeneratorTestOptions()
+ {
+ AdditionalNamespaces = AdditionalNamespaces.Add("My.Namespace");
+ AdditionalAssemblyTypes = AdditionalAssemblyTypes.AddRange(
+ typeof(SomeDependencyType),
+ typeof(TypeIdentity) // framework Shared assembly, when needed
+ );
+ DisableSourceGeneratorPropertyName = "DisableMyGenerator";
+ }
+}
+
+public class MyGeneratorTests : TUnitSourceGeneratorTestBase
+{
+ [Test]
+ public async Task GeneratesExpectedSource(CancellationToken ct) =>
+ await GenerateAsync("...source...", ct);
+}
+```
+
+Use the base hooks to customise per-run: `OnBeforeRun`/`OnBeforeRunAsync` (mutate sources/options, e.g.
+`options.WithAdditionalSources(markerAttributeSource)`) and `OnAfterRun`/`OnAfterRunAsync`. Use
+`options.Compile()` to opt into `CompileToAssembly` while preserving the derived options type.
+
+For code fixes/refactorings, select a specific registered action via `CodeFixTestOptions.EquivalenceKey`
+or `CodeActionIndex`, and `RefactorTestOptions.Span`/`NodeSelector` (e.g.
+`NodeSelector = query => query.GetMethod("M")`).
+
+## TUnit assertion extensions
+
+All assertion extensions live under `Purview.SourceGeneratorFramework.Testing.TUnit.Assertions`
+(globally imported by the package's props). `Assert.That(...)` calls are terminal and **return the value**
+when awaited.
+
+- **`CodeQueryAssertions`** — return `CodeQueryResult` (the matched node via `.Node`, plus a query
+ scoped to it via `.Query`): `HasGeneratedMethod` (optionally with `TypeReference[]` parameter types),
+ `HasGeneratedMethodReturnType`, `HasGeneratedClass` (by name or `TypeReference`/`TypeIdentity` identity),
+ `HasGeneratedProperty`, `HasGeneratedField`, `HasGeneratedSyntaxTree`; `HasFixedMethod` for code-fix and
+ refactor results. The assertions operate on a `CodeQuery` directly, so pass any query — `result.Generated()`
+ (generated trees), `result.Output()` (whole compilation), or `result.FixedCode()` — or use the convenience
+ overloads on the test result types, which query the relevant code for you.
+- **Scoped member chaining** — `HasPropertyOfType(name, type)`, `HasFieldOfType(name, type)`,
+ `HasMethodOfType(name, TypeReference[])`, `HasConstructorOfType(TypeReference[])`, and
+ `HasAttributeOfType(name)` chain from a scoped `CodeQueryResult` (e.g. the result of `HasGeneratedClass`)
+ and return the matched member. They are named to avoid colliding with the bool `Has*` predicates in
+ `MemberQueryExtensions`.
+- **Fluent `.And` chains** — the node-producing assertions (`HasGeneratedClass`, `HasPropertyOfType`,
+ `HasMethodOfType`, `HasNestedType`) move the chain onto the matched node, so further assertions can be
+ appended with `.And`. Node-inspection assertions — `WithAccessibility`, `WithGetterAccessibility`,
+ `WithSetterAccessibility`, `WithBaseType`, `WithGenericTypeParameter(s)`, `IsInNamespace`,
+ `IsInGlobalNamespace` — keep the node on the chain:
+ ```csharp
+ var method = await Assert.That(query)
+ .HasGeneratedClass("Service")
+ .And.HasNestedType("Builder")
+ .And.WithAccessibility(Accessibility.Private)
+ .And.HasMethodOfType("Build", []);
+ ```
+ Generic types are matched by arity — `HasGeneratedClass(name, arity)` or a `TypeIdentity` with arity —
+ so `new TypeIdentity("ResourceDefinition", ns, arity: 1)` finds `ResourceDefinition`
+ without matching the non-generic `ResourceDefinition`.
+- **Nullable expected types in tests** — use the test-only `query.MakeNullable(type)` extension (on a
+ `CodeQuery`). It resolves the annotation against the query's compilation and, unlike
+ `TypeReference.Nullable()`/`TypeIdentity.MakeNullable()`, does not trip the `PSGFR16` context-overload
+ suggestion, since tests have no generation context to pass.
+ ```csharp
+ CodeQueryResult method = await Assert.That(result.Generated()).HasGeneratedMethod("DoWork", [intType, nullableInt]);
+ CodeQueryResult cls = await Assert.That(result.Generated()).HasGeneratedClass("Service");
+ await Assert.That(cls.HasProperty("Count", intType)).IsTrue(); // chained member query
+ await Assert.That(cls.Node.Identifier.ValueText).IsEqualTo("Service"); // direct syntax access
+ await Assert.That(result.FixedCode()).HasFixedMethod("DoWork"); // code-fix / refactor results
+
+ var query = result.Generated();
+ var attributeClass = await Assert.That(query).HasGeneratedClass(hostKitAttribute);
+ await Assert.That(attributeClass).HasPropertyOfType("Name", query.MakeNullable(TypeLibrary.System.String));
+ ```
+- **`DiagnosticAssertions`** — `HasDiagnostic(descriptor|id)`, `HasDiagnostics(count)`,
+ `DoesNotHaveDiagnostic`, `HasNoDiagnostics`, `HasNoErrorDiagnostics` on generator/analyzer/code-fix results.
+- **`TypeIdentityAssertions`** — `HasSymbol(TypeIdentity)` / `HasSymbol("Namespace.Type")`.
+- **`GeneratedCodeAssertionsExtensions`** — `GeneratesCode(expected)`, `ContainsGeneratedCode(expected)`
+ (whitespace-flattened string comparison).
+
+For structural assertions (members, signatures, namespaces) prefer `result.Generated()` +
+`Get/Has/TryGet` from `CodeQuery` (see `source-generator-testing`).
+
+## Incremental cache tests (`GenerateIncrementalAsync`)
+
+`TUnitSourceGeneratorTestBase` exposes `GenerateIncrementalAsync`, which mirrors `RunIncrementalAsync` but
+also wires the base class hooks (`OnBeforeRun`/`OnBeforeRunAsync`) and your derived options record. Use it to
+prove the pipeline caches stage-by-stage. The reference is `ServiceRegistrationCacheTests` in
+`SourceGeneratorFramework.ExampleGenerator.UnitTests`; the framework-agnostic twin with a full walkthrough is
+in the `source-generator-testing` skill.
+
+Why the tests look the way they do:
+
+- `GenerateIncrementalAsync([Source])` runs the **same source twice** on a single shared driver. The first
+ run reports every stage `New`; the second must report `Cached`/`Unchanged` for unchanged stages — that is
+ the core "it caches" proof.
+- `GenerateIncrementalAsync([new IncrementalRunInput([Source]), new IncrementalRunInput([changed])])` runs two
+ **different** source sets, so a source-only change must mark `ForAttribute_*` `Modified` while
+ property/configuration stages stay `Cached`.
+- `new IncrementalRunInput([Source], [("build_property.X", "value")])` toggles an MSBuild property for one
+ run only, so a property-only change must mark `GetMSBuildPropertyValue_*`/`GetGenerationConfiguration`/
+ `GetGenerationContext_*` `Modified` while `ForAttribute_*` stays `Cached`.
+- The `StepReasons(IncrementalCacheRun)` helper flattens each tracked step's `Outputs` into a
+ `ImmutableDictionary>` so assertions can address a stage
+ by name (see `source-generator-testing` for the helper body).
+- If the generator's pipeline depends on its own post-initialization output (a self-referencing generated
+ attribute), Roslyn's internal `ForAttributeWithMetadataName` `Compilation` step is legitimately `Modified`
+ on rerun; assert on the framework-named stages (e.g. `ForAttribute_GenerateServiceAttribute`,
+ `GetGenerationConfiguration`, `GetGenerationContext_EmptyCapabilities`) rather than every tracked step.
+
+```csharp
+public class ServiceRegistrationCacheTests
+ : TUnitSourceGeneratorTestBase
+{
+ const string Source = """
+ namespace Test;
+
+ [GenerateService]
+ public class MyService { }
+ """;
+
+ [Test]
+ public async Task IdenticalRerun_AllStagesCached(CancellationToken cancellationToken)
+ {
+ var result = await GenerateIncrementalAsync([Source], cancellationToken: cancellationToken);
+
+ var second = StepReasons(result.Runs[1]);
+ string[] frameworkStages =
+ [
+ "GetMSBuildPropertyValue_EmitServiceRegistrationInfo",
+ "GetGenerationConfiguration",
+ "GetGenerationContext_EmptyCapabilities",
+ "ForAttribute_GenerateServiceAttribute",
+ ];
+ await Assert.That(
+ frameworkStages.All(stage =>
+ second.TryGetValue(stage, out var reasons)
+ && reasons.All(r => r is StepReason.Cached or StepReason.Unchanged))).IsTrue();
+ }
+
+ [Test]
+ public async Task PropertyChange_MarksPropertyStageModified_AttributeStageStaysCached(CancellationToken cancellationToken)
+ {
+ var result = await GenerateIncrementalAsync(
+ [
+ new IncrementalRunInput([Source]),
+ new IncrementalRunInput([Source], [(PropertyLibrary.EmitServiceRegistrationInfo, "true")]),
+ ],
+ cancellationToken: cancellationToken);
+
+ var second = StepReasons(result.Runs[1]);
+ await Assert.That(second["GetMSBuildPropertyValue_EmitServiceRegistrationInfo"]).Contains(StepReason.Modified);
+ await Assert.That(second["ForAttribute_GenerateServiceAttribute"].All(r => r is StepReason.Cached or StepReason.Unchanged)).IsTrue();
+ }
+}
+```
+
+Use `using StepReason = Microsoft.CodeAnalysis.IncrementalStepRunReason;` and your own stage names.
+
+## Modernising existing tests
+
+When converting legacy tests that do `result.GetGeneratedTree(...)` + `string.Contains(...)`:
+
+1. Replace tree-lookup + string matching with `result.Generated().GetClass/GetMethod/GetProperty(...)` and
+ the `Has*`/`TryGet*` family.
+2. Replace signature string checks with `TypeReference` parameter/return-type matching.
+3. Replace `Assert.That(text).Contains("...")` with the terminal assertion extensions that return nodes.
+4. Verify options use a derived record (namespaces + assemblies) rather than repeating `AdditionalNamespaces`
+ per test.
+5. Add a stage-by-stage cache test if the component has an incremental pipeline — first run `New`,
+ identical rerun `Cached`/`Unchanged`, and targeted changes mark only the affected stage `Modified`.
+ Use the inlined examples in this skill and in `source-generator-testing`'s "Incremental cache testing"
+ section, swapping in your own generator and stage names.
+
+## License
+
+This project is licensed under the MIT license.
\ No newline at end of file
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 3ae076f0..1b19636d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -3,7 +3,7 @@
true
5.9.0
1.67.0
- 1.0.0-prerelease.54
+ 1.0.0
10.0.12
10.10.0
1.18.0
diff --git a/global.json b/global.json
index 7f367b18..c5bd1b1a 100644
--- a/global.json
+++ b/global.json
@@ -3,7 +3,7 @@
"allowPrerelease": false
},
"msbuild-sdks": {
- "Purview.BuildSdk": "1.0.0"
+ "Purview.BuildSdk": "1.0.3"
},
"test": {
"runner": "Microsoft.Testing.Platform"
diff --git a/package.json b/package.json
index f9095d67..dd797a72 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "purview-telemetry-sourcegenerator",
- "version": "5.0.0",
+ "version": "5.0.1",
"description": "Generates [`ActivitySource`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.activitysource), [`ILogger`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.logging.ilogger), and [`Metrics`](https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.metrics) based on interface methods.",
"license": "MIT",
"readme": "README.md",
diff --git a/samples/SampleApp/Directory.Build.props b/samples/SampleApp/Directory.Build.props
index e681179d..d1dd7909 100644
--- a/samples/SampleApp/Directory.Build.props
+++ b/samples/SampleApp/Directory.Build.props
@@ -9,13 +9,12 @@
true
- $(NoWarn);CA1031;CA1515;CA1724;CA2007;CA2201;TSG2008;
+ $(NoWarn);CA1031;CA1724;CA2007;CA2201;TSG2008;
diff --git a/samples/SampleApp/SampleApp.APIService/Services/IWeatherService.cs b/samples/SampleApp/SampleApp.APIService/Services/IWeatherService.cs
index 21d5f211..092ddc2f 100644
--- a/samples/SampleApp/SampleApp.APIService/Services/IWeatherService.cs
+++ b/samples/SampleApp/SampleApp.APIService/Services/IWeatherService.cs
@@ -1,6 +1,6 @@
-namespace SampleApp.APIService.Services;
+namespace SampleApp.APIService.Services;
-public interface IWeatherService
+interface IWeatherService
{
Task>> GetWeatherForecastsAsync(
int requestCount,
diff --git a/samples/SampleApp/SampleApp.APIService/Services/IWeatherServiceTelemetry.cs b/samples/SampleApp/SampleApp.APIService/Services/IWeatherServiceTelemetry.cs
index 20598bfe..e06a898a 100644
--- a/samples/SampleApp/SampleApp.APIService/Services/IWeatherServiceTelemetry.cs
+++ b/samples/SampleApp/SampleApp.APIService/Services/IWeatherServiceTelemetry.cs
@@ -13,7 +13,7 @@ namespace SampleApp.APIService.Services;
[ActivitySource]
[Logger]
[Meter]
-public interface IWeatherServiceTelemetry
+interface IWeatherServiceTelemetry
{
// --> MULTI-TARGET: Activity, with Trace log entry
// Single Activity method
@@ -26,7 +26,7 @@ public interface IWeatherServiceTelemetry
void ForecastReceived(Activity? activity, int minTempInC, int maxTempInC);
// --> SINGLE-TARGET: Event (Error)
- [Event(ActivityStatusCode.Error)]
+ [Event("exception", StatusCode = ActivityStatusCode.Error)]
void FailedToRetrieveForecast(Activity? activity, Exception exception);
// --> SINGLE-TARGET: Event (Ok)
diff --git a/samples/SampleApp/SampleApp.ServiceDefaults/ConfigurationExtensions.cs b/samples/SampleApp/SampleApp.ServiceDefaults/ConfigurationExtensions.cs
index a4569c46..e72ddb32 100644
--- a/samples/SampleApp/SampleApp.ServiceDefaults/ConfigurationExtensions.cs
+++ b/samples/SampleApp/SampleApp.ServiceDefaults/ConfigurationExtensions.cs
@@ -4,12 +4,9 @@ namespace Microsoft.Extensions.Configuration;
public static class ConfigurationExtensions
{
- extension([NotNull] IConfiguration configuration)
- {
- public string GetRequiredValue(string name) =>
- configuration[name]
- ?? throw new InvalidOperationException(
- $"Configuration missing value for: {(configuration is IConfigurationSection s ? s.Path + ":" + name : name)}"
- );
- }
+ public static string GetRequiredValue([NotNull] this IConfiguration configuration, string name) =>
+ configuration[name]
+ ?? throw new InvalidOperationException(
+ $"Configuration missing value for: {(configuration is IConfigurationSection s ? s.Path + ":" + name : name)}"
+ );
}
diff --git a/samples/SampleApp/SampleApp.ServiceDefaults/ErrorOrExtensions.cs b/samples/SampleApp/SampleApp.ServiceDefaults/ErrorOrExtensions.cs
index 37914efc..02922603 100644
--- a/samples/SampleApp/SampleApp.ServiceDefaults/ErrorOrExtensions.cs
+++ b/samples/SampleApp/SampleApp.ServiceDefaults/ErrorOrExtensions.cs
@@ -1,4 +1,4 @@
-using System.ComponentModel;
+using System.ComponentModel;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
@@ -7,16 +7,13 @@ namespace ErrorOr;
[EditorBrowsable(EditorBrowsableState.Never)]
public static class ErrorOrExtensions
{
- extension(Error error)
- {
- public ProblemDetails ToProblemDetails() =>
- new()
- {
- Status = GetStatusCode(error),
- Title = error.Code,
- Detail = error.Description,
- };
- }
+ public static ProblemDetails ToProblemDetails(this Error error) =>
+ new()
+ {
+ Status = GetStatusCode(error),
+ Title = error.Code,
+ Detail = error.Description,
+ };
static int GetStatusCode(Error error)
{
diff --git a/samples/SampleApp/SampleApp.ServiceDefaults/HostApplicationBuilderExtensions.cs b/samples/SampleApp/SampleApp.ServiceDefaults/HostApplicationBuilderExtensions.cs
index b8accf73..c94f41ae 100644
--- a/samples/SampleApp/SampleApp.ServiceDefaults/HostApplicationBuilderExtensions.cs
+++ b/samples/SampleApp/SampleApp.ServiceDefaults/HostApplicationBuilderExtensions.cs
@@ -15,151 +15,151 @@ namespace Microsoft.Extensions.Hosting;
[EditorBrowsable(EditorBrowsableState.Never)]
public static class HostApplicationBuilderExtensions
{
- extension([NotNull] IHostApplicationBuilder builder)
+ public static IHostApplicationBuilder AddServiceDefaults(
+ [NotNull] this IHostApplicationBuilder builder,
+ string[]? meterNames = null,
+ string[]? activitySourceNames = null,
+ bool throwOnMissingOpenApiConfig = true
+ )
{
- public IHostApplicationBuilder AddServiceDefaults(
- string[]? meterNames = null,
- string[]? activitySourceNames = null,
- bool throwOnMissingOpenApiConfig = true
- )
+ ConfigureOpenTelemetry(builder, meterNames, activitySourceNames);
+ AddDefaultHealthChecks(builder);
+ AddDefaultOpenAPI(builder, throwOnMissing: throwOnMissingOpenApiConfig);
+
+ builder.Services.AddServiceDiscovery();
+
+ builder.Services.ConfigureHttpClientDefaults(http =>
{
- builder.ConfigureOpenTelemetry(meterNames, activitySourceNames);
+ // Turn on resilience by default
+ http.AddStandardResilienceHandler();
- builder.AddDefaultHealthChecks().AddDefaultOpenAPI(throwOnMissing: throwOnMissingOpenApiConfig);
+ // Turn on service discovery by default
+ http.AddServiceDiscovery();
+ });
- builder.Services.AddServiceDiscovery();
+ return builder;
+ }
- builder.Services.ConfigureHttpClientDefaults(http =>
+ static IHostApplicationBuilder ConfigureOpenTelemetry(
+ IHostApplicationBuilder builder,
+ string[]? meterNames = null,
+ string[]? activitySourceNames = null
+ )
+ {
+ builder.Logging.AddOpenTelemetry(logging =>
+ {
+ logging.IncludeFormattedMessage = true;
+ logging.IncludeScopes = true;
+ });
+
+ var metricsAssembly = Assembly.GetEntryAssembly()!.GetName().Name!;
+ builder
+ .Services.AddOpenTelemetry()
+ .WithMetrics(metrics =>
+ metrics
+ .AddAspNetCoreInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddProcessInstrumentation()
+ .AddRuntimeInstrumentation()
+ .AddMeter(meterNames ?? [])
+ )
+ .WithTracing(tracing =>
{
- // Turn on resilience by default
- http.AddStandardResilienceHandler();
-
- // Turn on service discovery by default
- http.AddServiceDiscovery();
+ if (builder.Environment.IsDevelopment())
+ {
+ // We want to view all traces in development
+ tracing.SetSampler(new AlwaysOnSampler());
+ }
+
+ tracing
+ .AddAspNetCoreInstrumentation()
+ .AddGrpcClientInstrumentation()
+ .AddHttpClientInstrumentation()
+ .AddSource(activitySourceNames ?? []);
});
- return builder;
- }
+ AddOpenTelemetryExporters(builder);
- IHostApplicationBuilder ConfigureOpenTelemetry(
- string[]? meterNames = null,
- string[]? activitySourceNames = null
- )
+ return builder;
+ }
+
+ static IHostApplicationBuilder AddDefaultHealthChecks(IHostApplicationBuilder builder)
+ {
+ builder
+ .Services.AddHealthChecks()
+ // Add a default liveness check to ensure app is responsive
+ .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
+
+ return builder;
+ }
+
+ static IHostApplicationBuilder AddDefaultOpenAPI(
+ IHostApplicationBuilder builder,
+ IApiVersioningBuilder? apiVersioning = default,
+ bool throwOnMissing = true
+ )
+ {
+ var openApiSection = builder.Configuration.GetSection("OpenAPI");
+ if (!openApiSection.Exists())
{
- builder.Logging.AddOpenTelemetry(logging =>
- {
- logging.IncludeFormattedMessage = true;
- logging.IncludeScopes = true;
- });
+ return throwOnMissing
+ ? throw new InvalidOperationException("OpenAPI configuration section is missing.")
+ : builder;
+ }
- var metricsAssembly = Assembly.GetEntryAssembly()!.GetName().Name!;
- builder
- .Services.AddOpenTelemetry()
- .WithMetrics(metrics =>
- metrics
- .AddAspNetCoreInstrumentation()
- .AddHttpClientInstrumentation()
- .AddProcessInstrumentation()
- .AddRuntimeInstrumentation()
- .AddMeter(meterNames ?? [])
- )
- .WithTracing(tracing =>
+ // the default format will just be ApiVersion.ToString(); for example, 1.0.
+ // this will format the version as "'v'major[.minor][-status]"
+ var versioned = apiVersioning?.AddApiExplorer(options => options.GroupNameFormat = "'v'VVV");
+ string[] versions = ["v1"];
+ foreach (var description in versions)
+ {
+ builder.Services.AddOpenApi(
+ description,
+ options =>
{
- if (builder.Environment.IsDevelopment())
- {
- // We want to view all traces in development
- tracing.SetSampler(new AlwaysOnSampler());
- }
-
- tracing
- .AddAspNetCoreInstrumentation()
- .AddGrpcClientInstrumentation()
- .AddHttpClientInstrumentation()
- .AddSource(activitySourceNames ?? []);
- });
-
- builder.AddOpenTelemetryExporters();
-
- return builder;
+ options.ApplyAPIVersionInfo(
+ openApiSection.GetRequiredValue("Document:Title"),
+ openApiSection.GetRequiredValue("Document:Description")
+ );
+
+ // Clear out the default servers so we can fallback to
+ // whatever ports have been allocated for the service by Aspire
+ options.AddDocumentTransformer(
+ (document, _, _) =>
+ {
+ document.Servers = [];
+ return Task.CompletedTask;
+ }
+ );
+ }
+ );
}
- IHostApplicationBuilder AddDefaultHealthChecks()
- {
- builder
- .Services.AddHealthChecks()
- // Add a default liveness check to ensure app is responsive
- .AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
+ return builder;
+ }
- return builder;
- }
+ static IHostApplicationBuilder AddOpenTelemetryExporters(IHostApplicationBuilder builder)
+ {
+ var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
- IHostApplicationBuilder AddDefaultOpenAPI(
- IApiVersioningBuilder? apiVersioning = default,
- bool throwOnMissing = true
- )
+ if (useOtlpExporter)
{
- var openApiSection = builder.Configuration.GetSection("OpenAPI");
- if (!openApiSection.Exists())
- {
- return throwOnMissing
- ? throw new InvalidOperationException("OpenAPI configuration section is missing.")
- : builder;
- }
-
- // the default format will just be ApiVersion.ToString(); for example, 1.0.
- // this will format the version as "'v'major[.minor][-status]"
- var versioned = apiVersioning?.AddApiExplorer(options => options.GroupNameFormat = "'v'VVV");
- string[] versions = ["v1"];
- foreach (var description in versions)
- {
- builder.Services.AddOpenApi(
- description,
- options =>
- {
- options.ApplyAPIVersionInfo(
- openApiSection.GetRequiredValue("Document:Title"),
- openApiSection.GetRequiredValue("Document:Description")
- );
-
- // Clear out the default servers so we can fallback to
- // whatever ports have been allocated for the service by Aspire
- options.AddDocumentTransformer(
- (document, _, _) =>
- {
- document.Servers = [];
- return Task.CompletedTask;
- }
- );
- }
- );
- }
-
- return builder;
+ builder.Services.Configure(logging => logging.AddOtlpExporter());
+ builder.Services.ConfigureOpenTelemetryMeterProvider(metrics => metrics.AddOtlpExporter());
+ builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter());
}
- IHostApplicationBuilder AddOpenTelemetryExporters()
- {
- var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
+ // Uncomment the following lines to enable the Prometheus exporter (requires the OpenTelemetry.Exporter.Prometheus.AspNetCore package)
+ // builder.Services.AddOpenTelemetry()
+ // .WithMetrics(metrics => metrics.AddPrometheusExporter());
- if (useOtlpExporter)
- {
- builder.Services.Configure(logging => logging.AddOtlpExporter());
- builder.Services.ConfigureOpenTelemetryMeterProvider(metrics => metrics.AddOtlpExporter());
- builder.Services.ConfigureOpenTelemetryTracerProvider(tracing => tracing.AddOtlpExporter());
- }
-
- // Uncomment the following lines to enable the Prometheus exporter (requires the OpenTelemetry.Exporter.Prometheus.AspNetCore package)
- // builder.Services.AddOpenTelemetry()
- // .WithMetrics(metrics => metrics.AddPrometheusExporter());
-
- // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
- //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
- //{
- // builder.Services.AddOpenTelemetry()
- // .UseAzureMonitor();
- //}
-
- return builder;
- }
+ // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package)
+ //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]))
+ //{
+ // builder.Services.AddOpenTelemetry()
+ // .UseAzureMonitor();
+ //}
+
+ return builder;
}
}
diff --git a/samples/SampleApp/SampleApp.ServiceDefaults/SampleApp.ServiceDefaults.csproj b/samples/SampleApp/SampleApp.ServiceDefaults/SampleApp.ServiceDefaults.csproj
index 3c0c937d..c4d1b8c7 100644
--- a/samples/SampleApp/SampleApp.ServiceDefaults/SampleApp.ServiceDefaults.csproj
+++ b/samples/SampleApp/SampleApp.ServiceDefaults/SampleApp.ServiceDefaults.csproj
@@ -9,12 +9,10 @@
(Microsoft.Extensions.*, Scalar.AspNetCore, etc.), so the SDK's namespace-prefix
global using is not resolvable here. -->
false
-
- $(NoWarn);CA1034;IDE0130;AV0027;AV0029;AV0030;
+ $(NoWarn);IDE0130;AV0027;AV0029;AV0030;
diff --git a/samples/SampleApp/SampleApp.ServiceDefaults/WebApplicationExtensions.cs b/samples/SampleApp/SampleApp.ServiceDefaults/WebApplicationExtensions.cs
index c45ef28b..9695279d 100644
--- a/samples/SampleApp/SampleApp.ServiceDefaults/WebApplicationExtensions.cs
+++ b/samples/SampleApp/SampleApp.ServiceDefaults/WebApplicationExtensions.cs
@@ -1,3 +1,4 @@
+using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
@@ -8,39 +9,36 @@ namespace Microsoft.AspNetCore.Builder;
public static class WebApplicationExtensions
{
- extension(WebApplication app)
+ public static WebApplication MapDefaultEndpoints([NotNull] this WebApplication app)
{
- public WebApplication MapDefaultEndpoints()
- {
- // Uncomment the following line to enable the Prometheus endpoint (requires the OpenTelemetry.Exporter.Prometheus.AspNetCore package)
- // app.MapPrometheusScrapingEndpoint();
+ // Uncomment the following line to enable the Prometheus endpoint (requires the OpenTelemetry.Exporter.Prometheus.AspNetCore package)
+ // app.MapPrometheusScrapingEndpoint();
- // All health checks must pass for app to be considered ready to accept traffic after starting
- app.MapHealthChecks("/health");
+ // All health checks must pass for app to be considered ready to accept traffic after starting
+ app.MapHealthChecks("/health");
- // Only health checks tagged with the "live" tag must pass for app to be considered alive
- app.MapHealthChecks("/alive", new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") });
+ // Only health checks tagged with the "live" tag must pass for app to be considered alive
+ app.MapHealthChecks("/alive", new HealthCheckOptions { Predicate = r => r.Tags.Contains("live") });
- app.UseDefaultOpenAPI();
+ UseDefaultOpenAPI(app);
- return app;
- }
+ return app;
+ }
- void UseDefaultOpenAPI()
- {
- var configuration = app.Configuration;
- var openApiSection = configuration.GetSection("OpenAPI");
+ static void UseDefaultOpenAPI(WebApplication app)
+ {
+ var configuration = app.Configuration;
+ var openApiSection = configuration.GetSection("OpenAPI");
- if (!openApiSection.Exists())
- return;
+ if (!openApiSection.Exists())
+ return;
- app.MapOpenApi("v1");
+ app.MapOpenApi("v1");
- if (app.Environment.IsDevelopment())
- {
- app.MapScalarApiReference();
- app.MapGet("/", () => Results.Redirect("/scalar/v1")).ExcludeFromDescription();
- }
+ if (app.Environment.IsDevelopment())
+ {
+ app.MapScalarApiReference();
+ app.MapGet("/", () => Results.Redirect("/scalar/v1")).ExcludeFromDescription();
}
}
}
diff --git a/samples/SampleApp/SampleApp.Web/Clients/IWeatherAPIClientTelemetry.cs b/samples/SampleApp/SampleApp.Web/Clients/IWeatherAPIClientTelemetry.cs
index 4020b585..2ce209fe 100644
--- a/samples/SampleApp/SampleApp.Web/Clients/IWeatherAPIClientTelemetry.cs
+++ b/samples/SampleApp/SampleApp.Web/Clients/IWeatherAPIClientTelemetry.cs
@@ -7,14 +7,14 @@ namespace SampleApp.Web.Clients;
[ActivitySource]
[Logger]
[Meter(InstrumentPrefix = "weather")]
-public interface IWeatherAPIClientTelemetry
+interface IWeatherAPIClientTelemetry
{
[Activity(ActivityKind.Client)]
[Info]
[AutoCounter]
Activity? GetWeatherForecasts(int? count);
- [Event]
+ [Event("exception")]
[Error]
[AutoCounter]
void FailedToGetForecast(Activity? activity, Exception ex, [ExcludeTargets(Targets.Activities)] int? count);
diff --git a/samples/SampleApp/SampleApp.Web/Clients/WeatherAPIClient.cs b/samples/SampleApp/SampleApp.Web/Clients/WeatherAPIClient.cs
index f4be1c36..d4a9f020 100644
--- a/samples/SampleApp/SampleApp.Web/Clients/WeatherAPIClient.cs
+++ b/samples/SampleApp/SampleApp.Web/Clients/WeatherAPIClient.cs
@@ -4,7 +4,7 @@ namespace SampleApp.Web.Clients;
/// Typed HTTP client for communicating with the Weather API service.
/// Uses Aspire service discovery and HTTP resiliency (retries, circuit breaker).
///
-public sealed class WeatherAPIClient(HttpClient httpClient, IWeatherAPIClientTelemetry telemetry)
+sealed class WeatherAPIClient(HttpClient httpClient, IWeatherAPIClientTelemetry telemetry)
{
///
/// Gets weather forecasts from the API service.
diff --git a/src/src/SourceGenerator/Records/TelemetryRules.Activities.cs b/src/src/SourceGenerator/Records/TelemetryRules.Activities.cs
index 2c2649d2..9ac29c5b 100644
--- a/src/src/SourceGenerator/Records/TelemetryRules.Activities.cs
+++ b/src/src/SourceGenerator/Records/TelemetryRules.Activities.cs
@@ -385,6 +385,7 @@ out var attributeData
if (!IsStandardExceptionEventName(GetLogEntryName(methodSymbol, attributeData!, token)))
return string.Empty;
+ // The log entry name is the same as the activity event name, so no hint is needed.
return $" The Name on [{matchingType.RenderAttributeTypeName}] renames the log entry, not the activity event.";
}