From 6163aa09f60fbd3c2f9951c101c53f447e123bf4 Mon Sep 17 00:00:00 2001
From: Kazuki Ota <117221407+kaota_microsoft@users.noreply.github.com>
Date: Sat, 20 Jun 2026 20:02:06 +0900
Subject: [PATCH 1/3] Add .NET 10 / C# 14 features skill for ReactiveProperty
Add a repo-committed Copilot skill at .github/skills/dotnet10-features
that summarizes the .NET 10 (and bundled C# 14) features relevant to this
repository, mapping each feature to concrete ReactiveProperty scenarios
(field keyword, extension members, null-conditional assignment, Blazor 10
form validation, etc.) and documenting the LangVersion/TFM constraints.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.github/skills/dotnet10-features/SKILL.md | 237 ++++++++++++++++++++++
1 file changed, 237 insertions(+)
create mode 100644 .github/skills/dotnet10-features/SKILL.md
diff --git a/.github/skills/dotnet10-features/SKILL.md b/.github/skills/dotnet10-features/SKILL.md
new file mode 100644
index 00000000..f6774834
--- /dev/null
+++ b/.github/skills/dotnet10-features/SKILL.md
@@ -0,0 +1,237 @@
+---
+name: dotnet10-features
+description: 'Reference for the .NET 10 / C# 14 features that are relevant to the ReactiveProperty repository. Use this when adopting or evaluating .NET 10 / C# 14 language and Blazor features in this codebase: modernizing INotifyPropertyChanged property accessors, refactoring the many `Reactive.Bindings` extension methods, raising LangVersion or adding a net10.0 target framework, or working on ReactiveProperty.Blazor / Blazor samples. Triggers include mentions of ".NET 10", "C# 14", "field keyword", "extension members", "net10.0", "LangVersion", or Blazor net10 work in this repo.'
+---
+
+# .NET 10 / C# 14 features relevant to ReactiveProperty
+
+This skill curates the **.NET 10** (and its bundled **C# 14**) features that actually
+matter for this repository, and maps each one to concrete ReactiveProperty scenarios.
+It is intentionally scoped: it skips .NET 10 features with no bearing on a reactive MVVM
+library.
+
+## Repository context (read this first)
+
+Target frameworks and language version are **not uniform** across the solution, so a C# 14
+feature may be unavailable in a given project:
+
+| Project | Target framework(s) | C# 14 available? |
+| --- | --- | --- |
+| `ReactiveProperty.Core` | `netstandard2.0;net8.0;net9.0;net472` | Only if `LangVersion` is raised; runtime-dependent features stay off on `netstandard2.0`/`net472` |
+| `ReactiveProperty` (main, `ReactiveProperty.NETStandard`) | `netstandard2.0;net8.0;net9.0;net472` | Same as above |
+| `ReactiveProperty.Platform.WPF` | `net8.0-windows;net9.0-windows;net472` | Same as above |
+| `ReactiveProperty.Platform.UWP` | (UWP TFMs) | Same as above |
+| `ReactiveProperty.Platform.Blazor` | `net8.0;net9.0;net10.0` | Yes on the `net10.0` leg |
+| `Samples/**`, Blazor samples | include `net10.0` | Yes |
+
+Key facts:
+
+- `Source/Directory.Build.props` currently pins **`12.0`**, so the
+ library compiles as **C# 12** today. Adopting any C# 14 feature in the library requires
+ raising `LangVersion` (to `14` / `latest` / `preview`).
+- `net10.0` is currently used only by **`ReactiveProperty.Platform.Blazor`** and the
+ **samples**. The core/main/WPF/UWP libraries do **not** target `net10.0`.
+- The library is **multi-targeted down to `netstandard2.0` and `net472`**. C# 14 *language*
+ features that the compiler can lower (e.g. `field`, extension members, null-conditional
+ assignment, partial members) work across all TFMs once `LangVersion` is raised, but
+ features that depend on the **.NET 10 runtime/BCL** (e.g. first-class `Span` conversions
+ that rely on new BCL APIs) will not light up on the older TFMs. Guard those with
+ `#if NET10_0_OR_GREATER` when needed.
+
+## How to enable C# 14 in a project here
+
+```xml
+
+14.0
+```
+
+For runtime/BCL-gated features, multi-target and branch with the framework constant:
+
+```csharp
+#if NET10_0_OR_GREATER
+ // .NET 10-only path
+#else
+ // fallback for net8.0 / net9.0 / netstandard2.0 / net472
+#endif
+```
+
+---
+
+## C# 14 features, ranked by relevance to this repo
+
+### 1. The `field` keyword — HIGH relevance
+
+`field` exposes the compiler-synthesized backing field inside a property accessor, so you can
+add validation/notification without declaring an explicit field. ReactiveProperty exists to
+serve MVVM/`INotifyPropertyChanged`, and both the library internals and consumer ViewModels
+are full of hand-written backing fields.
+
+Classic INPC property:
+
+```csharp
+private string _name = "";
+public string Name
+{
+ get => _name;
+ set
+ {
+ if (_name == value) return;
+ _name = value;
+ PropertyChanged?.Invoke(this, new(nameof(Name)));
+ }
+}
+```
+
+With `field`:
+
+```csharp
+public string Name
+{
+ get;
+ set
+ {
+ if (field == value) return;
+ field = value;
+ PropertyChanged?.Invoke(this, new(nameof(Name)));
+ }
+}
+```
+
+Notes for this repo:
+- Watch for the naming collision: identifiers literally named `field` must be disambiguated
+ with `@field` or `this.field`.
+- Works on all TFMs once `LangVersion >= 14`; this is the lowest-risk C# 14 adoption here.
+
+### 2. Extension members — HIGH relevance
+
+ReactiveProperty's public surface is heavily built on **extension methods** in the
+`Reactive.Bindings` namespace (`ToReactiveProperty`, `ToReadOnlyReactivePropertySlim`,
+`ObserveProperty`, `CombineLatest` helpers, etc.). C# 14 adds an `extension` block syntax that
+also enables **extension properties**, **static extension members**, and **extension
+operators** — grouped by receiver instead of repeating `this T source` on every method.
+
+```csharp
+public static class ObservableExtensions
+{
+ extension(IObservable source)
+ {
+ // extension method (same call site as today)
+ public ReactiveProperty ToReactiveProperty(T initialValue = default!)
+ => new ReactiveProperty(source, initialValue);
+
+ // NEW: extension property
+ public IObservable Shared => source.Publish().RefCount();
+ }
+}
+```
+
+Notes for this repo:
+- Lets related operators on `IObservable` / `IReactiveProperty` be grouped in one
+ `extension(...)` block, improving discoverability and enabling property-style helpers.
+- This is a **language** feature (compiler-lowered), so it can apply across all TFMs once
+ `LangVersion` is raised — but it is an API-shape decision; keep existing extension method
+ signatures for source/binary compatibility and only add new members.
+
+### 3. Null-conditional assignment (`?.`/`?[]` on the left side) — MEDIUM relevance
+
+You can now assign through `?.`/`?[]`:
+
+```csharp
+// before
+if (viewModel is not null) viewModel.SelectedItem = item;
+// C# 14
+viewModel?.SelectedItem = item;
+```
+
+Useful in ViewModel/event-handler glue and in sample apps. Increment/decrement
+(`++`/`--`) are not allowed on the conditional left side; compound assignments (`+=`, etc.)
+are.
+
+### 4. First-class `Span` / `ReadOnlySpan` conversions — MEDIUM (performance)
+
+C# 14 adds implicit conversions among `Span`, `ReadOnlySpan`, and `T[]`, improving
+overload resolution and generic inference for span-based, allocation-free code paths. Relevant
+only in hot paths (e.g. buffer/array handling inside operators).
+
+Caveat: the most useful conversions rely on **.NET 10 BCL** support, so gate span-optimized
+paths with `#if NET10_0_OR_GREATER` and keep an allocating fallback for `net8.0`/`net9.0`/
+`netstandard2.0`/`net472`.
+
+### 5. Simple lambda parameters with modifiers — MEDIUM/LOW relevance
+
+Modifiers (`ref`, `in`, `out`, `scoped`, `ref readonly`) can now be applied to lambda
+parameters **without** restating the type:
+
+```csharp
+delegate bool TryParse(string text, out T result);
+TryParse parse = (text, out result) => int.TryParse(text, out result);
+```
+
+Handy for Rx/LINQ-style lambdas and `TryParse`-shaped converters in samples. `params` still
+requires an explicit type.
+
+### 6. Partial events and constructors — MEDIUM relevance
+
+Instance constructors and events can now be `partial` (one defining + one implementing
+declaration). This is primarily a **source-generator** enabler. If any INPC/event plumbing in
+this repo (or a consumer toolkit pattern) is generated, partial events let a generator provide
+`add`/`remove` accessors while the field-like event is declared by hand.
+
+### 7. `nameof` with unbound generic types — LOW relevance
+
+`nameof(ReactiveProperty<>)` now evaluates to `"ReactiveProperty"`. Minor convenience for
+diagnostics, logging, and tests that reference open generic types.
+
+### 8. User-defined compound assignment operators — LOW relevance
+
+You can define `+=`, `-=`, etc. directly (instead of relying on the binary operator plus an
+assignment). Niche for this library; mentioned for completeness.
+
+---
+
+## ASP.NET Core / Blazor 10 (for `ReactiveProperty.Platform.Blazor` and Blazor samples)
+
+`ReactiveProperty.Platform.Blazor` targets `net10.0` and centers on **EditForm validation**
+(`ReactivePropertiesValidator`, `EditContext.EnableReactivePropertiesValidation()`) plus
+command helpers. The most relevant .NET 10 Blazor changes:
+
+- **Improved form validation** — new opt-in validation (`builder.Services.AddValidation()` +
+ `[ValidatableType]`) that validates nested objects and collection items. This overlaps with
+ ReactiveProperty's `EditContext` validation integration; be aware of how the two interact
+ when both are enabled on the same `EditForm`, and document the recommended combination.
+- **`OwningComponentBase` now implements `IAsyncDisposable`** — relevant to the disposal
+ pattern in `ReactivePropertiesValidator` (currently `IDisposable` wrapping a
+ `SingleAssignmentDisposable`). Async disposal can be useful where reactive subscriptions own
+ async resources.
+- **Declarative state persistence (`[PersistentState]`)** — public properties marked
+ `[PersistentState]` are auto-persisted via `PersistentComponentState` during prerendering,
+ removing the manual `RegisterOnPersisting` boilerplate. Useful for ViewModel-held state in
+ prerendered Blazor Web App samples.
+- **Not Found handling** (`Router` `NotFoundPage` parameter and
+ `NavigationManager`-based Not Found responses) and **Blazor script served as a static web
+ asset** — general improvements worth knowing when updating the Blazor samples to `net10.0`.
+
+When bumping the Blazor sample/template projects, also review the version-conditioned
+`Microsoft.AspNetCore.Components.*` package versions in `Directory.Packages.props`
+(`10.0.0` entries are gated on `'$(TargetFramework)' == 'net10.0'`).
+
+---
+
+## Adoption caveats (summary)
+
+- The core libraries are **C# 12 today** (`LangVersion 12.0`) and do **not** target `net10.0`.
+ Raising `LangVersion` is required before any C# 14 syntax compiles.
+- Multi-targeting to `netstandard2.0`/`net472` means **runtime/BCL-gated** features must be
+ `#if NET10_0_OR_GREATER`-guarded; language-only features are fine across TFMs.
+- This is a published NuGet library — preserve existing public/extension API signatures for
+ source and binary compatibility; prefer **adding** members over changing them.
+
+## References (official docs)
+
+- What's new in C# 14:
+- Extension members tutorial:
+- The `field` keyword:
+- Null-conditional assignment:
+- First-class spans:
+- What's new in .NET 10:
+- What's new in ASP.NET Core in .NET 10:
From a23fc65586af8c3b3198b706a6d95af20dad59d4 Mon Sep 17 00:00:00 2001
From: Kazuki Ota <117221407+kaota_microsoft@users.noreply.github.com>
Date: Sat, 20 Jun 2026 20:15:22 +0900
Subject: [PATCH 2/3] Install relevant dotnet/skills into repo (.agents/skills)
Install 14 ReactiveProperty-relevant agent skills from dotnet/skills@v1.0.0
at project scope via 'gh skill install', so contributors who clone the repo
get them (instead of a per-user plugin/marketplace install).
Skills (MSBuild/multi-targeting, MSTest testing, .NET upgrade, benchmarking,
NuGet publishing) are placed under .agents/skills with gh-injected source
tracking metadata for 'gh skill update'.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.agents/skills/check-bin-obj-clash/SKILL.md | 339 ++++++++++++
.agents/skills/coverage-analysis/SKILL.md | 463 ++++++++++++++++
.../references/guidelines.md | 59 ++
.../references/output-format.md | 83 +++
.../scripts/Compute-CrapScores.ps1 | 113 ++++
.../scripts/Extract-MethodCoverage.ps1 | 193 +++++++
.../directory-build-organization/SKILL.md | 180 +++++++
.../references/common-patterns.md | 56 ++
.../references/multi-level-examples.md | 164 ++++++
.../targetframework-props-pitfall.md | 54 ++
.agents/skills/microbenchmarking/SKILL.md | 131 +++++
.../references/bdn-internals-and-tuning.md | 132 +++++
.../references/comparison-strategies.md | 188 +++++++
.../references/diagnosers-and-exporters.md | 146 +++++
.../references/project-setup-and-running.md | 179 +++++++
.../references/writing-benchmarks.md | 296 ++++++++++
.../migrate-dotnet8-to-dotnet9/SKILL.md | 227 ++++++++
.../references/aspnet-core-dotnet8to9.md | 50 ++
.../containers-interop-dotnet8to9.md | 38 ++
.../references/core-libraries-dotnet8to9.md | 139 +++++
.../references/cryptography-dotnet8to9.md | 45 ++
.../references/csharp-compiler-dotnet8to9.md | 101 ++++
.../deployment-runtime-dotnet8to9.md | 58 ++
.../references/efcore-dotnet8to9.md | 155 ++++++
.../references/sdk-msbuild-dotnet8to9.md | 61 +++
.../serialization-networking-dotnet8to9.md | 119 ++++
.../references/winforms-wpf-dotnet8to9.md | 91 ++++
.../migrate-dotnet9-to-dotnet10/SKILL.md | 263 +++++++++
.../references/aspnet-core-dotnet9to10.md | 140 +++++
.../containers-interop-dotnet9to10.md | 63 +++
.../references/core-libraries-dotnet9to10.md | 93 ++++
.../references/cryptography-dotnet9to10.md | 79 +++
.../references/csharp-compiler-dotnet9to10.md | 118 ++++
.../references/efcore-dotnet9to10.md | 157 ++++++
.../extensions-hosting-dotnet9to10.md | 75 +++
.../references/sdk-msbuild-dotnet9to10.md | 50 ++
.../serialization-networking-dotnet9to10.md | 61 +++
.../references/winforms-wpf-dotnet9to10.md | 91 ++++
.../migrate-nullable-references/SKILL.md | 287 ++++++++++
.../references/aspnet-core.md | 17 +
.../references/breaking-changes.md | 8 +
.../references/ef-core.md | 18 +
.../references/nullable-attributes.md | 19 +
.../scripts/Get-NullableReadiness.ps1 | 487 +++++++++++++++++
.agents/skills/msbuild-antipatterns/SKILL.md | 392 ++++++++++++++
.../references/additional-antipatterns.md | 200 +++++++
.../incremental-build-inputs-outputs.md | 30 ++
.../references/private-assets.md | 22 +
.agents/skills/msbuild-modernization/SKILL.md | 506 ++++++++++++++++++
.../skills/nuget-trusted-publishing/SKILL.md | 164 ++++++
.../references/package-types.md | 256 +++++++++
.../references/publish-workflow.md | 102 ++++
.../resolve-project-references/SKILL.md | 63 +++
.agents/skills/run-tests/SKILL.md | 245 +++++++++
.agents/skills/test-anti-patterns/SKILL.md | 145 +++++
.agents/skills/writing-mstest-tests/SKILL.md | 355 ++++++++++++
56 files changed, 8366 insertions(+)
create mode 100644 .agents/skills/check-bin-obj-clash/SKILL.md
create mode 100644 .agents/skills/coverage-analysis/SKILL.md
create mode 100644 .agents/skills/coverage-analysis/references/guidelines.md
create mode 100644 .agents/skills/coverage-analysis/references/output-format.md
create mode 100644 .agents/skills/coverage-analysis/scripts/Compute-CrapScores.ps1
create mode 100644 .agents/skills/coverage-analysis/scripts/Extract-MethodCoverage.ps1
create mode 100644 .agents/skills/directory-build-organization/SKILL.md
create mode 100644 .agents/skills/directory-build-organization/references/common-patterns.md
create mode 100644 .agents/skills/directory-build-organization/references/multi-level-examples.md
create mode 100644 .agents/skills/directory-build-organization/references/targetframework-props-pitfall.md
create mode 100644 .agents/skills/microbenchmarking/SKILL.md
create mode 100644 .agents/skills/microbenchmarking/references/bdn-internals-and-tuning.md
create mode 100644 .agents/skills/microbenchmarking/references/comparison-strategies.md
create mode 100644 .agents/skills/microbenchmarking/references/diagnosers-and-exporters.md
create mode 100644 .agents/skills/microbenchmarking/references/project-setup-and-running.md
create mode 100644 .agents/skills/microbenchmarking/references/writing-benchmarks.md
create mode 100644 .agents/skills/migrate-dotnet8-to-dotnet9/SKILL.md
create mode 100644 .agents/skills/migrate-dotnet8-to-dotnet9/references/aspnet-core-dotnet8to9.md
create mode 100644 .agents/skills/migrate-dotnet8-to-dotnet9/references/containers-interop-dotnet8to9.md
create mode 100644 .agents/skills/migrate-dotnet8-to-dotnet9/references/core-libraries-dotnet8to9.md
create mode 100644 .agents/skills/migrate-dotnet8-to-dotnet9/references/cryptography-dotnet8to9.md
create mode 100644 .agents/skills/migrate-dotnet8-to-dotnet9/references/csharp-compiler-dotnet8to9.md
create mode 100644 .agents/skills/migrate-dotnet8-to-dotnet9/references/deployment-runtime-dotnet8to9.md
create mode 100644 .agents/skills/migrate-dotnet8-to-dotnet9/references/efcore-dotnet8to9.md
create mode 100644 .agents/skills/migrate-dotnet8-to-dotnet9/references/sdk-msbuild-dotnet8to9.md
create mode 100644 .agents/skills/migrate-dotnet8-to-dotnet9/references/serialization-networking-dotnet8to9.md
create mode 100644 .agents/skills/migrate-dotnet8-to-dotnet9/references/winforms-wpf-dotnet8to9.md
create mode 100644 .agents/skills/migrate-dotnet9-to-dotnet10/SKILL.md
create mode 100644 .agents/skills/migrate-dotnet9-to-dotnet10/references/aspnet-core-dotnet9to10.md
create mode 100644 .agents/skills/migrate-dotnet9-to-dotnet10/references/containers-interop-dotnet9to10.md
create mode 100644 .agents/skills/migrate-dotnet9-to-dotnet10/references/core-libraries-dotnet9to10.md
create mode 100644 .agents/skills/migrate-dotnet9-to-dotnet10/references/cryptography-dotnet9to10.md
create mode 100644 .agents/skills/migrate-dotnet9-to-dotnet10/references/csharp-compiler-dotnet9to10.md
create mode 100644 .agents/skills/migrate-dotnet9-to-dotnet10/references/efcore-dotnet9to10.md
create mode 100644 .agents/skills/migrate-dotnet9-to-dotnet10/references/extensions-hosting-dotnet9to10.md
create mode 100644 .agents/skills/migrate-dotnet9-to-dotnet10/references/sdk-msbuild-dotnet9to10.md
create mode 100644 .agents/skills/migrate-dotnet9-to-dotnet10/references/serialization-networking-dotnet9to10.md
create mode 100644 .agents/skills/migrate-dotnet9-to-dotnet10/references/winforms-wpf-dotnet9to10.md
create mode 100644 .agents/skills/migrate-nullable-references/SKILL.md
create mode 100644 .agents/skills/migrate-nullable-references/references/aspnet-core.md
create mode 100644 .agents/skills/migrate-nullable-references/references/breaking-changes.md
create mode 100644 .agents/skills/migrate-nullable-references/references/ef-core.md
create mode 100644 .agents/skills/migrate-nullable-references/references/nullable-attributes.md
create mode 100644 .agents/skills/migrate-nullable-references/scripts/Get-NullableReadiness.ps1
create mode 100644 .agents/skills/msbuild-antipatterns/SKILL.md
create mode 100644 .agents/skills/msbuild-antipatterns/references/additional-antipatterns.md
create mode 100644 .agents/skills/msbuild-antipatterns/references/incremental-build-inputs-outputs.md
create mode 100644 .agents/skills/msbuild-antipatterns/references/private-assets.md
create mode 100644 .agents/skills/msbuild-modernization/SKILL.md
create mode 100644 .agents/skills/nuget-trusted-publishing/SKILL.md
create mode 100644 .agents/skills/nuget-trusted-publishing/references/package-types.md
create mode 100644 .agents/skills/nuget-trusted-publishing/references/publish-workflow.md
create mode 100644 .agents/skills/resolve-project-references/SKILL.md
create mode 100644 .agents/skills/run-tests/SKILL.md
create mode 100644 .agents/skills/test-anti-patterns/SKILL.md
create mode 100644 .agents/skills/writing-mstest-tests/SKILL.md
diff --git a/.agents/skills/check-bin-obj-clash/SKILL.md b/.agents/skills/check-bin-obj-clash/SKILL.md
new file mode 100644
index 00000000..927092ec
--- /dev/null
+++ b/.agents/skills/check-bin-obj-clash/SKILL.md
@@ -0,0 +1,339 @@
+---
+description: 'Detects MSBuild projects with conflicting OutputPath or IntermediateOutputPath. Only activate in MSBuild/.NET build context. USE FOR: builds failing with ''Cannot create a file when that file already exists'', ''The process cannot access the file because it is being used by another process'', intermittent build failures that succeed on retry, missing outputs in multi-project builds, multi-targeting builds where project.assets.json conflicts. Diagnoses when multiple projects or TFMs write to the same bin/obj directories due to shared OutputPath, missing AppendTargetFrameworkToOutputPath, or extra global properties like PublishReadyToRun creating redundant evaluations. DO NOT USE FOR: file access errors unrelated to MSBuild (OS-level locking), single-project single-TFM builds, non-MSBuild build systems. INVOKES: dotnet msbuild binlog replay, grep for output path analysis.'
+metadata:
+ github-path: plugins/dotnet-msbuild/skills/check-bin-obj-clash
+ github-pinned: v1.0.0
+ github-ref: refs/tags/v1.0.0
+ github-repo: https://github.com/dotnet/skills
+ github-tree-sha: 4376159ee7ea3cdb86784dbd9841704c5369eab4
+name: check-bin-obj-clash
+---
+# Detecting OutputPath and IntermediateOutputPath Clashes
+
+## Overview
+
+This skill helps identify when multiple MSBuild project evaluations share the same `OutputPath` or `IntermediateOutputPath`. This is a common source of build failures including:
+
+- File access conflicts during parallel builds
+- Missing or overwritten output files
+- Intermittent build failures
+- "File in use" errors
+- **NuGet restore errors like `Cannot create a file when that file already exists`** - this strongly indicates multiple projects share the same `IntermediateOutputPath` where `project.assets.json` is written
+
+Clashes can occur between:
+- **Different projects** sharing the same output directory
+- **Multi-targeting builds** (e.g., `TargetFrameworks=net8.0;net9.0`) where the path doesn't include the target framework
+- **Multiple solution builds** where the same project is built from different solutions in a single build
+
+**Note:** Project instances with `BuildProjectReferences=false` should be **ignored** when analyzing clashes - these are P2P reference resolution builds that only query metadata (via `GetTargetPath`) and do not actually write to output directories.
+
+## When to Use This Skill
+
+**Invoke this skill immediately when you see:**
+- `Cannot create a file when that file already exists` during NuGet restore
+- `The process cannot access the file because it is being used by another process`
+- Intermittent build failures that succeed on retry
+- Missing output files or unexpected overwriting
+
+## Step 1: Generate a Binary Log
+
+Use the `binlog-generation` skill to generate a binary log with the correct naming convention.
+
+## Step 2: Replay the Binary Log to Text
+
+```bash
+dotnet msbuild build.binlog -noconlog -fl -flp:v=diag;logfile=full.log
+```
+
+## Step 3: List All Projects
+
+```bash
+grep -i 'done building project\|Building project' full.log | grep -oP '"[^"]+\.csproj"' | sort -u
+```
+
+This lists all project files that participated in the build.
+
+## Step 4: Check for Multiple Evaluations per Project
+
+Multiple evaluations for the same project indicate multi-targeting or multiple build configurations:
+
+```bash
+# Count how many times each project was evaluated
+grep -c 'Evaluation started' full.log
+grep 'Evaluation started.*\.csproj' full.log
+```
+
+## Step 5: Check Global Properties for Each Evaluation
+
+For each project, query the build properties to understand the build configuration:
+
+```bash
+# Search the diagnostic log for evaluated property values
+grep -i 'TargetFramework\|Configuration\|Platform\|RuntimeIdentifier' full.log | head -40
+```
+
+Look for properties like `TargetFramework`, `Configuration`, `Platform`, and `RuntimeIdentifier` that should differentiate output paths.
+
+Also check **solution-related properties** to identify multi-solution builds:
+- `SolutionFileName`, `SolutionName`, `SolutionPath`, `SolutionDir`, `SolutionExt` — differ when a project is built from multiple solutions
+- `CurrentSolutionConfigurationContents` — the number of project entries reveals which solution an evaluation belongs to (e.g., 1 project vs ~49 projects)
+
+Look for **extra global properties that don't affect output paths** but create distinct MSBuild project instances:
+- `PublishReadyToRun` — a publish setting that doesn't change `OutputPath` or `IntermediateOutputPath`, but MSBuild treats it as a distinct project instance, preventing result caching and causing redundant target execution (e.g., `CopyFilesToOutputDirectory` running again)
+- Any other global property that differs between evaluations but doesn't contribute to path differentiation
+
+### Filter Out Non-Build Evaluations
+
+When analyzing clashes, filter evaluations based on the type of clash you're investigating:
+
+1. **For OutputPath clashes**: Exclude restore-phase evaluations (where `MSBuildRestoreSessionId` global property is set). These don't write to output directories.
+
+2. **For IntermediateOutputPath clashes**: Include restore-phase evaluations, as NuGet restore writes `project.assets.json` to the intermediate output path.
+
+3. **Always exclude `BuildProjectReferences=false`**: These are P2P metadata queries, not actual builds that write files.
+
+## Step 6: Get Output Paths for Each Project
+
+Query each project's output path properties:
+
+```bash
+# From the diagnostic log - search for OutputPath assignments
+grep -i 'OutputPath\s*=\|IntermediateOutputPath\s*=\|BaseOutputPath\s*=\|BaseIntermediateOutputPath\s*=' full.log | head -40
+
+# Or query a specific project directly
+dotnet msbuild MyProject.csproj -getProperty:OutputPath
+dotnet msbuild MyProject.csproj -getProperty:IntermediateOutputPath
+dotnet msbuild MyProject.csproj -getProperty:BaseOutputPath
+dotnet msbuild MyProject.csproj -getProperty:BaseIntermediateOutputPath
+```
+
+## Step 7: Identify Clashes
+
+Compare the `OutputPath` and `IntermediateOutputPath` values across all evaluations:
+
+1. **Normalize paths** - Convert to absolute paths and normalize separators
+2. **Group by path** - Find evaluations that share the same OutputPath or IntermediateOutputPath
+3. **Report clashes** - Any group with more than one evaluation indicates a clash
+
+## Step 8: Verify Clashes via CopyFilesToOutputDirectory (Optional)
+
+As additional evidence for OutputPath clashes, check if multiple project builds execute the `CopyFilesToOutputDirectory` target to the same path. Note that not all clashes manifest here - compilation outputs and other targets may also conflict.
+
+```bash
+# Search for CopyFilesToOutputDirectory target execution per project
+grep 'Target "CopyFilesToOutputDirectory"' full.log
+
+# Look for Copy task messages showing file destinations
+grep 'Copying file from\|SkipUnchangedFiles' full.log | head -30
+```
+
+Look for evidence of clashes in the messages:
+- `Copying file from "..." to "..."` - Active file writes
+- `Did not copy from file "..." to file "..." because the "SkipUnchangedFiles" parameter was set to "true"` - Indicates a second build attempted to write to the same location
+
+The `SkipUnchangedFiles` skip message often masks clashes - the build succeeds but is vulnerable to race conditions in parallel builds.
+
+## Step 9: Check CoreCompile Execution Patterns (Optional)
+
+To understand which project instance did the actual compilation vs redundant work, check `CoreCompile`:
+
+```bash
+grep 'Target "CoreCompile"' full.log
+```
+
+Compare the durations:
+- The instance with a long `CoreCompile` duration (e.g., seconds) is the **primary build** that did the actual compilation
+- Instances where `CoreCompile` was skipped (duration ~0-10ms) are **redundant builds** — they didn't recompile but may still run other targets like `CopyFilesToOutputDirectory` that write to the same output directory
+
+This helps distinguish the "real" build from redundant instances created by extra global properties or multi-solution builds.
+
+### Caveat: Multi-Solution Builds
+
+When analyzing multi-solution builds, note that the diagnostic log interleaves output from all projects. To determine which solution a project instance belongs to, search for `SolutionFileName` property assignments in the diagnostic log:
+
+```bash
+grep -i "SolutionFileName\|CurrentSolutionConfigurationContents" full.log | head -20
+```
+
+### Expected Output Structure
+
+For each evaluation, collect:
+- Project file path
+- Evaluation ID
+- TargetFramework (if multi-targeting)
+- Configuration
+- OutputPath
+- IntermediateOutputPath
+
+### Clash Detection Logic
+
+```
+For each unique OutputPath:
+ - If multiple evaluations share it → CLASH
+
+For each unique IntermediateOutputPath:
+ - If multiple evaluations share it → CLASH
+```
+
+## Common Causes and Fixes
+
+### Multi-targeting without TargetFramework in path
+
+**Problem:** Project uses `TargetFrameworks` but OutputPath doesn't vary by framework.
+
+```xml
+
+bin\$(Configuration)\
+```
+
+**Fix:** Include TargetFramework in the path:
+
+```xml
+
+bin\$(Configuration)\$(TargetFramework)\
+```
+
+Or rely on SDK defaults which handle this automatically:
+
+```xml
+true
+true
+```
+
+### Shared output directory across projects (CANNOT be fixed with AppendTargetFramework)
+
+**Problem:** Multiple projects explicitly set the same `BaseOutputPath` or `BaseIntermediateOutputPath`.
+
+```xml
+
+..\SharedOutput\
+..\SharedObj\
+
+
+..\SharedOutput\
+..\SharedObj\
+```
+
+**IMPORTANT:** Even with `AppendTargetFrameworkToOutputPath=true`, this will still clash! .NET writes certain files directly to the `IntermediateOutputPath` without the TargetFramework suffix, including:
+
+- `project.assets.json` (NuGet restore output)
+- Other NuGet-related files
+
+This causes errors like `Cannot create a file when that file already exists` during parallel restore.
+
+**Fix:** Each project MUST have a unique `BaseIntermediateOutputPath`. Do not share intermediate output directories across projects:
+
+```xml
+
+..\obj\ProjectA\
+
+
+..\obj\ProjectB\
+```
+
+Or simply use the SDK defaults which place `obj` inside each project's directory.
+
+### RuntimeIdentifier builds clashing
+
+**Problem:** Building for multiple RIDs without RID in path.
+
+**Fix:** Ensure RuntimeIdentifier is in the path:
+
+```xml
+true
+```
+
+### Multiple solutions building the same project
+
+**Problem:** A single build invokes multiple solutions (e.g., via MSBuild task or command line) that include the same project. Each solution build evaluates and builds the project independently, with different `Solution*` global properties that don't affect the output path.
+
+**How to detect:** Compare `SolutionFileName` and `CurrentSolutionConfigurationContents` across evaluations for the same project. Different values indicate multi-solution builds. For example:
+
+| Property | Eval from Solution A | Eval from Solution B |
+|---|---|---|
+| `SolutionFileName` | `BuildAnalyzers.sln` | `Main.slnx` |
+| `CurrentSolutionConfigurationContents` | 1 project entry | ~49 project entries |
+| `OutputPath` | `bin\Release\netstandard2.0\` | `bin\Release\netstandard2.0\` ← **clash** |
+
+**Example:** A repo build script builds `BuildAnalyzers.sln` then `Main.slnx`, and both solutions include `SharedAnalyzers.csproj`. Both builds write to `bin\Release\netstandard2.0\`. The first build compiles; the second skips compilation but still runs `CopyFilesToOutputDirectory`.
+
+**Fix:** Options include:
+1. **Consolidate solutions** - Ensure each project is only built from one solution in a single build
+2. **Use different configurations** - Build solutions with different `Configuration` values that result in different output paths
+3. **Exclude duplicate projects** - Use solution filters or conditional project inclusion to avoid building the same project twice
+
+### Extra global properties creating redundant project instances
+
+**Problem:** A project is built multiple times within the same solution due to extra global properties (e.g., `PublishReadyToRun=false`) that create distinct MSBuild project instances. These properties don't affect output paths but prevent MSBuild from caching results across instances, causing redundant target execution.
+
+**How to detect:** Compare global properties across evaluations for the same project within the same solution (same `SolutionFileName`). Look for properties that differ but don't contribute to path differentiation:
+
+| Property | Eval A (from Razor.slnx) | Eval B (from Razor.slnx) |
+|---|---|---|
+| `PublishReadyToRun` | *(not set)* | `false` |
+| `OutputPath` | `bin\Release\netstandard2.0\` | `bin\Release\netstandard2.0\` ← **clash** |
+
+This is particularly wasteful for projects where the extra property has no effect (e.g., `PublishReadyToRun` on a `netstandard2.0` class library that doesn't use ReadyToRun compilation).
+
+**Fix:** Options include:
+1. **Remove the extra global property** - Investigate which parent target/task is injecting the property and prevent it from being passed to projects that don't need it
+2. **Use `RemoveGlobalProperties` metadata** - On `ProjectReference` items, use `RemoveGlobalProperties="PublishReadyToRun"` to strip the property before building the referenced project
+3. **Condition the property** - Only set the property on projects that actually use it (e.g., only for executable projects, not class libraries)
+
+## Example Workflow
+
+```bash
+# 1. Replay the binlog
+dotnet msbuild build.binlog -noconlog -fl -flp:v=diag;logfile=full.log
+
+# 2. List projects
+grep 'done building project' full.log | grep -oP '"[^"]+\.csproj"' | sort -u
+
+# 3. Check OutputPath for each evaluation
+grep -i 'OutputPath\s*=' full.log | sort -u
+# e.g. OutputPath = bin\Debug\net8.0\
+# OutputPath = bin\Debug\net9.0\
+
+# 4. Check IntermediateOutputPath
+grep -i 'IntermediateOutputPath\s*=' full.log | sort -u
+# e.g. IntermediateOutputPath = obj\Debug\net8.0\
+# IntermediateOutputPath = obj\Debug\net9.0\
+
+# 5. Compare paths → No clash (paths differ by TargetFramework)
+```
+
+## Tips
+
+- Use `grep -i 'OutputPath\s*=' full.log | sort -u` to quickly find all OutputPath property assignments
+- Check `BaseOutputPath` and `BaseIntermediateOutputPath` as they form the root of output paths
+- The SDK default paths include `$(TargetFramework)` - clashes often occur when projects override these defaults
+- Remember that paths may be relative - normalize to absolute paths before comparing
+- **Cross-project IntermediateOutputPath clashes cannot be fixed with `AppendTargetFrameworkToOutputPath`** - files like `project.assets.json` are written directly to the intermediate path
+- For multi-targeting clashes within the same project, `AppendTargetFrameworkToOutputPath=true` is the correct fix
+- Common error messages indicating path clashes:
+ - `Cannot create a file when that file already exists` (NuGet restore)
+ - `The process cannot access the file because it is being used by another process`
+ - Intermittent build failures that succeed on retry
+
+### Global Properties to Check When Comparing Evaluations
+
+When multiple evaluations share an output path, compare these global properties to understand why:
+
+| Property | Affects OutputPath? | Notes |
+|----------|---------------------|-------|
+| `TargetFramework` | Yes | Different TFMs should have different paths |
+| `RuntimeIdentifier` | Yes | Different RIDs should have different paths |
+| `Configuration` | Yes | Debug vs Release |
+| `Platform` | Yes | AnyCPU vs x64 etc. |
+| `SolutionFileName` | No | Identifies which solution built the project — different values indicate multi-solution clash |
+| `SolutionName` | No | Solution name without extension |
+| `SolutionPath` | No | Full path to the solution file |
+| `SolutionDir` | No | Directory containing the solution file |
+| `CurrentSolutionConfigurationContents` | No | XML with project entries — count of entries reveals which solution |
+| `BuildProjectReferences` | No | `false` = P2P query, not a real build - ignore these |
+| `MSBuildRestoreSessionId` | No | Present = restore phase evaluation |
+| `PublishReadyToRun` | No | Publish setting, doesn't change build output path but creates distinct project instances |
+
+## Testing Fixes
+
+After making changes to fix path clashes, clean and rebuild to verify. See the `binlog-generation` skill's "Cleaning the Repository" section on how to clean the repository while preserving binlog files.
diff --git a/.agents/skills/coverage-analysis/SKILL.md b/.agents/skills/coverage-analysis/SKILL.md
new file mode 100644
index 00000000..3897fcca
--- /dev/null
+++ b/.agents/skills/coverage-analysis/SKILL.md
@@ -0,0 +1,463 @@
+---
+description: 'Automated, project-wide code coverage and CRAP (Change Risk Anti-Patterns) score analysis for .NET projects with existing unit tests. Auto-detects solution structure, runs coverage collection via `dotnet test` (supports both Microsoft.Testing.Extensions.CodeCoverage and Coverlet), generates reports via ReportGenerator, calculates CRAP scores per method, and surfaces risk hotspots — complex code with low test coverage that is dangerous to modify. Use when the user wants project-wide coverage analysis with risk prioritization, coverage gap identification, CRAP score computation across an entire solution, or to diagnose why coverage is stuck or plateaued and identify what methods are blocking improvement. DO NOT USE FOR: targeted single-method CRAP analysis (use crap-score skill), writing tests, running tests without coverage collection, applying test filters, producing TRX reports, or troubleshooting test execution (use run-tests for all of these).'
+metadata:
+ github-path: plugins/dotnet-test/skills/coverage-analysis
+ github-pinned: v1.0.0
+ github-ref: refs/tags/v1.0.0
+ github-repo: https://github.com/dotnet/skills
+ github-tree-sha: a419bfee627f020b088df2eaa48bc038b903d983
+name: coverage-analysis
+---
+# Coverage Analysis
+
+## Purpose
+
+Raw coverage percentages answer "what code was executed?" — they don't answer what you actually need to know:
+
+- **What tests should I write next?** — ranked by risk and impact
+- **Which uncovered code is risky vs. trivial?** — CRAP scores separate the two
+- **Why has coverage plateaued?** — identify the files blocking further gains
+- **Is this code safe to refactor?** — complex + uncovered = dangerous to change
+
+This skill bridges that gap: from a bare .NET solution to a prioritized risk hotspot list, with no manual tool configuration required.
+
+## When to Use
+
+Use this skill when the user mentions test coverage, coverage gaps, code risk, CRAP scores, where to add tests, why coverage plateaued, or wants to know which code is safest to refactor — even if they don't explicitly say "coverage analysis".
+
+## When Not to Use
+
+- **Targeted single-method CRAP analysis** — use the `crap-score` skill instead
+- **Writing or generating tests** — this skill identifies where tests are needed, not write them
+- **General test execution** unrelated to coverage or CRAP analysis
+- **Coverage reporting without CRAP context** — use `dotnet test` with coverage collection directly
+
+## Inputs
+
+| Input | Required | Default | Description |
+|-------|----------|---------|-------------|
+| Project/solution path | No | Current directory | Path to the .NET solution or project |
+| Line coverage threshold | No | 80% | Minimum acceptable line coverage |
+| Branch coverage threshold | No | 70% | Minimum acceptable branch coverage |
+| CRAP threshold | No | 30 | Maximum acceptable CRAP score before flagging |
+| Top N hotspots | No | 10 | Number of risk hotspots to surface |
+
+### Prerequisites
+
+- .NET SDK installed (`dotnet` on PATH)
+- At least one test project referencing the production code (xUnit, NUnit, or MSTest)
+- Internet access for `dotnet tool install` (ReportGenerator) on first run, or ReportGenerator already installed globally
+
+The skill auto-detects coverage provider state per test project and selects the least-invasive execution strategy:
+
+- unified Microsoft CodeCoverage when all projects use it,
+- unified Coverlet when no project uses Microsoft CodeCoverage,
+- per-project provider execution when the solution is truly mixed.
+
+No pre-existing runsettings files or manually installed tools required.
+
+## Workflow
+
+If the user provides a path to existing Cobertura XML (or coverage data is already present in `TestResults/`), skip Steps 3–4 (test execution and provider detection) but **still run Steps 5–6** (ReportGenerator and CRAP score computation). The Risk Hotspots table and CRAP scores are mandatory in every output — they are the skill's core value-add over raw coverage numbers.
+
+The workflow runs in four phases. Phases 2 and 3 each contain steps that can run in parallel to reduce total wall-clock time.
+
+### Phase 1 — Setup (sequential)
+
+#### Step 1: Locate the solution or project
+
+Given the user's path (default: current directory), find the entry point:
+
+```powershell
+$root = ""
+
+# Prefer solution file; fall back to project file
+$sln = Get-ChildItem -Path $root -Filter "*.sln" -Recurse -Depth 2 -ErrorAction SilentlyContinue |
+ Select-Object -First 1
+if ($sln) {
+ Write-Host "ENTRY_TYPE:Solution"; Write-Host "ENTRY:$($sln.FullName)"
+} else {
+ $project = Get-ChildItem -Path $root -Filter "*.csproj" -Recurse -Depth 2 -ErrorAction SilentlyContinue |
+ Select-Object -First 1
+ if ($project) {
+ Write-Host "ENTRY_TYPE:Project"; Write-Host "ENTRY:$($project.FullName)"
+ } else {
+ Write-Host "ENTRY_TYPE:NotFound"
+ }
+}
+
+# Test projects: search path first, then git root, then parent
+$searchRoots = @($root)
+$gitRoot = (git -C $root rev-parse --show-toplevel 2>$null)
+if ($gitRoot) { $gitRoot = [System.IO.Path]::GetFullPath($gitRoot) }
+if ($gitRoot -and $gitRoot -ne $root) { $searchRoots += $gitRoot }
+$parentPath = Split-Path $root -Parent
+if ($parentPath -and $parentPath -ne $root -and $parentPath -ne $gitRoot) { $searchRoots += $parentPath }
+
+$testProjects = @()
+foreach ($sr in $searchRoots) {
+ # Primary: match by .csproj content (test framework references)
+ $testProjects = @(Get-ChildItem -Path $sr -Filter "*.csproj" -Recurse -Depth 5 -ErrorAction SilentlyContinue |
+ Where-Object { $_.FullName -notmatch '([/\\]obj[/\\]|[/\\]bin[/\\])' } |
+ Where-Object { (Select-String -Path $_.FullName -Pattern 'Microsoft\.NET\.Test\.Sdk|xunit|nunit|MSTest\.TestAdapter|"MSTest"|MSTest\.TestFramework|TUnit' -Quiet) })
+ if ($testProjects.Count -gt 0) {
+ if ($sr -ne $root) { Write-Host "SEARCHED:$sr" }
+ break
+ }
+}
+
+# Fallback: match by file name convention
+if ($testProjects.Count -eq 0) {
+ foreach ($sr in $searchRoots) {
+ $testProjects = @(Get-ChildItem -Path $sr -Filter "*.csproj" -Recurse -Depth 5 -ErrorAction SilentlyContinue |
+ Where-Object { $_.Name -match '(?i)(test|spec)' })
+ if ($testProjects.Count -gt 0) {
+ if ($sr -ne $root) { Write-Host "SEARCHED:$sr" }
+ break
+ }
+ }
+}
+Write-Host "TEST_PROJECTS:$($testProjects.Count)"
+$testProjects | ForEach-Object { Write-Host "TEST_PROJECT:$($_.FullName)" }
+
+# Resolve the test output root (where coverage-analysis artifacts will be written)
+if ($testProjects.Count -eq 1) {
+ $testOutputRoot = $testProjects[0].DirectoryName
+} else {
+ # Multiple test projects — find their deepest common parent directory
+ $dirs = $testProjects | ForEach-Object { $_.DirectoryName }
+ $common = $dirs[0]
+ foreach ($d in $dirs[1..($dirs.Count-1)]) {
+ $sep = [System.IO.Path]::DirectorySeparatorChar
+ while (-not $d.StartsWith("$common$sep", [System.StringComparison]::OrdinalIgnoreCase) -and $d -ne $common) {
+ $prevCommon = $common
+ $common = Split-Path $common -Parent
+ # Terminate if we can no longer move up (at filesystem root or no parent)
+ if ([string]::IsNullOrEmpty($common) -or $common -eq $prevCommon) {
+ $common = $null
+ break
+ }
+ }
+ }
+ if ([string]::IsNullOrEmpty($common)) {
+ # Fallback when no common parent directory exists (e.g., projects on different drives)
+ if ($gitRoot) {
+ $testOutputRoot = $gitRoot
+ } else {
+ $testOutputRoot = $root
+ }
+ } else {
+ $testOutputRoot = $common
+ }
+}
+Write-Host "TEST_OUTPUT_ROOT:$testOutputRoot"
+```
+
+- If `ENTRY_TYPE:NotFound` and test projects were found → use the test projects directly as entry points (run `dotnet test` on each test `.csproj`).
+- If `ENTRY_TYPE:NotFound` and no test projects found → stop: `No .sln or test projects found under . Provide the path to your .NET solution or project.`
+- If `TEST_PROJECTS:0` → stop: `No test projects found (expected projects with 'Test' or 'Spec' in the name). Ensure your solution has unit test projects before running coverage analysis.`
+
+#### Step 2: Create the output directory
+
+```powershell
+$coverageDir = Join-Path $testOutputRoot "TestResults" "coverage-analysis"
+if (Test-Path $coverageDir) { Remove-Item $coverageDir -Recurse -Force }
+New-Item -ItemType Directory -Path $coverageDir -Force | Out-Null
+Write-Host "COVERAGE_DIR:$coverageDir"
+```
+
+#### Step 2b: Recommend ignoring `TestResults/`
+
+```powershell
+$pattern = "**/TestResults/"
+$gitRoot = (git -C $testOutputRoot rev-parse --show-toplevel 2>$null)
+if ($gitRoot) { $gitRoot = [System.IO.Path]::GetFullPath($gitRoot) }
+if ($gitRoot) {
+ $gitignorePath = Join-Path $gitRoot ".gitignore"
+ $alreadyIgnored = $false
+ if (Test-Path $gitignorePath) {
+ $alreadyIgnored = (Select-String -Path $gitignorePath -Pattern '^\s*(\*\*/)?TestResults/?\s*$' -Quiet)
+ }
+ if ($alreadyIgnored) {
+ Write-Host "GITIGNORE_RECOMMENDATION:already-present"
+ } else {
+ Write-Host "GITIGNORE_RECOMMENDATION:$pattern"
+ }
+} else {
+ Write-Host "GITIGNORE_RECOMMENDATION:$pattern"
+}
+```
+
+### Phase 2 — Data collection (Steps 3 and 4 run in parallel)
+
+Steps 3 and 4 are independent — start both simultaneously. `dotnet test` is the slowest step, and ReportGenerator setup doesn't need coverage files, so running them concurrently cuts wall time significantly.
+
+#### Step 3: Detect coverage provider and run `dotnet test` with coverage collection
+
+Before running tests, detect which coverage provider the test projects use. Projects may reference
+`Microsoft.Testing.Extensions.CodeCoverage` (Microsoft's built-in provider, common on .NET 9+) or
+`coverlet.collector` (open-source, the default in xUnit templates). The provider determines which
+`dotnet test` arguments to use — both produce Cobertura XML.
+
+```powershell
+# Detect coverage provider per test project
+$coverageProvider = "unknown" # will be set to "ms-codecoverage" or "coverlet"
+$msCodeCovProjects = @()
+$coverletProjects = @()
+$neitherProjects = @()
+
+foreach ($tp in $testProjects) {
+ $hasMsCodeCov = Select-String -Path $tp.FullName -Pattern 'Microsoft\.Testing\.Extensions\.CodeCoverage' -Quiet
+ $hasCoverlet = Select-String -Path $tp.FullName -Pattern 'coverlet\.collector' -Quiet
+ if ($hasMsCodeCov) { $msCodeCovProjects += $tp }
+ elseif ($hasCoverlet) { $coverletProjects += $tp }
+ else { $neitherProjects += $tp }
+}
+
+# Determine the provider strategy
+if ($msCodeCovProjects.Count -gt 0 -and $coverletProjects.Count -eq 0) {
+ $coverageProvider = "ms-codecoverage"
+ Write-Host "COVERAGE_PROVIDER:ms-codecoverage (ms:$($msCodeCovProjects.Count), none:$($neitherProjects.Count))"
+} elseif ($coverletProjects.Count -gt 0 -and $msCodeCovProjects.Count -eq 0) {
+ $coverageProvider = "coverlet"
+ Write-Host "COVERAGE_PROVIDER:coverlet (coverlet:$($coverletProjects.Count), none:$($neitherProjects.Count))"
+} elseif ($msCodeCovProjects.Count -gt 0 -and $coverletProjects.Count -gt 0) {
+ $coverageProvider = "mixed-project"
+ Write-Host "COVERAGE_PROVIDER:mixed-project (ms:$($msCodeCovProjects.Count), coverlet:$($coverletProjects.Count), none:$($neitherProjects.Count))"
+} else {
+ $coverageProvider = "coverlet"
+ Write-Host "COVERAGE_PROVIDER:none-detected — defaulting to coverlet"
+}
+```
+
+If any discovered test projects have no provider, add one based on the selected strategy:
+
+```powershell
+if ($coverageProvider -eq "ms-codecoverage" -and $neitherProjects.Count -gt 0) {
+ Write-Host "ADDING_MS_CODECOVERAGE:$($neitherProjects.Count) project(s)"
+ foreach ($tp in $neitherProjects) {
+ dotnet add $tp.FullName package Microsoft.Testing.Extensions.CodeCoverage --no-restore
+ Write-Host " ADDED_MS_CODECOVERAGE:$($tp.FullName)"
+ }
+ foreach ($tp in $neitherProjects) {
+ dotnet restore $tp.FullName --quiet
+ }
+}
+
+if (($coverageProvider -eq "coverlet" -or $coverageProvider -eq "mixed-project") -and $neitherProjects.Count -gt 0) {
+ Write-Host "ADDING_COVERLET:$($neitherProjects.Count) project(s)"
+ foreach ($tp in $neitherProjects) {
+ dotnet add $tp.FullName package coverlet.collector --no-restore
+ Write-Host " ADDED:$($tp.FullName)"
+ }
+ foreach ($tp in $neitherProjects) {
+ dotnet restore $tp.FullName --quiet
+ }
+}
+```
+
+Log each addition to the console so the developer sees what changed. Document the additions in the final report (see Output Format).
+
+Run one `dotnet test` per entry point for the selected strategy:
+
+- In `ms-codecoverage` or `coverlet` mode: run a single command for the solution entry (or one per test project if no `.sln` was found).
+- In `mixed-project` mode: run one command per test project, using that project's existing provider to avoid dual-provider conflicts.
+
+**Coverlet** (`coverlet.collector`):
+
+```powershell
+$rawDir = Join-Path "" "raw"
+dotnet test "" `
+ --collect:"XPlat Code Coverage" `
+ --results-directory $rawDir `
+ -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura `
+ -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Include="[*]*" `
+ -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Exclude="[*.Tests]*,[*.Test]*,[*Tests]*,[*Test]*,[*.Specs]*,[*.Testing]*" `
+ -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.SkipAutoProps=true
+```
+
+**Microsoft CodeCoverage** (`Microsoft.Testing.Extensions.CodeCoverage`):
+
+The command syntax depends on the .NET SDK version. In .NET 9, Microsoft.Testing.Platform arguments
+must be passed after the `--` separator. In .NET 10+, `--coverage` is a top-level `dotnet test` flag.
+
+```powershell
+$rawDir = Join-Path "" "raw"
+
+# Detect SDK version for correct argument placement
+$sdkVersion = (dotnet --version 2>$null)
+$major = if ($sdkVersion -match '^(\d+)\.') { [int]$Matches[1] } else { 9 }
+
+if ($major -ge 10) {
+ # .NET 10+: --coverage is a first-class dotnet test flag
+ dotnet test "" `
+ --results-directory $rawDir `
+ --coverage `
+ --coverage-output-format cobertura `
+ --coverage-output $rawDir
+} else {
+ # .NET 9: pass Microsoft.Testing.Platform arguments after the -- separator
+ dotnet test "" `
+ --results-directory $rawDir `
+ -- --coverage --coverage-output-format cobertura --coverage-output $rawDir
+}
+```
+
+**Mixed-project mode** (`Microsoft.Testing.Extensions.CodeCoverage` + `coverlet.collector` in the same solution):
+
+```powershell
+$rawDir = Join-Path "" "raw"
+$sdkVersion = (dotnet --version 2>$null)
+$major = if ($sdkVersion -match '^(\d+)\.') { [int]$Matches[1] } else { 9 }
+
+foreach ($tp in $testProjects) {
+ $hasMsCodeCov = Select-String -Path $tp.FullName -Pattern 'Microsoft\.Testing\.Extensions\.CodeCoverage' -Quiet
+ if ($hasMsCodeCov) {
+ if ($major -ge 10) {
+ dotnet test $tp.FullName --results-directory $rawDir --coverage --coverage-output-format cobertura --coverage-output $rawDir
+ } else {
+ dotnet test $tp.FullName --results-directory $rawDir -- --coverage --coverage-output-format cobertura --coverage-output $rawDir
+ }
+ } else {
+ dotnet test $tp.FullName `
+ --collect:"XPlat Code Coverage" `
+ --results-directory $rawDir `
+ -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura `
+ -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Include="[*]*" `
+ -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Exclude="[*.Tests]*,[*.Test]*,[*Tests]*,[*Test]*,[*.Specs]*,[*.Testing]*" `
+ -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.SkipAutoProps=true
+ }
+}
+```
+
+Exit code handling:
+
+- **0** — all tests passed, coverage collected
+- **1** — some tests failed (coverage still collected — proceed with a warning)
+- **Other** — build failure; stop and report the error
+
+After the run, locate coverage files:
+
+```powershell
+$coberturaFiles = Get-ChildItem -Path (Join-Path "" "raw") -Filter "coverage.cobertura.xml" -Recurse
+Write-Host "COBERTURA_COUNT:$($coberturaFiles.Count)"
+$coberturaFiles | ForEach-Object { Write-Host "COBERTURA:$($_.FullName)" }
+$vsCovFiles = Get-ChildItem -Path (Join-Path "" "raw") -Filter "*.coverage" -Recurse -ErrorAction SilentlyContinue
+if ($vsCovFiles) { Write-Host "VS_BINARY_COVERAGE:$($vsCovFiles.Count)" }
+```
+
+If `COBERTURA_COUNT` is 0:
+
+- If `VS_BINARY_COVERAGE` > 0: warn the user — *"Found .coverage files (VS binary format) but no Cobertura XML. These were likely produced by Visual Studio's built-in collector, which outputs a binary format by default. This skill needs Cobertura XML. Re-running with the detected provider configured for Cobertura output."* Then re-run the appropriate `dotnet test` command above (Coverlet or Microsoft CodeCoverage) with Cobertura format.
+- If no `.coverage` files either: stop and report — *"Coverage files not generated. Ensure `dotnet test` completed successfully and check the build output for errors."*
+
+#### Step 4: Verify or install ReportGenerator (parallel with Step 3)
+
+```powershell
+$rgAvailable = $false
+$rgCommand = Get-Command reportgenerator -ErrorAction SilentlyContinue
+if ($rgCommand) {
+ $rgAvailable = $true
+ Write-Host "RG_INSTALLED:already-present"
+} else {
+ $rgToolPath = Join-Path "" ".tools"
+ dotnet tool install dotnet-reportgenerator-globaltool --tool-path $rgToolPath
+ if ($LASTEXITCODE -eq 0) {
+ $env:PATH = "$rgToolPath$([System.IO.Path]::PathSeparator)$env:PATH"
+ $rgCommand = Get-Command reportgenerator -ErrorAction SilentlyContinue
+ if ($rgCommand) {
+ $rgAvailable = $true
+ Write-Host "RG_INSTALLED:true (tool-path: $rgToolPath)"
+ } else {
+ Write-Host "RG_INSTALLED:false"
+ Write-Host "RG_INSTALL_ERROR:reportgenerator-not-available"
+ }
+ } else {
+ Write-Host "RG_INSTALLED:false"
+ Write-Host "RG_INSTALL_ERROR:reportgenerator-not-available"
+ }
+}
+Write-Host "RG_AVAILABLE:$rgAvailable"
+```
+
+If installation fails (no internet), keep `RG_AVAILABLE:false` and continue with raw Cobertura XML parsing + script-based analysis in Step 6. Skip HTML/Text/CSV report generation in Step 5 and note this in the output.
+
+### Phase 3 — Analysis (Steps 5 and 6 run in parallel)
+
+Once Phase 2 completes (coverage files available, ReportGenerator ready), start Steps 5 and 6 simultaneously — both read from the same Cobertura XML and produce independent outputs.
+
+#### Step 5: Generate reports with ReportGenerator (parallel with Step 6)
+
+```powershell
+$reportsDir = Join-Path "" "reports"
+if ($rgAvailable) {
+ reportgenerator `
+ -reports:"" `
+ -targetdir:$reportsDir `
+ -reporttypes:"Html;TextSummary;MarkdownSummaryGithub;CsvSummary" `
+ -title:"Coverage Report" `
+ -tag:"coverage-analysis-skill"
+
+ Get-Content (Join-Path $reportsDir "Summary.txt") -ErrorAction SilentlyContinue
+} else {
+ Write-Host "REPORTGENERATOR_SKIPPED:true"
+}
+```
+
+#### Step 6: Calculate CRAP scores using the bundled script (parallel with Step 5)
+
+Run `scripts/Compute-CrapScores.ps1` (co-located with this SKILL.md). It reads all Cobertura XML files, applies `CRAP(m) = comp² × (1 − cov)³ + comp` per method, and returns the top-N hotspots as JSON.
+
+To locate the script: find the directory containing this skill's `SKILL.md` file (the skill loader provides this context), then resolve `scripts/Compute-CrapScores.ps1` relative to it. If the script path cannot be determined, calculate CRAP scores inline using the formula below.
+
+```powershell
+& "/scripts/Compute-CrapScores.ps1" `
+ -CoberturaPath @() `
+ -CrapThreshold `
+ -TopN
+```
+
+Script outputs: `TOTAL_METHODS:`, `FLAGGED_METHODS:`, `HOTSPOTS:` (top-N sorted by CrapScore descending).
+
+Also run `scripts/Extract-MethodCoverage.ps1` to get per-method coverage data for the Coverage Gaps table:
+
+```powershell
+& "/scripts/Extract-MethodCoverage.ps1" `
+ -CoberturaPath @() `
+ -CoverageThreshold `
+ -BranchThreshold `
+ -Filter below-threshold
+```
+
+Script outputs: JSON array of methods below the coverage threshold, sorted by coverage ascending. Use this data to populate the Coverage Gaps by File table in the report.
+
+### Phase 4 — Output (sequential)
+
+#### Step 7: Build the output report
+
+Compose the analysis and save it to `TestResults/coverage-analysis/coverage-analysis.md` under the test project directory. Print the full report to the console.
+
+After saving the file, automatically open `TestResults/coverage-analysis/coverage-analysis.md` in the editor so the user can review it immediately.
+
+- In editor-hosted environments (VS Code, Visual Studio, or other IDE hosts): open the file in the current host session/editor context after writing it.
+- Do not launch a different app instance via hardcoded shell commands (for example `code`, `start`, or platform-specific open commands) unless the host has no native open-file mechanism.
+- In CLI or non-editor environments: print the absolute report path and clearly state that the file was generated.
+
+Do not ask for confirmation before opening the report file.
+
+Use `references/output-format.md` verbatim for all fixed headings, table structures, symbols, and emoji in the generated report. Use `references/guidelines.md` for execution constraints, prioritization rules, and style.
+
+## Validation
+
+- Verify that at least one `coverage.cobertura.xml` file was generated after `dotnet test`
+- Confirm `TestResults/coverage-analysis/coverage-analysis.md` was written and contains data
+- Spot-check one method's CRAP score: `comp² × (1 − cov)³ + comp` — a method with 100% coverage should have CRAP = complexity
+- If ReportGenerator ran, verify `TestResults/coverage-analysis/reports/index.html` exists
+
+## Common Pitfalls
+
+- **No Cobertura XML generated** — the test project may lack a coverage provider. The skill auto-adds one, but if `dotnet add package` fails (offline/proxy), coverage collection silently produces nothing. Check for `.coverage` binary files as a fallback indicator.
+- **Test failures (exit code 1)** — coverage is still collected from passing tests. Do not abort; proceed with partial data and note the failures in the summary.
+- **ReportGenerator install failure** — if `dotnet tool install` fails (no internet), skip HTML/CSV report generation and continue with raw Cobertura XML analysis + script-based CRAP scores. Note the skip in the report.
+- **Method name mismatches in Cobertura** — async methods, lambdas, and local functions may have compiler-generated names. The scripts use the Cobertura method name/signature directly; verify against source if results look unexpected.
+- **Mixed coverage providers** — when a solution contains both Coverlet and Microsoft CodeCoverage projects, the skill runs per-project to avoid dual-provider conflicts. This is slower but correct.
diff --git a/.agents/skills/coverage-analysis/references/guidelines.md b/.agents/skills/coverage-analysis/references/guidelines.md
new file mode 100644
index 00000000..cb382248
--- /dev/null
+++ b/.agents/skills/coverage-analysis/references/guidelines.md
@@ -0,0 +1,59 @@
+# Guidelines
+
+**Don't modify source or production code.** The only permitted project file modifications are adding a coverage provider package to test projects that currently have no provider: `coverlet.collector` (coverlet/mixed modes) or `Microsoft.Testing.Extensions.CodeCoverage` (ms-codecoverage mode). Do not add a second provider to projects that already have one. Always log package additions and document revert commands in the report. Write all other output to `TestResults/coverage-analysis/` under the test project directory.
+
+**Always show and open the generated markdown report.** After writing `TestResults/coverage-analysis/coverage-analysis.md`, print its contents to the console and open the file in the current host editor/session automatically (when an editor is available).
+
+**Don't generate new tests during the initial analysis run.** This skill surfaces where tests are needed. Test generation is a separate follow-up step outside the scope of this skill.
+
+**Use inline `dotnet test` arguments, not runsettings files.** Runsettings files require the developer to already know what they're doing — the whole point of this skill is that they shouldn't have to. Inline data collector args produce the same result with zero configuration.
+
+**Show the risk hotspots table even when all thresholds pass.** A project at 90% line coverage can still have a method with cyclomatic complexity 20 and 0% branch coverage. The thresholds measure averages; the hotspot table finds outliers. Don't hide it just because the summary looks green.
+
+**Always compute and surface CRAP scores.** The Risk Hotspots table is mandatory in every analysis output, whether analyzing pre-existing data, freshly collected data, or diagnosing a plateau. Never skip CRAP score computation — it is the primary differentiator between this skill and raw `dotnet test` coverage output.
+
+**Continue past test failures (exit code 1).** If some tests fail, coverage is still collected from the passing tests — partial data is better than no data. Note the failures in the summary and proceed. Aborting would leave the developer with nothing actionable.
+
+**Run `dotnet test` only once per entry point during normal flow.** When a solution is found, run it once against the solution. When no solution is found, run it once per test project. A single recovery rerun is allowed only if the first run produced no Cobertura XML and only `.coverage` binary output.
+
+**CRAP threshold of 30 is the default for a reason.** Scores above 30 are widely cited (by the original researchers) as "needs immediate attention." Scores between 15 and 30 are moderate — flag them in the table but don't make them sound catastrophic. Scores ≤ 5 are generally fine.
+
+**Priority assignment for coverage gaps:**
+
+- **HIGH** — file has both a CRAP score above threshold AND coverage below threshold (the double failure is what makes it urgent)
+- **MED** — coverage below threshold OR CRAP score above threshold, but not both
+- **LOW** — coverage below threshold with all methods having complexity ≤ 2 (trivial code — missing coverage here is unlikely to hide real bugs)
+
+---
+
+## Coverage Intelligence — Going Beyond the Numbers
+
+**Prioritize uncovered code that is** complex (cyclomatic complexity > 5), on critical paths (auth, payment, data access, error handling), or changed frequently. **Deprioritize** trivial getters (complexity 1–2), generated files (EF migrations, `*.Designer.cs`, `*.g.cs`), and DI/configuration glue code.
+
+**Coverage plateau diagnosis** — if coverage has stopped increasing, check for: `[Exclude]` attributes hiding large code sections, tests that execute code but assert nothing (inflated coverage without verification), or integration code that needs external dependencies (databases, file system).
+
+**AI-generated test quality** — coverage delta alone is insufficient. Flag methods where CRAP score is still above threshold after coverage increased (tests may be happy-path only), and methods covered by a single test with no branch variation.
+
+---
+
+## Style
+
+- **Keep risk hotspots prominent and immediately after the summary section** — developers should find the highest-risk methods quickly
+- **Quantify recommendations** — "adding 3 tests for `ProcessOrder` would cut the CRAP score from 48 to ~6"
+- **Be direct** — skip preamble, get to the table
+- **Emoji for visual scanning in generated output** (defined in `references/output-format.md`):
+
+ | Symbol | Meaning |
+ |--------|---------|
+ | 🔥 | hotspots |
+ | 📋 | gaps |
+ | 💡 | recommendations |
+ | 📁 | reports |
+ | ✅ | passing |
+ | ❌ | failing |
+ | ⚠️ | warning |
+ | 🔴 | HIGH priority |
+ | 🟡 | MED priority |
+ | 🟢 | LOW priority |
+
+- **Always use Unicode emoji in generated output** — never shortcodes like `:x:` or `:fire:`
diff --git a/.agents/skills/coverage-analysis/references/output-format.md b/.agents/skills/coverage-analysis/references/output-format.md
new file mode 100644
index 00000000..c768806e
--- /dev/null
+++ b/.agents/skills/coverage-analysis/references/output-format.md
@@ -0,0 +1,83 @@
+# Output Format
+
+Copy the template below **verbatim** for all fixed elements (headings, table headers, emoji, symbols). Only replace `` values with actual data. Do not substitute emoji with text equivalents, do not change `·` to `-`, do not change `×` to `x`, and do not drop section emoji prefixes.
+
+```markdown
+# Coverage Analysis -
+
+| Metric | Value |
+|--------|-------|
+| **Date** | |
+| **Line Coverage** | % |
+| **Branch Coverage** | % |
+| **Risk Hotspots** | (CRAP > ) |
+| **Tests** | passed · failed |
+
+## Summary
+
+| Metric | Value | Threshold | Status |
+|--------|-------|-----------|--------|
+| **Line Coverage** | % | % | ✅ / ❌ |
+| **Branch Coverage** | % | % | ✅ / ❌ |
+| **Methods Analyzed** | | — | — |
+| **Risk Hotspots** | | 0 | ✅ / ⚠️ |
+| **Test Result** | | — | ✅ / ⚠️ |
+
+> Coverage collected from ** of test project(s)**.
+> Reports saved to: `/reports/`
+
+If any coverage provider package was added to test projects, include this note after the summary:
+
+> ℹ️ **Coverage provider package updates**
+> - `coverlet.collector` added to `` project(s): ``, ``
+> - `Microsoft.Testing.Extensions.CodeCoverage` added to `` project(s): ``
+>
+> To revert: `git checkout -- `
+
+If all test projects already had a coverage provider, omit this note.
+
+---
+
+## 🔥 Risk Hotspots (Top by CRAP Score)
+
+Methods flagged as high-risk: complex code with low test coverage that is dangerous to change.
+
+| Rank | Method | Class | File | Complexity | Coverage | CRAP Score |
+|------|--------|-------|------|-----------|---------|-----------|
+| 1 | `` | `` | `` | | % | **** |
+| … | … | … | … | … | … | … |
+
+> **CRAP Score** = `Complexity² × (1 − Coverage)³ + Complexity`.
+> Scores above are flagged. A score ≤ 5 is considered safe.
+
+---
+
+## 📋 Coverage Gaps by File
+
+Files below the line or branch coverage threshold, ordered by uncovered lines descending:
+
+| File | Line Coverage | Branch Coverage | Uncovered Lines | Priority |
+|------|--------------|----------------|----------------|---------|
+| `` | % | % | | 🔴 HIGH / 🟡 MED / 🟢 LOW |
+| … | … | … | … | … |
+
+---
+
+## 💡 Recommendations
+
+1. **Write tests for the top risk hotspot first** — `` in `` has a CRAP score of (complexity , % coverage). Reducing it to 80% coverage would drop the score to ~.
+2. **Focus on ``** — uncovered lines, below threshold.
+3. ****
+
+---
+
+## 📁 Reports
+
+| Report | Path |
+|--------|------|
+| HTML (browsable) | `/reports/index.html` |
+| Text summary | `/reports/Summary.txt` |
+| GitHub markdown | `/reports/SummaryGithub.md` |
+| CSV data | `/reports/Summary.csv` |
+| Raw data | `/raw/` |
+```
diff --git a/.agents/skills/coverage-analysis/scripts/Compute-CrapScores.ps1 b/.agents/skills/coverage-analysis/scripts/Compute-CrapScores.ps1
new file mode 100644
index 00000000..a4b1799f
--- /dev/null
+++ b/.agents/skills/coverage-analysis/scripts/Compute-CrapScores.ps1
@@ -0,0 +1,113 @@
+# Compute-CrapScores.ps1
+#
+# Reads a Cobertura XML coverage file and calculates CRAP scores per method.
+# Uses Alberto Savoia's original CRAP formula:
+# CRAP(m) = comp(m)^2 * (1 - cov(m))^3 + comp(m)
+#
+# Usage:
+# .\Compute-CrapScores.ps1 -CoberturaPath ,,... [-CrapThreshold ] [-TopN ]
+#
+# Outputs:
+# - Hotspot rows (top N by CRAP score) as a JSON array to stdout (HOTSPOTS:)
+# - Summary counts as TOTAL_METHODS: and FLAGGED_METHODS:
+
+param(
+ [Parameter(Mandatory)][string[]]$CoberturaPath,
+ [int]$CrapThreshold = 30,
+ [int]$TopN = 10
+)
+
+# Merge methods across all Cobertura files using a stable key (Class|Method|Signature|File).
+# Line hits are accumulated so a line is counted as covered if any test project covered it.
+$methodMap = @{}
+
+foreach ($filePath in $CoberturaPath) {
+ if (-not (Test-Path $filePath)) {
+ Write-Error "Cobertura file not found: $filePath"
+ exit 2
+ }
+
+ try {
+ [xml]$cobertura = Get-Content $filePath -Encoding UTF8 -ErrorAction Stop
+ } catch {
+ Write-Error "Failed to parse Cobertura XML: $filePath. $_"
+ exit 2
+ }
+
+ foreach ($package in $cobertura.coverage.packages.package) {
+ foreach ($class in $package.classes.class) {
+ $className = $class.name
+ $fileName = $class.filename
+
+ foreach ($method in $class.methods.method) {
+ $key = "$className|$($method.name)|$($method.signature)|$fileName"
+
+ # Cyclomatic complexity is stored as an XML attribute in Cobertura format
+ $complexity = if ($null -ne $method.complexity) { [int]$method.complexity } else { 1 }
+ if ($complexity -lt 1) { $complexity = 1 }
+
+ if (-not $methodMap.ContainsKey($key)) {
+ $methodMap[$key] = @{
+ Class = $className
+ Method = $method.name
+ Signature = $method.signature
+ File = $fileName
+ Complexity = $complexity
+ LineHits = @{}
+ }
+ }
+
+ # Accumulate hit counts per line number across files
+ foreach ($line in $method.lines.line) {
+ $lineNo = $line.number
+ $hits = [int]$line.hits
+ if ($methodMap[$key].LineHits.ContainsKey($lineNo)) {
+ $methodMap[$key].LineHits[$lineNo] += $hits
+ } else {
+ $methodMap[$key].LineHits[$lineNo] = $hits
+ }
+ }
+ }
+ }
+ }
+}
+
+$results = [System.Collections.Generic.List[PSCustomObject]]::new()
+
+foreach ($entry in $methodMap.Values) {
+ $totalLines = $entry.LineHits.Count
+ $coveredLines = ($entry.LineHits.Values | Where-Object { $_ -gt 0 } | Measure-Object).Count
+ $lineCoverage = if ($totalLines -gt 0) { $coveredLines / $totalLines } else { 0.0 }
+
+ $complexity = $entry.Complexity
+
+ # Alberto Savoia's CRAP formula: comp^2 * (1 - cov)^3 + comp
+ # The cubic exponent on (1-cov) sharply penalizes low coverage:
+ # at 0% coverage the risk multiplier is 1.0; at 50% it drops to 0.125.
+ # Higher scores = more complex AND less covered = riskier to change
+ $uncovered = 1.0 - $lineCoverage
+ $crapScore = [Math]::Round(($complexity * $complexity * [Math]::Pow($uncovered, 3)) + $complexity, 2)
+
+ $results.Add([PSCustomObject]@{
+ Class = $entry.Class
+ Method = $entry.Method
+ Signature = $entry.Signature
+ File = $entry.File
+ TotalLines = $totalLines
+ CoveredLines = $coveredLines
+ LineCoverage = [Math]::Round($lineCoverage * 100, 1)
+ Complexity = $complexity
+ CrapScore = $crapScore
+ })
+}
+
+$hotspots = $results | Sort-Object CrapScore -Descending | Select-Object -First $TopN
+$flagged = $results | Where-Object { $_.CrapScore -gt $CrapThreshold }
+
+Write-Host "TOTAL_METHODS:$($results.Count)"
+Write-Host "FLAGGED_METHODS:$($flagged.Count)"
+if ($hotspots) {
+ Write-Output "HOTSPOTS:$(@($hotspots) | ConvertTo-Json -Compress)"
+} else {
+ Write-Output "HOTSPOTS:[]"
+}
diff --git a/.agents/skills/coverage-analysis/scripts/Extract-MethodCoverage.ps1 b/.agents/skills/coverage-analysis/scripts/Extract-MethodCoverage.ps1
new file mode 100644
index 00000000..999a8273
--- /dev/null
+++ b/.agents/skills/coverage-analysis/scripts/Extract-MethodCoverage.ps1
@@ -0,0 +1,193 @@
+param(
+ [Parameter(Mandatory=$true)]
+ [string[]]$CoberturaPath,
+
+ [Parameter(Mandatory=$false)]
+ [int]$CoverageThreshold = 80,
+
+ [Parameter(Mandatory=$false)]
+ [int]$BranchThreshold = 70,
+
+ [Parameter(Mandatory=$false)]
+ [ValidateSet('uncovered', 'below-threshold', 'all')]
+ [string]$Filter = 'all'
+)
+
+<#
+.SYNOPSIS
+Extract method-level coverage from Cobertura XML and output as JSON.
+
+.DESCRIPTION
+Parses one or more Cobertura code coverage XML files and extracts per-method coverage metrics:
+- Method name and class
+- Line coverage percentage
+- Branch coverage percentage
+- Lines covered / total
+- Branches covered / total
+- Complexity (if available)
+
+When multiple files are provided, line hits are merged across files so a line is counted
+as covered if any test project covered it.
+
+Filters by coverage status (uncovered, below threshold, or all).
+Output is JSON for easy post-processing into tables, CSV, or other formats.
+
+.PARAMETER CoberturaPath
+Path(s) to Cobertura coverage.cobertura.xml file(s). Accepts multiple paths for multi-test-project merging.
+
+.PARAMETER CoverageThreshold
+Minimum acceptable line coverage percentage. Methods below this threshold are flagged (default: 80).
+
+.PARAMETER BranchThreshold
+Minimum acceptable branch coverage percentage for methods that contain branches (default: 70).
+
+.PARAMETER Filter
+Which methods to include:
+ 'uncovered' - methods with 0% coverage only
+ 'below-threshold' - methods with line coverage < CoverageThreshold OR branch coverage < BranchThreshold (for methods with branches)
+ 'all' - all methods (default)
+
+.EXAMPLE
+PS> & .\Extract-MethodCoverage.ps1 -CoberturaPath "coverage.cobertura.xml" -CoverageThreshold 80 -BranchThreshold 70 -Filter uncovered
+Outputs a JSON array of uncovered methods.
+
+.EXAMPLE
+PS> & .\Extract-MethodCoverage.ps1 -CoberturaPath @("tests1/coverage.cobertura.xml","tests2/coverage.cobertura.xml")
+Merges coverage from multiple test projects and outputs combined method-level metrics.
+
+.OUTPUTS
+Writes JSON array to stdout.
+Sets exit code 0 on success, 2 on missing/invalid file.
+#>
+
+foreach ($p in $CoberturaPath) {
+ if (-not (Test-Path $p)) {
+ Write-Error "Cobertura file not found: $p"
+ exit 2
+ }
+}
+
+# Merge methods across all Cobertura files using a stable key (Class|Method|Signature|File).
+# Line hits and branch data are accumulated so coverage reflects all test projects.
+$methodMap = @{}
+
+foreach ($p in $CoberturaPath) {
+ try {
+ [xml]$xml = Get-Content $p -Encoding UTF8 -ErrorAction Stop
+ } catch {
+ Write-Error "Failed to parse Cobertura XML: $_"
+ exit 2
+ }
+
+ foreach ($package in $xml.coverage.packages.package) {
+ foreach ($class in $package.classes.class) {
+ $className = $class.name
+ $classFilename = $class.filename
+
+ foreach ($method in $class.methods.method) {
+ $key = "$className|$($method.name)|$($method.signature)|$classFilename"
+
+ if (-not $methodMap.ContainsKey($key)) {
+ $complexity = if ($null -ne $method.complexity) { [int]$method.complexity } else { 1 }
+ if ($complexity -lt 1) { $complexity = 1 }
+ $methodMap[$key] = @{
+ Class = $className
+ Method = $method.name
+ Signature = $method.signature
+ File = $classFilename
+ Complexity = $complexity
+ LineHits = @{}
+ BranchData = @{}
+ }
+ }
+
+ # Accumulate line hits across files
+ foreach ($line in $method.lines.line) {
+ $lineNo = $line.number
+ $hits = [int]$line.hits
+ if ($methodMap[$key].LineHits.ContainsKey($lineNo)) {
+ $methodMap[$key].LineHits[$lineNo] += $hits
+ } else {
+ $methodMap[$key].LineHits[$lineNo] = $hits
+ }
+
+ # Accumulate branch data
+ if ($line.branch -eq 'true' -and $line.'condition-coverage') {
+ if ($line.'condition-coverage' -match '\((\d+)/(\d+)\)') {
+ $covered = [int]$Matches[1]
+ $total = [int]$Matches[2]
+ if ($methodMap[$key].BranchData.ContainsKey($lineNo)) {
+ # Merge branch coverage across files by accumulating covered branches (capped at total)
+ $existingCovered = $methodMap[$key].BranchData[$lineNo].Covered
+ $existingTotal = $methodMap[$key].BranchData[$lineNo].Total
+ if ($existingTotal -ne $total) {
+ Write-Warning ("Branch total mismatch for {0} at line {1}: {2} vs {3}" -f $key, $lineNo, $existingTotal, $total)
+ }
+ $mergedTotal = [Math]::Max($existingTotal, $total)
+ $mergedCovered = [Math]::Min($existingCovered + $covered, $mergedTotal)
+ $methodMap[$key].BranchData[$lineNo] = @{ Covered = $mergedCovered; Total = $mergedTotal }
+ } else {
+ $methodMap[$key].BranchData[$lineNo] = @{ Covered = $covered; Total = $total }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+$methods = [System.Collections.Generic.List[PSCustomObject]]::new()
+
+foreach ($entry in $methodMap.Values) {
+ $totalLines = $entry.LineHits.Count
+ $coveredLineCount = ($entry.LineHits.Values | Where-Object { $_ -gt 0 } | Measure-Object).Count
+ $lineCoveragePercent = if ($totalLines -gt 0) { [math]::Round(($coveredLineCount / $totalLines) * 100, 1) } else { 0 }
+
+ $branchesTotal = 0
+ $branchesCovered = 0
+ foreach ($bd in $entry.BranchData.Values) {
+ $branchesCovered += $bd.Covered
+ $branchesTotal += $bd.Total
+ }
+ $branchCoveragePercent = if ($branchesTotal -gt 0) { [math]::Round(($branchesCovered / $branchesTotal) * 100, 1) } else { 0 }
+
+ # Apply filter
+ if ($Filter -eq 'uncovered' -and $lineCoveragePercent -gt 0) { continue }
+ if ($Filter -eq 'below-threshold') {
+ $lineOk = $lineCoveragePercent -ge $CoverageThreshold
+ $branchOk = ($branchesTotal -eq 0) -or ($branchCoveragePercent -ge $BranchThreshold)
+ if ($lineOk -and $branchOk) { continue }
+ }
+
+ $methods.Add([PSCustomObject]@{
+ Class = $entry.Class
+ Method = $entry.Method
+ Signature = $entry.Signature
+ File = $entry.File
+ Complexity = $entry.Complexity
+ LineCoverage = $lineCoveragePercent
+ BranchCoverage = $branchCoveragePercent
+ CoveredLines = $coveredLineCount
+ TotalLines = $totalLines
+ UncoveredLines = ($totalLines - $coveredLineCount)
+ CoveredBranches = $branchesCovered
+ TotalBranches = $branchesTotal
+ })
+}
+# Sort by uncovered lines descending, then by line coverage ascending
+$sorted = $methods | Sort-Object -Property @{Expression='UncoveredLines';Descending=$true}, @{Expression='LineCoverage';Descending=$false}, Class, Method
+
+# Output as JSON (empty array guard for zero results)
+if ($sorted.Count -eq 0) {
+ Write-Output "[]"
+} else {
+ $json = @($sorted) | ConvertTo-Json
+ Write-Output $json
+}
+
+# Summary
+Write-Host "METHODS_FILTERED:$($methods.Count)" -ForegroundColor Green
+$uncovered = $methods | Where-Object { $_.LineCoverage -eq 0 } | Measure-Object | Select-Object -ExpandProperty Count
+Write-Host "UNCOVERED_METHODS:$uncovered" -ForegroundColor $(if ($uncovered -gt 0) { 'Yellow' } else { 'Green' })
+exit 0
diff --git a/.agents/skills/directory-build-organization/SKILL.md b/.agents/skills/directory-build-organization/SKILL.md
new file mode 100644
index 00000000..ab81711a
--- /dev/null
+++ b/.agents/skills/directory-build-organization/SKILL.md
@@ -0,0 +1,180 @@
+---
+description: 'Guide for organizing MSBuild infrastructure with Directory.Build.props, Directory.Build.targets, Directory.Packages.props, and Directory.Build.rsp. Only activate in MSBuild/.NET build context. USE FOR: structuring multi-project repos, centralizing build settings, implementing NuGet Central Package Management (CPM) with ManagePackageVersionsCentrally, consolidating duplicated properties across .csproj files, setting up multi-level Directory.Build hierarchy with GetPathOfFileAbove, understanding evaluation order (Directory.Build.props → SDK .props → .csproj → SDK .targets → Directory.Build.targets). Critical pitfall: $(TargetFramework) conditions in .props silently fail for single-targeting projects — must use .targets. DO NOT USE FOR: non-MSBuild build systems, migrating legacy projects to SDK-style (use msbuild-modernization), single-project solutions with no shared settings. INVOKES: no tools — pure knowledge skill.'
+metadata:
+ github-path: plugins/dotnet-msbuild/skills/directory-build-organization
+ github-pinned: v1.0.0
+ github-ref: refs/tags/v1.0.0
+ github-repo: https://github.com/dotnet/skills
+ github-tree-sha: 4416b9c049af5ab708f2298a19a3973e345f09f8
+name: directory-build-organization
+---
+# Organizing Build Infrastructure with Directory.Build Files
+
+## Directory.Build.props vs Directory.Build.targets
+
+Understanding which file to use is critical. They differ in **when** they are imported during evaluation:
+
+**Evaluation order:**
+
+```
+Directory.Build.props → SDK .props → YourProject.csproj → SDK .targets → Directory.Build.targets
+```
+
+| Use `.props` for | Use `.targets` for |
+|---|---|
+| Setting property defaults | Custom build targets |
+| Common item definitions | Late-bound property overrides |
+| Properties projects can override | Post-build steps |
+| Assembly/package metadata | Conditional logic on final values |
+| Analyzer PackageReferences | Targets that depend on SDK-defined properties |
+
+**Rule of thumb:** Properties and items go in `.props`. Custom targets and late-bound logic go in `.targets`.
+
+Because `.props` is imported before the project file, the project can override any value set there. Because `.targets` is imported after everything, it gets the final say—but projects cannot override `.targets` values.
+
+### ⚠️ Critical: TargetFramework Availability in .props vs .targets
+
+**Property conditions on `$(TargetFramework)` in `.props` files silently fail for single-targeting projects** — the property is empty during `.props` evaluation. Move TFM-conditional properties to `.targets` instead. ItemGroup and Target conditions are not affected.
+
+See [targetframework-props-pitfall.md](references/targetframework-props-pitfall.md) for the full explanation.
+
+## Directory.Build.props
+
+Good candidates: language settings, assembly/package metadata, build warnings, code analysis, common analyzers.
+
+```xml
+
+
+ latest
+ enable
+ enable
+ true
+ true
+ Contoso
+ Contoso Engineering
+
+
+```
+
+**Do NOT put here:** project-specific TFMs, project-specific PackageReferences, targets/build logic, or properties depending on SDK-defined values (not available during `.props` evaluation).
+
+## Directory.Build.targets
+
+Good candidates: custom build targets, late-bound property overrides (values depending on SDK properties), post-build validation.
+
+```xml
+
+
+
+
+
+
+
+ $(OutputPath)$(AssemblyName).xml
+
+
+```
+
+## Directory.Packages.props (Central Package Management)
+
+Central Package Management (CPM) provides a single source of truth for all NuGet package versions. See [https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management](https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management) for details.
+
+**Enable CPM in `Directory.Packages.props` at the repo root:**
+
+```xml
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+## Directory.Build.rsp
+
+Contains default MSBuild CLI arguments applied to all builds under the directory tree.
+
+**Example `Directory.Build.rsp`:**
+
+```
+/maxcpucount
+/nodeReuse:false
+/consoleLoggerParameters:Summary;ForceNoAlign
+/warnAsMessage:MSB3277
+```
+
+- Works with both `msbuild` and `dotnet` CLI in modern .NET versions
+- Great for enforcing consistent CI and local build flags
+- Each argument goes on its own line
+
+## Multi-level Directory.Build Files
+
+MSBuild only auto-imports the **first** `Directory.Build.props` (or `.targets`) it finds walking up from the project directory. To chain multiple levels, explicitly import the parent at the **top** of the inner file. See [multi-level-examples](references/multi-level-examples.md) for full file examples.
+
+```xml
+
+
+
+
+
+```
+
+**Example layout:**
+
+```
+repo/
+ Directory.Build.props ← repo-wide (lang version, company info, analyzers)
+ Directory.Build.targets ← repo-wide targets
+ Directory.Packages.props ← central package versions
+ src/
+ Directory.Build.props ← src-specific (imports repo-level, sets IsPackable=true)
+ test/
+ Directory.Build.props ← test-specific (imports repo-level, sets IsPackable=false, adds test packages)
+```
+
+## Artifact Output Layout (.NET 8+)
+
+Set `$(MSBuildThisFileDirectory)artifacts` in `Directory.Build.props` to automatically produce project-name-separated `bin/`, `obj/`, and `publish/` directories under a single `artifacts/` folder, avoiding bin/obj clashes by default. See [common-patterns](references/common-patterns.md) for the directory layout and additional patterns (conditional settings by project type, post-pack validation).
+
+## Workflow: Organizing Build Infrastructure
+
+1. **Audit all `.csproj` files** — Catalog every ``, ``, and custom `` across the solution. Note which settings repeat and which are project-specific.
+2. **Create root `Directory.Build.props`** — Move shared property defaults (LangVersion, Nullable, TreatWarningsAsErrors, metadata) here. These are imported before the project file so projects can override them.
+3. **Create root `Directory.Build.targets`** — Move custom build targets, post-build validation, and any properties that depend on SDK-defined values (e.g., `OutputPath`, `TargetFramework` for single-targeting projects) here. These are imported after the SDK so all properties are available.
+4. **Create `Directory.Packages.props`** — Enable Central Package Management (`ManagePackageVersionsCentrally`), list all `PackageVersion` entries, and remove `Version=` from `PackageReference` items in `.csproj` files.
+5. **Set up multi-level hierarchy** — Create inner `Directory.Build.props` files for `src/` and `test/` folders with distinct settings. Use `GetPathOfFileAbove` to chain to the parent.
+6. **Simplify `.csproj` files** — Remove all centralized properties, version attributes, and duplicated targets. Each project should only contain what is unique to it.
+7. **Validate** — Run `dotnet restore && dotnet build` and verify no regressions. Use `dotnet msbuild -pp:output.xml` to inspect the final merged view if needed.
+
+## Troubleshooting
+
+| Problem | Cause | Fix |
+|---|---|---|
+| `Directory.Build.props` isn't picked up | File name casing wrong (exact match required on Linux/macOS) | Verify exact casing: `Directory.Build.props` (capital D, B) |
+| Properties from `.props` are ignored by projects | Project sets the same property after the import | Move the property to `Directory.Build.targets` to set it after the project |
+| Multi-level import doesn't work | Missing `GetPathOfFileAbove` import in inner file | Add the `` element at the top of the inner file (see Multi-level section) |
+| Properties using SDK values are empty in `.props` | SDK properties aren't defined yet during `.props` evaluation | Move to `.targets` which is imported after the SDK |
+| `Directory.Packages.props` not found | File not at repo root or not named exactly | Must be named `Directory.Packages.props` and at or above the project directory |
+| Property condition on `$(TargetFramework)` doesn't match in `.props` | `TargetFramework` isn't set yet for single-targeting projects during `.props` evaluation | Move property to `.targets`, or use ItemGroup/Target conditions instead (which evaluate late) |
+
+**Diagnosis:** Use the preprocessed project output to see all imports and final property values:
+
+```bash
+dotnet msbuild -pp:output.xml MyProject.csproj
+```
+
+This expands all imports inline so you can see exactly where each property is set and what the final evaluated value is.
diff --git a/.agents/skills/directory-build-organization/references/common-patterns.md b/.agents/skills/directory-build-organization/references/common-patterns.md
new file mode 100644
index 00000000..1b86b4cf
--- /dev/null
+++ b/.agents/skills/directory-build-organization/references/common-patterns.md
@@ -0,0 +1,56 @@
+# Common Directory.Build Patterns
+
+## Conditional Settings by Project Type
+
+Detect test projects by naming convention in `Directory.Build.props`:
+
+```xml
+
+ false
+ true
+
+```
+
+Use `Directory.Build.targets` for conditions on SDK-defined properties like `OutputType`:
+
+```xml
+
+ false
+
+
+
+ true
+
+```
+
+## Post-Build Validation
+
+Validate that `Pack` produced the expected output:
+
+```xml
+
+
+
+```
+
+## Artifact Output Layout (.NET 8+)
+
+Setting `ArtifactsPath` in `Directory.Build.props` produces this structure:
+
+```
+artifacts/
+ bin/
+ MyLib/
+ debug/
+ release/
+ MyApp/
+ debug/
+ release/
+ obj/
+ MyLib/
+ MyApp/
+ publish/
+ MyApp/
+```
diff --git a/.agents/skills/directory-build-organization/references/multi-level-examples.md b/.agents/skills/directory-build-organization/references/multi-level-examples.md
new file mode 100644
index 00000000..e8ac22f0
--- /dev/null
+++ b/.agents/skills/directory-build-organization/references/multi-level-examples.md
@@ -0,0 +1,164 @@
+# Multi-level Directory.Build Examples
+
+Full file examples for a typical multi-level repo layout.
+
+## Repo-level `Directory.Build.props`
+
+```xml
+
+
+
+ latest
+ enable
+ true
+
+
+
+```
+
+## `src/Directory.Build.props`
+
+```xml
+
+
+
+
+
+ true
+ true
+
+
+
+```
+
+## `test/Directory.Build.props`
+
+```xml
+
+
+
+
+
+ false
+ $(NoWarn);CS1591
+
+
+
+
+
+
+
+
+
+
+```
+
+## Before/After: Centralizing Duplicated Settings
+
+**Before — duplicated settings in every .csproj:**
+
+```xml
+
+
+
+
+ net8.0
+ latest
+ enable
+ enable
+ true
+ Contoso
+ Contoso Engineering
+
+
+
+
+
+
+
+
+
+
+
+
+
+ net8.0
+ latest
+ enable
+ enable
+ true
+ Contoso
+ Contoso Engineering
+
+
+
+
+
+
+
+
+```
+
+**After — centralized with Directory.Build files:**
+
+```xml
+
+
+
+
+ latest
+ enable
+ enable
+ true
+ Contoso
+ Contoso Engineering
+
+
+
+
+
+
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ net8.0
+
+
+
+
+
+
+
+
+
+
+
+
+ net8.0
+
+
+
+
+
+
+
+```
diff --git a/.agents/skills/directory-build-organization/references/targetframework-props-pitfall.md b/.agents/skills/directory-build-organization/references/targetframework-props-pitfall.md
new file mode 100644
index 00000000..c9eeabc2
--- /dev/null
+++ b/.agents/skills/directory-build-organization/references/targetframework-props-pitfall.md
@@ -0,0 +1,54 @@
+# AP-21: Property Conditioned on TargetFramework in .props Files
+
+**Smell**: `` or `` in `Directory.Build.props` or any `.props` file imported before the project body.
+
+**Why it's bad**: `$(TargetFramework)` is NOT reliably available in `Directory.Build.props` or any `.props` file imported before the project body. It is only set that early for multi-targeting projects, which receive `TargetFramework` as a global property from the outer build. Single-targeting projects (using singular ``) set it in the project body, which is evaluated *after* `.props`. This means property conditions on `$(TargetFramework)` in `.props` files silently fail for single-targeting projects — the condition never matches because the property is empty. This applies to both `` and individual `` elements.
+
+For a detailed explanation of MSBuild's evaluation and execution phases, see [Build process overview](https://learn.microsoft.com/en-us/visualstudio/msbuild/build-process-overview).
+
+```xml
+
+
+ $(DefineConstants);MY_FEATURE
+
+
+
+
+ $(DefineConstants);MY_FEATURE
+
+
+
+
+ $(DefineConstants);MY_FEATURE
+
+
+
+
+
+ $(DefineConstants);MY_FEATURE
+
+```
+
+**⚠️ Item and Target conditions are NOT affected.** This restriction applies ONLY to property conditions (`` and ``). Item conditions (``) and Target conditions in `.props` files are SAFE because items and targets evaluate after all properties (including those set in the project body) have been evaluated. This includes `PackageVersion` items in `Directory.Packages.props`, `PackageReference` items in `Directory.Build.props`, and any other item types.
+
+**Do NOT flag the following patterns — they are correct:**
+
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
diff --git a/.agents/skills/microbenchmarking/SKILL.md b/.agents/skills/microbenchmarking/SKILL.md
new file mode 100644
index 00000000..62b2835a
--- /dev/null
+++ b/.agents/skills/microbenchmarking/SKILL.md
@@ -0,0 +1,131 @@
+---
+description: Activate this skill when BenchmarkDotNet (BDN) is involved in the task — creating, running, configuring, or reviewing BDN benchmarks. Also activate when microbenchmarking .NET code would be useful and BenchmarkDotNet is the likely tool. Consider activating when answering a .NET performance question requires measurement and BenchmarkDotNet may be needed. Covers microbenchmark design, BDN configuration and project setup, how to run BDN microbenchmarks efficiently and effectively, and using BDN for side-by-side performance comparisons. Do NOT use for profiling/tracing .NET code (dotnet-trace, PerfView), production telemetry, or load/stress testing (Crank, k6).
+metadata:
+ github-path: plugins/dotnet-diag/skills/microbenchmarking
+ github-pinned: v1.0.0
+ github-ref: refs/tags/v1.0.0
+ github-repo: https://github.com/dotnet/skills
+ github-tree-sha: e4ed4ceb337451c570c17b9f670429f843a24ee4
+name: microbenchmarking
+---
+# Benchmark Authoring Guidelines
+
+BenchmarkDotNet (BDN) is a .NET library for writing and running microbenchmarks. Throughout this skill, "BDN" refers to BenchmarkDotNet.
+
+> **Note:** Evaluations of LLMs writing BenchmarkDotNet benchmarks have revealed common failure patterns caused by outdated assumptions about BDN's behavior — particularly around runtime comparison, job configuration, and execution defaults that have changed in recent versions. The reference files in this skill contain verified, current information. **You MUST read the reference files relevant to the task before writing any code** — your training data likely contains outdated or incorrect BDN patterns.
+
+## Key concepts
+
+- **Job** — describes how to run a benchmark: runtime, iteration counts, launch count, run strategy, and environment settings. Multiple jobs can be configured to run the same benchmarks under different conditions.
+- **Benchmark case** — one method × one parameter combination × one job. The atomic unit BDN measures.
+- **Operation** — the logical unit of work being measured. All BDN output columns (Mean, Error, etc.) report time per operation.
+- **Invocation** — a single call to the benchmark method. By default, 1 invocation = 1 operation. With `OperationsPerInvoke=N`, each invocation counts as N operations.
+- **Iteration** — a timed batch of invocations. BDN measures the total time for all invocations in an iteration, then divides by the total operation count to get per-operation time.
+
+## Benchmarks are comparative instruments
+
+A single benchmark number has limited value — it can confirm the order of magnitude of a measurement, but the exact value changes across machines, operating systems, and runtime configurations. Benchmarks produce the most useful information when compared against something. Before writing benchmarks, identify the **comparison axis** for the current task:
+
+- **Approaches (A vs B)**: comparing alternative implementations side-by-side in the same run.
+- **Runtimes**: comparing the same code across .NET versions (e.g., net8.0 vs net9.0).
+- **Package versions**: comparing different versions of a NuGet dependency.
+- **Builds (before/after)**: comparing a saved DLL of the old code against the current source.
+- **Runtime configuration (GC mode, JIT settings)**: understanding how runtime settings affect performance — compared via multiple jobs in a single run.
+- **Scale (N=100 vs N=1000)**: understanding how performance changes as input size grows.
+- **Hardware/OS**: comparing across different machines or operating systems — requires separate runs on each environment.
+- **Historical measurements**: comparing against measurements recorded at a previous point in time.
+
+BDN can compare the first six axes side-by-side in a single run, but each requires specific CLI flags or configuration that differ from what you might expect — read [references/comparison-strategies.md](references/comparison-strategies.md) for the correct approach for each strategy before configuring a comparison.
+
+## Use cases and benchmark lifecycle
+
+There are four distinct reasons a developer writes a benchmark, and each one changes how the benchmark should be designed and where it should live:
+
+1. **Coverage suite**: Write benchmarks to maximize coverage of real-world usage patterns so that regressions affecting most users are caught. These benchmarks are permanent — they belong in the project's benchmark suite, follow its conventions (directory structure, base classes, naming), and are checked in.
+
+2. **Issue investigation**: Someone has reported a specific performance problem. Write benchmarks to reproduce and diagnose that specific issue. These benchmarks are task-scoped — they persist across the investigation (reproduce → isolate → verify fix) but are not part of the permanent suite.
+
+3. **Change validation**: A developer has a PR or change and wants to understand its performance characteristics before merging. These benchmarks are task-scoped — they persist across the review cycle but are not checked in.
+
+4. **Development feedback**: A developer is actively working on a task and wants to use benchmarks to evaluate approaches and get information early. These benchmarks are task-scoped and throwaway — they persist across the development session but are deleted when the decision is made.
+
+For use case 1, add to the existing benchmark project following its conventions. For use cases 2–4, create a standalone project in a working directory that persists for the task but is clearly not part of the permanent codebase.
+
+For **coverage suite** benchmarks, design from the perspective of real callers — what code patterns use this API, what inputs they pass, and what performance characteristics matter to them. Each permanent benchmark should justify its maintenance cost through real-world relevance. For **temporary benchmarks**, keep the case count intentional — each additional test case costs wall-clock time (read [Cost awareness](#cost-awareness)).
+
+## Cost awareness
+
+Each benchmark case (one method × one parameter combination × one job) takes **15–25 seconds** with default settings. `[Params]` creates a Cartesian product: two `[Params]` with 3 and 4 values across 5 methods = 60 cases ≈ 20 minutes. Multiple jobs multiply this further. Before running, estimate the total case count and match the job preset to the situation:
+
+| Preset | Per-case time | When to use |
+|--------|--------------|-------------|
+| `--job Dry` | <1s | Validate correctness — confirms compilation and execution without measurement |
+| `--job Short` | 5–8s | Quick measurements during development or investigation |
+| *(default)* | 15–25s | Final measurements for a coverage suite |
+| `--job Medium` | 33–52s | Higher confidence when results matter |
+| `--job Long` | 3–12 min | High statistical confidence |
+
+If benchmark runs take longer than expected, results seem unstable, or you need to tune iteration counts or execution settings, read [references/bdn-internals-and-tuning.md](references/bdn-internals-and-tuning.md) for detailed information about BDN's execution pipeline and configuration options.
+
+## Entry points and configuration
+
+BDN programs use either **`BenchmarkSwitcher`** (provides interactive benchmark selection for humans, parses CLI arguments) or **`BenchmarkRunner`** (runs specified benchmarks directly). Both support CLI flags like `--filter` and `--runtimes`, but only when `args` is passed through — without it, CLI flags are silently ignored. When using `BenchmarkSwitcher`, always pass `--filter` to avoid hanging on an interactive prompt.
+
+BDN behavior is customized through **attributes**, **config objects**, and **CLI flags**.
+
+Read [references/project-setup-and-running.md](references/project-setup-and-running.md) for entry point setup, config object patterns, and CLI flags. If you need to collect data beyond wall-clock time — such as memory allocations, hardware counters, or profiling traces — read [references/diagnosers-and-exporters.md](references/diagnosers-and-exporters.md).
+
+## Running benchmarks
+
+BenchmarkDotNet console output is extremely verbose — hundreds of lines per case showing internal calibration, warmup, and measurement details. Redirect all output to a file to avoid consuming context on verbose iteration output:
+
+```
+dotnet run -c Release -- --filter "*MethodName" --noOverwrite > benchmark.log 2>&1
+```
+
+Each benchmark method can take several minutes. Rather than running all benchmarks at once, use `--filter` to run a subset at a time (e.g. one or two methods per invocation), read the results, then run the next subset. This keeps each invocation short — avoiding session or terminal timeouts — and lets you verify results incrementally. Read [references/project-setup-and-running.md](references/project-setup-and-running.md) for filter syntax, CLI flags, and project setup.
+
+After each run, read the Markdown report (`*-report-github.md`) from the results directory for the summary table. Only read `benchmark.log` if you need to investigate errors or unexpected results.
+
+## Writing new benchmarks
+
+### Step 1: Plan the test cases
+
+Before writing any code, determine:
+- Which use case this benchmark serves (coverage, investigation, change validation, or development feedback).
+- Which comparison axis applies (what will the number be compared against?).
+- What real-world scenarios to benchmark, based on how callers actually use the API.
+
+Each benchmark case should justify its cost. An uncovered scenario is usually more valuable than another parameter combination for one already covered, but when a specific parameter dimension genuinely affects performance characteristics, the depth is warranted.
+
+Decide on the list of test cases. For each test case, think through:
+
+- **How to express variation**: BenchmarkDotNet provides several mechanisms for parameterizing benchmarks — `[Params]` and `[ParamsSource]` for property-level parameters, `[Arguments]` and `[ArgumentsSource]` for method-level arguments, `[ParamsAllValues]` to enumerate all values of a `bool` or enum, and `[GenericTypeArguments]` for varying type parameters on generic benchmark classes. Choose the mechanism that best fits the dimension being varied. Read [references/writing-benchmarks.md](references/writing-benchmarks.md) for the full set of options and correctness patterns.
+- **Where input data comes from** — consider which sources are appropriate (these can be combined):
+ - Hard-coded values — small, fixed values where the exact input matters (e.g., specific strings, known edge-case sizes). Store in fields or `[Params]` to avoid constant folding.
+ - Asset files — static data that is too large or impractical to embed in source code such as binary blobs.
+ - Programmatically generated via `[ParamsSource]`/`[ArgumentsSource]`/`[GlobalSetup]` — when data shape matters more than specific content, or when input must be parameterized by size.
+- **Whether randomness is appropriate**: If using generated data, use seeded randomness for reproducibility. When generating random data, use a large enough sample that the generated distribution is representative (e.g., 4 random values may cluster in a narrow range, while 1000 will better exercise the full distribution).
+
+### Step 2: Implement the benchmarks
+
+For **coverage suite** benchmarks, add to the existing benchmark project and follow its conventions. For **temporary benchmarks** (investigation, change validation, development feedback), create a standalone project — read [references/project-setup-and-running.md](references/project-setup-and-running.md) for project setup and entry point configuration.
+
+**Adding the BenchmarkDotNet package**: Always use `dotnet add package BenchmarkDotNet` (no version) — this lets NuGet resolve the latest compatible version. Do NOT manually write a `` with a version number into the `.csproj`; BDN versions in training data are outdated and may lack support for current .NET runtimes.
+
+Write the benchmark code. Follow the patterns in [references/writing-benchmarks.md](references/writing-benchmarks.md) to avoid common measurement errors — in particular:
+- **Return results** from benchmark methods to prevent dead code elimination
+- **Move initialization to `[GlobalSetup]`** — setup inside the benchmark method is measured; use `[IterationSetup]` only when the benchmark mutates state that must be reset between iterations
+- **Do not add manual loops** — BDN controls invocation count automatically
+- **Mark a baseline** when comparing alternatives — use `[Benchmark(Baseline = true)]` for method-level comparisons or `.AsBaseline()` on a job for multi-job comparisons so results show relative ratios
+- **Store inputs in fields or `[Params]`**, not as literals or `const` values — the JIT can fold constant expressions at compile time, making the benchmark measure a precomputed result instead of the actual computation
+
+### Step 3: Validate and run
+
+Validate before committing to a long run:
+
+1. Run with `--job Dry` first to catch compilation errors and runtime exceptions without spending time on measurement.
+2. Run a single representative case with default settings to verify the output looks correct and the numbers are in the expected range.
+3. Only run the full suite after validation passes.
+
+When iterating on benchmark design, use `--job Short` until confident, then switch to default for final numbers.
diff --git a/.agents/skills/microbenchmarking/references/bdn-internals-and-tuning.md b/.agents/skills/microbenchmarking/references/bdn-internals-and-tuning.md
new file mode 100644
index 00000000..41728b86
--- /dev/null
+++ b/.agents/skills/microbenchmarking/references/bdn-internals-and-tuning.md
@@ -0,0 +1,132 @@
+# BenchmarkDotNet Execution and Configuration
+
+## Build and execution
+
+BDN generates a standalone project containing the measurement harness, builds it **once per unique build configuration**, and runs each case in its **own process**. Cases are partitioned into separate builds when they differ in factors including runtime, toolchain, platform, JIT, GC settings, build configuration, or MSBuild arguments. All cases sharing the same build configuration share a single build (~5s).
+
+BDN determines the number of invocations per iteration automatically.
+
+`[Params]` creates a full Cartesian product: two `[Params]` properties with 3 and 4 values across 5 methods = 3 × 4 × 5 = 60 cases.
+
+Build overhead (~5s) is paid once regardless of case count. When running cases individually (separate `dotnet run` per case), each pays the build cost, adding ~5s per case.
+
+## Execution stages per case (default job settings)
+
+Each case runs through these stages sequentially. Some stage defaults have changed between BDN versions and may change in future releases — check which version of the BenchmarkDotNet NuGet package is installed when the exact behavior matters.
+
+1. **Process launch + JIT** — The benchmark method is invoked to trigger initial JIT compilation, ensuring the method is compiled before the pilot stage calibrates invocation count. This stage's time is not included in results.
+2. **Pilot** — Determines the invocation count per iteration. Fast methods get many invocations per iteration (targeting ~500ms per iteration). Methods slower than ~333ms/op (where two invocations would exceed the target) are fixed at one invocation per iteration.
+3. **Warmup** — Adaptive, typically 6–10 iterations for stable benchmarks (those with low variance). During warmup the runtime may further optimize the method (e.g., tiered JIT recompilation).
+4. **Actual** — Adaptive, typically 15 iterations for stable benchmarks. Stops when the confidence interval is sufficiently narrow (controlled by `MaxRelativeError`, default 2%).
+
+**Note (versions before 0.16.0):** prior to BDN 0.16.0, an additional **Overhead** stage ran between Pilot and Warmup by default. It measured the internal invocation loop's own cost and subtracted it from results. Starting in 0.16.0 this stage is off by default and the API to re-enable it (`[EvaluateOverhead]` / `.WithEvaluateOverhead()`) is marked obsolete.
+
+## Job preset configurations
+
+| Preset | Per-case time | Warmup | Target iterations | Launch count | Strategy |
+|--------|--------------|--------|-------------------|-------------|----------|
+| `Dry` | <1s | 0 | 1 | 1 | ColdStart |
+| `Short` | 5–8s | 3 | 3 | 1 | Throughput |
+| Default | 15–25s | 6–50 (adaptive) | 15–100 (adaptive) | 1 | Throughput |
+| `Medium` | 33–52s | 10 | 15 | 2 | Throughput |
+| `Long` | 3–12 min | 15 | 100 | 3 | Throughput |
+
+The `Dry` preset reuses the ColdStart strategy for minimal execution; its purpose is validation (confirming the benchmark compiles and runs), not first-call measurement.
+
+## Run strategies
+
+The `RunStrategy` controls how BDN calibrates and measures:
+
+- **Throughput** (default) — measures steady-state performance. BDN auto-calibrates invocation count via the pilot stage, runs warmup, and adapts iteration count. Use for microbenchmarks.
+- **ColdStart** — single invocation per iteration, skips all preliminary stages (JIT pre-trigger, pilot, warmup, overhead). The first measured invocation includes JIT compilation time. Use for startup time evaluation.
+- **Monitoring** — single invocation per iteration. Skips JIT pre-trigger, pilot, and overhead stages (like ColdStart), but includes the warmup stage (default 0 iterations, configurable). Use for macrobenchmarks with high variance that don't reach a steady state.
+
+## Jobs and mutators
+
+A **job** defines a complete set of run conditions — runtime, iteration counts, launch count, strategy, etc. A benchmark class can have **multiple jobs**, and BDN will run all benchmark methods under each job separately. For example, `[SimpleJob(RuntimeMoniker.Net80)]` and `[SimpleJob(RuntimeMoniker.Net90)]` on the same class runs every method twice — once per runtime.
+
+A **mutator** is not a standalone job. It is a partial override that gets applied to every existing job. This distinction matters when a class has multiple jobs:
+
+```csharp
+[SimpleJob(RuntimeMoniker.Net80)] // Job A
+[SimpleJob(RuntimeMoniker.Net90)] // Job B
+[WarmupCount(3)] // Mutator — applied to BOTH Job A and Job B
+public class MyBenchmarks { ... }
+```
+
+Without the mutator distinction, `[WarmupCount(3)]` would be a third job with just a warmup count override and default everything else — probably not what was intended.
+
+Jobs are resolved by collecting all standard jobs (from `[SimpleJob]`, etc.; `Job.Default` if none), then applying all mutators to every job. CLI flags like `--warmupCount 3` act as mutators — they override that setting on all jobs defined in source code. `--job Short` adds a standard job.
+
+## Execution-related attributes
+
+**Class or method level (mutator attributes):**
+These override specific job settings. When applied to a method, they affect only that method's cases. When applied to a class, they affect all methods in the class.
+
+| Attribute | Default | Config (on Job) | CLI flag |
+|-----------|---------|----------------|----------|
+| `[WarmupCount(N)]` | null (adaptive) | `.WithWarmupCount(N)` | `--warmupCount N` |
+| `[IterationCount(N)]` | null (adaptive) | `.WithIterationCount(N)` | `--iterationCount N` |
+| `[MinWarmupCount(N)]` | 6 | `.WithMinWarmupCount(N)` | `--minWarmupCount N` |
+| `[MaxWarmupCount(N)]` | 50 | `.WithMaxWarmupCount(N)` | `--maxWarmupCount N` |
+| `[MinIterationCount(N)]` | 15 | `.WithMinIterationCount(N)` | `--minIterationCount N` |
+| `[MaxIterationCount(N)]` | 100 | `.WithMaxIterationCount(N)` | `--maxIterationCount N` |
+| `[InvocationCount(N)]` | null (auto pilot) | `.WithInvocationCount(N)` | `--invocationCount N` |
+| `[IterationTime(ms)]` | 500ms | `.WithIterationTime(TimeInterval.FromMilliseconds(ms))` | `--iterationTime ms` |
+| `[ProcessCount(N)]` | 1 | `.WithLaunchCount(N)` | `--launchCount N` |
+| `[MaxRelativeError(pct)]` | 0.02 (= 2%) | `.WithMaxRelativeError(pct)` | — |
+
+Note: `[ProcessCount]` and `--launchCount` control the same setting (how many separate processes BDN launches per case). The naming differs between API surfaces.
+
+The `[RunOncePerIteration]` attribute (also available as `.RunOncePerIteration()` or `--runOncePerIteration`) is shorthand for setting `InvocationCount=1` and `UnrollFactor=1`.
+
+**Note (versions before 0.16.0):** `[EvaluateOverhead(bool)]` / `.WithEvaluateOverhead(bool)` / `--noOverheadEvaluation` controlled overhead subtraction. These are obsolete since 0.16.0 when overhead evaluation was disabled by default.
+
+**Class or assembly level:**
+- Job attributes: `[SimpleJob(...)]`, `[DryJob]`, `[ShortRunJob]`, `[MediumRunJob]`, `[LongRunJob]`, `[VeryLongRunJob]`. AllowMultiple — can stack to run the same benchmarks under different job configurations.
+
+## Unroll factor
+
+BDN generates a measurement method that contains the benchmark code repeated `UnrollFactor` times (default 16). This reduces measurement loop overhead relative to the actual work.
+
+UnrollFactor is distinct from `OperationsPerInvoke`: UnrollFactor is an engine optimization, while `OperationsPerInvoke` is a user declaration that each invocation performs multiple logical operations.
+
+When `[IterationSetup]` or `[IterationCleanup]` is used, BDN defaults to `UnrollFactor=1` and `InvocationCount=1` so that setup/cleanup runs before and after each single invocation of the benchmark method. Explicitly setting `InvocationCount` or `UnrollFactor` overrides this behavior.
+
+| Attribute | Config API | CLI flag |
+|-----------|------------|----------|
+| `[UnrollFactor(N)]` | `.WithUnrollFactor(N)` | `--unrollFactor N` |
+
+## Execution environment
+
+The settings below configure the runtime environment the benchmark process runs in. They are orthogonal to the execution pipeline above — they don't change how many iterations run or how the engine measures, they change *what* is being measured.
+
+| Purpose | Attribute | Config API | CLI flag |
+|---------|-----------|------------|----------|
+| Target runtimes | `[SimpleJob(RuntimeMoniker.Net80)]` | `Job.Default.WithRuntime(CoreRuntime.Core80)` | `--runtimes net8.0 net9.0` (first is baseline) |
+| Environment variables | — | `job.WithEnvironmentVariable("key", "value")` | `--envVars KEY:VALUE` |
+
+Multiple runtimes multiply the total case count and each requires a separate build.
+
+### GC configuration
+
+| Setting | Attribute | Config API |
+|---------|-----------|------------|
+| Server GC | `[GcServer(true)]` | `.WithGcServer(true)` |
+| Concurrent GC | `[GcConcurrent(bool)]` | `.WithGcConcurrent(bool)` |
+
+### MemoryRandomization
+
+| Surface | Usage |
+|---------|-------|
+| Attribute | `[MemoryRandomization]` |
+| Config | `.WithMemoryRandomization(true)` |
+| CLI | `--memoryRandomization` |
+
+The `[MemoryRandomization]` attribute causes BDN to allocate random-sized memory between iterations, shifting heap object alignment. This reveals whether benchmark results depend on memory layout (cache line alignment, GC heap position) rather than algorithmic performance.
+
+When enabled, BDN:
+- Allocates random-sized Gen0 and LOH objects between iterations
+- Uses `stackalloc` with random size to shift stack alignment
+- Re-runs `[GlobalCleanup]` and `[GlobalSetup]` after every iteration
+- Disables outlier removal (since multimodal results may be genuine)
diff --git a/.agents/skills/microbenchmarking/references/comparison-strategies.md b/.agents/skills/microbenchmarking/references/comparison-strategies.md
new file mode 100644
index 00000000..f161bd3e
--- /dev/null
+++ b/.agents/skills/microbenchmarking/references/comparison-strategies.md
@@ -0,0 +1,188 @@
+# Comparing benchmark results
+
+BenchmarkDotNet can compare multiple implementations or versions in a single run, producing a side-by-side table with ratio columns and statistical analysis. This is generally preferable to separate runs because it controls for environmental variance.
+
+## Strategy 1: Side-by-side methods (preferred)
+
+When both the old and new implementations can coexist in the same compilation, define separate benchmark methods:
+
+```csharp
+public class SortBenchmark
+{
+ private int[] _data;
+
+ [GlobalSetup]
+ public void Setup() => _data = Enumerable.Range(0, 1000).Reverse().ToArray();
+
+ [Benchmark(Baseline = true)]
+ public void BubbleSort() => Sorting.BubbleSort((int[])_data.Clone());
+
+ [Benchmark]
+ public void QuickSort() => Sorting.QuickSort((int[])_data.Clone());
+}
+```
+
+BDN displays a Ratio column showing each method's mean relative to the baseline. This is the simplest approach and should be preferred whenever both implementations are available at compile time.
+
+## Strategy 2: Comparing runtimes
+
+To compare the same benchmark across different .NET runtime versions, use `--runtimes` on the command line or configure jobs. The `.csproj` must use `net8.0;net9.0` (plural) listing all targets:
+
+```
+dotnet run -c Release --framework net9.0 -- --runtimes net8.0 net9.0
+```
+
+```csharp
+var config = DefaultConfig.Instance
+ .AddJob(Job.Default.WithRuntime(CoreRuntime.Core80).AsBaseline())
+ .AddJob(Job.Default.WithRuntime(CoreRuntime.Core90));
+```
+
+Each runtime becomes a separate job. BDN builds separate executables per runtime and shows them side-by-side with ratio columns.
+
+## Strategy 3: Comparing NuGet package versions
+
+To compare different versions of a NuGet dependency, use MSBuild properties to control the package version per job. Each job gets a separate build with the specified version.
+
+**.csproj** — use an MSBuild property for the version:
+```xml
+
+ 2.0.0
+
+
+
+
+```
+
+**Config** — create a job per version:
+```csharp
+var config = DefaultConfig.Instance
+ .AddJob(Job.Default
+ .WithMsBuildArguments("/p:MyLibVersion=1.0.0")
+ .WithId("v1.0.0")
+ .AsBaseline())
+ .AddJob(Job.Default
+ .WithMsBuildArguments("/p:MyLibVersion=2.0.0")
+ .WithId("v2.0.0"));
+```
+
+All versions must be source-compatible with the benchmark code (same namespace, same method signatures). BDN builds a separate exe per job because `WithArguments` changes the build partition.
+
+This works with packages from any NuGet source — public registries, private feeds, or local folders created with `dotnet pack`.
+
+## Strategy 4: Comparing a saved build against current source (DLL reference)
+
+When the code is not published as a NuGet package but you want to compare before/after a code change, you can save the build output of the baseline version and reference it directly.
+
+**Step 1** — build and save the baseline:
+```
+dotnet build ../MyLib -c Release -o ./saved-baseline
+```
+
+**Step 2** — make your changes to MyLib.
+
+**Step 3** — set up the benchmark .csproj with conditional references:
+```xml
+
+
+
+ $(BaselineDll)
+
+
+```
+
+**Step 4** — configure two jobs:
+```csharp
+var config = DefaultConfig.Instance
+ .AddJob(Job.Default
+ .WithMsBuildArguments("/p:BaselineDll=../saved-baseline/MyLib.dll")
+ .WithId("baseline")
+ .AsBaseline())
+ .AddJob(Job.Default
+ .WithId("current"));
+```
+
+The `baseline` job builds with a direct DLL reference to the saved output. The `current` job builds with the `ProjectReference` to the current source. BDN produces a side-by-side table with ratio columns.
+
+This approach works for any class library. The namespace and public API must be the same between the two versions.
+
+## Strategy 5: Comparing runtime configurations
+
+To compare how different runtime settings (GC mode, JIT options, PGO) affect performance, define multiple jobs with different environment settings. This works with any `Job` setting — GC mode, environment variables, platform, JIT settings:
+
+```csharp
+// GC mode comparison
+var config = DefaultConfig.Instance
+ .AddJob(Job.Default.WithGcServer(false).WithId("Workstation GC").AsBaseline())
+ .AddJob(Job.Default.WithGcServer(true).WithId("Server GC"));
+
+// JIT setting comparison
+var config = DefaultConfig.Instance
+ .AddJob(Job.Default.WithId("Default").AsBaseline())
+ .AddJob(Job.Default.WithEnvironmentVariable("DOTNET_TieredCompilation", "0").WithId("No Tiered"));
+```
+
+## Strategy 6: Comparing across input scale
+
+Use `[Params]` on a size property to understand how performance changes with input size. BDN runs each parameter value as a separate case. Geometrically spaced values (e.g., 10×, 100×) help reveal scaling behavior:
+
+```csharp
+[Params(100, 1_000, 10_000)]
+public int N;
+
+private int[] _data;
+
+[GlobalSetup]
+public void Setup()
+{
+ var rng = new Random(42);
+ _data = Enumerable.Range(0, N).OrderBy(_ => rng.Next()).ToArray();
+}
+
+[Benchmark]
+public void Sort() => Array.Sort((int[])_data.Clone());
+```
+
+## How it works
+
+All multi-version strategies rely on the same BDN mechanism:
+
+1. **Build partitioning** — different MSBuild arguments, runtimes, or platform → separate compiled executables. Each case runs in its own process.
+2. **Ratio calculation** — when a baseline is marked, BDN adds `Ratio` and `RatioSD` columns. `Ratio` is the mean of current/baseline per-operation time ratios. A `Ratio` of `0.85` means ~15% faster.
+
+### Marking a baseline
+
+For side-by-side methods (Strategy 1), mark one method with `[Benchmark(Baseline = true)]`. This compares methods within each job.
+
+For multi-job strategies (Strategies 2–5), mark one job with `.AsBaseline()` in the config. This compares jobs against each other. For `--runtimes`, the first runtime listed is the baseline.
+
+These are separate mechanisms — using the wrong one produces no ratio columns.
+
+Without a baseline, BDN shows absolute numbers only — no ratio columns.
+
+## Apples-to-apples comparison
+
+When comparing across jobs (Strategies 2–5), the default behavior runs each job's pilot stage independently, which may choose different invocation counts per job for the same benchmark case. This creates asymmetric measurement conditions that can bias the comparison.
+
+The `--apples` flag forces symmetric measurement by running in two phases:
+
+1. **Pilot phase** — BDN runs the baseline job once per benchmark case (method × parameter combination) to determine the invocation count for that case. Different cases get different invocation counts based on their speed. This produces a first results table in the log showing only the baseline with `Ratio = 1.00`.
+2. **Measurement phase** — for each benchmark case, ALL jobs run with that case's fixed invocation count, ensuring every job measures the same number of operations per iteration. The final comparison table includes `Ratio` and `InvocationCount` columns and is written to `BenchmarkDotNet.Artifacts/`.
+
+Outlier removal is disabled in this mode since the controlled setup makes outliers more meaningful.
+
+```
+dotnet run -c Release --framework net9.0 -- --runtimes net8.0 net9.0 --apples --job short
+```
+
+Because the pilot runs only once per benchmark case instead of once per job, `--apples` is faster than the default when there are many benchmark cases. The savings grow with the number of methods and parameter combinations.
+
+Requirements:
+- At least two jobs, with exactly one marked as baseline (via `.AsBaseline()` on the job, not `[Benchmark(Baseline = true)]` on the method). When using `--runtimes`, the first runtime listed is automatically treated as the baseline.
+- An explicit iteration count, such as by using `--job short` (which sets `IterationCount=3`) or `--iterationCount N`. Adaptive iteration count is disabled in this mode.
+
+This does not apply to Strategy 1 (side-by-side methods) or Strategy 6 (input scale) because those use a single job — `--apples` requires multiple job objects.
+
+## Mann-Whitney equivalence test
+
+BDN can add a `Faster` / `Same` / `Slower` column based on a Mann-Whitney equivalence test. Add it via config: `.AddColumn(StatisticalTestColumn.Create(threshold: "5%"))`. The `threshold` is the equivalence margin — results within that percentage are reported as `Same`.
diff --git a/.agents/skills/microbenchmarking/references/diagnosers-and-exporters.md b/.agents/skills/microbenchmarking/references/diagnosers-and-exporters.md
new file mode 100644
index 00000000..5deea82c
--- /dev/null
+++ b/.agents/skills/microbenchmarking/references/diagnosers-and-exporters.md
@@ -0,0 +1,146 @@
+# Measurement and diagnostics
+
+BenchmarkDotNet measures wall-clock time by default. This reference covers how to collect additional data (allocations, disassembly, hardware counters), customize statistical output, and export results.
+
+## Diagnosers
+
+Diagnosers add columns or produce artifacts beyond basic timing. They are enabled via class-level attributes or CLI flags.
+
+### MemoryDiagnoser
+
+| Surface | Usage |
+|---------|-------|
+| Attribute | `[MemoryDiagnoser]` or `[MemoryDiagnoser(displayGenColumns: false)]` |
+| Config | `.AddDiagnoser(MemoryDiagnoser.Default)` |
+| CLI | `--memory` |
+
+Tracks managed heap allocations and GC collections per operation.
+
+Adds columns: `Gen0`, `Gen1`, `Gen2` (GC collections per 1000 operations — a value of `1.0000` means one collection per 1000 operations), `Allocated` (bytes per operation).
+
+Set `displayGenColumns: false` if only total allocated bytes matter.
+
+### DisassemblyDiagnoser
+
+| Surface | Usage |
+|---------|-------|
+| Attribute | `[DisassemblyDiagnoser(maxDepth: 2)]` |
+| Config | `.AddDiagnoser(new DisassemblyDiagnoser(new DisassemblyDiagnoserConfig(maxDepth: 2, printSource: true)))` |
+| CLI | `--disasm`, `--disasmDepth N`, `--disasmDiff`, `--disasmFilter "Method*"` |
+
+Captures the JIT-compiled assembly code for the benchmark method.
+
+Produces: a per-benchmark disassembly report (exported as GitHub Markdown and/or HTML).
+
+Config options (`DisassemblyDiagnoserConfig`):
+- `maxDepth` (default 1) — how many levels of called methods to include in the disassembly.
+- `syntax` — `DisassemblySyntax.Masm` (default), `Intel`, or `Att`
+- `printSource` (default false) — include C#/F#/VB source alongside assembly
+- `exportDiff` (default false) — when comparing multiple jobs, export a diff view
+- `exportCombinedDisassemblyReport` (default false) — all benchmarks in a single HTML report for side-by-side comparison
+
+When using `printSource: true`, ensure the benchmark project emits PDB files so BDN can map assembly instructions to source lines:
+
+```xml
+pdbonly
+true
+```
+
+
+### ThreadingDiagnoser
+
+`[ThreadingDiagnoser]` (CLI: `--threading`, Config: `.AddDiagnoser(ThreadingDiagnoser.Default)`) — adds `Completed Work Items` and `Lock Contentions` columns.
+
+### EventPipeProfiler
+
+| Surface | Usage |
+|---------|-------|
+| Attribute | `[EventPipeProfiler(EventPipeProfile.CpuSampling)]` |
+| Config | `.AddDiagnoser(new EventPipeProfiler(EventPipeProfile.CpuSampling))` |
+| CLI | `--profiler EP` |
+
+Collects CPU sampling profiles using .NET's EventPipe infrastructure. Cross-platform.
+
+Profiles available: `CpuSampling`, `GcVerbose`, `GcCollect`, `Jit`.
+
+Produces: `.nettrace` and `.speedscope.json` files. By default (attribute/config), runs an extra benchmark iteration dedicated to profiling so timing results are unaffected.
+
+**CLI note:** `--profiler EP` uses `CpuSampling` with `performExtraBenchmarksRun: false` — profiling is attached to normal measurement runs (no extra iteration, results include profiler overhead, no way to select a different profile). Use the attribute or config API for full control.
+
+
+### HardwareCounters (Windows only, requires elevation)
+
+| Surface | Usage |
+|---------|-------|
+| Attribute | `[HardwareCounters(HardwareCounter.CacheMisses, HardwareCounter.BranchMispredictions)]` |
+| Config | `.AddHardwareCounters(HardwareCounter.CacheMisses, HardwareCounter.BranchMispredictions)` |
+| CLI | `--counters CacheMisses+BranchMispredictions` |
+
+Collects CPU hardware performance counters via ETW.
+
+Available counters include: `TotalCycles`, `InstructionRetired`, `CacheMisses`, `BranchMispredictions`, `BranchInstructions`, `LlcMisses`, `LlcReference`.
+
+Requires: Windows, elevated (admin) process, and ETW support. Not available on all hardware.
+
+
+## Statistical output
+
+### Default columns
+
+| Column | Meaning |
+|--------|---------|
+| `Mean` | Average per-operation time across all measured iterations |
+| `Error` | Half-width of the 99.9% confidence interval — if large relative to Mean, results are noisy |
+| `StdDev` | Standard deviation of measurements (hidden when negligible) |
+| `Median` | Median per-operation time (auto-shown when data is skewed or median diverges from mean) |
+| `P95` | 95th percentile (auto-shown when extreme outliers are present) |
+| `Ratio` | Performance relative to the baseline (current / baseline) — only shown when a baseline is marked. A value of `0.85` means ~15% faster. See [comparison-strategies.md](comparison-strategies.md) for how this is calculated |
+| `RatioSD` | Standard deviation of the ratio distribution |
+
+### Adding columns
+
+Additional statistics can be added via attributes on the benchmark class:
+
+| Attribute | Column added |
+|-----------|-------------|
+| `[MedianColumn]` | Median (P50) |
+| `[MinColumn]`, `[MaxColumn]` | Minimum, Maximum |
+| `[Q1Column]`, `[Q3Column]` | First quartile (P25), Third quartile (P75) |
+| `[AllStatisticsColumn]` | Mean, StdErr, StdDev, Ops/s, Min, Q1, Median, Q3, Max |
+| `[RankColumn]` | Rank (1, 2, 3...) |
+| `[SkewnessColumn]`, `[KurtosisColumn]` | Distribution shape metrics |
+| `[OperationsPerSecond]` | Throughput (ops/sec) |
+
+Percentile columns are available via code: `StatisticColumn.P50`, `StatisticColumn.P85`, `StatisticColumn.P95`, etc.
+
+### Hiding columns
+
+Use `[HideColumns]` to remove unwanted columns from the output:
+
+```csharp
+[HideColumns("Error", "StdDev", "RatioSD")]
+public class MyBenchmark { ... }
+```
+
+## Outlier handling
+
+| Surface | Usage |
+|---------|-------|
+| Attribute | `[Outliers(OutlierMode.DontRemove)]` |
+| Config | `.WithOutlierMode(OutlierMode.DontRemove)` |
+| CLI | `--outliers DontRemove` |
+
+By default, BDN removes upper outliers (unusually slow iterations, typically caused by GC or OS interference). Available modes: `DontRemove`, `RemoveUpper` (default), `RemoveLower`, `RemoveAll`.
+
+## Export formats
+
+BDN exports results in multiple formats. By default it produces Markdown and CSV in the `BenchmarkDotNet.Artifacts/results/` directory.
+
+| Format | Default | Config | CLI flag |
+|--------|---------|--------|----------|
+| JSON (full, indented) | no | `.AddExporter(JsonExporter.Full)` | `--exporters fulljson` |
+| JSON (compressed) | no | `.AddExporter(JsonExporter.Default)` | `--exporters json` |
+| CSV | yes | `.AddExporter(CsvExporter.Default)` | `--exporters csv` |
+| GitHub Markdown | yes | `.AddExporter(MarkdownExporter.GitHub)` | `--exporters github` |
+
+Use `JsonExporter.Full` for individual measurements (needed for programmatic comparison).
diff --git a/.agents/skills/microbenchmarking/references/project-setup-and-running.md b/.agents/skills/microbenchmarking/references/project-setup-and-running.md
new file mode 100644
index 00000000..c46ec7dd
--- /dev/null
+++ b/.agents/skills/microbenchmarking/references/project-setup-and-running.md
@@ -0,0 +1,179 @@
+# Running benchmarks
+
+This reference covers how to build, run, and read BenchmarkDotNet results. It also covers common CLI flags for controlling output and debugging.
+
+## Entry points: BenchmarkSwitcher vs BenchmarkRunner
+
+BenchmarkDotNet programs use one of two entry points. This determines whether CLI arguments work:
+
+**BenchmarkSwitcher** — passes `args` to BDN, CLI flags work:
+```csharp
+BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
+BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, config); // with config
+```
+
+**BenchmarkRunner** — only forwards args if explicitly passed:
+```csharp
+BenchmarkRunner.Run(); // CLI flags IGNORED
+BenchmarkRunner.Run(args: args); // CLI flags work
+BenchmarkRunner.Run(config); // config only, no CLI
+BenchmarkRunner.Run(config, args); // both config and CLI
+```
+
+Before passing CLI flags like `--filter` or `--job`, check which entry point the program uses. If it uses `BenchmarkRunner.Run()` without args, CLI flags will be silently ignored — configuration must be done through attributes and `ManualConfig` instead.
+
+**Critical**: when `BenchmarkSwitcher` is used, it prompts the user interactively to select which benchmarks to run unless `--filter` is provided. For AI agents, always pass `--filter` to avoid hanging on an interactive prompt. `--filter "*"` matches everything and is the standard way to run all benchmarks non-interactively.
+
+## Config objects
+
+Configs can be built in three ways:
+
+**Modify the default config** — starts with all default loggers, exporters, and columns:
+```csharp
+var config = DefaultConfig.Instance
+ .AddJob(Job.Default.WithRuntime(CoreRuntime.Core90))
+ .AddDiagnoser(MemoryDiagnoser.Default);
+```
+
+**Subclass ManualConfig** — useful when config is complex or reused across classes:
+```csharp
+public class MyConfig : ManualConfig
+{
+ public MyConfig()
+ {
+ AddJob(Job.Default.WithRuntime(CoreRuntime.Core80));
+ AddJob(Job.Default.WithRuntime(CoreRuntime.Core90).AsBaseline());
+ AddDiagnoser(MemoryDiagnoser.Default);
+ }
+}
+```
+
+**Start from empty** — `ManualConfig.CreateEmpty()` removes all defaults (no loggers, no exporters, no columns). Use `ManualConfig.CreateMinimumViable()` instead to start with the default columns and console logger.
+
+Configs can be applied in several ways:
+
+| Method | Scope |
+|--------|-------|
+| `BenchmarkRunner.Run(config)` | All benchmarks in the run |
+| `BenchmarkSwitcher...Run(args, config)` | All benchmarks in the run |
+| `[Config(typeof(MyConfig))]` on class or assembly | That class, or all classes in the assembly |
+
+## Creating a new benchmark project
+
+If there is no existing benchmark project to add to, create a new console project with `dotnet new console` and add the BenchmarkDotNet package. Choose a temporary or permanent location based on the scope of the work.
+
+Use `dotnet add package BenchmarkDotNet` without specifying a version — `dotnet` resolves the latest compatible version automatically. Pinning to a specific version risks missing support for newer runtimes and CLI features.
+
+**Multi-runtime targeting**: to compare across runtimes (e.g., `--runtimes net8.0 net9.0`), the `.csproj` must use the plural `` property listing all targets:
+
+```xml
+net8.0;net9.0
+```
+
+If the project uses the singular `` with only one TFM, multi-runtime runs will fail at build time.
+
+Then replace `Program.cs` with:
+```csharp
+using BenchmarkDotNet.Running;
+
+BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
+```
+
+Adjust the `ProjectReference` path to match the project under test. If benchmarking code that isn't in a separate project, skip `dotnet add reference` and put the code under test directly in the benchmark project.
+
+## Build and run strategy
+
+BenchmarkDotNet console output is extremely verbose — hundreds of lines per case showing pilot, warmup, and actual iteration details. Redirect all output to a file to avoid consuming context on verbose iteration output.
+
+**Recommended approach**: build first, then run with output redirected to a file. Always pass `--noOverwrite` so each run writes results to a timestamped subdirectory instead of `BenchmarkDotNet.Artifacts/results/` — without it, each run overwrites the previous results, making it easy to get confused about which run the results are for.
+
+Each benchmark method can take several minutes. Rather than running all benchmarks at once, use `--filter` to run a subset at a time (e.g. one or two methods per invocation), read the results, then run the next subset. This keeps each invocation short — avoiding session or terminal timeouts — and lets you verify results incrementally.
+
+```
+dotnet build -c Release
+dotnet run -c Release --no-build -- --filter "*MethodName" --noOverwrite > benchmark.log 2>&1
+```
+
+After each run, read the Markdown report (`*-report-github.md`) for the summary table. Only read `benchmark.log` if you need to investigate errors or unexpected results.
+
+## Artifacts directory
+
+By default, BDN writes to `BenchmarkDotNet.Artifacts/` relative to the working directory. Override with:
+
+```
+dotnet run -c Release --no-build -- --artifacts ./my-results
+```
+
+The artifacts directory contains:
+```
+BenchmarkDotNet.Artifacts/
+├── MyBenchmark-20240101-120000.log # full console log
+└── results/
+ ├── MyBenchmark-report-github.md # markdown summary table
+ └── MyBenchmark-report.csv # CSV data
+```
+
+Use `--noOverwrite` to prevent overwriting previous results (writes to `BenchmarkDotNet.Artifacts//` instead of `BenchmarkDotNet.Artifacts/results/`). Use `--artifacts` with different paths when comparing results from separate runs.
+
+## Useful CLI flags for agents
+
+### Filtering and discovery
+
+| Flag | Purpose |
+|------|---------|
+| `--filter "*"` | Run all benchmarks (avoids interactive prompt from BenchmarkSwitcher) |
+| `--filter "*.MethodName"` | Run specific benchmark methods |
+| `--list flat` | List available benchmark names without running them |
+| `--list tree` | List benchmarks in a tree hierarchy |
+| `--allCategories ` | Run only benchmarks matching all specified categories |
+| `--anyCategories ` | Run benchmarks matching any specified category |
+
+Tag benchmarks with `[BenchmarkCategory("name")]` (on methods, classes, or assemblies) to enable category-based filtering.
+
+**How `--filter` works**: BDN matches each pattern against two forms of the benchmark name:
+- **Short form**: `Namespace.ClassName.MethodName` (no parameters)
+- **Full form**: `Namespace.ClassName.MethodName(param: value, ...)` (includes parameters when present)
+
+Patterns use glob wildcards (`*` = any characters, `?` = single character), are **case-insensitive**, and must match the **entire** name (anchored). Multiple `--filter` values are OR'd — a case runs if it matches any pattern.
+
+| Goal | Pattern |
+|------|---------|
+| Run all benchmarks | `--filter "*"` |
+| Run one method by name | `--filter "*MyMethod"` |
+| Run all methods in a class | `--filter "*MyClass*"` |
+| Run a specific class.method | `--filter "MyNamespace.MyClass.MyMethod"` |
+| Same, using wildcard for namespace | `--filter "*MyClass.MyMethod"` |
+| Run two specific methods | `--filter "*MethodA" --filter "*MethodB"` |
+| Run methods matching a substring | `--filter "*Sort*"` |
+
+**Tip**: because patterns are anchored, `--filter "MyMethod"` does **not** match `MyNamespace.MyClass.MyMethod` — always use a leading `*` unless matching the short form exactly. Use `--list flat` to see the exact names BDN uses for filtering.
+
+**Category-based filtering**: tag benchmarks with `[BenchmarkCategory("name")]` on methods, classes, or assemblies to enable category filtering. Categories are inherited — a method tagged `"Parsing"` on a class tagged `"Strings"` has both categories.
+
+| Flag | Behavior |
+|------|----------|
+| `--anyCategories Parsing Sorting` | Run benchmarks that have **at least one** of the listed categories |
+| `--allCategories Strings Parsing` | Run benchmarks that have **all** of the listed categories |
+
+When `--filter` and `--allCategories`/`--anyCategories` are used together, a benchmark must satisfy **both** — the glob filter AND the category filter are AND'd together.
+
+### Output control
+
+| Flag | Purpose |
+|------|---------|
+| `--artifacts ./path` | Change where results are written |
+| `--noOverwrite` | Don't overwrite previous result files |
+| `--exporters json` | Add JSON export (useful for programmatic comparison) |
+| `--join` | Combine results from multiple benchmark classes into a single table |
+| `--hide "Error" "StdDev"` | Hide specific columns from the results table |
+| `--keepFiles` | Keep the generated benchmark project files — useful for debugging build failures |
+
+## Dry run validation
+
+Always run `--job Dry` first to catch compilation and runtime errors before committing to a full run:
+
+```
+dotnet run -c Release --no-build -- --filter "*" --job Dry --noOverwrite
+```
+
+A dry run executes each case once (<1 second per case) without meaningful measurement. It validates that setup methods work, parameters are valid, and the benchmark compiles and executes. Only after dry validation passes should you run with default or other job presets.
diff --git a/.agents/skills/microbenchmarking/references/writing-benchmarks.md b/.agents/skills/microbenchmarking/references/writing-benchmarks.md
new file mode 100644
index 00000000..586638e9
--- /dev/null
+++ b/.agents/skills/microbenchmarking/references/writing-benchmarks.md
@@ -0,0 +1,296 @@
+# Benchmark authoring techniques
+
+This reference covers BenchmarkDotNet features and practices for writing correct benchmark methods. Incorrect benchmarks silently produce misleading results — the techniques here prevent common measurement errors.
+
+## Dead code elimination
+
+The JIT compiler can eliminate code whose result is never used. If a benchmark method returns `void` and doesn't store its result anywhere observable, the JIT may partially or fully optimize away the computation.
+
+**Returning a value from the benchmark method prevents this.** When the method has a non-void return type, the JIT cannot prove the result is unused (the call site in BDN's generated harness is opaque to the JIT), so it must preserve the computation:
+
+```csharp
+// Correct — non-void return prevents DCE
+[Benchmark]
+public int Parse() => int.Parse("12345");
+
+// Wrong — void return, JIT may eliminate the call
+[Benchmark]
+public void Parse() => int.Parse("12345");
+```
+
+For benchmarks that must return `void` (e.g., methods with side effects like `Array.Sort`), DCE is not a concern because the operation itself has observable effects.
+
+For iterating deferred sequences where returning the sequence object doesn't force enumeration, use the `Consumer` class explicitly — see [Deferred execution](#deferred-execution).
+
+## Setup and cleanup
+
+### GlobalSetup / GlobalCleanup
+
+A method marked `[GlobalSetup]` runs once per benchmark case, before all iterations of that case. Use it to allocate buffers, load data, and initialize state that the benchmark reads but does not mutate.
+
+```csharp
+private int[] _array;
+
+[GlobalSetup]
+public void Setup() => _array = Enumerable.Range(0, 1000).ToArray();
+
+[Benchmark]
+public int Sum() => _array.Sum();
+```
+
+Initialization logic inside the benchmark method itself is measured — this is almost never what you want.
+
+When a class has multiple benchmarks needing different setup, use the `Target` or `Targets` property:
+
+```csharp
+[GlobalSetup(Target = nameof(BenchmarkA))]
+public void SetupA() { ... }
+
+[GlobalSetup(Targets = new[] { nameof(BenchmarkB), nameof(BenchmarkC) })]
+public void SetupBC() { ... }
+```
+
+`[GlobalCleanup]` runs once after all iterations. Use it to dispose resources (files, connections) created during setup.
+
+**Note**: when `[MemoryRandomization]` is enabled, BDN re-runs `[GlobalCleanup]` and `[GlobalSetup]` after every iteration (overriding the normal once-only behavior). See [references/bdn-internals-and-tuning.md](references/bdn-internals-and-tuning.md) for details.
+
+### IterationSetup / IterationCleanup
+
+A method marked `[IterationSetup]` runs before every iteration. Use it only when the benchmark mutates state that must be reset — for example, sorting an array in-place.
+
+**Critical constraint**: when `[IterationSetup]` or `[IterationCleanup]` is used, BDN defaults to `InvocationCount=1` and `UnrollFactor=1` (unless explicitly overridden). This means the entire iteration is a single invocation, so the operation should be long enough (generally hundreds of milliseconds or more) for a single-invocation measurement to be reliable — with only one invocation, OS scheduling jitter and timer resolution become a significant fraction of the measured time.
+
+```csharp
+[Params(10_000_000)] // large enough for single-invocation measurement
+public int Size { get; set; }
+
+private int[] _data, _scratch;
+
+[GlobalSetup]
+public void Setup() => _data = Enumerable.Range(0, Size).Reverse().ToArray();
+
+[IterationSetup]
+public void ResetArray() => Array.Copy(_data, _scratch = new int[Size], Size);
+
+[Benchmark]
+public void Sort() => Array.Sort(_scratch);
+```
+
+**If `[GlobalSetup]` is sufficient, do not use `[IterationSetup]`.** It adds overhead and constrains the measurement.
+
+## OperationsPerInvoke
+
+When part of the benchmark setup cannot be moved to `[GlobalSetup]` — typically because it involves a stack-only type like `Span` — use `OperationsPerInvoke` to amortize the setup cost by performing multiple logical operations per invocation.
+
+```csharp
+private byte[] _array = new byte[18];
+
+[Benchmark(OperationsPerInvoke = 16)]
+public Span Slice()
+{
+ Span span = new Span(_array); // setup: can't go in GlobalSetup (stack-only)
+
+ // 16 operations, unrolled without a loop
+ span = span.Slice(1); span = span.Slice(1); span = span.Slice(1); span = span.Slice(1);
+ span = span.Slice(1); span = span.Slice(1); span = span.Slice(1); span = span.Slice(1);
+ span = span.Slice(1); span = span.Slice(1); span = span.Slice(1); span = span.Slice(1);
+ span = span.Slice(1); span = span.Slice(1); span = span.Slice(1); span = span.Slice(1);
+
+ return span;
+}
+```
+
+BDN divides the measured time by `OperationsPerInvoke`, so the reported result reflects a single `Slice` operation with the `Span` creation cost amortized. The operations are manually unrolled (not in a loop) to avoid loop overhead affecting the measurement.
+
+## Avoiding loops
+
+BDN determines how many times to invoke the benchmark method per iteration automatically (the pilot stage). Adding a manual loop inside the benchmark is unnecessary and harmful:
+
+```csharp
+// Wrong — manual loop adds loop overhead and hides per-operation variance
+[Benchmark]
+public int ParseLoop()
+{
+ int result = 0;
+ for (int i = 0; i < 1000; i++)
+ result += int.Parse("12345");
+ return result;
+}
+
+// Correct — BDN handles invocation count
+[Benchmark]
+public int Parse() => int.Parse("12345");
+```
+
+The exception is `OperationsPerInvoke` — when per-invocation setup cannot be avoided, repeating the operation inside the method and declaring the count lets BDN divide the result correctly.
+
+## No side effects
+
+Benchmarks should be idempotent — running the method N times should produce the same performance characteristics as running it once. A benchmark that grows a list on every invocation violates this:
+
+```csharp
+// Wrong — list grows with every invocation, later calls are slower
+List _numbers = new();
+
+[Benchmark]
+public void Add() => _numbers.Add(12345);
+```
+
+If the operation inherently mutates state (e.g., `Array.Sort`), use `[IterationSetup]` to reset state between iterations, with input large enough for reliable single-invocation measurement.
+
+## Async benchmarks
+
+BDN natively supports benchmark methods that return `Task`, `Task`, `ValueTask`, or `ValueTask`. BDN awaits the result automatically — there is no special attribute or configuration needed:
+
+```csharp
+[Benchmark]
+public async Task ReadAsync()
+{
+ using var stream = new FileStream(_path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, useAsync: true);
+ var buffer = new byte[4096];
+ return await stream.ReadAsync(buffer);
+}
+```
+
+**Async setup/cleanup**: `[GlobalSetup]` and `[GlobalCleanup]` methods can also return `Task` or `ValueTask` — BDN awaits them automatically. `[IterationSetup]` and `[IterationCleanup]` do **not** support async return types; they must be synchronous.
+
+**When NOT to use async**: if the operation being benchmarked is synchronous, do not wrap it in `async`/`await`. The async state machine adds overhead that would be included in the measurement:
+
+```csharp
+// Wrong — adds async state machine overhead to a synchronous operation
+[Benchmark]
+public async Task ParseAsync() => await Task.FromResult(int.Parse("12345"));
+
+// Correct — benchmark the synchronous method directly
+[Benchmark]
+public int Parse() => int.Parse("12345");
+```
+
+## Deferred execution
+
+Returning `IEnumerable`, `IQueryable`, or `Lazy` from a benchmark measures only the creation of the deferred sequence — not its execution. This is almost always a bug:
+
+```csharp
+// Wrong — measures only the creation of the LINQ query object, not its execution
+[Benchmark]
+public IEnumerable WhereQuery() => _numbers.Where(n => n > 50);
+
+// Correct — materialize the result so the query actually executes
+[Benchmark]
+public List WhereQuery() => _numbers.Where(n => n > 50).ToList();
+```
+
+BDN includes a `DeferredExecutionValidator` that detects this mistake and raises a validation error, preventing the benchmark from running. To fix it, either materialize the result (`.ToList()`, `.ToArray()`) or consume it manually using the `.Consume()` extension method:
+
+```csharp
+private Consumer _consumer = new();
+
+[Benchmark]
+public void WhereQuery() => _numbers.Where(n => n > 50).Consume(_consumer);
+```
+
+## Constant folding
+
+The JIT can evaluate expressions at compile time when all inputs are constants or literals. If a benchmark operates on constant inputs, the JIT may fold the computation into a precomputed result, and the benchmark measures nothing:
+
+```csharp
+// Wrong — JIT can compute Math.Sqrt(144.0) at compile time
+[Benchmark]
+public double Sqrt() => Math.Sqrt(144.0);
+
+// Correct — field value is not a compile-time constant
+private double _value = 144.0;
+
+[Benchmark]
+public double Sqrt() => Math.Sqrt(_value);
+```
+
+Store inputs in fields, `[Params]`, or `[Arguments]` — never as literals or `const` values in the benchmark method.
+
+## Randomness and reproducibility
+
+When benchmarks use generated input data, use a fixed seed so results are comparable across runs:
+
+```csharp
+[GlobalSetup]
+public void Setup()
+{
+ var rng = new Random(42); // fixed seed
+ _data = new byte[Size];
+ rng.NextBytes(_data);
+}
+```
+
+## Benchmark class constraints
+
+BDN generates source code for a derived type from the benchmark class, which is compiled as part of its generated project. This imposes constraints:
+- The class must be `public`
+- The class must not be `sealed`, `static`, or `abstract`
+- The class must be a `class`, not a `struct` or `ref struct`
+- Benchmark methods must be `public` and instance (not `static`)
+
+## Parameterization summary
+
+| Feature | Target | Use when |
+|---------|--------|----------|
+| `[Params]` | Field/Property | Values needed in setup; applies to all benchmarks in the class |
+| `[ParamsSource]` | Field/Property | Dynamic or complex values needed in setup |
+| `[ParamsAllValues]` | Field/Property | Enum or bool — test every possible value |
+| `[Arguments]` | Method | Inline values for a specific benchmark method |
+| `[ArgumentsSource]` | Method | Dynamic or complex values for a specific benchmark method |
+| `[GenericTypeArguments]` | Class | Vary type parameters (e.g., value type vs reference type) |
+
+`[Params]` applies to all benchmarks in the class. If different benchmarks need different parameter ranges, split them into separate classes.
+
+`[Arguments]` and `[ArgumentsSource]` are method-specific and their initialization time is excluded from the measurement. `[ArgumentsSource]` methods return `IEnumerable` for single-parameter benchmarks, or `IEnumerable