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` for multi-parameter benchmarks (each array is one set of arguments). `[ParamsSource]` methods return `IEnumerable` (each element is a single value assigned to the field/property). + +```csharp +// Single-parameter: IEnumerable +[Benchmark] +[ArgumentsSource(nameof(Inputs))] +public int Parse(string text) => int.Parse(text); + +public IEnumerable Inputs() +{ + yield return "12345"; + yield return "999999999"; + yield return "-42"; +} + +// Multi-parameter: IEnumerable +[Benchmark] +[ArgumentsSource(nameof(Inputs))] +public bool TryParse(string text, NumberStyles style) => int.TryParse(text, style, null, out _); + +public IEnumerable Inputs() +{ + yield return new object[] { "12345", NumberStyles.Integer }; + yield return new object[] { "0xFF", NumberStyles.HexNumber }; +} +``` + +`[ParamsSource]` works the same way but targets a field/property and applies to all benchmarks in the class: + +```csharp +[ParamsSource(nameof(Sizes))] +public int N; + +public IEnumerable Sizes() => new[] { 100, 1_000, 10_000 }; +``` + +`[GenericTypeArguments]` varies type parameters on a generic benchmark class. Each attribute creates a separate closed generic type that BDN runs independently: + +```csharp +[GenericTypeArguments(typeof(int))] +[GenericTypeArguments(typeof(string))] +public class CollectionBenchmark +{ + private List _list; + + [GlobalSetup] + public void Setup() => _list = new List(Enumerable.Repeat(default(T)!, 1000)); + + [Benchmark] + public bool Contains() => _list.Contains(default!); +} +``` diff --git a/.agents/skills/migrate-dotnet8-to-dotnet9/SKILL.md b/.agents/skills/migrate-dotnet8-to-dotnet9/SKILL.md new file mode 100644 index 00000000..f35b629c --- /dev/null +++ b/.agents/skills/migrate-dotnet8-to-dotnet9/SKILL.md @@ -0,0 +1,227 @@ +--- +description: 'Migrate a .NET 8 project to .NET 9 and resolve all breaking changes. USE FOR: upgrading TargetFramework from net8.0 to net9.0, fixing build errors after updating the .NET 9 SDK, resolving behavioral changes in .NET 9 / C# 13 / ASP.NET Core 9 / EF Core 9, replacing BinaryFormatter (now always throws), resolving SYSLIB0054-SYSLIB0057, adapting to params span overload resolution, fixing C# 13 compiler changes, updating HttpClientFactory for SocketsHttpHandler, and resolving EF Core 9 migration/Cosmos DB changes. DO NOT USE FOR: .NET Framework migrations, upgrading from .NET 7 or earlier, greenfield .NET 9 projects, or cosmetic modernization unrelated to the upgrade.' +metadata: + github-path: plugins/dotnet-upgrade/skills/migrate-dotnet8-to-dotnet9 + github-pinned: v1.0.0 + github-ref: refs/tags/v1.0.0 + github-repo: https://github.com/dotnet/skills + github-tree-sha: 7221a4dd1aa19e6e339a825cb9e5bd798c4221c6 +name: migrate-dotnet8-to-dotnet9 +--- +# .NET 8 → .NET 9 Migration + +Migrate a .NET 8 project or solution to .NET 9, systematically resolving all breaking changes. The outcome is a project targeting `net9.0` that builds cleanly, passes tests, and accounts for every behavioral, source-incompatible, and binary-incompatible change introduced in the .NET 9 release. + +## When to Use + +- Upgrading `TargetFramework` from `net8.0` to `net9.0` +- Resolving build errors or new warnings after updating the .NET 9 SDK +- Adapting to behavioral changes in .NET 9 runtime, ASP.NET Core 9, or EF Core 9 +- Replacing `BinaryFormatter` usage (now always throws at runtime) +- Updating CI/CD pipelines, Dockerfiles, or deployment scripts for .NET 9 + +## When Not to Use + +- The project already targets `net9.0` and builds cleanly — migration is done. If the goal is to reach `net10.0`, use the `migrate-dotnet9-to-dotnet10` skill as the next step. +- Upgrading from .NET 7 or earlier — address the prior version breaking changes first +- Migrating from .NET Framework — that is a separate, larger effort +- Greenfield projects that start on .NET 9 (no migration needed) + +## Inputs + +| Input | Required | Description | +|-------|----------|-------------| +| Project or solution path | Yes | The `.csproj`, `.sln`, or `.slnx` entry point to migrate | +| Build command | No | How to build (e.g., `dotnet build`, a repo build script). Auto-detect if not provided | +| Test command | No | How to run tests (e.g., `dotnet test`). Auto-detect if not provided | +| Project type hints | No | Whether the project uses ASP.NET Core, EF Core, WinForms, WPF, containers, etc. Auto-detect from PackageReferences and SDK attributes if not provided | + +## Workflow + +> **Answer directly from the loaded reference documents.** Do not search the filesystem or fetch web pages for breaking change information — the references contain the authoritative details. Focus on identifying which breaking changes apply and providing concrete fixes. +> +> **Commit strategy:** Commit at each logical boundary — after updating the TFM (Step 2), after resolving build errors (Step 3), after addressing behavioral changes (Step 4), and after updating infrastructure (Step 5). This keeps each commit focused and reviewable. + +### Step 1: Assess the project + +1. Identify how the project is built and tested. Look for build scripts, `.sln`/`.slnx` files, or individual `.csproj` files. +2. Run `dotnet --version` to confirm the .NET 9 SDK is installed. If it is not, stop and inform the user. +3. Determine which technology areas the project uses by examining: + - **SDK attribute**: `Microsoft.NET.Sdk.Web` → ASP.NET Core; `Microsoft.NET.Sdk.WindowsDesktop` with `` or `` → WPF/WinForms + - **PackageReferences**: `Microsoft.EntityFrameworkCore.*` → EF Core; `Microsoft.Extensions.Http` → HttpClientFactory + - **Dockerfile presence** → Container changes relevant + - **P/Invoke or native interop usage** → Interop changes relevant + - **`BinaryFormatter` usage** → Serialization migration needed + - **`System.Text.Json` usage** → Serialization changes relevant + - **X509Certificate constructors** → Cryptography changes relevant +4. Record which reference documents are relevant (see the reference loading table in Step 3). +5. Do a **clean build** (`dotnet build --no-incremental` or delete `bin`/`obj`) on the current `net8.0` target to establish a clean baseline. Record any pre-existing warnings. + +### Step 2: Update the Target Framework + +1. In each `.csproj` (or `Directory.Build.props` if centralized), change: + ```xml + net8.0 + ``` + to: + ```xml + net9.0 + ``` + For multi-targeted projects, add `net9.0` to `` or replace `net8.0`. + +2. Update all `Microsoft.Extensions.*`, `Microsoft.AspNetCore.*`, `Microsoft.EntityFrameworkCore.*`, and other Microsoft package references to their 9.0.x versions. If using Central Package Management (`Directory.Packages.props`), update versions there. + +3. Run `dotnet restore`. Watch for: + - **Version requirements**: .NET 9 SDK requires Visual Studio 17.12+ to target `net9.0` (17.11 for `net8.0` and earlier). + - **New warnings for .NET Standard 1.x** and **.NET 7 targets** — consider updating or removing outdated target frameworks. + +4. Run a clean build. Collect all errors and new warnings. These will be addressed in Step 3. + +### Step 3: Resolve build errors and source-incompatible changes + +Work through compilation errors and new warnings systematically. Load the appropriate reference documents based on the project type: + +| If the project uses… | Load reference | +|-----------------------|----------------| +| Any .NET 9 project | `references/csharp-compiler-dotnet8to9.md` | +| Any .NET 9 project | `references/core-libraries-dotnet8to9.md` | +| Any .NET 9 project | `references/sdk-msbuild-dotnet8to9.md` | +| ASP.NET Core | `references/aspnet-core-dotnet8to9.md` | +| Entity Framework Core | `references/efcore-dotnet8to9.md` | +| Cryptography APIs | `references/cryptography-dotnet8to9.md` | +| System.Text.Json, HttpClient, networking | `references/serialization-networking-dotnet8to9.md` | +| Windows Forms or WPF | `references/winforms-wpf-dotnet8to9.md` | +| Docker containers, native interop | `references/containers-interop-dotnet8to9.md` | +| Runtime configuration, deployment | `references/deployment-runtime-dotnet8to9.md` | + +**Common source-incompatible changes to check for:** + +1. **`params` span overload resolution** — New `params ReadOnlySpan` overloads on `String.Join`, `String.Concat`, `Path.Combine`, `Task.WhenAll`, and many more now bind preferentially. Code calling these methods inside `Expression` lambdas will fail (CS8640/CS9226). See `references/core-libraries-dotnet8to9.md`. + +2. **`StringValues` ambiguous overload** — The `params Span` feature creates ambiguity with `StringValues` implicit operators on methods like `String.Concat`, `String.Join`, `Path.Combine`. Fix by explicitly casting arguments. See `references/core-libraries-dotnet8to9.md`. + +3. **New obsoletion warnings (SYSLIB0054–SYSLIB0057)**: + - `SYSLIB0054`: Replace `Thread.VolatileRead`/`VolatileWrite` with `Volatile.Read`/`Volatile.Write` + - `SYSLIB0057`: Replace `X509Certificate2`/`X509Certificate` binary/file constructors with `X509CertificateLoader` methods + - Also `SYSLIB0055` (ARM AdvSimd signed overloads) and `SYSLIB0056` (Assembly.LoadFrom with hash algorithm) — see `references/core-libraries-dotnet8to9.md` + +4. **C# 13 `InlineArray` on record structs** — `[InlineArray]` attribute on `record struct` types is now disallowed (CS9259). Change to a regular `struct`. See `references/csharp-compiler-dotnet8to9.md`. + +5. **C# 13 iterator safe context** — Iterators now introduce a safe context in C# 13. Local functions inside iterators that used unsafe code inherited from an outer `unsafe` class will now error. Add `unsafe` modifier to the local function. See `references/csharp-compiler-dotnet8to9.md`. + +6. **C# 13 collection expression overload resolution** — Empty collection expressions (`[]`) no longer use span vs non-span to tiebreak overloads. Exact element type is now preferred. See `references/csharp-compiler-dotnet8to9.md`. + +7. **`String.Trim(params ReadOnlySpan)` removed** — Code compiled against .NET 9 previews that passes `ReadOnlySpan` to `Trim`/`TrimStart`/`TrimEnd` must rebuild; the overload was removed in GA. See `references/core-libraries-dotnet8to9.md`. + +8. **`BinaryFormatter` always throws** — If the project uses `BinaryFormatter`, **stop and inform the user** — this is a major decision. See `references/serialization-networking-dotnet8to9.md`. + +9. **`HttpListenerRequest.UserAgent` is nullable** — The property is now `string?`. Add null checks. See `references/serialization-networking-dotnet8to9.md`. + +10. **Windows Forms nullability annotation changes** — Some WinForms API parameters changed from nullable to non-nullable. Update call sites. See `references/winforms-wpf-dotnet8to9.md`. + +11. **Windows Forms security analyzers (WFO1000)** — New analyzers produce errors for properties without explicit serialization configuration. See `references/winforms-wpf-dotnet8to9.md`. + +Build again after each batch of fixes. Repeat until the build is clean. + +### Step 4: Address behavioral changes + +Behavioral changes do not cause build errors but may change runtime behavior. Review each applicable item and determine whether the previous behavior was relied upon. + +**High-impact behavioral changes (check first):** + +1. **Floating-point to integer conversions are now saturating** — Conversions from `float`/`double` to integer types now saturate instead of wrapping on x86/x64. See `references/deployment-runtime-dotnet8to9.md`. + +2. **EF Core: Pending model changes exception** — `Migrate()`/`MigrateAsync()` now throws if the model has pending changes. **Search for `DateTime.Now`, `DateTime.UtcNow`, or `Guid.NewGuid()` in any `HasData` call — these must be replaced with fixed constants** (e.g., `new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc)`). See `references/efcore-dotnet8to9.md`. + +3. **EF Core: Explicit transaction exception** — `Migrate()` inside a user transaction now throws. See `references/efcore-dotnet8to9.md`. + +4. **HttpClientFactory uses `SocketsHttpHandler` by default** — Code that casts the primary handler to `HttpClientHandler` will get `InvalidCastException`. See `references/serialization-networking-dotnet8to9.md`. + +5. **HttpClientFactory header redaction by default** — All header values in `Trace`-level logs are now redacted. See `references/serialization-networking-dotnet8to9.md`. + +6. **Environment variables take precedence over runtimeconfig.json** — Runtime configuration settings from environment variables now override `runtimeconfig.json`. See `references/deployment-runtime-dotnet8to9.md`. + +7. **ASP.NET Core `ValidateOnBuild`/`ValidateScopes` in development** — `HostBuilder` now enables DI validation in development by default. See `references/aspnet-core-dotnet8to9.md`. + +**Other behavioral changes to review (may cause runtime exceptions ⚠️ or subtle behavioral differences):** + +- ⚠️ `FromKeyedServicesAttribute` no longer injects non-keyed service fallback — throws `InvalidOperationException` +- ⚠️ Container images no longer install zlib — apps depending on system zlib will fail +- ⚠️ Intel CET is now enabled by default — non-CET-compatible native libraries may cause process termination +- `BigInteger` now has a maximum length of `(2^31) - 1` bits +- `JsonDocument` deserialization of JSON `null` now returns non-null `JsonDocument` with `JsonValueKind.Null` instead of C# `null` +- `System.Text.Json` metadata reader now unescapes metadata property names +- `ZipArchiveEntry` names/comments now respect the UTF-8 flag +- `IncrementingPollingCounter` initial callback is now asynchronous +- `InMemoryDirectoryInfo` prepends rootDir to files +- `RuntimeHelpers.GetSubArray` returns a different type +- `PictureBox` raises `HttpRequestException` instead of `WebException` +- `StatusStrip` uses a different default renderer +- `IMsoComponent` support is opt-in +- `SafeEvpPKeyHandle.DuplicateHandle` up-refs the handle +- `HttpClient` metrics report `server.port` unconditionally +- URI query strings redacted in HttpClient EventSource events and IHttpClientFactory logs +- `dotnet watch` is incompatible with Hot Reload for old frameworks +- WPF `GetXmlNamespaceMaps` returns `Hashtable` instead of `String` + +### Step 5: Update infrastructure + +1. **Dockerfiles**: Update base images. Note that .NET 9 container images no longer install zlib. If your app depends on zlib, add `RUN apt-get update && apt-get install -y zlib1g` to your Dockerfile. + ```dockerfile + # Before + FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build + FROM mcr.microsoft.com/dotnet/aspnet:8.0 + # After + FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build + FROM mcr.microsoft.com/dotnet/aspnet:9.0 + ``` + +2. **CI/CD pipelines**: Update SDK version references. If using `global.json`, update: + ```json + { + "sdk": { + "version": "9.0.100", + "rollForward": "latestFeature" + } + } + ``` + Review the `rollForward` policy — if set to `"disable"` or `"latestPatch"`, the SDK may not resolve correctly after upgrading. `"latestFeature"` (recommended) allows the SDK to roll forward to the latest 9.0.x feature band. + +3. **Visual Studio version**: .NET 9 SDK requires VS 17.12+ to target `net9.0`. VS 17.11 can only target `net8.0` and earlier. + +4. **Terminal Logger**: `dotnet build` now uses Terminal Logger by default in interactive terminals. CI scripts that parse MSBuild console output may need `--tl:off` or `MSBUILDTERMINALLOGGER=off`. + +5. **`dotnet workload` output**: Output format has changed. Update any scripts that parse workload command output. + +6. **.NET Monitor images**: Tags simplified to version-only (affects container orchestration referencing specific tags). + +### Step 6: Verify + +1. Run a full clean build: `dotnet build --no-incremental` +2. Run all tests: `dotnet test` +3. If the application is containerized, build and test the container image +4. Smoke-test the application, paying special attention to: + - BinaryFormatter usage (will throw at runtime) + - Floating-point to integer conversion behavior + - EF Core migration application + - HttpClientFactory handler casting and logging + - DI validation in development environment + - Runtime configuration settings (environment variable precedence) +5. Review the diff and ensure no unintended behavioral changes were introduced + +## Reference Documents + +The `references/` folder contains detailed breaking change information organized by technology area. Load only the references relevant to the project being migrated: + +| Reference file | When to load | +|----------------|-------------| +| `references/csharp-compiler-dotnet8to9.md` | Always (C# 13 compiler breaking changes — InlineArray on records, iterator safe context, collection expression overloads) | +| `references/core-libraries-dotnet8to9.md` | Always (applies to all .NET 9 projects) | +| `references/sdk-msbuild-dotnet8to9.md` | Always (SDK and build tooling changes) | +| `references/aspnet-core-dotnet8to9.md` | Project uses ASP.NET Core | +| `references/efcore-dotnet8to9.md` | Project uses Entity Framework Core | +| `references/cryptography-dotnet8to9.md` | Project uses System.Security.Cryptography or X.509 certificates | +| `references/serialization-networking-dotnet8to9.md` | Project uses BinaryFormatter, System.Text.Json, HttpClient, or networking APIs | +| `references/winforms-wpf-dotnet8to9.md` | Project uses Windows Forms or WPF | +| `references/containers-interop-dotnet8to9.md` | Project uses Docker containers or native interop (P/Invoke) | +| `references/deployment-runtime-dotnet8to9.md` | Project uses runtime configuration, deployment, or has floating-point to integer conversions | diff --git a/.agents/skills/migrate-dotnet8-to-dotnet9/references/aspnet-core-dotnet8to9.md b/.agents/skills/migrate-dotnet8-to-dotnet9/references/aspnet-core-dotnet8to9.md new file mode 100644 index 00000000..a53c526e --- /dev/null +++ b/.agents/skills/migrate-dotnet8-to-dotnet9/references/aspnet-core-dotnet8to9.md @@ -0,0 +1,50 @@ +# ASP.NET Core 9 Breaking Changes + +These changes affect projects using ASP.NET Core (Microsoft.NET.Sdk.Web). + +## Behavioral Changes + +### HostBuilder enables ValidateOnBuild/ValidateScopes in development + +**Impact: Medium.** In the development environment, `ValidateOnBuild` and `ValidateScopes` are now enabled by default when options haven't been set with `UseDefaultServiceProvider`. This means DI registration issues that were previously silent will now throw at startup in development. + +**Mitigation:** Fix the DI registration issues (recommended), or disable validation: +```csharp +builder.Host.UseDefaultServiceProvider(options => +{ + options.ValidateOnBuild = false; + options.ValidateScopes = false; +}); +``` + +### Middleware types with multiple constructors + +**Impact: Low.** Middleware activation behavior has changed when a middleware type has multiple constructors. Previously the behavior was undefined; now it selects the most appropriate constructor. + +### Forwarded Headers Middleware ignores X-Forwarded-* headers from unknown proxies + +**Impact: Medium.** The `ForwardedHeadersMiddleware` now ignores `X-Forwarded-*` headers from proxies not in the `KnownProxies` or `KnownNetworks` list. + +**Mitigation:** Add your trusted proxy addresses to `KnownProxies` or `KnownNetworks`: +```csharp +services.Configure(options => +{ + options.KnownProxies.Add(IPAddress.Parse("10.0.0.1")); +}); +``` + +> **Warning:** Do not clear `KnownProxies` or `KnownNetworks` to accept forwarded headers from any source. This disables ASP.NET Core's protection against spoofed `X-Forwarded-*` headers and is unsafe for production. Only register the specific proxy addresses or network ranges your infrastructure uses. + +## Source-Incompatible Changes + +### Legacy Mono and Emscripten APIs not exported to global namespace + +**Impact: Low.** Legacy Mono and Emscripten interop APIs are no longer exported to the global namespace in Blazor WebAssembly. Use the specific namespace imports. + +### DefaultKeyResolution.ShouldGenerateNewKey altered meaning + +The meaning of `DefaultKeyResolution.ShouldGenerateNewKey` has changed. Review code that checks this property. + +### Dev cert export no longer creates folder + +`dotnet dev-certs https --export-path` no longer automatically creates the target folder. Ensure the directory exists before exporting. diff --git a/.agents/skills/migrate-dotnet8-to-dotnet9/references/containers-interop-dotnet8to9.md b/.agents/skills/migrate-dotnet8-to-dotnet9/references/containers-interop-dotnet8to9.md new file mode 100644 index 00000000..967b9eda --- /dev/null +++ b/.agents/skills/migrate-dotnet8-to-dotnet9/references/containers-interop-dotnet8to9.md @@ -0,0 +1,38 @@ +# Containers and Interop Breaking Changes (.NET 9) + +These changes affect Docker containers, native interop, and CET (Control-flow Enforcement Technology). + +## Containers + +### Container images no longer install zlib + +**Impact: Medium.** .NET 9 container images no longer install `zlib` because the .NET Runtime now includes a statically linked `zlib-ng`. If your app has a direct dependency on the `zlib` system package, install it manually: + +```dockerfile +FROM mcr.microsoft.com/dotnet/aspnet:9.0 +RUN apt-get update && apt-get install -y zlib1g +``` + +### .NET Monitor images simplified to version-only tags + +**Impact: Low.** .NET Monitor container image tags have been simplified to version-only format. Update any container orchestration configuration that references specific tag formats. + +## Interop + +### CET supported by default + +**Impact: Medium (binary incompatible).** `apphost` and `singlefilehost` are now compiled with the `/CETCOMPAT` flag, enabling Intel CET (Control-flow Enforcement Technology) hardware-enforced stack protection. This enhances security against ROP exploits but imposes restrictions on shared libraries: + +- Libraries cannot set thread context to locations not on the shadow stack +- Libraries cannot use exception handlers that jump to unlisted continuation addresses +- Non-CET-compatible native libraries loaded via P/Invoke may cause process termination + +**Mitigation:** If a native library is incompatible: +```xml + + + false + +``` + +> **Warning:** Disabling CET removes hardware-enforced control-flow integrity, reducing protection against ROP (return-oriented programming) and JOP (jump-oriented programming) exploits. Only disable CET after confirming the specific native library is incompatible, and re-enable it once the library is updated. Prefer per-application opt-out via Windows Security / group policy over a project-wide setting when possible. diff --git a/.agents/skills/migrate-dotnet8-to-dotnet9/references/core-libraries-dotnet8to9.md b/.agents/skills/migrate-dotnet8-to-dotnet9/references/core-libraries-dotnet8to9.md new file mode 100644 index 00000000..900455c3 --- /dev/null +++ b/.agents/skills/migrate-dotnet8-to-dotnet9/references/core-libraries-dotnet8to9.md @@ -0,0 +1,139 @@ +# Core .NET Libraries Breaking Changes (.NET 9) + +These breaking changes affect all .NET 9 projects regardless of application type. + +## Source-Incompatible Changes + +### C# overload resolution prefers `params` span-type overloads + +**Impact: High.** .NET 9 added `params ReadOnlySpan` overloads to many core methods. C# 13 overload resolution prefers `params Span`/`params ReadOnlySpan` over `params T[]`. This causes errors inside `Expression` lambdas, which cannot contain `ref struct` types. + +Affected methods include: `String.Join`, `String.Concat`, `String.Format`, `String.Split`, `Path.Combine`, `Path.Join`, `Task.WhenAll`, `Task.WhenAny`, `Task.WaitAll`, `Console.Write`, `Console.WriteLine`, `StringBuilder.AppendFormat`, `StringBuilder.AppendJoin`, `CancellationTokenSource.CreateLinkedTokenSource`, `ImmutableArray.Create`, `Delegate.Combine`, `JsonArray` constructors, `JsonTypeInfoResolver.Combine`, and more. + +```csharp +// BREAKS — CS8640/CS9226 inside Expression lambda +Expression> join = + (x, y) => string.Join("", x, y); // binds to ReadOnlySpan overload +``` + +**Fix:** Pass an explicit array to force binding to the `params T[]` overload: +```csharp +Expression> join = + (x, y) => string.Join("", new string[] { x, y }); +``` + +### Ambiguous overload resolution affecting StringValues + +**Impact: Medium.** `StringValues` (from `Microsoft.Extensions.Primitives`) has implicit operators for `string` and `string[]` that conflict with the new `params Span` overloads. The compiler throws CS0121 for ambiguous calls. + +**Fix:** Explicitly cast arguments to the appropriate type or use named parameters. + +### New TimeSpan.From*() overloads that take integers + +**Impact: Low (primarily F#).** New integer overloads of `TimeSpan.FromDays`, `FromHours`, `FromMinutes`, etc. cause ambiguity in F# code. Specify the argument type to select the correct overload. + +### String.Trim(params ReadOnlySpan) overload removed + +**Impact: Medium.** The `Trim`, `TrimStart`, and `TrimEnd` overloads accepting `ReadOnlySpan` were added in .NET 9 previews but removed in GA because they caused behavioral changes with common extension methods (e.g., `"prefixinfixsuffix".TrimEnd("suffix")` would change behavior). + +Code compiled against .NET 9 previews that explicitly passes `ReadOnlySpan` to these overloads may fail with `MissingMethodException` at runtime when run on the GA runtime, or fail to compile when retargeted to .NET 9 GA. Code using `params char[]` continues to work. + +```csharp +// BREAKS — compiled against .NET 9 Preview with ReadOnlySpan overloads +static string TrimLogEntry(string str) +{ + ReadOnlySpan trimChars = [';', ',', '.']; + return str.Trim(trimChars); // calls Trim(ReadOnlySpan) in previews only +} + +// Fix — target GA and use char[] so Trim binds to existing overloads +static string TrimLogEntry(string str) +{ + char[] trimChars = [';', ',', '.']; + return str.Trim(trimChars); // calls Trim(char[]) / Trim(params char[]) +} +``` + +> **Note:** Assemblies compiled against .NET 9 Preview 6 through RC2 must be recompiled to avoid `MissingMethodException` at runtime. + +### API obsoletions (SYSLIB0054–SYSLIB0057) + +| Diagnostic | What's obsolete | Replacement | +|------------|----------------|-------------| +| SYSLIB0054 | `Thread.VolatileRead`/`Thread.VolatileWrite` | `Volatile.Read`/`Volatile.Write` | +| SYSLIB0055 | `AdvSimd.ShiftRightLogicalRoundedNarrowingSaturate*` signed overloads | Unsigned overloads | +| SYSLIB0056 | `Assembly.LoadFrom` with `AssemblyHashAlgorithm` | Overloads without `AssemblyHashAlgorithm` | +| SYSLIB0057 | `X509Certificate2`/`X509Certificate` binary/file constructors and `X509Certificate2Collection.Import` | `X509CertificateLoader` methods | + +These use custom diagnostic IDs — suppressing `CS0618` does not suppress them. + +### New version of some OOB packages + +**Impact: Low.** Some out-of-band packages have updated major versions. Review and update your package references. + +## Behavioral Changes + +### BinaryFormatter always throws + +**Impact: High.** `BinaryFormatter.Serialize` and `BinaryFormatter.Deserialize` now always throw `NotSupportedException` regardless of any configuration. The `EnableUnsafeBinaryFormatterSerialization` AppContext switch has been removed. See `serialization-networking-dotnet8to9.md` for full details. + +### BigInteger maximum length restriction + +**Impact: Low.** `BigInteger` is now limited to `(2^31) - 1` bits (approximately 2.14 billion bits / ~256 MB). Values exceeding this throw `OverflowException`. + +### Default InlineArray Equals() and GetHashCode() throw + +`Equals()` and `GetHashCode()` on types marked with `[InlineArrayAttribute]` now throw instead of returning incorrect results. + +### Inline array struct size limit is enforced + +**Impact: Low.** `[InlineArray]` structs with a byte size exceeding 1 MiB (1,048,576 bytes) now fail to load at runtime. + +### Creating type of array of System.Void not allowed + +Attempting to create `typeof(void[])` or similar constructs now throws `TypeLoadException`. + +### EnumConverter validates registered types + +`EnumConverter` now validates that the type passed is actually an enum, throwing for non-enum types. + +### FromKeyedServicesAttribute no longer injects non-keyed parameter + +**Impact: Medium.** When `[FromKeyedServices("key")]` is used and the keyed service isn't registered, it no longer falls back to injecting a non-keyed service. Instead, `InvalidOperationException` is thrown. + +### IncrementingPollingCounter initial callback is asynchronous + +The initial measurement callback for `IncrementingPollingCounter` is now invoked asynchronously. + +### InMemoryDirectoryInfo prepends rootDir to files + +`InMemoryDirectoryInfo` now prepends the `rootDir` to file paths, which may change how matching works. + +### RuntimeHelpers.GetSubArray returns different type + +`RuntimeHelpers.GetSubArray` may return a different concrete array type than before. + +### Support for empty environment variables + +Empty environment variables are now supported and no longer treated as unset. + +### ZipArchiveEntry names and comments respect UTF8 flag + +`ZipArchiveEntry` now correctly uses UTF-8 encoding for names and comments when the UTF-8 flag is set in the entry header, which may change how non-ASCII entry names are read. + +### Adding ZipArchiveEntry with CompressionLevel sets header flags + +Adding a `ZipArchiveEntry` with an explicit `CompressionLevel` now sets the general-purpose bit flags in the ZIP central directory header. + +### Altered UnsafeAccessor support for non-open generics + +`UnsafeAccessor` behavior changed for non-open generic types. + +### BinaryReader.ReadString() returns "\uFFFD" on malformed sequences + +`BinaryReader.ReadString()` now returns the Unicode replacement character instead of throwing for malformed byte sequences. + +### Other behavioral changes (lower impact) + +- `ServicePointManager` (SYSLIB0014) is now fully obsolete — settings do not affect `SslStream` or `HttpClient` +- `AuthenticationManager` (SYSLIB0009) methods now no-op or throw `PlatformNotSupportedException` diff --git a/.agents/skills/migrate-dotnet8-to-dotnet9/references/cryptography-dotnet8to9.md b/.agents/skills/migrate-dotnet8-to-dotnet9/references/cryptography-dotnet8to9.md new file mode 100644 index 00000000..94d9fe72 --- /dev/null +++ b/.agents/skills/migrate-dotnet8-to-dotnet9/references/cryptography-dotnet8to9.md @@ -0,0 +1,45 @@ +# Cryptography Breaking Changes (.NET 9) + +These changes affect projects using `System.Security.Cryptography`, X.509 certificates, or OpenSSL. + +## Source-Incompatible Changes + +### X509Certificate2 and X509Certificate constructors are obsolete (SYSLIB0057) + +**Impact: High.** The constructors on `X509Certificate` and `X509Certificate2` that accept content as `byte[]`, `ReadOnlySpan`, or a file path are now obsolete. The `Import` methods on `X509Certificate2Collection` are also obsolete. Using them produces warning `SYSLIB0057`. + +```csharp +// BREAKS — SYSLIB0057 warning +var cert = new X509Certificate2(certBytes); +var cert2 = new X509Certificate2("cert.pfx", "password"); +collection.Import(certBytes); +``` + +**Why:** These APIs accepted multiple formats (X.509, PKCS7, PKCS12/PFX) from a single parameter, making it possible to load a different format than intended with user-supplied data. + +**Fix:** Use `X509CertificateLoader` methods: +```csharp +// Load DER/PEM encoded certificate +var derCert = X509CertificateLoader.LoadCertificate(certBytes); + +// Load PFX/PKCS12 +var pfxCert = X509CertificateLoader.LoadPkcs12(pfxBytes, "password"); + +// Load from file +var fileCert = X509CertificateLoader.LoadCertificateFromFile("cert.pem"); +var filePfxCert = X509CertificateLoader.LoadPkcs12FromFile("cert.pfx", "password"); +``` + +### APIs removed from System.Security.Cryptography.Pkcs netstandard2.0 + +Some APIs were removed from the `netstandard2.0` target of the `System.Security.Cryptography.Pkcs` package. These APIs are still available when targeting .NET. + +## Behavioral Changes + +### SafeEvpPKeyHandle.DuplicateHandle up-refs the handle + +**Impact: Low.** `SafeEvpPKeyHandle.DuplicateHandle` now increments the reference count on the underlying OpenSSL key handle instead of creating a fully independent copy. This is more efficient but means changes to one handle may affect duplicates. + +### Windows private key lifetime simplified + +**Impact: Low.** The lifetime management of private keys associated with certificates on Windows has been simplified. This may affect code that makes assumptions about when private key handles are released. diff --git a/.agents/skills/migrate-dotnet8-to-dotnet9/references/csharp-compiler-dotnet8to9.md b/.agents/skills/migrate-dotnet8-to-dotnet9/references/csharp-compiler-dotnet8to9.md new file mode 100644 index 00000000..2e98721e --- /dev/null +++ b/.agents/skills/migrate-dotnet8-to-dotnet9/references/csharp-compiler-dotnet8to9.md @@ -0,0 +1,101 @@ +# C# 13 Compiler Breaking Changes (.NET 9) + +These breaking changes are introduced by the Roslyn compiler shipping with the .NET 9 SDK. They affect all projects targeting `net9.0` (which uses C# 13 by default). These are maintained separately from the runtime breaking changes at: https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/breaking-changes/compiler%20breaking%20changes%20-%20dotnet%209 + +## Source-Incompatible Changes + +### InlineArray attribute on record structs is disallowed + +**Impact: Medium.** You can no longer apply the `[InlineArray]` attribute to `record struct` types. This produces error CS9259. + +```csharp +// BREAKS — CS9259 error +[System.Runtime.CompilerServices.InlineArray(10)] +record struct Buffer() +{ + private int _element0; +} +``` + +**Fix:** Change `record struct` to a plain `struct`: +```csharp +[System.Runtime.CompilerServices.InlineArray(10)] +struct Buffer +{ + private int _element0; +} +``` + +### Iterators introduce safe context in C# 13 + +**Impact: Low–Medium.** Although the language spec states that iterators introduce a safe context, Roslyn did not enforce this in C# 12 and lower. In C# 13, iterators always introduce a safe context, which can break scenarios where unsafe context was inherited by nested local functions. + +```csharp +// BREAKS in C# 13 +unsafe class C +{ + System.Collections.Generic.IEnumerable M() + { + yield return 1; + local(); + void local() + { + int* p = null; // error: unsafe code in safe context + } + } +} +``` + +**Fix:** Add the `unsafe` modifier to the local function: +```csharp +unsafe void local() +{ + int* p = null; // OK +} +``` + +### Collection expression overload resolution changes + +**Impact: Low–Medium.** Two changes in how collection expressions resolve overloads: + +1. **Empty collection expressions no longer use span to tiebreak**: When passing `[]` to an overloaded method without a clear element type, `ReadOnlySpan` vs `Span` is no longer used to disambiguate. This can turn previously successful calls into errors. + +```csharp +class C +{ + static void M(ReadOnlySpan ros) {} + static void M(Span s) {} + + static void Main() + { + M([]); // Chose ReadOnlySpan in C# 12, error in C# 13 + } +} +``` + +2. **Exact element type is now preferred**: Overload resolution prefers an exact element type match from expressions. This can change which overload is selected: + +```csharp +class C +{ + static void M(ReadOnlySpan ros) {} + static void M(Span s) {} + + static void Main() + { + M([1]); // ReadOnlySpan in C# 12, Span in C# 13 + } +} +``` + +**Fix:** Add explicit casts or use a typed variable to select the desired overload. + +### Default and params parameters considered in method group natural type + +**Impact: Low.** The compiler previously inferred different delegate types depending on candidate order when default parameter values or `params` arrays were used. Now an ambiguity error is emitted. Fix by using explicit delegate types instead of `var`. + +### Other low-impact source changes + +- **`scoped` in lambda parameters (inherited from C# 12 changes)**: Always treated as a modifier in newer language versions. +- **Indexers without `DefaultMemberAttribute`**: No longer allowed (CS0656). +- **`dotnet_style_require_accessibility_modifiers`** now enforces on interface members consistently. diff --git a/.agents/skills/migrate-dotnet8-to-dotnet9/references/deployment-runtime-dotnet8to9.md b/.agents/skills/migrate-dotnet8-to-dotnet9/references/deployment-runtime-dotnet8to9.md new file mode 100644 index 00000000..b744d715 --- /dev/null +++ b/.agents/skills/migrate-dotnet8-to-dotnet9/references/deployment-runtime-dotnet8to9.md @@ -0,0 +1,58 @@ +# Deployment and Runtime Breaking Changes (.NET 9) + +These changes affect runtime configuration, deployment, and JIT compiler behavior. + +## Deployment + +### Environment variables take precedence in app runtime configuration settings + +**Impact: Medium.** When both an environment variable and a corresponding `runtimeconfig.json` setting are provided, the environment variable now takes precedence. + +Example `runtimeconfig.json`: +```json +{ + "runtimeOptions": { + "configProperties": { + "System.GC.Server": true + } + } +} +``` + +Previously, this `runtimeconfig.json` setting would override `DOTNET_gcServer=0`. Now `DOTNET_gcServer=0` overrides the config file, disabling server GC. + +**Mitigation:** If your app runs in an environment with runtime configuration environment variables, either unset them or set them to the desired values. Ensure environment variables and config files are consistent. + +### Deprecated desktop Windows/macOS/Linux MonoVM runtime packages + +**Impact: Low.** The desktop MonoVM runtime packages (`Microsoft.NETCore.App.Runtime.Mono.*`) are deprecated. Apps that explicitly used Mono on desktop should migrate to CoreCLR or use other supported runtimes. + +## JIT Compiler + +### Floating point to integer conversions are saturating + +**Impact: Medium.** On x86/x64, conversions from `float`/`double` to integer types now use **saturating** behavior instead of the previous platform-specific wrapping: + +| Scenario | .NET 8 (x86/x64) | .NET 9 | +|----------|-------------------|--------| +| `(int)float.MaxValue` | `int.MinValue` | `int.MaxValue` | +| `(int)float.NaN` | `int.MinValue` | `0` | +| `(uint)(-1.0f)` | Wrapping result | `0` | +| `(ulong)(double.MaxValue)` | Wrapping result | `ulong.MaxValue` | + +```csharp +// .NET 8: (int)float.PositiveInfinity == int.MinValue (wrapping) +// .NET 9: (int)float.PositiveInfinity == int.MaxValue (saturating) + +// .NET 8: (int)float.NaN == int.MinValue +// .NET 9: (int)float.NaN == 0 + +// .NET 8: (uint)(-1.0f) == some wrapping result +// .NET 9: (uint)(-1.0f) == 0 +``` + +**Mitigation:** If you relied on the previous wrapping behavior (which was already non-deterministic across platforms), use the new `ConvertToIntegerNative` methods on `Single`, `Double`, and `Half` for the fast, platform-native behavior. Or use platform-specific hardware intrinsics for exact control. + +### Some SVE APIs removed + +A small number of ARM SVE (Scalable Vector Extension) APIs were removed in RC2 that were previously available in previews. diff --git a/.agents/skills/migrate-dotnet8-to-dotnet9/references/efcore-dotnet8to9.md b/.agents/skills/migrate-dotnet8-to-dotnet9/references/efcore-dotnet8to9.md new file mode 100644 index 00000000..de524a83 --- /dev/null +++ b/.agents/skills/migrate-dotnet8-to-dotnet9/references/efcore-dotnet8to9.md @@ -0,0 +1,155 @@ +# Entity Framework Core 9 Breaking Changes + +These changes affect projects using EF Core. + +## High-Impact Changes + +### Exception is thrown when applying migrations if there are pending model changes + +**Impact: High.** `dotnet ef database update`, `Migrate()`, and `MigrateAsync()` now throw if the model has pending changes compared to the last migration: + +> The model for context 'DbContext' has pending changes. Add a new migration before updating the database. + +**Common causes:** +- No migrations exist at all (database managed through other means) +- Model snapshot is missing (migration was created manually) +- Non-deterministic model building (`DateTime.Now`, `Guid.NewGuid()` in `HasData`) +- Last migration created for a different provider +- ASP.NET Core Identity options that affect the model aren't applied in design-time factory + +**Non-deterministic `HasData` example (common pitfall):** +```csharp +// BREAKS — DateTime.UtcNow changes every evaluation, causing "pending model changes" +modelBuilder.Entity().HasData( + new Order { Id = 1, CreatedAt = DateTime.UtcNow }); + +// Fix — use a fixed constant +modelBuilder.Entity().HasData( + new Order { Id = 1, CreatedAt = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc) }); +``` + +**Mitigation (temporary workaround — not recommended for production):** +```csharp +// Suppress the warning if intentional — review before deploying to production, +// as this risks silent schema drift between the model and the database. +options.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)); +``` + +### Exception is thrown when applying migrations in an explicit transaction + +**Impact: High.** `Migrate()` and `MigrateAsync()` now manage their own transactions and `ExecutionStrategy`. Wrapping them in a user transaction throws: + +> A transaction was started before applying migrations. This prevents a database lock to be acquired. + +```csharp +// BREAKS +await dbContext.Database.CreateExecutionStrategy().ExecuteAsync(async () => +{ + await using var transaction = await dbContext.Database.BeginTransactionAsync(ct); + await dbContext.Database.MigrateAsync(ct); + await transaction.CommitAsync(ct); +}); + +// Fix — remove the external transaction +await dbContext.Database.MigrateAsync(ct); +``` + +**Mitigation (temporary workaround):** If you need the explicit transaction: +```csharp +// Suppress if you understand the transaction safety implications. +// EF Core manages its own transactions during migration; wrapping in a user +// transaction can prevent proper lock acquisition. +options.ConfigureWarnings(w => w.Ignore(RelationalEventId.MigrationsUserTransactionWarning)); +``` + +## Medium-Impact Changes + +### `Microsoft.EntityFrameworkCore.Design` not found when using EF tools + +**Impact: Medium.** Starting with .NET SDK 9.0.200, the EF tools may fail with: `Could not load file or assembly 'Microsoft.EntityFrameworkCore.Design'`. This is caused by a change in how private assets are included in `.deps.json`. + +**Mitigation:** Mark the Design package as publishable: +```xml + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + true + +``` + +## Low-Impact Changes + +### `EF.Functions.Unhex()` now returns `byte[]?` + +The return type annotation changed from `byte[]` to `byte[]?` to match SQLite's `unhex` function which returns NULL for invalid inputs. Add the null-forgiving operator (`!`) if you're certain the input is valid. + +### Compiled models reference value converter methods directly + +Value converter methods referenced by compiled models must now be `public` or `internal` (not `private`), as the generated code references them directly for NativeAOT support. + +### SqlFunctionExpression nullability arguments arity validated + +The number of arguments and nullability propagation arguments must now match. When in doubt, use `false` for the nullability argument. + +### `ToString()` returns empty string for null instances + +`ToString()` on nullable value types in EF queries now consistently returns empty string when the value is `null`, matching C# behavior. Previously the result was inconsistent across types. + +### Shared framework dependencies updated to 9.0.x + +EF Core 9.0 references 9.0.x versions of `System.Text.Json`, `Microsoft.Extensions.Caching.Memory`, etc. Apps targeting `net8.0` will deploy these assemblies separately instead of using the shared framework. + +## Azure Cosmos DB Breaking Changes + +EF Core 9.0 has extensive Cosmos DB changes. If using the Cosmos DB provider: + +### Discriminator property renamed to `$type` (High Impact) + +The default discriminator property name changed from `Discriminator` to `$type` to align with `System.Text.Json` conventions. Existing documents use the old name. + +**Mitigation:** +```csharp +modelBuilder.Entity().HasDiscriminator("Discriminator"); +``` + +### `id` property no longer contains discriminator (High Impact) + +The `id` property now contains only the EF key property value (e.g., `123`), not `EntityType|KeyValue` (e.g., `Product|123`). Existing documents written by EF Core 8 still have the old compound format. + +**Implications for existing data:** +- `FindAsync` and point-reads by id will fail because EF Core 9 generates `123` but the stored document has `Product|123` +- New documents get the short format, creating id inconsistency with existing documents +- Direct Cosmos SQL queries matching on `c.id` need updating + +**Mitigation:** Preserve the old id format so existing documents remain accessible: +```csharp +modelBuilder.Entity().HasRootDiscriminatorInJsonId(true); +``` + +### JSON `id` property mapped to key (High Impact) + +The JSON `id` property is now mapped directly to the entity key property. + +### Sync I/O no longer supported (Medium Impact) + +Synchronous methods like `SaveChanges()` and `ToList()` on the Cosmos DB provider now throw. Use async equivalents. + +### SQL queries must project JSON values directly (Medium Impact) + +Raw SQL queries against the Cosmos DB provider must now project values from the JSON document directly using `VALUE`. + +### Undefined results filtered from query results (Medium Impact) + +Query results that are `undefined` in Cosmos DB are now automatically filtered out. + +### Incorrectly translated queries no longer translated (Medium Impact) + +Some queries that were previously translated incorrectly now throw `InvalidOperationException` to prevent silent data corruption. + +### `HasIndex` now throws (Medium Impact) + +`HasIndex` on a Cosmos DB entity type now throws `InvalidOperationException` at startup instead of being silently ignored. Remove all `HasIndex` calls for Cosmos entities — Cosmos DB indexing is managed through the container's indexing policy (Azure Portal, Bicep, or Terraform), not through EF Core. + +### `IncludeRootDiscriminatorInJsonId` renamed (Low Impact) + +Renamed to `HasRootDiscriminatorInJsonId` after RC2. diff --git a/.agents/skills/migrate-dotnet8-to-dotnet9/references/sdk-msbuild-dotnet8to9.md b/.agents/skills/migrate-dotnet8-to-dotnet9/references/sdk-msbuild-dotnet8to9.md new file mode 100644 index 00000000..431dc0f4 --- /dev/null +++ b/.agents/skills/migrate-dotnet8-to-dotnet9/references/sdk-msbuild-dotnet8to9.md @@ -0,0 +1,61 @@ +# SDK and MSBuild Breaking Changes (.NET 9) + +These changes affect the .NET SDK, CLI tooling, NuGet, and MSBuild behavior. + +## Source-Incompatible Changes + +### Version requirements for .NET 9 SDK + +The .NET 9 SDK has updated minimum Visual Studio and MSBuild version requirements: + +- With VS 17.11, the .NET 9.0.100 SDK can only target `net8.0` and earlier frameworks +- VS 17.12 or later is required to target `net9.0` + +Attempting to target `net9.0` in VS 17.11 produces: `NETSDK1223: Targeting .NET 9.0 or higher in Visual Studio 2022 17.11 is not supported.` + +### Warning emitted for .NET Standard 1.x target + +Projects targeting .NET Standard 1.x now produce a build warning encouraging migration to a newer target. + +### Warning emitted for .NET 7 target + +Projects targeting `net7.0` now produce a build warning because .NET 7 is out of support. Update to `net8.0` or `net9.0`. + +### New default RID used when targeting .NET Framework + +A new default Runtime Identifier (RID) is used when targeting .NET Framework, which may affect build output paths. + +## Behavioral Changes + +### Terminal Logger is default + +**Impact: Medium for CI scripts.** `dotnet build` and other build-related CLI commands now use Terminal Logger by default for interactive terminal sessions. Terminal Logger formats output differently from the console logger. + +**Mitigation:** +- Per-command: `--tl:off` +- Global: set `MSBUILDTERMINALLOGGER=off` environment variable + +### `dotnet workload` commands output change + +`dotnet workload` commands have changed their output format. Scripts that parse workload command output may need updating. + +### `dotnet sln add` doesn't allow invalid file names + +`dotnet sln add` now validates file names more strictly. + +### `dotnet watch` incompatible with Hot Reload for old frameworks + +`dotnet watch` in .NET 9 SDK is not compatible with Hot Reload when targeting older frameworks. Use the SDK matching the target framework for `dotnet watch` scenarios. + +### `installer` repo version no longer documented + +The `installer` repository version is no longer included in `productcommits` information. + +### MSBuild custom culture resource handling + +MSBuild custom culture resource handling has changed (also applies to .NET 9 SDK 9.0.200/9.0.300). + +### Other behavioral changes + +- `--interactive` may have different defaults in some scenarios +- Default workload management may behave differently diff --git a/.agents/skills/migrate-dotnet8-to-dotnet9/references/serialization-networking-dotnet8to9.md b/.agents/skills/migrate-dotnet8-to-dotnet9/references/serialization-networking-dotnet8to9.md new file mode 100644 index 00000000..bcc5adb4 --- /dev/null +++ b/.agents/skills/migrate-dotnet8-to-dotnet9/references/serialization-networking-dotnet8to9.md @@ -0,0 +1,119 @@ +# Serialization and Networking Breaking Changes (.NET 9) + +These changes affect projects using BinaryFormatter, System.Text.Json, HttpClient, and networking APIs. + +## Serialization + +### BinaryFormatter always throws + +**Impact: High.** The in-box `BinaryFormatter` implementation now always throws `NotSupportedException` on `Serialize` and `Deserialize`, even with settings that previously enabled its use. The `EnableUnsafeBinaryFormatterSerialization` AppContext switch has been removed. + +```csharp +// Serialization — BREAKS at runtime with NotSupportedException +var formatter = new BinaryFormatter(); +formatter.Serialize(stream, obj); // throws +``` + +```csharp +// Deserialization — BREAKS at runtime with NotSupportedException +var formatter = new BinaryFormatter(); +var obj = formatter.Deserialize(stream); // throws +``` + +**This is a major architectural decision.** If the project uses BinaryFormatter, the agent should stop and ask the user which replacement to use. Options include: +1. **System.Text.Json** — Modern, high-performance, built-in +2. **MessagePack** or **protobuf-net** — Compact binary formats +3. **XmlSerializer** or **DataContractSerializer** — XML-based formats + +> **Security: Do not re-enable BinaryFormatter.** A NuGet compatibility package exists (`System.Runtime.Serialization.Formatters`) but it is unsupported and **inherently unsafe** — BinaryFormatter deserialization enables remote code execution from crafted payloads, which is why it was removed. Do not suggest or use this package. + +The choice depends on existing data format, backward compatibility requirements, and performance needs. Stored data may need a migration strategy. + +See the [BinaryFormatter migration guide](https://learn.microsoft.com/en-us/dotnet/standard/serialization/binaryformatter-migration-guide/) for detailed guidance. + +### Nullable JsonDocument properties deserialize to JsonValueKind.Null + +**Impact: Medium.** Deserializing JSON `null` into `JsonDocument` now returns a non-null `JsonDocument` with `RootElement.ValueKind == JsonValueKind.Null` instead of returning C# `null`. + +```csharp +var doc = JsonSerializer.Deserialize("null"); +// .NET 8: doc is null +// .NET 9: doc is not null, doc.RootElement.ValueKind == JsonValueKind.Null +``` + +**Fix:** Update code that checks `doc is null` to also check `doc.RootElement.ValueKind`: +```csharp +if (doc is null || doc.RootElement.ValueKind == JsonValueKind.Null) +{ + // handle null +} +``` + +### System.Text.Json metadata reader now unescapes metadata property names + +**Impact: Low.** The JSON metadata reader now unescapes metadata property names (like `$type`, `$id`). This may affect custom converters or code that processes raw JSON metadata. + +## Networking + +### HttpClientFactory uses SocketsHttpHandler as primary handler + +**Impact: Medium.** The default primary handler for `HttpClientFactory`-created clients is now `SocketsHttpHandler` instead of `HttpClientHandler` on platforms that support it. Code that casts the handler to `HttpClientHandler` will throw `InvalidCastException`. + +```csharp +// BREAKS — InvalidCastException at runtime in .NET 9 +services.AddHttpClient("test") + .ConfigureHttpMessageHandlerBuilder(b => + { + ((HttpClientHandler)b.PrimaryHandler).UseCookies = false; // throws + }); +``` + +**Fix options:** +```csharp +// Option 1: Explicitly configure a primary handler +services.AddHttpClient("test") + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler() { UseCookies = false }); + +// Option 2: Check for both handler types +services.AddHttpClient("test") + .ConfigureHttpMessageHandlerBuilder(b => + { + if (b.PrimaryHandler is HttpClientHandler hch) hch.UseCookies = false; + else if (b.PrimaryHandler is SocketsHttpHandler shh) shh.UseCookies = false; + }); + +// Option 3: Set defaults for all clients +services.ConfigureHttpClientDefaults(b => + b.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler() { UseCookies = false })); +``` + +`SocketsHttpHandler` also has `PooledConnectionLifetime` preset to match `HandlerLifetime`, improving DNS rotation for captured clients. + +### HttpClientFactory logging redacts header values by default + +**Impact: Medium.** All header values in `Trace`-level `HttpClientFactory` logs are now redacted by default. Previously, unspecified headers were logged in full. + +**Fix:** Explicitly allowlist specific non-sensitive headers that need to be logged: +```csharp +// Allow specific non-sensitive headers to be logged unredacted +services.ConfigureHttpClientDefaults(b => + b.RedactLoggedHeaders(h => h != "Cache-Control" && h != "Accept")); +``` + +> **Warning:** Do not disable redaction globally with `RedactLoggedHeaders(_ => false)`. This logs all header values in cleartext, including `Authorization` tokens, cookies, and other credentials. Logs are often broadly accessible or exported to external systems, making this a credential leak risk. + +### HttpClient metrics report `server.port` unconditionally + +`HttpClient` metrics now always include the `server.port` attribute, even for default ports (80/443). This may affect metric dashboards or alerting rules. + +### HttpListenerRequest.UserAgent is nullable + +`HttpListenerRequest.UserAgent` is now `string?` instead of `string`. Add null checks. + +### URI query redaction in HttpClient EventSource events + +URI query strings are now redacted in `HttpClient` EventSource events for security. + +### URI query redaction in IHttpClientFactory logs + +URI query strings are now redacted in `IHttpClientFactory` logs for security. diff --git a/.agents/skills/migrate-dotnet8-to-dotnet9/references/winforms-wpf-dotnet8to9.md b/.agents/skills/migrate-dotnet8-to-dotnet9/references/winforms-wpf-dotnet8to9.md new file mode 100644 index 00000000..84f4f196 --- /dev/null +++ b/.agents/skills/migrate-dotnet8-to-dotnet9/references/winforms-wpf-dotnet8to9.md @@ -0,0 +1,91 @@ +# Windows Forms and WPF Breaking Changes (.NET 9) + +These changes affect projects using Windows Forms (`true`) or WPF (`true`). + +## Windows Forms + +### Source-Incompatible Changes + +#### Changes to nullability annotations + +**Impact: Medium.** Some WinForms API parameters changed nullability. Specifically, `IWindowsFormsEditorService.DropDownControl(Control)` parameter was previously nullable and is now non-nullable. Update implementations and call sites to match. + +#### New security analyzers (WFO1000) + +**Impact: Medium.** New analyzers enforce that properties in controls and `UserControl` objects have explicit serialization configuration via `DesignerSerializationVisibilityAttribute`, `DefaultValueAttribute`, or `ShouldSerialize[PropertyName]` methods. By default, the analyzer produces an **error**. + +``` +WFO1000: Property 'property' does not configure the code serialization for its property content. +``` + +**Fix:** Add appropriate serialization attributes to flagged properties — `DesignerSerializationVisibilityAttribute`, `DefaultValueAttribute`, or a `ShouldSerialize[PropertyName]` method. + +> **Warning:** Do not suppress WFO1000 globally. This analyzer guards against insecure deserialization of control properties in the WinForms designer. Suppressing it can leave controls vulnerable to deserialization attacks through crafted designer files. If specific properties are intentionally excluded from serialization, use `[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]` on those properties rather than silencing the analyzer project-wide. + +### Behavioral Changes + +#### StatusStrip uses a different default renderer + +**Impact: Low.** `StatusStrip.RenderMode` no longer defaults to `ToolStripRenderMode.System`. The visual appearance may differ. Set `RenderMode` explicitly to restore the previous look: +```csharp +statusStrip.RenderMode = ToolStripRenderMode.System; +``` + +> Note: This change was reverted in a .NET 9 servicing release. + +#### PictureBox raises HttpClient exceptions + +**Impact: Low.** `PictureBox` now raises `HttpRequestException` and `TaskCanceledException` instead of `WebException` when loading images from URLs fails. Update catch blocks: +```csharp +// Before +try { pictureBox.Load(url); } +catch (WebException) { } + +// After +try { pictureBox.Load(url); } +catch (HttpRequestException) { } +catch (TaskCanceledException) { } +``` + +#### IMsoComponent support is opt-in + +**Impact: Low.** WinForms threads no longer automatically register with `IMsoComponentManager` instances. To restore: +```xml + + + +``` + +#### BindingSource.SortDescriptions doesn't return null + +`SortDescriptions` now returns an empty collection instead of null. + +#### ComponentDesigner.Initialize throws ArgumentNullException + +`ComponentDesigner.Initialize` now throws `ArgumentNullException` for null input. + +#### DataGridViewRowAccessibleObject.Name starting row index + +The starting row index in accessible object names has changed. + +#### No exception if DataGridView is null + +`DataGridViewHeaderCell` no longer throws `NullReferenceException` when `DataGridView` is null. + +## WPF + +### Behavioral Changes / Source-Incompatible + +#### `XmlNamespaceMaps` type change + +**Impact: Low.** The backing property of `XmlAttributeProperties.XmlNamespaceMaps` changed from `String` to `Hashtable`. The `SetXmlNamespaceMaps` method now accepts `Hashtable` instead of `String`. + +```csharp +// Before — passed string +XmlAttributeProperties.SetXmlNamespaceMaps(obj, someString); + +// After — pass Hashtable +XmlAttributeProperties.SetXmlNamespaceMaps(obj, someHashtable); +``` + +`GetXmlNamespaceMaps` now returns `Hashtable` instead of `String`. diff --git a/.agents/skills/migrate-dotnet9-to-dotnet10/SKILL.md b/.agents/skills/migrate-dotnet9-to-dotnet10/SKILL.md new file mode 100644 index 00000000..f792bf7a --- /dev/null +++ b/.agents/skills/migrate-dotnet9-to-dotnet10/SKILL.md @@ -0,0 +1,263 @@ +--- +description: 'Migrate a .NET 9 project or solution to .NET 10 and resolve all breaking changes. USE FOR: upgrading TargetFramework from net9.0 to net10.0, fixing build errors after updating the .NET 10 SDK, resolving source and behavioral changes in .NET 10 / C# 14 / ASP.NET Core 10 / EF Core 10, updating Dockerfiles for Debian-to-Ubuntu base images, resolving obsoletion warnings (SYSLIB0058-SYSLIB0062), adapting to SDK/NuGet changes (NU1510, PrunePackageReference), migrating System.Linq.Async to built-in AsyncEnumerable, fixing OpenApi v2 API changes, cryptography renames, and C# 14 compiler changes (field keyword, extension keyword, span overloads). DO NOT USE FOR: .NET Framework migrations, upgrading from .NET 8 or earlier (use migrate-dotnet8-to-dotnet9 first), greenfield .NET 10 projects, or cosmetic modernization. LOADS REFERENCES: csharp-compiler, core-libraries, sdk-msbuild (always); aspnet-core, efcore, cryptography, extensions-hosting, serialization-networking, winforms-wpf, containers-interop (selective).' +metadata: + github-path: plugins/dotnet-upgrade/skills/migrate-dotnet9-to-dotnet10 + github-pinned: v1.0.0 + github-ref: refs/tags/v1.0.0 + github-repo: https://github.com/dotnet/skills + github-tree-sha: 566845910e3c74dc0a6156e48a99b67b762d3259 +name: migrate-dotnet9-to-dotnet10 +--- +# .NET 9 → .NET 10 Migration + +Migrate a .NET 9 project or solution to .NET 10, systematically resolving all breaking changes. The outcome is a project targeting `net10.0` that builds cleanly, passes tests, and accounts for every behavioral, source-incompatible, and binary-incompatible change introduced in the .NET 10 release. + +## When to Use + +- Upgrading `TargetFramework` from `net9.0` to `net10.0` +- Resolving build errors or new warnings after updating the .NET 10 SDK +- Adapting to behavioral changes in .NET 10 runtime, ASP.NET Core 10, or EF Core 10 +- Updating CI/CD pipelines, Dockerfiles, or deployment scripts for .NET 10 +- Migrating from the community `System.Linq.Async` package to the built-in `System.Linq.AsyncEnumerable` + +## When Not to Use + +- The project already targets `net10.0` and builds cleanly — migration is done +- Upgrading from .NET 8 or earlier — use the `migrate-dotnet8-to-dotnet9` skill first to reach `net9.0`, then return to this skill for the `net9.0` → `net10.0` migration +- Migrating from .NET Framework — that is a separate, larger effort +- Greenfield projects that start on .NET 10 (no migration needed) + +## Inputs + +| Input | Required | Description | +|-------|----------|-------------| +| Project or solution path | Yes | The `.csproj`, `.sln`, or `.slnx` entry point to migrate | +| Build command | No | How to build (e.g., `dotnet build`, a repo build script). Auto-detect if not provided | +| Test command | No | How to run tests (e.g., `dotnet test`). Auto-detect if not provided | +| Project type hints | No | Whether the project uses ASP.NET Core, EF Core, WinForms, WPF, containers, etc. Auto-detect from PackageReferences and SDK attributes if not provided | + +## Workflow + +> **Answer directly from the loaded reference documents.** Do not search the filesystem or fetch web pages for breaking change information — the references contain the authoritative details. Focus on identifying which breaking changes apply and providing concrete fixes. **Exception:** If you suspect a security vulnerability (CVE) may apply to the project's dependencies, check for published security advisories — the reference documents may not cover post-publication CVEs. +> +> **Commit strategy:** Commit at each logical boundary — after updating the TFM (Step 2), after resolving build errors (Step 3), after addressing behavioral changes (Step 4), and after updating infrastructure (Step 5). This keeps each commit focused and reviewable. + +### Step 1: Assess the project + +1. Identify how the project is built and tested. Look for build scripts, `.sln`/`.slnx` files, or individual `.csproj` files. +2. Run `dotnet --version` to confirm the .NET 10 SDK is installed. If it is not, stop and inform the user. +3. Determine which technology areas the project uses by examining: + - **SDK attribute**: `Microsoft.NET.Sdk.Web` → ASP.NET Core; `Microsoft.NET.Sdk.WindowsDesktop` with `` or `` → WPF/WinForms + - **PackageReferences**: `Microsoft.EntityFrameworkCore.*` → EF Core; `Microsoft.Data.Sqlite` → Sqlite; `Microsoft.Extensions.Hosting` → Generic Host / BackgroundService + - **Dockerfile presence** → Container changes relevant + - **P/Invoke or native interop usage** → Interop changes relevant + - **`System.Linq.Async` package reference** → AsyncEnumerable migration needed + - **`System.Text.Json` usage with polymorphism** → Serialization changes relevant +4. Record which reference documents are relevant (see the reference loading table in Step 3). +5. Do a **clean build** (`dotnet build --no-incremental` or delete `bin`/`obj`) on the current `net9.0` target to establish a clean baseline. Record any pre-existing warnings. + +### Step 2: Update the Target Framework + +1. In each `.csproj` (or `Directory.Build.props` if centralized), change: + ```xml + net9.0 + ``` + to: + ```xml + net10.0 + ``` + For multi-targeted projects, add `net10.0` to `` or replace `net9.0`. + +2. Update all `Microsoft.Extensions.*`, `Microsoft.AspNetCore.*`, `Microsoft.EntityFrameworkCore.*`, and other Microsoft package references to their 10.0.x versions. If using Central Package Management (`Directory.Packages.props`), update versions there. + +3. Run `dotnet restore`. Watch for: + - **NU1510**: Direct references pruned by NuGet — the package may be included in the shared framework now. Remove the explicit `` if so. + - **PackageReference without a version now raises an error** — every `` must have a `Version` (or use CPM). + - **NuGet auditing of transitive packages** (`dotnet restore` now audits transitive deps) — review any new vulnerability warnings. + +4. Run a clean build. Collect all errors and new warnings. These will be addressed in Step 3. + +### Step 3: Resolve build errors and source-incompatible changes + +Work through compilation errors and new warnings systematically. Load the appropriate reference documents based on the project type: + +| If the project uses… | Load reference | +|-----------------------|----------------| +| Any .NET 10 project | `references/csharp-compiler-dotnet9to10.md` | +| Any .NET 10 project | `references/core-libraries-dotnet9to10.md` | +| Any .NET 10 project | `references/sdk-msbuild-dotnet9to10.md` | +| ASP.NET Core | `references/aspnet-core-dotnet9to10.md` | +| Entity Framework Core | `references/efcore-dotnet9to10.md` | +| Cryptography APIs | `references/cryptography-dotnet9to10.md` | +| Microsoft.Extensions.Hosting, BackgroundService, configuration | `references/extensions-hosting-dotnet9to10.md` | +| System.Text.Json, XmlSerializer, HttpClient, MailAddress, Uri | `references/serialization-networking-dotnet9to10.md` | +| Windows Forms or WPF | `references/winforms-wpf-dotnet9to10.md` | +| Docker containers, single-file apps, native interop | `references/containers-interop-dotnet9to10.md` | + +**Common source-incompatible changes to check for:** + +1. **`System.Linq.Async` conflicts** — Remove the `System.Linq.Async` package reference or upgrade to v7.0.0. If consumed transitively, add `compile`. Rename `SelectAwait` calls to `Select` where needed. + +2. **New obsoletion warnings (SYSLIB0058–SYSLIB0062)**: + - `SYSLIB0058`: Replace `SslStream.KeyExchangeAlgorithm`/`CipherAlgorithm`/`HashAlgorithm` with `NegotiatedCipherSuite` — if the old properties were used to reject weak TLS ciphers, preserve equivalent validation logic using the new API + - `SYSLIB0059`: Replace `SystemEvents.EventsThreadShutdown` with `AppDomain.ProcessExit` + - `SYSLIB0060`: Replace `Rfc2898DeriveBytes` constructors with `Rfc2898DeriveBytes.Pbkdf2` + - `SYSLIB0061`: Replace `Queryable.MaxBy`/`MinBy` overloads taking `IComparer` with ones taking `IComparer` + - `SYSLIB0062`: Replace `XsltSettings.EnableScript` usage + +3. **C# 14 `field` keyword in property accessors** — The identifier `field` is now a contextual keyword inside property `get`/`set`/`init` accessors. Local variables named `field` cause CS9272 (error). Class members named `field` referenced without `this.` cause CS9258 (warning). Fix by renaming (e.g., `fieldValue`) or escaping with `@field`. See `references/csharp-compiler-dotnet9to10.md`. + +4. **C# 14 `extension` contextual keyword** — Types, aliases, or type parameters named `extension` are disallowed. Rename or escape with `@extension`. + +5. **C# 14 overload resolution with span parameters** — Expression trees containing `.Contains()` on arrays may now bind to `MemoryExtensions.Contains` instead of `Enumerable.Contains`. `Enumerable.Reverse` on arrays may resolve to the in-place `Span` extension. Fix by casting to `IEnumerable`, using `.AsEnumerable()`, or explicit static invocations. See `references/csharp-compiler-dotnet9to10.md` for full details. + +6. **ASP.NET Core obsoletions** (if applicable): + - `WebHostBuilder`, `IWebHost`, `WebHost` are obsolete — migrate to `Host.CreateDefaultBuilder` or `WebApplication.CreateBuilder` + - `IActionContextAccessor` / `ActionContextAccessor` obsolete + - `WithOpenApi` extension method deprecated + - `IncludeOpenAPIAnalyzers` property deprecated + - `IPNetwork` and `ForwardedHeadersOptions.KnownNetworks` obsolete + - Razor runtime compilation is obsolete + - `Microsoft.Extensions.ApiDescription.Client` package deprecated + - **`Microsoft.OpenApi` v2.x breaking changes** — `Microsoft.AspNetCore.OpenApi 10.0` pulls in `Microsoft.OpenApi` v2.x which restructures namespaces and models. `OpenApiString`/`OpenApiAny` types are removed (use `JsonNode`), `OpenApiSecurityScheme.Reference` replaced by `OpenApiSecuritySchemeReference`, collections on OpenAPI model objects may be null, and `OpenApiSchema.Nullable` is removed. See `references/aspnet-core-dotnet9to10.md` for migration patterns. + +7. **SDK changes**: + - `dotnet new sln` now defaults to SLNX format — use `--format sln` if the old format is needed + - Double quotes in file-level directives are disallowed + - `dnx.ps1` removed from .NET SDK + - `project.json` no longer supported in `dotnet restore` + +8. **EF Core source changes** (if applicable) — See `references/efcore-dotnet9to10.md` for: + - `ExecuteUpdateAsync` now accepts a regular lambda (expression tree construction code must be rewritten) + - `IDiscriminatorPropertySetConvention` signature changed + - `IRelationalCommandDiagnosticsLogger` methods add `logCommandText` parameter + +9. **WinForms/WPF source changes** (if applicable): + - Applications referencing both WPF and WinForms must disambiguate `MenuItem` and `ContextMenu` types + - Renamed parameter in `HtmlElement.InsertAdjacentElement` + - Empty `ColumnDefinitions` and `RowDefinitions` are disallowed in WPF + +10. **Cryptography source changes** (if applicable): + - `MLDsa` and `SlhDsa` members renamed from `SecretKey` to `PrivateKey` (e.g., `ExportMLDsaSecretKey` → `ExportMLDsaPrivateKey`, `SecretKeySizeInBytes` → `PrivateKeySizeInBytes`) + - `Rfc2898DeriveBytes` constructors are obsolete (SYSLIB0060) — replace with static `Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, hashAlgorithm, outputLength)` + - `CoseSigner.Key` can now be null — check for null before use + - `X509Certificate.GetKeyAlgorithmParameters()` and `PublicKey.EncodedParameters` can return null + - Environment variable renamed from `CLR_OPENSSL_VERSION_OVERRIDE` to `DOTNET_OPENSSL_VERSION_OVERRIDE` + +Build again after each batch of fixes. Repeat until the build is clean. + +### Step 4: Address behavioral changes + +Behavioral changes do not cause build errors but may change runtime behavior. Review each applicable item and determine whether the previous behavior was relied upon. + +**High-impact behavioral changes (check first):** + +1. **SIGTERM signal handling removed** — The .NET runtime no longer registers default SIGTERM handlers. If you rely on `AppDomain.ProcessExit` or `AssemblyLoadContext.Unloading` being raised on SIGTERM: + - ASP.NET Core and Generic Host apps are unaffected (they register their own handlers) + - Console apps and containerized apps without Generic Host must register `PosixSignalRegistration.Create(PosixSignal.SIGTERM, _ => Environment.Exit(0))` explicitly + +2. **BackgroundService.ExecuteAsync runs entirely on a background thread** — The synchronous portion before the first `await` no longer blocks startup. If startup ordering matters, move that code to `StartAsync` or the constructor, or implement `IHostedLifecycleService`. + +3. **Configuration null values are now preserved** — JSON `null` values are no longer converted to empty strings. Properties initialized with non-default values will be overwritten with `null`. Review configuration binding code. + +4. **Microsoft.Data.Sqlite DateTimeOffset changes** (all High impact): + - `GetDateTimeOffset` without an offset now assumes UTC (previously assumed local) + - Writing `DateTimeOffset` into REAL columns now converts to UTC first + - `GetDateTime` with an offset now returns UTC with `DateTimeKind.Utc` + - Mitigation: `AppContext.SetSwitch("Microsoft.Data.Sqlite.Pre10TimeZoneHandling", true)` as a temporary workaround + +5. **EF Core parameterized collections** — `.Contains()` on collections now uses multiple scalar parameters instead of JSON/OPENJSON. May affect query performance for large collections. Mitigation: `UseParameterizedCollectionMode(ParameterTranslationMode.Parameter)` to revert. + +6. **EF Core JSON data type on Azure SQL** — Azure SQL and compatibility level ≥170 now use the `json` data type instead of `nvarchar(max)`. A migration will be generated to alter existing columns. Mitigation: set compatibility level to 160 or use `HasColumnType("nvarchar(max)")` explicitly. + +7. **System.Text.Json property name conflict validation** — Polymorphic types with properties conflicting with metadata names (`$type`, `$id`, `$ref`) now throw `InvalidOperationException`. Add `[JsonIgnore]` to conflicting properties. + +**Other behavioral changes to review:** + +- `BufferedStream.WriteByte` no longer implicitly flushes — add explicit `Flush()` calls if needed +- Default trace context propagator updated to W3C standard +- `DriveInfo.DriveFormat` returns actual Linux filesystem type names +- LDAP `DirectoryControl` parsing is more stringent +- Default .NET container images switched from Debian to Ubuntu (Debian images no longer shipped) +- Single-file apps no longer look for native libraries in executable directory by default +- `DllImportSearchPath.AssemblyDirectory` only searches the assembly directory +- `MailAddress` enforces validation for consecutive dots +- Streaming HTTP responses enabled by default in browser HTTP clients +- `Uri` length limits removed — add explicit length validation if `Uri` was used to reject oversized input from untrusted sources +- Cookie login redirects disabled for known API endpoints (ASP.NET Core) +- `XmlSerializer` no longer ignores `[Obsolete]` properties — audit obsolete properties for sensitive data and add `[XmlIgnore]` to prevent unintended data exposure +- `dotnet restore` audits transitive packages +- `dotnet watch` logs to stderr instead of stdout +- `dotnet` CLI commands log non-command-relevant data to stderr +- Various NuGet behavioral changes (see `references/sdk-msbuild-dotnet9to10.md`) +- `StatusStrip` uses System RenderMode by default (WinForms) +- `TreeView` checkbox image truncation fix (WinForms) +- `DynamicResource` incorrect usage causes crash (WPF) + +### Step 5: Update infrastructure + +1. **Dockerfiles**: Update base images. Default tags now use Ubuntu instead of Debian. Debian images are no longer shipped for .NET 10. + ```dockerfile + # Before + FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build + FROM mcr.microsoft.com/dotnet/aspnet:9.0 + # After + FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build + FROM mcr.microsoft.com/dotnet/aspnet:10.0 + ``` + +2. **CI/CD pipelines**: Update SDK version references. If using `global.json`, update: + ```json + { + "sdk": { + "version": "10.0.100" + } + } + ``` + +3. **Environment variables renamed**: + - `DOTNET_OPENSSL_VERSION_OVERRIDE` replaces the old name + - `DOTNET_ICU_VERSION_OVERRIDE` replaces the old name + - `NUGET_ENABLE_ENHANCED_HTTP_RETRY` has been removed + +4. **OpenSSL requirements**: OpenSSL 1.1.1 or later is now required on Unix. OpenSSL cryptographic primitives are no longer supported on macOS. + +5. **Solution file format**: If `dotnet new sln` is used in scripts, note it now generates SLNX format. Pass `--format sln` if the old format is needed. + +### Step 6: Verify + +1. Run a full clean build: `dotnet build --no-incremental` +2. Run all tests: `dotnet test` +3. If the application is containerized, build and test the container image +4. Smoke-test the application, paying special attention to: + - Signal handling / graceful shutdown behavior + - Background services startup ordering + - Configuration binding with null values + - Date/time handling with Sqlite + - JSON serialization with polymorphic types + - EF Core queries using `.Contains()` on collections +5. **Security review** — verify that the migration has not weakened security controls: + - TLS cipher validation logic is preserved after `SslStream` API migration (SYSLIB0058) + - Obsolete properties containing sensitive data are excluded from serialization (`[XmlIgnore]`, `[JsonIgnore]`) + - Input validation still rejects oversized URIs if `Uri` was used as a length gate + - Exception handlers emit security-relevant telemetry (auth failures, access violations) before returning `true` + - Connection strings set an explicit `Application Name` that does not leak version info + - `dotnet restore` vulnerability audit findings are addressed, not suppressed +6. Review the diff and ensure no unintended behavioral changes were introduced + +## Reference Documents + +The `references/` folder contains detailed breaking change information organized by technology area. Load only the references relevant to the project being migrated: + +| Reference file | When to load | +|----------------|-------------| +| `references/csharp-compiler-dotnet9to10.md` | Always (C# 14 compiler breaking changes — field keyword, extension keyword, span overloads) | +| `references/core-libraries-dotnet9to10.md` | Always (applies to all .NET 10 projects) | +| `references/sdk-msbuild-dotnet9to10.md` | Always (SDK and build tooling changes) | +| `references/aspnet-core-dotnet9to10.md` | Project uses ASP.NET Core | +| `references/efcore-dotnet9to10.md` | Project uses Entity Framework Core or Microsoft.Data.Sqlite | +| `references/cryptography-dotnet9to10.md` | Project uses System.Security.Cryptography or X.509 certificates | +| `references/extensions-hosting-dotnet9to10.md` | Project uses Generic Host, BackgroundService, or Microsoft.Extensions.Configuration | +| `references/serialization-networking-dotnet9to10.md` | Project uses System.Text.Json, XmlSerializer, HttpClient, or networking APIs | +| `references/winforms-wpf-dotnet9to10.md` | Project uses Windows Forms or WPF | +| `references/containers-interop-dotnet9to10.md` | Project uses Docker containers, single-file publishing, or native interop (P/Invoke) | diff --git a/.agents/skills/migrate-dotnet9-to-dotnet10/references/aspnet-core-dotnet9to10.md b/.agents/skills/migrate-dotnet9-to-dotnet10/references/aspnet-core-dotnet9to10.md new file mode 100644 index 00000000..efd66a26 --- /dev/null +++ b/.agents/skills/migrate-dotnet9-to-dotnet10/references/aspnet-core-dotnet9to10.md @@ -0,0 +1,140 @@ +# ASP.NET Core 10 Breaking Changes + +These changes affect projects using ASP.NET Core (Microsoft.NET.Sdk.Web). + +## Source-Incompatible Changes + +### WebHostBuilder, IWebHost, and WebHost are obsolete + +The legacy `WebHostBuilder` and related APIs are now marked obsolete. Migrate to the modern hosting model: + +```csharp +// Before (.NET 9) +var host = new WebHostBuilder() + .UseKestrel() + .UseStartup() + .Build(); + +// After (.NET 10) +var builder = WebApplication.CreateBuilder(args); +// Configure services in builder.Services +var app = builder.Build(); +// Configure middleware pipeline +app.Run(); +``` + +If still using `Startup` classes, the `WebApplication` model supports them via `builder.Host.ConfigureWebHostDefaults(...)` or inline configuration. + +### IActionContextAccessor and ActionContextAccessor are obsolete + +These types are obsolete. Access `ActionContext` through dependency injection or the `HttpContext` instead: +```csharp +// Before +services.AddSingleton(); + +// After — use IHttpContextAccessor or inject ActionContext directly in filters/middleware +``` + +### Deprecation of WithOpenApi extension method + +The `WithOpenApi()` extension method is deprecated. Use the built-in OpenAPI document generation in ASP.NET Core 10 instead. + +### IncludeOpenAPIAnalyzers property and MVC API analyzers deprecated + +The `` MSBuild property is deprecated. Remove it from `.csproj` files. The analyzers are no longer needed with the new OpenAPI infrastructure. + +### IPNetwork and ForwardedHeadersOptions.KnownNetworks are obsolete + +`IPNetwork` is obsolete. Use `System.Net.IPNetwork` (the new runtime type) and the new `KnownIpNetworks` property instead: +```csharp +// Before +app.UseForwardedHeaders(new ForwardedHeadersOptions +{ + KnownNetworks = { new IPNetwork(IPAddress.Parse("10.0.0.0"), 8) } +}); + +// After — use KnownIpNetworks with the new System.Net.IPNetwork type +app.UseForwardedHeaders(new ForwardedHeadersOptions +{ + KnownIpNetworks = { new System.Net.IPNetwork(IPAddress.Parse("10.0.0.0"), 8) } +}); +``` + +### Razor runtime compilation is obsolete + +`AddRazorRuntimeCompilation()` is obsolete. Razor views and pages should be precompiled. For development, use hot reload (`dotnet watch`) instead. + +### Microsoft.Extensions.ApiDescription.Client package deprecated + +The `Microsoft.Extensions.ApiDescription.Client` package is deprecated. Use the built-in OpenAPI client generation tooling instead. + +### Microsoft.OpenApi 2.x breaking API changes + +`Microsoft.AspNetCore.OpenApi 10.0` depends on `Microsoft.OpenApi` v2.x (from [`microsoft/OpenAPI.NET`](https://github.com/microsoft/OpenAPI.NET) repo), which has significant breaking API changes from v1.x. Projects that customize OpenAPI document generation using transformers or directly manipulate OpenAPI models will need code changes. Note: no official v1→v2 migration guide exists; the repo has a [v3 upgrade guide](https://github.com/microsoft/OpenAPI.NET/blob/main/docs/upgrade-guide-3.md) only. These changes are also not listed on the ASP.NET Core 10 breaking changes page. + +**Namespace changes — `Microsoft.OpenApi.Models` and `Microsoft.OpenApi.Any` are completely removed:** + +All types previously in these namespaces (`OpenApiSchema`, `OpenApiParameter`, `OpenApiResponse`, `OpenApiSecurityScheme`, etc.) have moved to the root `Microsoft.OpenApi` namespace. Replace all `using Microsoft.OpenApi.Models;` and `using Microsoft.OpenApi.Any;` with `using Microsoft.OpenApi;`. + +```csharp +// Before (.NET 9 — Microsoft.OpenApi v1.x) +using Microsoft.OpenApi.Any; +using Microsoft.OpenApi.Models; + +// After (.NET 10 — Microsoft.OpenApi v2.x) +using Microsoft.OpenApi; // ALL model types are here now +using System.Text.Json.Nodes; // replaces OpenApiAny types +``` + +**Key API changes:** + +1. **`OpenApiString` / `OpenApiAny` types removed** — Use `System.Text.Json.Nodes.JsonNode` instead: + ```csharp + // Before + parameter.Schema.Example = new OpenApiString("1.0"); + // After + parameter.Schema.Example = JsonNode.Parse("\"1.0\""); + ``` + +2. **`OpenApiSecurityScheme.Reference` replaced** — Use `OpenApiSecuritySchemeReference` directly: + ```csharp + // Before + var scheme = new OpenApiSecurityScheme + { + Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "oauth2" } + }; + // After + var scheme = new OpenApiSecuritySchemeReference("oauth2", null); + ``` + +3. **Collections now nullable** — `operation.Parameters`, `operation.Responses`, `document.Components.SecuritySchemes`, and other collection properties may be null and must be checked or initialized: + ```csharp + operation.Responses ??= new OpenApiResponses(); + operation.Parameters?.FirstOrDefault(p => p.Name == "api-version"); + document.Components ??= new(); + document.Components.SecuritySchemes ??= new Dictionary(); + ``` + +4. **`OpenApiSchema.Nullable` removed** — OpenAPI 3.1 uses JSON Schema `type: ["string", "null"]` instead of `nullable: true`. Schema transformers that set `.Nullable = false` can be removed. + +5. **Security requirement values require `List`** — Implicit conversion from `string[]` may not work: + ```csharp + // Before + [oAuthScheme] = scopes + // After + [oAuthScheme] = scopes.ToList() + ``` + +6. **Interface types in some positions** — Some properties now use interfaces (e.g., `IOpenApiSecurityScheme` instead of `OpenApiSecurityScheme`). Check for compilation errors in transformer code. + +## Behavioral Changes + +### Cookie login redirects disabled for known API endpoints + +ASP.NET Core no longer redirects to login pages for requests to known API endpoints (e.g., those returning `ProblemDetails`). Instead, a `401` status code is returned directly. This is controlled by `IApiEndpointMetadata`, which is automatically applied to endpoints with `[ApiController]`, minimal API endpoints that read/write JSON, SignalR hubs, and endpoints returning `TypedResults`. + +This is generally the desired behavior for APIs, but may affect apps that relied on the redirect for API calls. To influence this behavior, you can manually add or check for `IApiEndpointMetadata` on specific endpoints. + +### Exception diagnostics suppressed when TryHandleAsync returns true + +**Security consideration:** When `IExceptionHandler.TryHandleAsync` returns `true`, the exception diagnostics middleware no longer emits diagnostic events for that exception. If handled exceptions include security-relevant events (authentication failures, authorization violations, injection attempts), suppressing diagnostics could create blind spots in security monitoring and audit logging. Ensure your exception handler explicitly emits security-relevant telemetry before returning `true`. diff --git a/.agents/skills/migrate-dotnet9-to-dotnet10/references/containers-interop-dotnet9to10.md b/.agents/skills/migrate-dotnet9-to-dotnet10/references/containers-interop-dotnet9to10.md new file mode 100644 index 00000000..40d93c89 --- /dev/null +++ b/.agents/skills/migrate-dotnet9-to-dotnet10/references/containers-interop-dotnet9to10.md @@ -0,0 +1,63 @@ +# Containers, Interop, and Deployment Breaking Changes (.NET 10) + +These changes affect Docker containers, single-file apps, native interop (P/Invoke), and deployment scenarios. + +## Containers + +### Default .NET images use Ubuntu (Debian images discontinued) + +**Impact: Medium.** Default .NET container image tags now reference Ubuntu 24.04 (Noble Numbat) instead of Debian. Debian-based images are no longer provided for .NET 10. + +```dockerfile +# .NET 10 images — all Ubuntu-based +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +FROM mcr.microsoft.com/dotnet/aspnet:10.0 +FROM mcr.microsoft.com/dotnet/runtime:10.0 + +# Explicit Ubuntu tag (same as default) +FROM mcr.microsoft.com/dotnet/sdk:10.0-noble +``` + +**What to check:** +- Dockerfile `RUN apt-get` commands — Ubuntu and Debian share `apt` but may differ in available packages and versions +- Native library dependencies that were Debian-specific +- Custom base image layers that assumed Debian +- CI/CD scripts that referenced Debian-specific image tags + +**If you need Debian:** Create custom images following [Microsoft's guide for installing .NET in a Dockerfile](https://github.com/dotnet/dotnet-docker/blob/main/documentation/scenarios/installing-dotnet.md). + +## Interop + +### Single-file apps no longer look for native libraries in executable directory + +In single-file apps, the application directory is no longer automatically added to `NATIVE_DLL_SEARCH_DIRECTORIES`. The directory is only searched when `DllImportSearchPath.AssemblyDirectory` is included in the search paths (which is the default for P/Invokes without explicit search paths). + +**Breaking scenario:** P/Invokes with `[DefaultDllImportSearchPaths]` that explicitly exclude `AssemblyDirectory`: +```csharp +// This no longer finds "lib" in the app directory: +[DllImport("lib")] +[DefaultDllImportSearchPaths(DllImportSearchPath.System32)] +static extern void Method(); + +// Fix: Add AssemblyDirectory to the search paths: +[DllImport("lib")] +[DefaultDllImportSearchPaths(DllImportSearchPath.System32 | DllImportSearchPath.AssemblyDirectory)] +static extern void Method(); +``` + +For NativeAOT on non-Windows, the `rpath` is no longer set to the application directory. Add explicit linker arguments if needed. + +### DllImportSearchPath.AssemblyDirectory only searches the assembly directory + +`DllImportSearchPath.AssemblyDirectory` now strictly searches only the assembly directory, not additional directories that were previously included. + +### Casting IDispatchEx COM object to IReflect fails + +Casting a COM object that implements `IDispatchEx` to `IReflect` now fails. Use `dynamic` or explicit COM interop interfaces instead. + +## Other Changes + +- **Globalization**: Environment variable renamed to `DOTNET_ICU_VERSION_OVERRIDE` +- **Reflection**: `[DynamicallyAccessedMembers]` annotations on `IReflect.InvokeMember`, `Type.FindMembers` are more restrictive (new trim warnings possible) +- **Reflection**: `Type.MakeGenericSignatureType` arguments validated more strictly +- **VS Code**: `dotnet.acquire` API no longer always downloads latest — pin specific versions diff --git a/.agents/skills/migrate-dotnet9-to-dotnet10/references/core-libraries-dotnet9to10.md b/.agents/skills/migrate-dotnet9-to-dotnet10/references/core-libraries-dotnet9to10.md new file mode 100644 index 00000000..195962b8 --- /dev/null +++ b/.agents/skills/migrate-dotnet9-to-dotnet10/references/core-libraries-dotnet9to10.md @@ -0,0 +1,93 @@ +# Core .NET Libraries Breaking Changes (.NET 10) + +These breaking changes affect all .NET 10 projects regardless of application type. + +## Source-Incompatible Changes + +### System.Linq.AsyncEnumerable included in core libraries + +.NET 10 adds `System.Linq.AsyncEnumerable` with full LINQ support for `IAsyncEnumerable`, replacing the community `System.Linq.Async` NuGet package. Projects referencing `System.Linq.Async` will get ambiguity errors. + +**Fix:** +- Remove the `System.Linq.Async` package reference, or upgrade to v7.0.0 +- If consumed transitively, suppress with ``: + ```xml + + compile + + ``` +- Rename `SelectAwait` calls to `Select` where the new API uses a different name + +### API obsoletions (SYSLIB0058–SYSLIB0062) + +| Diagnostic | What's obsolete | Replacement | +|------------|----------------|-------------| +| SYSLIB0058 | `SslStream.KeyExchangeAlgorithm`, `CipherAlgorithm`, `HashAlgorithm` and their strength properties | `SslStream.NegotiatedCipherSuite` — **Security note:** if existing code used these properties to reject weak ciphers (e.g., RC4, 3DES, NULL), ensure equivalent validation is preserved using `NegotiatedCipherSuite` | +| SYSLIB0059 | `SystemEvents.EventsThreadShutdown` | `AppDomain.ProcessExit` | +| SYSLIB0060 | `Rfc2898DeriveBytes` constructors | `Rfc2898DeriveBytes.Pbkdf2` static method | +| SYSLIB0061 | `Queryable.MaxBy`/`MinBy` overloads with `IComparer` | New overloads with `IComparer` | +| SYSLIB0062 | `XsltSettings.EnableScript` | N/A (XSLT scripting deprecated) | + +These use custom diagnostic IDs — suppressing `CS0618` does not suppress them. + +### FilePatternMatch.Stem changed to non-nullable + +`FilePatternMatch.Stem` is now `string` instead of `string?`. Code checking for null may get warnings. + +### Other source-incompatible changes (low impact) + +- `[DynamicallyAccessedMembers]` annotation removed from `DefaultValueAttribute` constructor (affects trimming annotations) +- ARM64 SVE nonfaulting load intrinsics now require a mask parameter + +## Behavioral Changes + +### .NET runtime no longer provides default termination signal handlers + +**Impact: High for console/containerized apps without Generic Host.** + +On Unix, the runtime no longer registers SIGTERM/SIGHUP handlers. On Windows, `CTRL_SHUTDOWN_EVENT` and `CTRL_CLOSE_EVENT` are no longer handled. `AppDomain.ProcessExit` and `AssemblyLoadContext.Unloading` will NOT be raised on termination signals. + +- **ASP.NET Core and Generic Host apps are unaffected** (they register their own handlers) +- **Console apps** must register handlers explicitly: + ```csharp + using var sigterm = PosixSignalRegistration.Create( + PosixSignal.SIGTERM, _ => Environment.Exit(0)); + using var sighup = PosixSignalRegistration.Create( + PosixSignal.SIGHUP, _ => Environment.Exit(0)); + ``` + +### C# 14 overload resolution with span parameters + +Methods with `ReadOnlySpan` or `Span` parameters now participate in type inference and extension method resolution. This can cause `MemoryExtensions.Contains` to bind instead of `Enumerable.Contains` inside Expression lambdas, causing runtime exceptions. + +**Fix:** Cast to `IEnumerable`, use `.AsEnumerable()`, or call the static method explicitly: +```csharp +// Fails — binds to MemoryExtensions.Contains +M((array, num) => array.Contains(num)); +// Fix options: +M((array, num) => ((IEnumerable)array).Contains(num)); +M((array, num) => array.AsEnumerable().Contains(num)); +M((array, num) => Enumerable.Contains(array, num)); +``` + +### BufferedStream.WriteByte no longer performs implicit flush + +`BufferedStream.WriteByte` no longer flushes when the buffer is full. Add explicit `Flush()` calls if your code relied on the implicit behavior. + +### Consistent shift behavior in generic math + +Shift operations in generic math now behave consistently. If custom types relied on the previous inconsistent behavior, update them. + +### Default trace context propagator updated to W3C standard + +The default `DistributedContextPropagator` now uses W3C Trace Context format. If your system relies on legacy propagation formats, configure the propagator explicitly. + +### Other behavioral changes (lower impact) + +- `DriveInfo.DriveFormat` returns actual Linux filesystem type names (e.g., `ext4`) instead of generic values +- `GnuTarEntry`/`PaxTarEntry` no longer include atime/ctime by default — set explicitly if needed +- LDAP `DirectoryControl` parsing is more stringent — invalid data now throws +- MacCatalyst versions normalized differently (affects .NET MAUI) +- `ActivitySource.CreateActivity`/`StartActivity` sampling behavior changed +- `[InlineArray]` structs can no longer have explicit `[StructLayout(Size = ...)]` — assembly fails to load +- `Type.MakeGenericSignatureType` arguments validated more strictly diff --git a/.agents/skills/migrate-dotnet9-to-dotnet10/references/cryptography-dotnet9to10.md b/.agents/skills/migrate-dotnet9-to-dotnet10/references/cryptography-dotnet9to10.md new file mode 100644 index 00000000..ea4c87fa --- /dev/null +++ b/.agents/skills/migrate-dotnet9-to-dotnet10/references/cryptography-dotnet9to10.md @@ -0,0 +1,79 @@ +# Cryptography Breaking Changes (.NET 10) + +These changes affect projects using `System.Security.Cryptography`, X.509 certificates, or OpenSSL. + +## Source-Incompatible Changes + +### MLDsa and SlhDsa 'SecretKey' members renamed to 'PrivateKey' + +All members containing `SecretKey` on `MLDsa` and `SlhDsa` types have been renamed to use `PrivateKey`. This affects methods and properties: + +```csharp +// Before +int size = key.Algorithm.SecretKeySizeInBytes; +byte[] output = new byte[size]; +key.ExportMLDsaSecretKey(output); +key.ImportMLDsaSecretKey(data); + +// After +int size = key.Algorithm.PrivateKeySizeInBytes; +byte[] output = new byte[size]; +key.ExportMLDsaPrivateKey(output); +key.ImportMLDsaPrivateKey(data); +// Same pattern for SlhDsa: ExportSlhDsaSecretKey → ExportSlhDsaPrivateKey, etc. +``` + +### Rfc2898DeriveBytes constructors are obsolete (SYSLIB0060) + +All `Rfc2898DeriveBytes` constructors are now obsolete. Use the static `Rfc2898DeriveBytes.Pbkdf2` method instead: + +```csharp +// Before +using var deriveBytes = new Rfc2898DeriveBytes(password, salt, iterations, HashAlgorithmName.SHA256); +byte[] key = deriveBytes.GetBytes(32); + +// After +byte[] key = Rfc2898DeriveBytes.Pbkdf2(password, salt, iterations, HashAlgorithmName.SHA256, 32); +``` + +### CoseSigner.Key can be null + +`CoseSigner.Key` is now nullable (`AsymmetricAlgorithm?`). Code that assumes it's non-null needs null checks: + +```csharp +// Before +var algorithm = signer.Key.SignatureAlgorithm; + +// After +var algorithm = signer.Key?.SignatureAlgorithm + ?? throw new InvalidOperationException("Key is null"); +``` + +### X509Certificate and PublicKey key parameters can be null + +`X509Certificate.GetKeyAlgorithmParameters()` and `PublicKey.EncodedParameters` can now return null. Add null checks where these values are consumed. + +## Behavioral Changes + +### OpenSSL 1.1.1 or later required on Unix + +.NET 10 requires OpenSSL 1.1.1+ on Unix systems. Older OpenSSL versions are no longer supported. Check with: +```bash +openssl version +``` + +### OpenSSL cryptographic primitives aren't supported on macOS + +Using OpenSSL-specific cryptographic primitives on macOS is no longer supported. Use the platform's native cryptography (Apple Security framework) instead. + +### X500DistinguishedName validation is stricter + +`X500DistinguishedName` now validates input more strictly. Malformed distinguished names that were previously accepted may now throw exceptions. + +### CompositeMLDsa updated to draft-08 + +The Composite ML-DSA implementation has been updated to align with draft-08. Key and signature formats from earlier drafts are incompatible. + +### Environment variable renamed from CLR_OPENSSL_VERSION_OVERRIDE to DOTNET_OPENSSL_VERSION_OVERRIDE + +If you use `CLR_OPENSSL_VERSION_OVERRIDE` to specify the preferred OpenSSL library version on Linux, rename it to `DOTNET_OPENSSL_VERSION_OVERRIDE`. diff --git a/.agents/skills/migrate-dotnet9-to-dotnet10/references/csharp-compiler-dotnet9to10.md b/.agents/skills/migrate-dotnet9-to-dotnet10/references/csharp-compiler-dotnet9to10.md new file mode 100644 index 00000000..caa692a5 --- /dev/null +++ b/.agents/skills/migrate-dotnet9-to-dotnet10/references/csharp-compiler-dotnet9to10.md @@ -0,0 +1,118 @@ +# C# 14 Compiler Breaking Changes (.NET 10) + +These breaking changes are introduced by the Roslyn compiler shipping with the .NET 10 SDK. They affect all projects targeting `net10.0` (which uses C# 14 by default). These are maintained separately from the runtime breaking changes at: https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/breaking-changes/compiler%20breaking%20changes%20-%20dotnet%2010 + +## Source-Incompatible Changes + +### `field` keyword in property accessors + +**Impact: High.** `field` is now a contextual keyword inside property `get`, `set`, and `init` accessors (for the semi-auto properties feature). + +Two diagnostics apply: +- **CS9258** (warning, VS 17.12+): `field` binds to the synthesized backing field instead of an existing member (e.g., a class field named `field`). Use `this.field` or `@field` to refer to the member. +- **CS9272** (error, VS 17.14+): A local variable or nested-function parameter named `field` is **disallowed** inside a property accessor. Rename the variable or use `@field`. + +```csharp +// BREAKS — CS9272 error +public object Property +{ + get + { + int field = 0; // error: 'field' is a keyword in a property accessor + return @field; + } +} + +// Also BREAKS — CS9272 error +public string Name +{ + get + { + payload.TryGetProperty("field", out var field); // error: local named 'field' + return field.GetString(); + } +} +``` + +**Fix options:** +1. Rename the variable: `var fieldValue = ...`, `var fieldElem = ...` +2. Escape the identifier: `var @field = ...` (compiles but less readable) +3. For class members named `field`, qualify with `this.field` or `@field` + +### `extension` contextual keyword + +**Impact: Medium.** Starting in C# 14, `extension` is a contextual keyword for extension containers. Code that uses `extension` as a type name, constructor, or return type will break. + +```csharp +// BREAKS in C# 14 +class extension { } // type cannot be named "extension" +using extension = SomeNamespace.Foo; // alias cannot be named "extension" +class C { } // type parameter cannot be named "extension" +``` + +**Fix:** Rename the type, or escape as `@extension`. + +### `Span` and `ReadOnlySpan` overloads applicable in more scenarios + +**Impact: Medium.** C# 14 introduces new built-in span conversions and type inference rules. Different overloads may be chosen, and new ambiguity errors can arise. + +Common patterns affected: +```csharp +// Ambiguity — Assert.Equal(T[], T[]) vs Assert.Equal(ReadOnlySpan, Span) +var x = new long[] { 1 }; +Assert.Equal([2], x); // ambiguous +Assert.Equal([2], x.AsSpan()); // fix + +// Enumerable.Reverse now resolves to MemoryExtensions.Reverse (in-place, returns void) +int[] arr = [1, 2, 3]; +var reversed = arr.Reverse(); // BREAKS: resolves to Span extension, not Enumerable +var reversed = Enumerable.Reverse(arr); // fix + +// ReadOnlySpan preferred over Span — MemoryMarshal.Cast may fail +Span y = MemoryMarshal.Cast(x); // BREAKS +Span y = MemoryMarshal.Cast(x.AsSpan()); // fix + +// ArrayTypeMismatchException with covariant arrays +string[] s = ["a"]; +object[] o = s; +C.R(o); // BREAKS at runtime: Span ctor throws ArrayTypeMismatchException +C.R(o.AsEnumerable()); // fix +``` + +**Fix options:** +- Add `.AsSpan()`, `.AsEnumerable()`, or explicit casts to disambiguate +- Call static methods explicitly (e.g., `Enumerable.Reverse(arr)`) +- API authors: use `[OverloadResolutionPriority]` attribute + +> **Note:** The span overload resolution change is also listed in the runtime breaking changes (`core-libraries.md`). This entry provides the full Roslyn-side detail. + +### Other low-impact source changes + +- **`scoped` in lambda parameters**: Always treated as a modifier. If you have a ref struct type named `scoped`, escape as `@scoped`. +- **`partial` as return type**: Cannot use a type named `partial` as a return type. Escape as `@partial`. + +## Behavioral Changes + +### Enumerator state set to "after" during disposal + +`MoveNext()` on a disposed enumerator now properly returns `false` without executing further user code. Previously, the state machine allowed resuming execution after disposal. + +```csharp +var enumerator = GetItems().GetEnumerator(); +enumerator.MoveNext(); // True, yields 1 +enumerator.Dispose(); +enumerator.MoveNext(); // now returns False (previously could continue) +``` + +### Diagnostics reported for pattern-based disposal in `foreach` + +Obsolete `DisposeAsync` methods on enumerator types are now reported in `await foreach`. Previously these diagnostics were silently ignored. + +### Redundant pattern warning in `or` patterns + +The compiler now warns when the second pattern in a disjunctive `or` is redundant due to precedence: +```csharp +_ = o is not null or 42; // warning: pattern "42" is redundant +_ = o is not int or string; // warning: pattern "string" is redundant +// Likely intended: is not (null or 42) / is not (int or string) +``` diff --git a/.agents/skills/migrate-dotnet9-to-dotnet10/references/efcore-dotnet9to10.md b/.agents/skills/migrate-dotnet9-to-dotnet10/references/efcore-dotnet9to10.md new file mode 100644 index 00000000..28ed51a1 --- /dev/null +++ b/.agents/skills/migrate-dotnet9-to-dotnet10/references/efcore-dotnet9to10.md @@ -0,0 +1,157 @@ +# Entity Framework Core 10 Breaking Changes + +These changes affect projects using EF Core or Microsoft.Data.Sqlite. + +## Medium-Impact Changes + +### EF tools require framework to be specified for multi-targeted projects + +When running EF tools on a project with `` (plural), you must now specify `--framework`: + +```bash +dotnet ef migrations add MyMigration --framework net10.0 +dotnet ef database update --framework net10.0 +``` + +Without this, you'll get: "The project targets multiple frameworks. Use the --framework option to specify which target framework to use." + +## Low-Impact Changes + +### Application Name injected into connection string + +EF now inserts an `Application Name` containing EF and SqlClient version info into connection strings that don't already have one. This changes the effective connection string, which can cause: +- Separate connection pools when mixing EF and non-EF data access (e.g., Dapper) +- Potential distributed transaction escalation within `TransactionScope` +- **Information disclosure**: version strings are visible in `sys.dm_exec_sessions`, database server logs, and monitoring tools, potentially aiding attackers in fingerprinting your stack + +**Mitigation:** Explicitly set `Application Name` in your connection string to a value that does not reveal version information. + +### SQL Server json data type used by default on Azure SQL and compatibility level 170 + +For Azure SQL (`UseAzureSql`) or compatibility level ≥170, EF now maps JSON columns to the `json` data type instead of `nvarchar(max)`. A migration will be generated to alter existing columns. + +**Considerations:** +- SQL Server does not support `DISTINCT` over JSON arrays — queries using it will fail +- The column alteration is a non-trivial schema change + +**Mitigation:** +```csharp +// Option 1: Set compatibility level below 170 +optionsBuilder.UseAzureSql(connStr, o => o.UseCompatibilityLevel(160)); + +// Option 2: Explicitly set column type per property +modelBuilder.Entity() + .PrimitiveCollection(b => b.Tags) + .HasColumnType("nvarchar(max)"); +``` + +### Parameterized collections now use multiple parameters by default + +`.Contains()` on collections now translates to `WHERE x IN (@p1, @p2, @p3)` instead of using `OPENJSON`. This gives the query planner cardinality information but may regress performance for large collections. + +**Mitigation:** +```csharp +// Global: revert to JSON parameter mode +optionsBuilder.UseSqlServer(connStr, + o => o.UseParameterizedCollectionMode(ParameterTranslationMode.Parameter)); + +// Per-query: use EF.Parameter() for JSON array translation +var blogs = await context.Blogs + .Where(b => EF.Parameter(ids).Contains(b.Id)) + .ToListAsync(); +``` + +### ExecuteUpdateAsync now accepts a regular lambda + +`ExecuteUpdateAsync` now takes `Action<...>` instead of `Expression>` for column setters. Code that manually builds expression trees for dynamic setters will no longer compile but can be dramatically simplified: + +```csharp +// Before (.NET 9) — complex expression tree construction for dynamic setters +Expression, SetPropertyCalls>> setters = + s => s.SetProperty(b => b.Views, 8); + +if (nameChanged) +{ + var blogParameter = Expression.Parameter(typeof(Blog), "b"); + setters = Expression.Lambda, SetPropertyCalls>>( + Expression.Call( + instance: setters.Body, + methodName: nameof(SetPropertyCalls.SetProperty), + typeArguments: [typeof(string)], + arguments: + [ + Expression.Lambda>( + Expression.Property(blogParameter, nameof(Blog.Name)), blogParameter), + Expression.Constant("foo") + ]), + setters.Parameters); +} +await context.Blogs.ExecuteUpdateAsync(setters); + +// After (.NET 10) — simple lambda with conditionals +await context.Blogs.ExecuteUpdateAsync(s => +{ + s.SetProperty(b => b.Views, 8); + if (nameChanged) + { + s.SetProperty(b => b.Name, "foo"); + } +}); +``` + +### Complex type column names are now uniquified + +If multiple complex types have properties with the same name, column names are now uniquified by appending a number. This may generate a migration that renames columns. + +**Mitigation:** Explicitly configure column names with `HasColumnName()`. + +### Nested complex type properties use full path in column names + +`EntityType.Complex.NestedComplex.Property` is now mapped to `Complex_NestedComplex_Property` (was `NestedComplex_Property`). This generates a migration renaming columns. + +**Mitigation:** Use `HasColumnName()` to preserve old names. + +### IDiscriminatorPropertySetConvention signature changed + +The method parameter changed from `IConventionEntityTypeBuilder` to `IConventionTypeBaseBuilder`. Update custom convention implementations. + +### IRelationalCommandDiagnosticsLogger methods add logCommandText parameter + +Methods like `CommandReaderExecuting`, `CommandReaderExecuted`, `CommandScalarExecuting`, etc. now have an additional `string logCommandText` parameter containing redacted SQL for logging. Update custom implementations: + +```csharp +public InterceptionResult CommandReaderExecuting( + IRelationalConnection connection, + DbCommand command, + DbContext context, + Guid commandId, + Guid connectionId, + DateTimeOffset startTime, + string logCommandText) // New parameter — redacted SQL for logging +{ + // Use logCommandText for logging (may have constants redacted) + // Use command.CommandText for actual SQL execution +} +``` + +## Microsoft.Data.Sqlite Breaking Changes (All High Impact) + +### GetDateTimeOffset without an offset now assumes UTC + +Previously, a textual timestamp without an offset (e.g., `2014-04-15 10:47:16`) was parsed using the local timezone. Now it's treated as UTC. + +### Writing DateTimeOffset into REAL column now writes in UTC + +`DateTimeOffset` values written to REAL columns are now converted to UTC before writing. Previously the offset was ignored. + +### GetDateTime with an offset now returns value in UTC + +`GetDateTime` on timestamps with offsets (e.g., `2014-04-15 10:47:16+02:00`) now returns the value converted to UTC with `DateTimeKind.Utc`. Previously it returned `DateTimeKind.Local`. + +**Mitigation for all three:** +```csharp +// Temporary workaround to revert to .NET 9 behavior +AppContext.SetSwitch("Microsoft.Data.Sqlite.Pre10TimeZoneHandling", true); +``` + +Review all date/time handling code that reads from or writes to SQLite databases. The new behavior aligns with SQLite's convention that timestamps are UTC. diff --git a/.agents/skills/migrate-dotnet9-to-dotnet10/references/extensions-hosting-dotnet9to10.md b/.agents/skills/migrate-dotnet9-to-dotnet10/references/extensions-hosting-dotnet9to10.md new file mode 100644 index 00000000..9ef5a011 --- /dev/null +++ b/.agents/skills/migrate-dotnet9-to-dotnet10/references/extensions-hosting-dotnet9to10.md @@ -0,0 +1,75 @@ +# Extensions and Hosting Breaking Changes (.NET 10) + +These changes affect projects using `Microsoft.Extensions.Hosting`, `BackgroundService`, `Microsoft.Extensions.Configuration`, and related libraries. + +## Behavioral Changes + +### BackgroundService.ExecuteAsync runs entirely on a background thread + +**Impact: Medium.** Previously, the synchronous code in `ExecuteAsync` before the first `await` ran on the main thread during startup, blocking other hosted services from starting. Now ALL of `ExecuteAsync` runs on a background thread. + +**If startup ordering matters:** + +```csharp +// Option 1: Move synchronous startup code to StartAsync +public override async Task StartAsync(CancellationToken cancellationToken) +{ + // This still runs synchronously during startup + InitializeResources(); + await base.StartAsync(cancellationToken); +} + +// Option 2: Use IHostedLifecycleService for fine-grained lifecycle control +public class MyService : BackgroundService, IHostedLifecycleService +{ + public Task StartingAsync(CancellationToken ct) + { + // runs before StartAsync + return Task.CompletedTask; + } + + public Task StartedAsync(CancellationToken ct) + { + // runs after StartAsync + return Task.CompletedTask; + } + // ... other lifecycle methods +} + +// Option 3: Move code to the constructor +public MyService(ILogger logger) +{ + // Constructor code runs during DI resolution +} +``` + +### Null values preserved in configuration + +**Impact: Medium.** JSON `null` values are now properly bound instead of being converted to empty strings or ignored. **Empty arrays (`[]`) are also now correctly bound as empty arrays instead of being ignored.** + +| Scenario | .NET 9 behavior | .NET 10 behavior | +|----------|----------------|-----------------| +| `"StringProperty": null` | Bound as `""` (empty string) | Bound as `null` (overwrites constructor default) | +| `"IntProperty": null` | Ignored (kept constructor default) | Bound as `null` (if `int?`) | +| `"Array": [null, null]` | Bound as `["", ""]` | Bound as `[null, null]` | +| **`"Array": []`** | **Ignored (`null`)** | **Bound as empty array `[]`** | + +**If you need the old behavior:** +- Replace `null` with `""` in JSON config files +- Or remove `null` entries to skip binding + +### Fix issues in GetKeyedService() and GetKeyedServices() with AnyKey + +`GetKeyedService()` and `GetKeyedServices()` with `AnyKey` now work correctly. If your code relied on the previous buggy behavior, update it. + +### Message no longer duplicated in Console log output + +When using the JSON console logger, messages are no longer duplicated. If your log parsing relied on the duplicated format, update it. + +### ProviderAliasAttribute moved to Microsoft.Extensions.Logging.Abstractions assembly + +`ProviderAliasAttribute` has moved assemblies. If you reference it directly by assembly-qualified name, update the reference. Source-incompatible for code using assembly-qualified type references. + +### Removed DynamicallyAccessedMembers annotation from trim-unsafe configuration code + +The `[DynamicallyAccessedMembers]` annotation was removed from certain configuration APIs that are not trim-safe. Binary incompatible for code that relied on the annotation for trimming analysis. diff --git a/.agents/skills/migrate-dotnet9-to-dotnet10/references/sdk-msbuild-dotnet9to10.md b/.agents/skills/migrate-dotnet9-to-dotnet10/references/sdk-msbuild-dotnet9to10.md new file mode 100644 index 00000000..a962cc67 --- /dev/null +++ b/.agents/skills/migrate-dotnet9-to-dotnet10/references/sdk-msbuild-dotnet9to10.md @@ -0,0 +1,50 @@ +# SDK and MSBuild Breaking Changes (.NET 10) + +These changes affect the .NET SDK, CLI tooling, NuGet, and MSBuild behavior. + +## Source-Incompatible Changes + +### NU1510 raised for direct references pruned by NuGet + +If NuGet prunes a direct `PackageReference` because the package is already part of the shared framework, a `NU1510` warning is raised. Remove the explicit reference if it's provided by the framework. + +### PackageReference without a version raises an error + +Every `` must now have a `Version` attribute or use Central Package Management. Missing versions raise an error instead of resolving to the latest. + +### Other source-incompatible changes + +- `dnx.ps1` file removed from .NET SDK — remove any references +- File-level directives (`#r`, `#load`) no longer accept double-quoted paths — remove the quotes (e.g., `#r MyLib.dll` instead of `#r "MyLib.dll"`) +- `project.json` no longer recognized by `dotnet restore` — use `PackageReference` format +- NuGet packages with no runtime assets excluded from `deps.json` +- `ToolCommandName` not set for non-tool packages +- HTTP sources in `dotnet package list`/`dotnet package search` now error (use HTTPS) + +## Behavioral Changes + +### `dotnet new sln` defaults to SLNX file format + +`dotnet new sln` now generates an XML-based `.slnx` file instead of the classic `.sln` format: +```xml + + +``` +**Mitigation:** Use `dotnet new sln --format sln` for the classic format. Ensure your tooling (Visual Studio, Rider, etc.) supports SLNX. + +### Other behavioral changes + +- `--interactive` defaults to `true` in user scenarios — use `--interactive false` in scripts +- CLI diagnostic output now goes to stderr +- Tool packages now include RID-specific content +- Default workload management mode is 'workload sets' (not 'loose manifests') +- `EnableDynamicNativeInstrumentation` defaults to false for code coverage +- `dotnet package list` performs restore before listing +- `dotnet tool install --local` creates manifest by default +- `dotnet watch` logs to stderr instead of stdout +- `PrunePackageReference` privatizes direct prunable references (`PrivateAssets="All"`) +- SHA-1 fingerprints deprecated in `dotnet nuget sign` — use SHA-256 +- `MSBUILDCUSTOMBUILDEVENTWARNING` escape hatch removed +- MSBuild custom culture resource handling changed +- `NUGET_ENABLE_ENHANCED_HTTP_RETRY` env var removed (enhanced retry always on) +- NuGet logs errors for invalid package IDs diff --git a/.agents/skills/migrate-dotnet9-to-dotnet10/references/serialization-networking-dotnet9to10.md b/.agents/skills/migrate-dotnet9-to-dotnet10/references/serialization-networking-dotnet9to10.md new file mode 100644 index 00000000..c6b48cb8 --- /dev/null +++ b/.agents/skills/migrate-dotnet9-to-dotnet10/references/serialization-networking-dotnet9to10.md @@ -0,0 +1,61 @@ +# Serialization and Networking Breaking Changes (.NET 10) + +These changes affect projects using System.Text.Json, XmlSerializer, HttpClient, and networking APIs. + +## Serialization + +### System.Text.Json checks for property name conflicts + +**Impact: Medium.** Polymorphic types with properties that conflict with metadata names (`$type`, `$id`, `$ref`, or custom `TypeDiscriminatorPropertyName`) now throw `InvalidOperationException` during serialization instead of producing invalid JSON. + +```csharp +// This now throws InvalidOperationException at serialization time: +[JsonPolymorphic(TypeDiscriminatorPropertyName = "Type")] +[JsonDerivedType(typeof(Dog), "dog")] +public abstract class Animal +{ + public abstract string Type { get; } // Conflicts with "Type" discriminator +} +``` + +**Fix:** rename the conflicting property, or suppress it with `[JsonIgnore]`: + +```csharp +[JsonPolymorphic(TypeDiscriminatorPropertyName = "Type")] +[JsonDerivedType(typeof(Dog), "dog")] +public abstract class Animal +{ + [JsonIgnore] + public abstract string Type { get; } +} +``` + +### XmlSerializer no longer ignores properties marked with ObsoleteAttribute + +**Security consideration:** Properties marked `[Obsolete]` are now included in XML serialization. Previously they were silently skipped. If obsolete properties contain sensitive data (e.g., deprecated password fields, legacy PII, or internal-only values), they will now appear in serialized output. Audit obsolete properties for sensitive data and mark them with `[XmlIgnore]` if they should not be serialized. + +## Networking + +### HTTP/3 support disabled by default with PublishTrimmed + +**When `true` or `true` is set, HTTP/3 support is completely disabled by default.** The HTTP/3 code is stripped by the trimmer. This is NOT a native library search issue — the HTTP/3 implementation code itself is removed. + +**Fix:** Add `true` to your `.csproj` to preserve HTTP/3 support when trimming: +```xml + + true + true + +``` + +### MailAddress enforces validation for consecutive dots + +`MailAddress` now rejects email addresses with consecutive dots (e.g., `user..name@example.com`). Previously these were accepted. + +### Streaming HTTP responses enabled by default in browser HTTP clients + +In Blazor WebAssembly and other browser-based HTTP clients, streaming responses are now enabled by default. This may change how response content is buffered and consumed. + +### Uri length limits removed + +**Security consideration:** The `Uri` class no longer enforces length limits. Previously, very long URIs could throw exceptions. If your application relied on `Uri` to reject excessively long input (as an input validation or sanitization gate), this removal may expose denial-of-service or resource exhaustion attack surface. Add explicit length validation before constructing `Uri` instances from untrusted input. diff --git a/.agents/skills/migrate-dotnet9-to-dotnet10/references/winforms-wpf-dotnet9to10.md b/.agents/skills/migrate-dotnet9-to-dotnet10/references/winforms-wpf-dotnet9to10.md new file mode 100644 index 00000000..1bdd4781 --- /dev/null +++ b/.agents/skills/migrate-dotnet9-to-dotnet10/references/winforms-wpf-dotnet9to10.md @@ -0,0 +1,91 @@ +# Windows Forms and WPF Breaking Changes (.NET 10) + +These changes affect projects using Windows Forms (`true`) or WPF (`true`). + +## Windows Forms + +### Source-Incompatible Changes + +#### API obsoletions + +Several Windows Forms APIs have been marked obsolete with custom diagnostic IDs. Follow the guidance in the warning message for each. + +#### Applications referencing both WPF and WinForms must disambiguate MenuItem and ContextMenu types + +If a project references both WPF and WinForms, the `MenuItem` and `ContextMenu` types are ambiguous. Use fully qualified names: + +```csharp +// Before (ambiguous in .NET 10) +var item = new MenuItem("File"); + +// After +var item = new System.Windows.Forms.MenuItem("File"); +// or +var item = new System.Windows.Controls.MenuItem(); +``` + +#### Renamed parameter in HtmlElement.InsertAdjacentElement + +The parameter name in `HtmlElement.InsertAdjacentElement` has changed from `orientation`. Calls that use named arguments with `orientation` will no longer compile; update them to use positional arguments: + +```csharp +// Before — named arguments with old parameter name +element.InsertAdjacentElement(orientation: HtmlElementInsertionOrientation.BeforeBegin, newElement: newElement); + +// After — use positional arguments +element.InsertAdjacentElement(HtmlElementInsertionOrientation.BeforeBegin, newElement); +``` + +### Behavioral Changes + +#### TreeView checkbox image truncation + +The checkbox rendering in `TreeView` controls has been adjusted, which changes text positioning. Visual appearance may differ slightly. + +#### StatusStrip uses System RenderMode by default + +`StatusStrip` now uses the system render mode by default instead of a custom renderer. The visual appearance may change. Set `RenderMode` explicitly to restore the previous look. + +#### System.Drawing OutOfMemoryException changed to ExternalException + +**Important:** Some `System.Drawing` operations that previously threw `OutOfMemoryException` now throw `ExternalException` (from `System.Runtime.InteropServices` — NOT `ArgumentException`). This reflects the actual GDI+ error code. Update catch blocks: + +```csharp +// Before — only catching OutOfMemoryException +try { /* drawing operation */ } +catch (OutOfMemoryException) { /* handle */ } + +// After — catch ExternalException (the new exception type in .NET 10) +try { /* drawing operation */ } +catch (ExternalException) { /* handle */ } +catch (OutOfMemoryException) { /* handle — for older runtimes */ } +``` + +## WPF + +### Source-Incompatible / Behavioral Changes + +#### Empty ColumnDefinitions and RowDefinitions are disallowed + +Empty `` and `` elements in XAML now cause errors. Remove them if they don't contain any definitions: + +```xml + + + + + + + + + + +``` + +#### Incorrect usage of DynamicResource causes application crash + +Incorrect `DynamicResource` usage that was silently ignored now causes crashes at runtime. Common issues: +- Using `DynamicResource` where `StaticResource` is required (e.g., in non-dependency-property contexts) +- Referencing resources that don't exist + +Audit all `DynamicResource` usage in XAML and ensure each reference points to a valid resource and is used in a context that supports dynamic resources. diff --git a/.agents/skills/migrate-nullable-references/SKILL.md b/.agents/skills/migrate-nullable-references/SKILL.md new file mode 100644 index 00000000..aee69de1 --- /dev/null +++ b/.agents/skills/migrate-nullable-references/SKILL.md @@ -0,0 +1,287 @@ +--- +description: 'Enable nullable reference types in a C# project and systematically resolve all warnings. USE FOR: adopting NRTs in existing codebases, file-by-file or project-wide migration, fixing CS8602/CS8618/CS86xx warnings, annotating APIs for nullability, cleaning up null-forgiving operators, upgrading dependencies with new nullable annotations. DO NOT USE FOR: projects already fully migrated with zero warnings (unless auditing suppressions), fixing a handful of nullable warnings in code that already has NRTs enabled, suppressing warnings without fixing them, C# 7.3 or earlier projects. INVOKES: Get-NullableReadiness.ps1 scanner script.' +metadata: + github-path: plugins/dotnet-upgrade/skills/migrate-nullable-references + github-pinned: v1.0.0 + github-ref: refs/tags/v1.0.0 + github-repo: https://github.com/dotnet/skills + github-tree-sha: b4651ed7f4c454561392756b1cc3677c897331b4 +name: migrate-nullable-references +--- +# Nullable Reference Migration + +Enable C# nullable reference types (NRTs) in an existing codebase and systematically resolve all warnings. The outcome is a project (or solution) with `enable`, zero nullable warnings, and accurately annotated public API surfaces — giving both the compiler and consumers reliable nullability information. + +## When to Use + +- Enabling nullable reference types in an existing C# project or solution +- Systematically resolving CS86xx nullable warnings after enabling the feature +- Annotating a library's public API surface so consumers get accurate nullability information +- Upgrading a dependency that has added nullable annotations and new warnings appear +- Analyzing suppressions in a code base that has already enabled NRTs to determine whether they can be removed + +## When Not to Use + +- The project already has `enable` and zero warnings — the migration is done unless the user wants to re-examine suppressions with a view to removing unnecessary ones (see Step 6) +- The user only wants to suppress warnings without fixing them (recommend against this) +- The code targets C# 7.3 or earlier, which does not support nullable reference types + +## Inputs + +| Input | Required | Description | +|-------|----------|-------------| +| Project or solution path | Yes | The `.csproj`, `.sln`, or build entry point to migrate | +| Migration scope | No | `project-wide` (default) or `file-by-file` — controls the rollout strategy | +| Build command | No | How to build the project (e.g., `dotnet build`, `msbuild`, or a repo-specific build script). Detect from the repo if not provided | +| Test command | No | How to run tests (e.g., `dotnet test`, or a repo-specific test script). Detect from the repo if not provided | + +## Workflow + +> 🛑 **Zero runtime behavior changes.** NRT migration is strictly a metadata and annotation exercise. The generated IL must not change — no new branches, no new null checks, no changed control flow, no added or removed method calls. The only acceptable changes are nullable annotations (`?`), nullable attributes (`[NotNullWhen]`, etc.), `!` operators (metadata-only), and `#nullable` directives. If you discover a missing runtime null guard or a latent bug during migration, **do not fix it inline**. Instead, offer to insert a `// TODO: Consider adding ArgumentNullException.ThrowIfNull(param)` comment at the site so the user can address it as a separate change. Never mix behavioral fixes into an annotation commit. + +> **Commit strategy:** Commit at each logical boundary — after enabling `` (Step 2), after fixing dereference warnings (Step 3), after annotating declarations (Step 4), after applying nullable attributes (Step 5), and after cleaning up suppressions (Step 6). This keeps each commit focused and reviewable, and prevents losing work if a later step reveals a design issue that requires rethinking. For file-by-file migrations, commit each file or batch of related files individually. + +### Step 1: Evaluate readiness + +> **Optional:** Run `scripts/Get-NullableReadiness.ps1 -Path ` to automate the checks below. The script reports ``, ``, ``, `` settings and counts `#nullable disable` directives, `!` operators, and `#pragma warning disable CS86xx` suppressions. Use `-Json` for machine-readable output. + +1. Identify how the project is built and tested. Look for build scripts (e.g., `build.cmd`, `build.sh`, `Makefile`), a `.sln` file, or individual `.csproj` files. If the repo uses a custom build script, use it instead of `dotnet build` throughout this workflow. +2. Run `dotnet --version` to confirm the SDK is installed. Nullable reference types (NRTs) require C# 8.0+ (`.NET Core 3.0` / `.NET Standard 2.1` or later). +3. Open the `.csproj` (or `Directory.Build.props` if properties are set at the repo level) and check the `` and ``. If the project multi-targets, note all TFMs. + +> **Stop if the language version or target framework is insufficient.** If `` is below 8.0, or the project targets a framework that defaults to C# 7.x (e.g., `.NET Framework 4.x` without an explicit ``), NRTs cannot be enabled as-is. Inform the user explicitly: explain what needs to change (set `8.0` or higher, or retarget to `.NET Core 3.0+` / `.NET 5+`), and ask whether they want to make that update and continue, or abort the migration. Do not silently proceed or assume the update is acceptable. +4. Check whether `` is already set. If it is set to `enable`, skip to Step 5 to audit remaining warnings. +5. Determine the project type — this shapes annotation priorities throughout the migration: + - **Library**: Focus on public API contracts first. Every `?` on a public parameter or return type is a contract change that consumers depend on. Be precise and conservative. + - **Application (web, console, desktop)**: Focus on null safety at boundaries — deserialization, database queries, user input, external API responses. Internal plumbing can be annotated more liberally. + - **Test project**: Lower priority for annotation precision. Use `!` more freely on test setup and assertions where null is never expected. Focus on ensuring test code compiles cleanly. + +### Step 2: Choose a rollout strategy + +Pick one of the following strategies based on codebase size and activity level. Recommend the strategy to the user and confirm before proceeding. + +> **Multi-project solutions:** Migrate in dependency order — shared libraries and core projects first, then projects that consume them. Annotating a dependency first eliminates cascading warnings in its consumers and prevents doing work twice. + +Regardless of strategy, **start at the center and work outward**:begin with core domain models, DTOs, and shared utility types that have few dependencies but are used widely. Annotating these first eliminates cascading warnings across the codebase and gives the biggest return on effort. Then move on to higher-level services, controllers, and UI code that depend on the core types. This approach minimizes the number of warnings at each step and prevents getting overwhelmed by a flood of warnings from a large project-wide enable. Prefer to create at least one PR per project, or per layer, to keep changesets reviewable and focused. If there are relatively few annotations needed, a single project-wide enable and single PR may be appropriate. + +#### Strategy A — Project-wide enable (small to medium projects) + +Best when the project has fewer than roughly 50 source files or the team wants to finish in one pass. + +1. Add `enable` to the `` in the `.csproj`. +2. Build and address all warnings at once. + +#### Strategy B — Warnings-first, then annotations (large or active projects) + +Best when the codebase is large or under active development by multiple contributors. + +1. Add `warnings` to the `.csproj`. This enables warnings without changing type semantics. +2. Build, fix all warnings from Step 3 onward. +3. Change to `enable` to activate annotations — this triggers a second wave of warnings. +4. Resolve the annotation-phase warnings from Step 4 onward. + +#### Strategy C — File-by-file (very large projects) + +Best for large legacy codebases where enabling project-wide would produce an unmanageable number of warnings. + +1. Set `disable` (or omit it) at the project level. +2. Add `#nullable enable` at the top of each file as it is migrated. +3. Prioritize files in dependency order: shared utilities and models first, then higher-level consumers. + +> **Build checkpoint:** After enabling `` (or adding `#nullable enable` to the first batch of files), do a **clean build** (e.g., `dotnet build --no-incremental`, or delete `bin`/`obj` first). Incremental builds only recompile changed files and will hide warnings in untouched files. Record the initial warning count — this is the baseline to work down from. Do not proceed to fixing warnings without first confirming the project still compiles. Use clean builds for all subsequent build checkpoints in this workflow. + +### Step 3: Fix dereference warnings + +> **Prioritization:** Work through files in dependency order — start with core models and shared utilities that other code depends on, then move to higher-level consumers. Within each file, fix public and protected members first (these define the contract), then internal and private members. This order minimizes cascading warnings: fixing a core type's annotations often resolves warnings in its consumers automatically. + +Build the project and work through dereference warnings. These are the most common: + +| Warning | Meaning | Typical fix | +|---------|---------|-------------| +| CS8602 | Dereference of a possibly null reference | Prefer annotation-only fixes: make the upstream type nullable (`T?`) if null is valid, or use `!` if you can verify the value is never null at this point. Adding a null check or `?.` changes runtime behavior — reserve those for a separate commit (see zero-behavior-change rule above) | +| CS8600 | Converting possible null to non-nullable type | Add `?` to the target type if null is valid, or use `!` if you can verify the value is never null. Adding a null guard changes runtime behavior | +| CS8603 | Possible null reference return | Change the return type to nullable (`T?`) if the method can genuinely return null. **Do not suppress with `!` if the method can genuinely return null** — fix the return type instead. This is the single most important rule in NRT migration: a non-nullable return type is a promise to every caller that null will never be returned | +| CS8604 | Possible null reference argument | Mark the parameter as nullable if null is valid, or use `!` if the argument is verifiably non-null. Adding a null check before passing changes runtime behavior | + +> ❌ **Do not use `?.` as a quick fix for dereference warnings.** Replacing `obj.Method()` with `obj?.Method()` silently changes runtime behavior — the call is skipped instead of throwing. Only use `?.` when you intentionally want to tolerate null. + +> ❌ **Do not sprinkle `!` to silence warnings.** Each `!` is a claim that the value is never null. If that claim is wrong, you have hidden a `NullReferenceException`. Add a null check or make the type nullable instead. + +> ❌ **Never use `return null!` to keep a return type non-nullable.** If a method returns `null`, the return type must be `T?`. Writing `return null!` hides a null behind a non-nullable signature — callers trust the signature, skip null checks, and get `NullReferenceException` at runtime. This applies to `null!`, `default!`, and any cast that makes the compiler accept null in a non-nullable position. The only acceptable use of `!` on a return value is when the value is **provably never null** but the compiler cannot see why. + +> ⚠️ **Do not add `?` to value types unless you intend to change the runtime type.** For reference types, `?` is metadata-only. For value types (`int`, enums, structs), `?` changes the type to `Nullable`, altering the method signature, binary layout, and boxing behavior. + +**Decision flowchart for each warning:** + +1. **Is null a valid value here by design?** + - **Yes** → add `?` to the declaration (make it nullable). + - **No** → go to step 2. + - **Unsure** → ask the user before proceeding. +2. **Can you prove the value is never null at this point?** + - **Yes, with a code path the compiler can't see** → add `!` with a comment explaining why. + - **Yes, by adding a guard** → add a null check (`if`, `??`, `is not null`). + - **No** → the type should be nullable (go back to step 1 — the answer is "Yes"). + +Guidance: + +- Prefer explicit null checks (`if`, `is not null`, `??`) over the null-forgiving operator (`!`). +- Use the null-forgiving operator only when you can prove the value is never null but the compiler cannot, and add a comment explaining why. +- Guard clause libraries (e.g., Ardalis.GuardClauses, Dawn.Guard) often decorate parameters with `[NotNull]`, which narrows null state after the guard call. After `Guard.Against.NullOrEmpty(value, nameof(value))`, the compiler already narrows `string?` to `string` — do not add a redundant `!` at the subsequent assignment. Check whether the guard method uses `[NotNull]` before assuming the compiler needs help. +- When a method legitimately returns null, change the return type to `T?` — do not hide nulls behind a non-nullable signature. +- `Debug.Assert(x != null)` acts as a null-state hint to the compiler just like an `if` check. Use it at the top of a method or block to inform the flow analyzer about invariants and eliminate subsequent `!` operators in that scope. Note: `Debug.Assert` informs the compiler but is stripped from Release builds — it does not protect against null at runtime. For public API boundaries, prefer an explicit null check or `ArgumentNullException`. +- If you find yourself adding `!` at every call site of an internal method, consider making that parameter nullable instead. Reserve `!` for cases where the compiler genuinely cannot prove non-nullness. +- When a boolean-returning helper method's result guarantees a nullable parameter is non-null (e.g., `if (IsValid(x))` implies `x != null`), prefer adding `[NotNullWhen(true)]` to the helper's parameter over using `!` at every call site. This is a metadata-only change (no behavior change) that eliminates `!` operators downstream while giving the compiler real flow information. +- For fields that are always set after construction (e.g., by a framework, an `Init()` method, or a builder pattern), prefer `= null!` on the field declaration over adding `!` at every use site. A field accessed 50 times should have one `= null!`, not fifty `field!` assertions. This keeps the field non-nullable in the type system while acknowledging the late initialization. Pair with `[MemberNotNull]` on the initializing method when possible. +- For generic methods returning `default` on an unconstrained type parameter (e.g., `FirstOrDefault`), use `[return: MaybeNull] T` rather than `T?`. Writing `T?` on an unconstrained generic changes value-type signatures to `Nullable`, altering the method signature and binary layout. `[return: MaybeNull]` preserves the original signature while communicating that the return may be null for reference types. +- LINQ's `Where(x => x != null)` does not narrow `T?` to `T` — the compiler cannot track nullability through lambdas passed to generic methods. Use `source.OfType()` to filter nulls with correct type narrowing. + +> **Build checkpoint:** After fixing dereference warnings, build and confirm zero CS8602/CS8600/CS8603/CS8604 warnings remain before moving to annotation warnings. + +### Step 4: Annotate declarations + +Start by deciding the **intended nullability** of each member based on its design purpose — should this parameter accept null? Can this return value ever be null? Annotate accordingly, then address any resulting warnings. Do not let warnings drive your annotations; that leads to over-annotating with `?` or scattering `!` to silence the compiler. + +> **When to ask the user:** Do not guess API contracts. Never infer nullability intent from usage frequency or naming conventions alone — if intent is not explicit in code or documentation, ask the user. Specifically, ask before: (1) changing a public method's return type to nullable or adding `?` to a public parameter — this changes the API contract consumers depend on; (2) deciding whether a property should be nullable vs. required when the design intent is unclear; (3) choosing between a null check and `!` when you cannot determine from context whether null is a valid state. For internal/private members where the answer is obvious from usage, proceed without asking. + +> ❌ **Do not let warnings drive annotations.** Decide the intended nullability of each member first, then annotate. Adding `?` everywhere to make warnings disappear defeats the purpose — callers must then add unnecessary null checks. Adding `!` everywhere hides bugs. + +> ⚠️ **Return types must reflect semantic nullability, not just compiler satisfaction.** A common mistake is removing `?` from a return type because the implementation uses `default!` or a cast that satisfies the compiler. If the method can return null by design, its return type must be nullable — regardless of whether the compiler warns. Key patterns: +> - Methods named `*OrDefault` (`FirstOrDefault`, `SingleOrDefault`, `FindOrDefault`) → return type must be nullable (`T?`, `object?`, `dynamic?`) because "or default" means "or null" for reference types. +> - `ExecuteScalar` and similar database methods → return type must be `object?` because the result can be `DBNull.Value` or null when no rows match. +> - `Find`, `TryGet*` (out parameter), and lookup methods → return type should be nullable when the item may not exist. +> - Any method documented or designed to return null on failure, not-found, or empty-input → nullable return type. +> +> The compiler cannot catch a *missing* `?` on a return type when the implementation hides null behind `!` or `default!`. This makes the annotation wrong for consumers — they trust the non-nullable signature and skip null checks, leading to `NullReferenceException` at runtime. + +> ⚠️ **Do not remove existing `ArgumentNullException` checks.** A non-nullable parameter annotation is a compile-time hint only — it does not prevent null at runtime. Callers using older C# versions, other .NET languages, reflection, or `!` can still pass null. + +> ⚠️ **Flag public API methods missing runtime null validation — but do not add checks.** While annotating, check each `public` and `protected` method: if a parameter is non-nullable (`T`, not `T?`), there should be a runtime null check (e.g., `ArgumentNullException.ThrowIfNull(param)` or `if (param is null) throw new ArgumentNullException(...)`). Without one, a null passed at runtime causes a `NullReferenceException` deep in the method body instead of a clear `ArgumentNullException` at the entry point. Adding a null guard is a runtime behavior change and must not be part of the NRT migration. Instead, ask the user whether they want a `// TODO: Consider adding ArgumentNullException.ThrowIfNull(param)` comment inserted at the site. This is especially important for libraries where callers may not have NRTs enabled. + +> **Methods with defined behavior for null should accept nullable parameters.** If a method handles null input gracefully — returning null, returning a default, or returning a failure result instead of throwing — the parameter should be `T?`, not `T`. The BCL follows this convention: `Path.GetPathRoot(string?)` returns null for null input, while `Path.GetFullPath(string)` throws. Only use a non-nullable parameter when null causes an exception. Marking a parameter as non-nullable when the method actually tolerates null forces callers to add unnecessary null checks before calling. +> +> **Gray areas:** When a parameter is neither validated, sanitized, nor documented for null, consider: (1) Is null ever passed in your own codebase? If yes → nullable. (2) Is null likely used as a "default" or no-op placeholder by callers? If yes → nullable. (3) Do similar methods in the same area accept null? If yes → nullable for consistency. (4) If the method is largely oblivious to null and just happens to work, but null makes no semantic sense for the API's purpose → non-nullable. When in doubt between nullable and non-nullable for a parameter, prefer nullable — it is safer and can be tightened later. + +After dereference warnings are resolved, address annotation warnings: + +| Warning | Meaning | Typical fix | +|---------|---------|-------------| +| CS8618 | Non-nullable field/property not initialized in constructor | Initialize the member, make it nullable (`?`), or use `required` (C# 11+). For fields that are always set after construction but outside the constructor (e.g., by a framework lifecycle method, an `Init()` call, or a builder pattern), use `= null!` to declare intent while keeping the field non-nullable at every use site. If a helper method initializes fields, decorate it with `[MemberNotNull(nameof(field))]` so the compiler knows the field is non-null after the call | +| CS8625 | Cannot convert null literal to non-nullable type | Make the target nullable or provide a non-null value | +| CS8601 | Possible null reference assignment | Same techniques as CS8600 | + +For each type, decide: **should this member ever be null?** + +- **Yes** → add `?` to its declaration. +- **No** → ensure it is initialized in every constructor path, or mark it `required` (C# 11+). +- **No, but it is set after the constructor** (e.g., by a framework method, a builder, or a two-phase init pattern) → use `= null!` on the field declaration. This keeps the field's type non-nullable everywhere it is used, while telling the compiler "I guarantee this will be set before access." This is far preferable to adding `!` at every use site — a field accessed 50 times would need 50 `!` operators instead of one `= null!`. If the initialization is done by a specific method, also consider `[MemberNotNull(nameof(field))]` on that method. + +Focus annotation effort on public and protected APIs first — these define the contract that consumers depend on. Internal and private code can tolerate `!` more liberally since it does not affect external callers. + +> **Public libraries: track breaking changes.** If the project is a library consumed by others, create a `nullable-breaking-changes.md` file (or equivalent) and record every public API change that could affect consumers. While adding `?` to a reference type is metadata-only and not binary-breaking, it IS source-breaking for consumers who have NRTs enabled — they will get new warnings or errors. Key changes to document: +> - Return types changed from `T` to `T?` (consumers must now handle null) +> - Parameters changed from `T?` to `T` (consumers can no longer pass null) +> - Parameters changed from `T` to `T?` (existing null checks in callers become unnecessary — low impact but worth noting) +> - `?` added to a value type parameter or return (changes `T` to `Nullable` — binary-breaking) +> - New `ArgumentNullException` guards added where none existed +> - Any behavioral changes discovered and fixed during annotation (e.g., a method that silently accepted null now throws) +> +> Present this file to the user for review. It may also serve as the basis for release notes. + +Pay special attention to: + +- **DTOs vs domain models**: Apply different nullability strategies depending on the role of the class. **DTOs and serialization models** cross trust boundaries (JSON, forms, external APIs) — their properties should be nullable by default unless enforced by the serializer, because deserialized data can always be null regardless of the declared type. Use `required` (C# 11+), `[JsonRequired]` (.NET 7+), or runtime validation to enforce non-null constraints. **Domain models** represent internal invariants — prefer non-nullable properties with constructor enforcement, making invalid state unrepresentable. This distinction is where migrations most often go wrong: treating a DTO as a domain model leads to runtime `NullReferenceException`; treating a domain model as a DTO leads to unnecessary null checks everywhere. +- **Event handlers and delegates**: The pattern `EventHandler? handler = SomeEvent; handler?.Invoke(...)` is idiomatic. +- **Struct reference-type fields**: Reference-type fields in structs are null when using `default(T)`. If `default` is valid usage for the struct, those fields must be nullable. If `default` is never expected (the struct is only created by specific APIs), keep them non-nullable to avoid burdening every consumer with unnecessary null checks. +- **Post-Dispose state**: If a field or property is non-null for the entire useful lifetime of the object but may become null after `Dispose`, keep it non-nullable. Using an object after disposal is a contract violation — do not weaken annotations for that case. +- **Overrides and interface implementations**: An override can return a stricter (non-nullable) type than the base method declares. If your implementation never returns null but the base/interface returns `T?`, you can declare the override as returning `T`. Parameter types must match the base exactly. +- **Widely-overridden virtual return types**: For virtual/abstract methods that many classes override, consider whether existing overrides actually return null. If they commonly do (like `Object.ToString()`), annotate the return as `T?` — callers need to know. If null overrides are vanishingly rare (like `Exception.Message`), annotate as `T`. When in doubt for broadly overridden virtuals, prefer `T?`. +- **`IEquatable` and `IComparable`**: Reference types should implement `IEquatable` and `IComparable` (with nullable `T`), because callers commonly pass null to `Equals` and `CompareTo`. +- **`Equals(object?)` overrides**: Add `[NotNullWhen(true)]` to the parameter of `Equals(object? obj)` overrides — if `Equals` returns `true`, the argument is guaranteed non-null. This lets callers skip redundant null checks after an equality test. + +> **Build checkpoint:** After annotating declarations, build and confirm zero CS8618/CS8625/CS8601 warnings remain before moving to nullable attributes. + +### Step 5: Apply nullable attributes for advanced scenarios + +When a simple `?` annotation cannot express the null contract, apply attributes from `System.Diagnostics.CodeAnalysis` — see [references/nullable-attributes.md](references/nullable-attributes.md) for the full attribute table (`[NotNullWhen]`, `[MaybeNullWhen]`, `[MemberNotNull]`, `[AllowNull]`, `[DisallowNull]`, `[DoesNotReturn]`, etc.) with usage guidance for each. + +> **Build checkpoint:** After applying nullable attributes, build to verify the attributes resolved the targeted warnings and did not introduce new ones. + +### Step 6: Clean up suppressions + +> **Optional:** Re-run `scripts/Get-NullableReadiness.ps1` to get current counts of `#nullable disable` directives, `!` operators, and `#pragma warning disable CS86xx` suppressions across the project. + +1. Search for any `#nullable disable` directives or `!` operators that were added as temporary workarounds. +2. For each one, determine whether the suppression is still needed. +3. Remove suppressions that are no longer necessary. For any that remain, add a comment explaining why. +4. Search for `#pragma warning disable CS86` to find suppressed nullable warnings and evaluate whether the underlying issue can be fixed instead. + +> **Build checkpoint:** After removing suppressions, build again — removing a `#nullable disable` or `!` may surface new warnings that need fixing. + +### Step 7: Validate + +1. Build the project and confirm zero nullable warnings. +2. Add `nullable` to the project file (or `Directory.Build.props` for the whole repo) to permanently prevent nullable regressions. This is the project-file equivalent of `dotnet build /warnaserror:nullable`. +3. Run existing tests to confirm no regressions. +4. If the project is a library, inspect the public API surface to verify that nullable annotations match the intended contracts (parameters that accept null are `T?`, parameters that reject null are `T`). + +> **Verify before claiming the migration is complete.** Zero warnings alone does not mean the migration is correct. Before reporting success: (1) spot-check public API signatures — confirm `?` annotations match actual design intent, not just compiler silence; (2) verify no `?.` operators were added that change runtime behavior (search for `?.` in the diff); (3) confirm no `ArgumentNullException` checks were removed; (4) check that `!` operators are rare and each has a justifying comment. + +## Validation + +- [ ] Project file(s) contain `enable` (or `#nullable enable` per-file for file-by-file strategy) +- [ ] Build produces zero CS86xx warnings +- [ ] `nullable` added to project file to prevent regressions +- [ ] Tests pass with no regressions +- [ ] No `#nullable disable` directives remain unless justified with a comment +- [ ] Null-forgiving operators (`!`) are rare, each with a justifying comment +- [ ] Public API signatures accurately reflect null contracts +- [ ] For public libraries: breaking changes documented in `nullable-breaking-changes.md` and reviewed by the user + +### Code review checklist + +Nullable migration changes require broader review than a typical diff: + +1. **Verify no behavior changes**: confirm that `?` and `!` are the only additions — no accidental `?.`, no removed null checks, no new branches. The generated IL should be unchanged except for nullable metadata. +2. **Review explicit annotation changes**: for every `?` added to a parameter or return type, confirm it matches the intended design. Does the method really accept null? Can it really return null? +3. **Review unchanged APIs in scope**: enabling `enable` implicitly makes every unannotated reference type in that scope non-nullable. Scan unchanged public members for parameters that actually do accept null but were not annotated. + +## Breaking Changes from NRT Annotations (Libraries) + +For libraries, see [references/breaking-changes.md](references/breaking-changes.md) — NRT annotations are part of the public API contract and incorrect annotations are source-breaking changes for consumers. + +## Common Pitfalls + +| Pitfall | Solution | +|---------|----------| +| Sprinkling `!` everywhere to silence warnings | The null-forgiving operator hides bugs. Add null checks or change the type to nullable instead | +| Marking everything `T?` to eliminate warnings quickly | Over-annotating with `?` defeats the purpose — callers must add unnecessary null checks. Only use `?` when null is a valid value | +| Constructor does not initialize all non-nullable members | Initialize fields and properties in every constructor, use `required` (C# 11+), or make the member nullable | +| Serialization bypasses constructors — non-nullable ≠ runtime safety | Serializers create objects without calling constructors, so non-nullable DTO properties can still be null at runtime. See "DTOs vs domain models" in Step 4 for detailed guidance | +| Generated code produces warnings | Generated files are excluded from nullable analysis automatically if they contain `` comments. If warnings persist, add `#nullable disable` at the top of the generated file or configure `.editorconfig` with `generated_code = true` | +| Multi-target projects and older TFMs | NRT annotations compile on older TFMs (e.g., .NET Standard 2.0) with C# 8.0+, but nullable attributes like `[NotNullWhen]` may not exist. Use a polyfill package such as `Nullable` from NuGet, or define the attributes internally | +| Warnings reappear after upgrading a dependency | The dependency added nullable annotations. This is expected and beneficial — fix the new warnings as in Steps 3–5 | +| Accidentally changing behavior while annotating | Adding `?` to a type or `!` to an expression is metadata-only and does not change generated IL. But replacing `obj.Method()` with `obj?.Method()` (null-conditional) changes runtime behavior — the call is silently skipped instead of throwing. Only use `?.` when you intentionally want to tolerate null, not as a quick fix for a warning | +| Adding `?` to a value type (enum, struct) | For reference types, `?` is a metadata annotation with no runtime effect. For value types like `int` or an enum, `?` changes the type to `Nullable`, altering the method signature, binary layout, and boxing behavior. Double-check that you are only adding `?` to reference types unless you truly intend to make a value type nullable | +| Removing existing null argument validation | Non-nullable annotations are compile-time only — callers can still pass null at runtime. Keep existing `ArgumentNullException` checks. See Step 4 for details | +| `var` infers nullability from the assigned expression | When using `var`, the inferred type includes nullability from the assigned expression, which can be surprising compared to explicitly declaring `T` vs `T?`. Flow analysis determines the actual null-state from that point forward, but the inferred declaration type may carry nullability you did not expect. If precise nullability at the declaration matters, use an explicit type instead of `var` | +| Consuming unannotated (nullable-oblivious) libraries | When a dependency has not opted into nullable annotations, the compiler treats all its types as "oblivious" — you get no warnings for dereferencing or assigning null. This gives a false sense of safety. Treat return values from oblivious APIs as potentially null, especially for methods that could conceptually return null (dictionary lookups, `FirstOrDefault`-style calls). Upgrade dependencies or wrap calls when possible | + +## Entity Framework Core Considerations + +If the project uses EF Core, see [references/ef-core.md](references/ef-core.md) — enabling NRTs can change database schema inference and migration output. + +## ASP.NET Core Considerations + +If the project uses ASP.NET Core, see [references/aspnet-core.md](references/aspnet-core.md) — enabling NRTs can change MVC model validation and JSON serialization behavior. + +## More Info + +- [Nullable reference types](https://learn.microsoft.com/dotnet/csharp/nullable-references) — overview of the feature, nullable contexts, and compiler analysis +- [Nullable reference types (C# reference)](https://learn.microsoft.com/dotnet/csharp/language-reference/builtin-types/nullable-reference-types) — language reference for nullable annotation and warning contexts +- [Nullable migration strategies](https://learn.microsoft.com/dotnet/csharp/nullable-migration-strategies) +- [Embracing Nullable Reference Types](https://devblogs.microsoft.com/dotnet/embracing-nullable-reference-types/) — Mads Torgersen's guidance on adoption timing and ecosystem considerations +- [Resolve nullable warnings](https://learn.microsoft.com/dotnet/csharp/language-reference/compiler-messages/nullable-warnings) +- [Attributes for nullable static analysis](https://learn.microsoft.com/dotnet/csharp/language-reference/attributes/nullable-analysis) +- [! (null-forgiving) operator](https://learn.microsoft.com/dotnet/csharp/language-reference/operators/null-forgiving) — language reference for the operator and when to use it +- [EF Core and nullable reference types](https://learn.microsoft.com/ef/core/miscellaneous/nullable-reference-types) +- [.NET Runtime nullable annotation guidelines](https://github.com/dotnet/runtime/blob/main/docs/coding-guidelines/api-guidelines/nullability.md) — the annotation principles used when annotating the .NET libraries themselves diff --git a/.agents/skills/migrate-nullable-references/references/aspnet-core.md b/.agents/skills/migrate-nullable-references/references/aspnet-core.md new file mode 100644 index 00000000..ebedcb75 --- /dev/null +++ b/.agents/skills/migrate-nullable-references/references/aspnet-core.md @@ -0,0 +1,17 @@ +# ASP.NET Core Considerations + +ASP.NET Core reads nullable annotations at runtime to drive model validation and serialization behavior. Enabling NRTs in an ASP.NET Core project can change request validation outcomes, not just compiler warnings: + +- **MVC model validation treats non-nullable properties as `[Required]`**: When NRTs are enabled, ASP.NET Core MVC and Web API implicitly add `[Required(AllowEmptyStrings = true)]` to every non-nullable reference type property in DTOs and view models. A `string Name` property that previously accepted null from JSON or form posts will now return a 400 Bad Request. Review all model classes when enabling NRTs. To disable this behavior during gradual migration, set `SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true` in `AddControllers` options. +- **Minimal API parameter optionality changes with NRTs**: When NRTs are enabled, minimal API parameter binding uses nullable annotations to determine whether a parameter is required or optional. A `string name` parameter that was previously treated as optional (accepting null) becomes required and returns a 400 Bad Request if missing. To preserve the previous behavior, explicitly mark the parameter as nullable (`string? name`). Review all minimal API endpoint parameters when enabling NRTs. See [optional parameters](https://learn.microsoft.com/aspnet/core/fundamentals/minimal-apis/parameter-binding#optional-parameters). +- **Enable `JsonSerializerOptions.RespectNullableAnnotations = true` (.NET 9+)**: For .NET 9+ projects, always enable `RespectNullableAnnotations` (along with `RespectRequiredConstructorParameters`) to align runtime serialization behavior with your NRT annotations. Without this, `System.Text.Json` silently assigns `null` to non-nullable properties, undermining compile-time null safety. When enabled, the serializer throws `JsonException` when a non-nullable property receives an explicit `null` during deserialization, or emits `null` for a non-nullable property during serialization. Be aware this enforcement has hard limitations rooted in how NRTs are represented in IL. It does **not** cover: + - Collection element types (`List` and `List` are indistinguishable via reflection) + - Dictionary value types (`Dictionary` vs `Dictionary`) + - Top-level types passed directly to `Deserialize` + - Generic type parameter nullability + + For these gaps, use manual validation or custom converters. Do not rely on `RespectNullableAnnotations` alone for complete null safety in your JSON layer. +- **Use `#nullable disable`, not `#nullable disable warnings` on model files**: Just as with EF Core, `#nullable disable warnings` only suppresses compiler diagnostics — the annotations remain active and MVC still reads them via reflection to infer `[Required]`. Use `#nullable disable` to fully opt out for files not yet migrated. +- **Razor Pages `[BindProperty]` properties**: Properties decorated with `[BindProperty]` (e.g., `public InputModel Input { get; set; }`) are populated by model binding during POST requests — similar to how EF Core initializes `DbSet` properties. Initialize with `= default!` or suppress CS8618 with a pragma. After a `ModelState.IsValid` check succeeds, sub-properties with `[Required]` can be accessed with the null-forgiving operator (`!`), since validation guarantees they are non-null. +- **Collection properties in ViewModels and DTOs**: Prefer non-nullable with an empty initializer (`= new List()`) over nullable. An empty collection means "no items"; null means "unknown/not loaded." This avoids forcing every consumer to null-check before iterating and matches the EF Core convention for collection navigations. +- **Avoid `?.` followed by `!`**: The pattern `obj?.Property!` is contradictory — `?.` handles the null case by producing null, then `!` immediately asserts the result is non-null. Use either `obj!.Property` (assert non-null, then access) or `obj?.Property` (conditionally access and handle null downstream). The `?.` + `!` combination often appears in Razor Page code-behind when accessing `[BindProperty]` model sub-properties; prefer `obj!.Property` after validation confirms the model is bound. diff --git a/.agents/skills/migrate-nullable-references/references/breaking-changes.md b/.agents/skills/migrate-nullable-references/references/breaking-changes.md new file mode 100644 index 00000000..a8790ebe --- /dev/null +++ b/.agents/skills/migrate-nullable-references/references/breaking-changes.md @@ -0,0 +1,8 @@ +# Breaking Changes from NRT Annotations (Libraries) + +For libraries consumed by other projects, NRT annotations are part of the public API contract. Incorrect annotations are source-breaking changes for consumers: + +- **Making a parameter non-nullable when it should be nullable**: If consumers previously passed null to a parameter and the method handled it gracefully, marking that parameter as `T` (non-nullable) causes compile warnings or errors for those callers. For example, annotating a logging enricher's `value` parameter as `object` instead of `object?` when the method has always accepted null values would break every caller that passes null. +- **Implicit non-nullability of unannotated types**: Enabling `enable` implicitly makes every unannotated reference-type parameter non-nullable. If the method previously accepted null without throwing, this is a silent contract change. Scan all public methods for parameters that tolerate null. +- **Return types that can be null**: If a method can return null, the return type must be `T?`. Marking it as `T` hides a potential `NullReferenceException` from callers who trust the annotation. +- **Ship annotations in a minor version, not a patch**: Because annotations can cause new warnings for consumers (especially those using `TreatWarningsAsErrors`), treat the NRT migration as a minor version bump, not a patch. Document the change in release notes. diff --git a/.agents/skills/migrate-nullable-references/references/ef-core.md b/.agents/skills/migrate-nullable-references/references/ef-core.md new file mode 100644 index 00000000..7b04bd8e --- /dev/null +++ b/.agents/skills/migrate-nullable-references/references/ef-core.md @@ -0,0 +1,18 @@ +# Entity Framework Core Considerations + +EF Core uses nullable annotations to infer database schema. Enabling NRTs in a project that uses EF Core has effects beyond compiler warnings: + +- **Schema changes from annotations**: When NRTs are enabled, EF Core treats `string` properties as required (NOT NULL) columns and `string?` as optional (NULL) columns. If you enable NRTs on an existing model without reviewing every entity property, running `Add-Migration` can generate migrations that make previously nullable columns required — potentially causing data loss if those columns already store nulls. +- **Always review generated migrations**: After enabling NRTs on entity classes, run `Add-Migration` and carefully inspect the output before applying it. Look for unexpected `AlterColumn` calls that change column nullability. +- **Navigation properties**: Required navigation properties present a design choice because they are null until loaded. The official EF Core docs describe three approaches: **(a)** Non-nullable with `= null!` — appropriate when accessing an unloaded navigation is a programmer error; **(b)** Nullable (`public Order? Order { get; set; }`) — appropriate when code legitimately checks whether the navigation is loaded; **(c)** Non-nullable property wrapping a nullable backing field that throws `InvalidOperationException` on uninitialized access — the strictest pattern. Collection navigations should always be non-nullable (initialize to an empty collection, e.g., `= new List()`; an empty collection means no related entities exist, but the list itself should never be null). +- **Migrate entity classes carefully**: Consider annotating entity model classes one at a time rather than enabling NRTs project-wide, to control the scope of schema impact. +- **Use `#nullable disable`, not `#nullable disable warnings` on entity files**: `#nullable disable warnings` only suppresses compiler warnings — the nullable annotations remain active and EF Core still reads them via reflection. This means properties without `?` are still treated as required, potentially altering schema. To fully opt entity files out of NRT effects, use `#nullable disable` which disables both warnings and the annotation context. +- **Private parameterless constructors — always pair `#pragma` disable with restore**: When suppressing CS8618 for a private parameterless constructor required by EF Core, always pair `#pragma warning disable CS8618` with `#pragma warning restore CS8618` immediately after the constructor. Without `restore`, the suppression leaks to all subsequent members in the file — any new property or constructor added later will silently skip the CS8618 check. Example: + ```csharp + #pragma warning disable CS8618 // Required by Entity Framework + private Order() { } + #pragma warning restore CS8618 + ``` + As an alternative, use `= null!` on each non-nullable property instead of a pragma — this is more explicit and does not risk suppression leakage, but is more verbose for entities with many properties. +- **DbSet properties**: Keep `DbSet` properties non-nullable — EF Core always initializes them. EF Core 7.0+ (`.NET 7`) automatically suppresses CS8618 for DbSet properties. On older versions, initialize with `= null!` or use a read-only expression body: `public DbSet Customers => Set();`. +- **LINQ queries with optional navigations**: EF Core translates LINQ queries to SQL, so navigating through an optional relationship in `Where` or `Include` won't cause a `NullReferenceException` at runtime — EF handles the null case server-side. However, the compiler doesn't know this and will warn. Use the null-forgiving operator in these expressions: `.Where(o => o.OptionalNav!.Prop == "foo")` and `.Include(o => o.OptionalNav!).ThenInclude(n => n.Child)`. diff --git a/.agents/skills/migrate-nullable-references/references/nullable-attributes.md b/.agents/skills/migrate-nullable-references/references/nullable-attributes.md new file mode 100644 index 00000000..3a2e00e5 --- /dev/null +++ b/.agents/skills/migrate-nullable-references/references/nullable-attributes.md @@ -0,0 +1,19 @@ +# Nullable Attributes Reference + +When a simple `?` annotation cannot express the null contract, use attributes from `System.Diagnostics.CodeAnalysis`: + +| Attribute | Use case | +|-----------|----------| +| `[NotNullWhen(true/false)]` | `TryGet` or `IsNullOrEmpty` patterns — the argument is not null when the method returns the specified bool. For `Try` methods with a **non-generic** out parameter, declare the parameter nullable and use `[NotNullWhen(true)] out MyType? result` — it is `null` on failure and non-null on success. Also add to `Equals(object? obj)` overrides to indicate the argument is non-null when returning `true` | +| `[MaybeNullWhen(true/false)]` | For `Try` methods with a **generic** out parameter, keep the parameter non-nullable and use `[MaybeNullWhen(false)] out T result` — the value may be `default` (null for reference types) on failure. Using `[NotNullWhen]` with `T?` here would change value-type signatures to `Nullable` | +| `[NotNull]` | A nullable parameter is guaranteed non-null when the method returns (e.g., a `ThrowIfNull` helper) | +| `[MaybeNull]` | A non-nullable generic return might be `default` (null). Rare in practice — prefer `T?` when possible. Reserve for cases like `AsyncLocal.Value` where `T?` is wrong because setting to null is invalid when `T` is non-nullable | +| `[AllowNull]` | A non-nullable property setter accepts null (e.g., falls back to a default value) | +| `[DisallowNull]` | A nullable property should never be explicitly set to null | +| `[MemberNotNull(nameof(...))]` | A helper method guarantees that specific members are non-null after it returns. When initializing multiple fields, prefer multiple `[MemberNotNull("field1")]` `[MemberNotNull("field2")]` attributes over one `[MemberNotNull("field1", "field2")]` — the `params` overload is not CLS-compliant | +| `[NotNullIfNotNull("paramName")]` | The return is non-null if the named parameter is non-null | +| `[DoesNotReturn]` | The method always throws — code after the call is unreachable | + +Add `using System.Diagnostics.CodeAnalysis;` where needed. + +> **Caution:** The compiler does not warn when nullable attributes are misapplied — for example, `[DisallowNull]` on an already non-nullable parameter or `[MaybeNull]` on a by-value input parameter (not `ref`/`out`) are silently ignored. Verify each attribute is placed where it has an effect. diff --git a/.agents/skills/migrate-nullable-references/scripts/Get-NullableReadiness.ps1 b/.agents/skills/migrate-nullable-references/scripts/Get-NullableReadiness.ps1 new file mode 100644 index 00000000..ac912f53 --- /dev/null +++ b/.agents/skills/migrate-nullable-references/scripts/Get-NullableReadiness.ps1 @@ -0,0 +1,487 @@ +<# +.SYNOPSIS + Scans a C# project or solution for nullable reference type (NRT) readiness. + +.DESCRIPTION + Reports project-level NRT settings (, , , + ) and source-level counts (#nullable directives, null-forgiving + operators, #pragma warning disable CS86xx) to help assess migration status. + + Automates the manual checks in Steps 1 and 6 of the migrate-nullable-references skill. + +.PARAMETER Path + Path to a .csproj, .sln, or directory. Defaults to the current directory. + +.PARAMETER Json + Output as JSON instead of a human-readable summary. + +.PARAMETER Recurse + When Path is a directory (not a .sln), scan recursively for all .csproj files. + +.EXAMPLE + ./Get-NullableReadiness.ps1 + Scans the current directory for a .sln or .csproj and reports NRT readiness. + +.EXAMPLE + ./Get-NullableReadiness.ps1 -Path ./src/MyLib/MyLib.csproj + Scans a single project. + +.EXAMPLE + ./Get-NullableReadiness.ps1 -Path ./src -Recurse -Json + Scans all projects under ./src and outputs JSON. + +.NOTES + Example output BEFORE NRT migration: + + === NRT Readiness Report === + Project: System.Text.RegularExpressions + Path: src\System.Text.RegularExpressions.csproj + : (not set) + : latest (inherited) + : (not set) + Warning enforcement: all warnings as errors + Source files: 39 + #nullable enable: 1 + #nullable disable: 0 + #pragma CS86xx: 0 + ! operators (approx): 0 + Uninit ref fields: ~322 (estimated CS8618 warnings) + Migration progress: 1/39 files (2.6%) + Migration work needed: + CaptureCollection.cs: ~6 uninit fields + GroupCollection.cs: ~9 uninit fields + .... + === Summary === + Projects scanned: 1 + NRT enabled: 0/1 + Total .cs files: 39 + Total #nullable disable: 0 + Total #pragma CS86xx: 0 + Total ! operators: 0 + Total uninit ref fields: ~322 (estimated CS8618 warnings) + + Example output AFTER NRT migration (same project, all 502 CS86xx warnings resolved): + + === NRT Readiness Report === + Project: System.Text.RegularExpressions + Path: src\System.Text.RegularExpressions.csproj + : enable + : latest (inherited) + : (not set) + Warning enforcement: all warnings as errors + Source files: 39 + #nullable enable: 1 + #nullable disable: 0 + #pragma CS86xx: 0 + ! operators (approx): 192 + null!/default!: 47 + assertions: 145 + Suppression audit (review ! operators for possible removal): + Match.cs: 7 ! + Regex.Cache.cs: 22 ! + Regex.cs: 11 ! + RegexCompiler.cs: 31 ! (24 null!/default!, 7 assertions) + ... + === Summary === + Projects scanned: 1 + NRT enabled: 1/1 + Total .cs files: 39 + Total #nullable disable: 0 + Total #pragma CS86xx: 0 + Total ! operators: 192 + null!/default!: 47 + assertions: 145 +#> + +[CmdletBinding()] +param( + [string]$Path = ".", + [switch]$Json, + [switch]$Recurse +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +#region Helpers + +function Get-ProjectFiles { + param([string]$InputPath, [switch]$Recurse) + + $resolved = Resolve-Path $InputPath -ErrorAction Stop + + if (Test-Path $resolved -PathType Leaf) { + $ext = [System.IO.Path]::GetExtension($resolved) + if ($ext -eq ".csproj") { + return @($resolved.Path) + } + if ($ext -eq ".sln") { + return Get-ProjectsFromSolution $resolved.Path + } + Write-Error "Unsupported file type: $ext. Provide a .csproj, .sln, or directory." + } + + # Directory + if ($Recurse) { + $projects = Get-ChildItem -Path $resolved -Filter "*.csproj" -Recurse | Select-Object -ExpandProperty FullName + } else { + # Look for .sln first, then .csproj in the directory + $sln = Get-ChildItem -Path $resolved -Filter "*.sln" -File | Select-Object -First 1 + if ($sln) { + return Get-ProjectsFromSolution $sln.FullName + } + $projects = Get-ChildItem -Path $resolved -Filter "*.csproj" -File | Select-Object -ExpandProperty FullName + } + + if (-not $projects -or $projects.Count -eq 0) { + Write-Error "No .csproj files found in '$resolved'." + } + return $projects +} + +function Get-ProjectsFromSolution { + param([string]$SlnPath) + + $slnDir = Split-Path $SlnPath -Parent + $projects = @() + foreach ($line in Get-Content $SlnPath) { + if ($line -match 'Project\("[^"]*"\)\s*=\s*"[^"]*"\s*,\s*"([^"]*\.csproj)"') { + $relPath = $Matches[1] -replace '\\', [System.IO.Path]::DirectorySeparatorChar + $fullPath = Join-Path $slnDir $relPath + if (Test-Path $fullPath) { + $projects += (Resolve-Path $fullPath).Path + } + } + } + return $projects +} + +function Read-ProjectSettings { + param([string]$CsprojPath) + + $xml = [xml](Get-Content $CsprojPath -Raw) + $ns = $xml.DocumentElement.NamespaceURI + + # Check for Directory.Build.props in parent directories + $propsSettings = Find-DirectoryBuildProps (Split-Path $CsprojPath -Parent) + + $nullable = Select-XmlValue $xml "//Nullable" $ns + $langVersion = Select-XmlValue $xml "//LangVersion" $ns + $tfm = Select-XmlValue $xml "//TargetFramework" $ns + $tfms = Select-XmlValue $xml "//TargetFrameworks" $ns + $warningsAsErrors = Select-XmlValue $xml "//WarningsAsErrors" $ns + $treatWarningsAsErrors = Select-XmlValue $xml "//TreatWarningsAsErrors" $ns + + # Fall back to Directory.Build.props values + if (-not $nullable -and $propsSettings.Nullable) { $nullable = $propsSettings.Nullable + " (inherited)" } + if (-not $langVersion -and $propsSettings.LangVersion) { $langVersion = $propsSettings.LangVersion + " (inherited)" } + if (-not $warningsAsErrors -and $propsSettings.WarningsAsErrors) { $warningsAsErrors = $propsSettings.WarningsAsErrors + " (inherited)" } + if (-not $treatWarningsAsErrors -and $propsSettings.TreatWarningsAsErrors) { $treatWarningsAsErrors = $propsSettings.TreatWarningsAsErrors + " (inherited)" } + + $framework = if ($tfms) { $tfms } elseif ($tfm) { $tfm } else { "(not set)" } + + $warningEnforcement = "none" + if ($treatWarningsAsErrors -and $treatWarningsAsErrors -match "true") { + $warningEnforcement = "all warnings as errors" + } elseif ($warningsAsErrors -and $warningsAsErrors -match "nullable") { + $warningEnforcement = "nullable warnings as errors" + } + + return [PSCustomObject]@{ + Nullable = if ($nullable) { $nullable } else { "(not set)" } + LangVersion = if ($langVersion) { $langVersion } else { "(not set)" } + TargetFramework = $framework + WarningEnforcement = $warningEnforcement + } +} + +function Select-XmlValue { + param($Xml, [string]$XPath, [string]$Namespace) + + if ($Namespace) { + $nsmgr = New-Object System.Xml.XmlNamespaceManager($Xml.NameTable) + $nsmgr.AddNamespace("ns", $Namespace) + $nsXPath = $XPath -replace '//', '//ns:' -replace '/ns:ns:', '/ns:' + $node = $Xml.SelectSingleNode($nsXPath, $nsmgr) + } else { + $node = $Xml.SelectSingleNode($XPath) + } + + if ($node) { return $node.InnerText.Trim() } + return $null +} + +function Find-DirectoryBuildProps { + param([string]$StartDir) + + $result = [PSCustomObject]@{ + Nullable = $null + LangVersion = $null + WarningsAsErrors = $null + TreatWarningsAsErrors = $null + } + + $dir = $StartDir + while ($dir) { + $propsPath = Join-Path $dir "Directory.Build.props" + if (Test-Path $propsPath) { + $xml = [xml](Get-Content $propsPath -Raw) + $ns = $xml.DocumentElement.NamespaceURI + if (-not $result.Nullable) { $result.Nullable = Select-XmlValue $xml "//Nullable" $ns } + if (-not $result.LangVersion) { $result.LangVersion = Select-XmlValue $xml "//LangVersion" $ns } + if (-not $result.WarningsAsErrors) { $result.WarningsAsErrors = Select-XmlValue $xml "//WarningsAsErrors" $ns } + if (-not $result.TreatWarningsAsErrors) { $result.TreatWarningsAsErrors = Select-XmlValue $xml "//TreatWarningsAsErrors" $ns } + } + $parent = Split-Path $dir -Parent + if ($parent -eq $dir) { break } + $dir = $parent + } + + return $result +} + +function Scan-SourceFiles { + param([string]$CsprojPath) + + $projectDir = Split-Path $CsprojPath -Parent + $csFiles = @(Get-ChildItem -Path $projectDir -Filter "*.cs" -Recurse -File | + Where-Object { $_.FullName -notmatch '[\\/](obj|bin)[\\/]' }) + + $totalFiles = $csFiles.Count + $filesWithNullableEnable = 0 + $totalNullableDisable = 0 + $totalNullableEnable = 0 + $totalPragmaDisable = 0 + $totalBangOperator = 0 + $totalBangNullInit = 0 + $totalBangAssertions = 0 + $totalUninitFields = 0 + $fileDetails = @() + + foreach ($file in $csFiles) { + $content = Get-Content $file.FullName -Raw -ErrorAction SilentlyContinue + if (-not $content) { continue } + + $lines = $content -split '\r?\n' + + $nullableDisable = @($lines | Where-Object { $_ -match '^\s*#nullable\s+disable' }).Count + $nullableEnable = @($lines | Where-Object { $_ -match '^\s*#nullable\s+enable' }).Count + $pragmaDisable = @($lines | Where-Object { $_ -match '#pragma\s+warning\s+disable\s+CS86' }).Count + + # Count null-forgiving operators (approximate). + # Strip string literals before comments to avoid false positives — a string + # like "http://..." contains // that would otherwise be mis-parsed as a comment. + # Then match ! preceded by ), ], >, or a word character, not followed by =. + $strippedContent = $content + $strippedContent = [regex]::Replace($strippedContent, '(?\w])!(?!=)') + $bangCount = $bangMatches.Count + + # Categorize: null! initializers (= null!, => null!, default!) vs other assertions + $nullInitCount = ([regex]::Matches($strippedContent, '(?:=\s*null!|=>\s*null!|default!)')).Count + $bangAssertionCount = $bangCount - $nullInitCount + + # Estimate uninitialised reference-type fields and auto-properties (approximate CS8618 predictor). + # Matches field declarations ending in ; without an = initializer, and auto-properties + # without initializers, excluding value types and events. + $valueTypes = 'bool|byte|sbyte|char|decimal|double|float|int|uint|long|ulong|short|ushort|nint|nuint|void|IntPtr|UIntPtr|Guid|DateTime|DateTimeOffset|TimeSpan|CancellationToken' + $uninitFields = @($lines | Where-Object { + ( + # Field declarations: type name; + ($_ -match '^\s*(private|protected|internal|public|static|readonly|\s)+\s+\w[\w<>\[\],\?\.]*\s+\w+\s*;') -or + # Auto-properties: type Name { get; set; } or { get; } + ($_ -match '^\s*(private|protected|internal|public|static|virtual|override|abstract|\s)+\s+\w[\w<>\[\],\?\.]*\s+\w+\s*\{\s*get;') + ) -and + $_ -notmatch '=' -and + $_ -notmatch '\brequired\b' -and + $_ -notmatch "^\s*(private|protected|internal|public|static|readonly|virtual|override|abstract|\s)+\s+($valueTypes)\b" -and + $_ -notmatch '^\s*(private|protected|internal|public|static|readonly|\s)+\s*(const|event)\b' + }).Count + + if ($nullableEnable -gt 0) { $filesWithNullableEnable++ } + $totalNullableDisable += $nullableDisable + $totalNullableEnable += $nullableEnable + $totalPragmaDisable += $pragmaDisable + $totalBangOperator += $bangCount + $totalBangNullInit += $nullInitCount + $totalBangAssertions += $bangAssertionCount + $totalUninitFields += $uninitFields + + $relativePath = $file.FullName.Substring($projectDir.Length).TrimStart([System.IO.Path]::DirectorySeparatorChar) + + if ($nullableDisable -gt 0 -or $pragmaDisable -gt 0 -or $bangCount -gt 5 -or $uninitFields -gt 5) { + $fileDetails += [PSCustomObject]@{ + File = $relativePath + NullableDisable = $nullableDisable + PragmaDisable = $pragmaDisable + BangOperators = $bangCount + BangNullInit = $nullInitCount + BangAssertions = $bangAssertionCount + UninitFields = $uninitFields + } + } + } + + return [PSCustomObject]@{ + TotalFiles = $totalFiles + FilesWithEnable = $filesWithNullableEnable + NullableDisableCount = $totalNullableDisable + NullableEnableCount = $totalNullableEnable + PragmaDisableCount = $totalPragmaDisable + BangOperatorCount = $totalBangOperator + BangNullInitCount = $totalBangNullInit + BangAssertionCount = $totalBangAssertions + UninitFieldCount = $totalUninitFields + FilesOfInterest = $fileDetails + } +} + +#endregion + +#region Main + +$projectFiles = Get-ProjectFiles -InputPath $Path -Recurse:$Recurse + +$results = @() + +foreach ($proj in $projectFiles) { + $projName = [System.IO.Path]::GetFileNameWithoutExtension($proj) + + Write-Verbose "Scanning $projName..." + + $settings = Read-ProjectSettings $proj + $sourceStats = Scan-SourceFiles $proj + + $results += [PSCustomObject]@{ + Project = $projName + Path = $proj + Nullable = $settings.Nullable + LangVersion = $settings.LangVersion + TargetFramework = $settings.TargetFramework + WarningEnforcement = $settings.WarningEnforcement + TotalCsFiles = $sourceStats.TotalFiles + FilesWithEnable = $sourceStats.FilesWithEnable + NullableDisable = $sourceStats.NullableDisableCount + NullableEnable = $sourceStats.NullableEnableCount + PragmaDisableCS86 = $sourceStats.PragmaDisableCount + BangOperators = $sourceStats.BangOperatorCount + BangNullInit = $sourceStats.BangNullInitCount + BangAssertions = $sourceStats.BangAssertionCount + UninitFields = $sourceStats.UninitFieldCount + FilesOfInterest = $sourceStats.FilesOfInterest + } +} + +if ($Json) { + $results | ConvertTo-Json -Depth 4 + return +} + +# Human-readable output +Write-Host "" +Write-Host "=== NRT Readiness Report ===" -ForegroundColor Cyan +Write-Host "" + +foreach ($r in $results) { + Write-Host "Project: $($r.Project)" -ForegroundColor Yellow + Write-Host " Path: $($r.Path)" + Write-Host " : $($r.Nullable)" + Write-Host " : $($r.LangVersion)" + Write-Host " : $($r.TargetFramework)" + Write-Host " Warning enforcement: $($r.WarningEnforcement)" + Write-Host "" + Write-Host " Source files: $($r.TotalCsFiles)" + Write-Host " #nullable enable: $($r.NullableEnable)" + Write-Host " #nullable disable: $($r.NullableDisable)" + Write-Host " #pragma CS86xx: $($r.PragmaDisableCS86)" + Write-Host " ! operators (approx): $($r.BangOperators)" + if ($r.BangOperators -gt 0) { + Write-Host " null!/default!: $($r.BangNullInit)" + Write-Host " assertions: $($r.BangAssertions)" + } + + if ($r.UninitFields -gt 0 -and $r.Nullable -notmatch "enable") { + Write-Host " Uninit ref fields: ~$($r.UninitFields) (estimated CS8618 warnings)" -ForegroundColor DarkYellow + } + + if ($r.FilesWithEnable -gt 0 -and $r.Nullable -notmatch "enable") { + $pct = [math]::Round(($r.FilesWithEnable / $r.TotalCsFiles) * 100, 1) + Write-Host " Migration progress: $($r.FilesWithEnable)/$($r.TotalCsFiles) files ($pct%)" -ForegroundColor Green + } + + # Per-file details — context-dependent heading and content + $nrtEnabled = $r.Nullable -match "enable" + $interestFiles = @($r.FilesOfInterest) + + # Filter to files with displayable parts + $displayFiles = @() + foreach ($f in $interestFiles) { + $parts = @() + if ($f.NullableDisable -gt 0) { $parts += "$($f.NullableDisable) #nullable disable" } + if ($f.PragmaDisable -gt 0) { $parts += "$($f.PragmaDisable) #pragma" } + if ($f.BangOperators -gt 5) { + $bangDetail = "$($f.BangOperators) !" + if ($f.BangNullInit -gt 0) { + $bangDetail += " ($($f.BangNullInit) null!/default!, $($f.BangAssertions) assertions)" + } + $parts += $bangDetail + } + if (-not $nrtEnabled -and $f.UninitFields -gt 5) { $parts += "~$($f.UninitFields) uninit fields" } + if ($parts.Count -gt 0) { + $displayFiles += [PSCustomObject]@{ File = $f.File; Detail = ($parts -join ', ') } + } + } + + if ($displayFiles.Count -gt 0) { + Write-Host "" + if (-not $nrtEnabled) { + Write-Host " Migration work needed:" -ForegroundColor Magenta + } elseif ($r.NullableDisable -gt 0 -or $r.PragmaDisableCS86 -gt 0) { + Write-Host " Remaining cleanup:" -ForegroundColor Magenta + } else { + Write-Host " Suppression audit (review ! operators for possible removal):" -ForegroundColor DarkYellow + } + foreach ($df in $displayFiles) { + Write-Host " $($df.File): $($df.Detail)" + } + } + + Write-Host "" +} + +# Summary +if (@($results).Count -gt 1) { + $total = [PSCustomObject]@{ + Projects = @($results).Count + CsFiles = ($results | Measure-Object -Property TotalCsFiles -Sum).Sum + NullDisable = ($results | Measure-Object -Property NullableDisable -Sum).Sum + PragmaCS86 = ($results | Measure-Object -Property PragmaDisableCS86 -Sum).Sum + BangOps = ($results | Measure-Object -Property BangOperators -Sum).Sum + BangNullInit = ($results | Measure-Object -Property BangNullInit -Sum).Sum + BangAssert = ($results | Measure-Object -Property BangAssertions -Sum).Sum + UninitFields = ($results | Measure-Object -Property UninitFields -Sum).Sum + NrtEnabled = @($results | Where-Object { $_.Nullable -match "enable" }).Count + } + + Write-Host "=== Summary ===" -ForegroundColor Cyan + Write-Host " Projects scanned: $($total.Projects)" + Write-Host " NRT enabled: $($total.NrtEnabled)/$($total.Projects)" + Write-Host " Total .cs files: $($total.CsFiles)" + Write-Host " Total #nullable disable: $($total.NullDisable)" + Write-Host " Total #pragma CS86xx: $($total.PragmaCS86)" + Write-Host " Total ! operators: $($total.BangOps)" + if ($total.BangOps -gt 0) { + Write-Host " null!/default!: $($total.BangNullInit)" + Write-Host " assertions: $($total.BangAssert)" + } + if ($total.UninitFields -gt 0) { + Write-Host " Total uninit ref fields: ~$($total.UninitFields) (estimated CS8618 warnings)" + } + Write-Host "" +} + +#endregion diff --git a/.agents/skills/msbuild-antipatterns/SKILL.md b/.agents/skills/msbuild-antipatterns/SKILL.md new file mode 100644 index 00000000..4b37c7c8 --- /dev/null +++ b/.agents/skills/msbuild-antipatterns/SKILL.md @@ -0,0 +1,392 @@ +--- +description: 'Catalog of MSBuild anti-patterns with detection rules and fix recipes. Only activate in MSBuild/.NET build context. USE FOR: reviewing, auditing, or cleaning up .csproj, .vbproj, .fsproj, .props, .targets, or .proj files. Each anti-pattern has a symptom, explanation, and concrete BAD→GOOD transformation. Covers Exec-instead-of-built-in-task, unquoted conditions, hardcoded paths, restating SDK defaults, scattered package versions, and more. DO NOT USE FOR: non-MSBuild build systems (npm, Maven, CMake, etc.), project migration to SDK-style (use msbuild-modernization).' +metadata: + github-path: plugins/dotnet-msbuild/skills/msbuild-antipatterns + github-pinned: v1.0.0 + github-ref: refs/tags/v1.0.0 + github-repo: https://github.com/dotnet/skills + github-tree-sha: b0d1c5220d44eee7df2c6bc04b26ed1284a88835 +name: msbuild-antipatterns +--- +# MSBuild Anti-Pattern Catalog + +A numbered catalog of common MSBuild anti-patterns. Each entry follows the format: + +- **Smell**: What to look for +- **Why it's bad**: Impact on builds, maintainability, or correctness +- **Fix**: Concrete transformation + +Use this catalog when scanning project files for improvements. + +--- + +## AP-01: `` for Operations That Have Built-in Tasks + +**Smell**: ``, ``, `` + +**Why it's bad**: Built-in tasks are cross-platform, support incremental build, emit structured logging, and handle errors consistently. `` is opaque to MSBuild. + +```xml + + + + + + + + + + + + + +``` + +**Built-in task alternatives:** + +| Shell Command | MSBuild Task | +|--------------|--------------| +| `mkdir` | `` | +| `copy` / `cp` | `` | +| `del` / `rm` | `` | +| `move` / `mv` | `` | +| `echo text > file` | `` | +| `touch` | `` | +| `xcopy /s` | `` with item globs | + +--- + +## AP-02: Unquoted Condition Expressions + +**Smell**: `Condition="$(Foo) == Bar"` — either side of a comparison is unquoted. + +**Why it's bad**: If the property is empty or contains spaces/special characters, the condition evaluates incorrectly or throws a parse error. MSBuild requires single-quoted strings for reliable comparisons. + +```xml + + + true + + + + + true + +``` + +**Rule**: Always quote **both** sides of `==` and `!=` comparisons with single quotes. + +--- + +## AP-03: Hardcoded Absolute Paths + +**Smell**: Paths like `C:\tools\`, `D:\packages\`, `/usr/local/bin/` in project files. + +**Why it's bad**: Breaks on other machines, CI environments, and other operating systems. Not relocatable. + +```xml + + + C:\tools\mytool\mytool.exe + + + + + + $(MSBuildThisFileDirectory)tools\mytool\mytool.exe + + +``` + +**Preferred path properties:** + +| Property | Meaning | +|----------|---------| +| `$(MSBuildThisFileDirectory)` | Directory of the current .props/.targets file | +| `$(MSBuildProjectDirectory)` | Directory of the .csproj | +| `$([MSBuild]::GetDirectoryNameOfFileAbove(...))` | Walk up to find a marker file | +| `$([MSBuild]::NormalizePath(...))` | Combine and normalize path segments | + +--- + +## AP-04: Restating SDK Defaults + +**Smell**: Properties set to values that the .NET SDK already provides by default. + +**Why it's bad**: Adds noise, hides intentional overrides, and makes it harder to identify what's actually customized. When defaults change in newer SDKs, the redundant properties may silently pin old behavior. + +```xml + + + Library + true + true + MyLib + MyLib + true + + + + + net8.0 + +``` + +--- + +## AP-05: Manual File Listing in SDK-Style Projects + +**Smell**: ``, `` in SDK-style projects. + +**Why it's bad**: SDK-style projects automatically glob `**/*.cs` (and other file types). Explicit listing is redundant, creates merge conflicts, and new files may be accidentally missed if not added to the list. + +```xml + + + + + + + + + + + +``` + +**Exception**: Non-SDK-style (legacy) projects require explicit file includes. If migrating, see `msbuild-modernization` skill. + +--- + +## AP-06: Using `` with HintPath for NuGet Packages + +**Smell**: `` + +**Why it's bad**: This is the legacy `packages.config` pattern. It doesn't support transitive dependencies, version conflict resolution, or automatic restore. The `packages/` folder must be committed or restored separately. + +```xml + + + + ..\packages\Newtonsoft.Json.13.0.3\lib\netstandard2.0\Newtonsoft.Json.dll + + + + + + + +``` + +**Note**: `` without HintPath is still valid for .NET Framework GAC assemblies like `WindowsBase`, `PresentationCore`, etc. + +--- + +## AP-07: Missing `PrivateAssets="all"` on Analyzer/Tool Packages + +**Smell**: `` without `PrivateAssets="all"`. + +**Why it's bad**: Without `PrivateAssets="all"`, analyzer and build-tool packages flow as transitive dependencies to consumers of your library. Consumers get unwanted analyzers or build-time tools they didn't ask for. + +See [`references/private-assets.md`](references/private-assets.md) for BAD/GOOD examples and the full list of packages that need this. + +--- + +## AP-08: Copy-Pasted Properties Across Multiple .csproj Files + +**Smell**: The same `` block appears in 3+ project files. + +**Why it's bad**: Maintenance burden — a change must be made in every file. Inconsistencies creep in over time. + +```xml + + + + latest + enable + true + enable + + + + + + + latest + enable + true + enable + + +``` + +See `directory-build-organization` skill for full guidance on structuring `Directory.Build.props` / `Directory.Build.targets`. + +--- + +## AP-09: Scattered Package Versions Without Central Package Management + +**Smell**: `` with different versions of the same package across projects. + +**Why it's bad**: Version drift — different projects use different versions of the same package, leading to runtime mismatches, unexpected behavior, or diamond dependency conflicts. + +```xml + + + + + +``` + +**Fix:** Use Central Package Management. 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. + +--- + +## AP-10: Monolithic Targets (Too Much in One Target) + +**Smell**: A single `` with 50+ lines doing multiple unrelated things. + +**Why it's bad**: Can't skip individual steps via incremental build, hard to debug, hard to extend, and the target name becomes meaningless. + +```xml + + + + + + + + + + + + + + + + + + + + + + +``` + +--- + +## AP-11: Custom Targets Missing `Inputs` and `Outputs` + +**Smell**: `` with no `Inputs` / `Outputs` attributes. + +**Why it's bad**: The target runs on every build, even when nothing changed. This defeats incremental build and slows down no-op builds. + +See [`references/incremental-build-inputs-outputs.md`](references/incremental-build-inputs-outputs.md) for BAD/GOOD examples and the full pattern including FileWrites registration. + +See `incremental-build` skill for deep guidance on Inputs/Outputs, FileWrites, and up-to-date checks. + +--- + +## AP-12: Setting Defaults in .targets Instead of .props + +**Smell**: `` with default values inside a `.targets` file. + +**Why it's bad**: `.targets` files are imported late (after project files). By the time they set defaults, other `.targets` files may have already used the empty/undefined value. `.props` files are imported early and are the correct place for defaults. + +```xml + + + 2.0 + + + + + + + + + 2.0 + + + + + + +``` + +**Rule**: `.props` = defaults and settings (evaluated early). `.targets` = build logic and targets (evaluated late). + +--- + +## AP-13: Import Without `Exists()` Guard + +**Smell**: `` without a `Condition="Exists('...')"` check. + +**Why it's bad**: If the file doesn't exist (not yet created, wrong path, deleted), the build fails with a confusing error. Optional imports should always be guarded. + +```xml + + + + + + + + +``` + +**Exception**: Imports that are *required* for the build to work correctly should fail fast — don't guard those. Guard imports that are optional or environment-specific (e.g., local developer overrides, CI-specific settings). + +--- + +## AP-14: Using Backslashes in Paths (Cross-Platform Issue) + +**Smell**: `` with backslash separators in `.props`/`.targets` files meant to be cross-platform. + +**Why it's bad**: Backslashes work on Windows but fail on Linux/macOS. MSBuild normalizes forward slashes on all platforms. + +```xml + + + + + + + +``` + +**Note**: `$(MSBuildThisFileDirectory)` already ends with a platform-appropriate separator, so `$(MSBuildThisFileDirectory)tools/mytool` works on both platforms. + +--- + +## AP-15: Unconditional Property Override in Multiple Scopes + +**Smell**: A property set unconditionally in both `Directory.Build.props` and a `.csproj` — last write wins silently. + +**Why it's bad**: Hard to trace which value is actually used. Makes the build fragile and confusing for anyone reading the project files. + +```xml + + + + bin\custom\ + + + + bin\other\ + + + + + + bin\custom\ + + +``` + +--- + +For additional anti-patterns (AP-16 through AP-21) and a quick-reference checklist, see [additional-antipatterns.md](references/additional-antipatterns.md). diff --git a/.agents/skills/msbuild-antipatterns/references/additional-antipatterns.md b/.agents/skills/msbuild-antipatterns/references/additional-antipatterns.md new file mode 100644 index 00000000..53e9a63f --- /dev/null +++ b/.agents/skills/msbuild-antipatterns/references/additional-antipatterns.md @@ -0,0 +1,200 @@ +## AP-16: Using `` for String/Path Operations + +**Smell**: `` or `` for simple string manipulation. + +**Why it's bad**: Shell-dependent, not cross-platform, slower than property functions, and the result is hard to capture back into MSBuild properties. + +```xml + + + + + + + + $(Version.Replace('-preview', '')) + $(Version.Contains('-')) + $(AssemblyName.ToLowerInvariant()) + + + + + $([MSBuild]::NormalizeDirectory($(OutputPath))) + $([System.IO.Path]::Combine($(MSBuildThisFileDirectory), 'tools', 'mytool.exe')) + +``` + +--- + +## AP-17: Mixing `Include` and `Update` for the Same Item Type in One ItemGroup + +**Smell**: Same `` has both `` and ``. + +**Why it's bad**: `Update` acts on items already in the set. If `Include` hasn't been processed yet (evaluation order), `Update` may not find the item. Separating them avoids subtle ordering bugs. + +```xml + + + + + + + + + + + + + +``` + +--- + +## AP-18: Redundant `` to Transitively-Referenced Projects + +**Smell**: A project references both `Core` and `Utils`, but `Core` already depends on `Utils`. + +**Why it's bad**: Adds unnecessary coupling, makes the dependency graph harder to understand, and can cause ordering issues in large builds. MSBuild resolves transitive references automatically. + +```xml + + + + + + + + + + +``` + +**Caveat**: If you need to use types from `Utils` directly (not just transitively), the explicit reference is appropriate. But verify whether the direct dependency is actually needed. + +--- + +## AP-19: Side Effects During Property Evaluation + +**Smell**: Property functions that write files, make network calls, or modify state during `` evaluation. + +**Why it's bad**: Property evaluation happens during the evaluation phase, which can run multiple times (e.g., during design-time builds in Visual Studio). Side effects are unpredictable and can corrupt state. + +```xml + + + $([System.IO.File]::WriteAllText('stamp.txt', 'built')) + + + + + + +``` + +--- + +## AP-20: Platform-Specific Exec Without OS Condition + +**Smell**: `` or `` without an OS condition. + +**Why it's bad**: Fails on the wrong platform. If the project is cross-platform, guard platform-specific commands. + +```xml + + + + + + + + + +``` + +--- + +## 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 + + + + + + + + + + + + + + + + + +``` + +--- + +## Quick-Reference Checklist + +When reviewing an MSBuild file, scan for these in order: + +| # | Check | Severity | +|---|-------|----------| +| AP-02 | Unquoted conditions | 🔴 Error-prone | +| AP-19 | Side effects in evaluation | 🔴 Dangerous | +| AP-21 | Property conditioned on TargetFramework in .props | 🔴 Silent failure | +| AP-03 | Hardcoded absolute paths | 🔴 Broken on other machines | +| AP-06 | `` with HintPath for NuGet | 🟡 Legacy | +| AP-07 | Missing `PrivateAssets="all"` on tools | 🟡 Leaks to consumers | +| AP-11 | Missing Inputs/Outputs on targets | 🟡 Perf regression | +| AP-13 | Import without Exists guard | 🟡 Fragile | +| AP-05 | Manual file listing in SDK-style | 🔵 Noise | +| AP-04 | Restating SDK defaults | 🔵 Noise | +| AP-08 | Copy-paste across csproj files | 🔵 Maintainability | +| AP-09 | Scattered package versions | 🔵 Version drift | +| AP-01 | `` for built-in tasks | 🔵 Cross-platform | +| AP-14 | Backslashes in cross-platform paths | 🔵 Cross-platform | +| AP-10 | Monolithic targets | 🔵 Maintainability | +| AP-12 | Defaults in .targets instead of .props | 🔵 Ordering issue | +| AP-15 | Unconditional property override | 🔵 Confusing | +| AP-16 | `` for string operations | 🔵 Preference | +| AP-17 | Mixed Include/Update in one ItemGroup | 🔵 Subtle bugs | +| AP-18 | Redundant transitive ProjectReferences | 🔵 Graph noise | +| AP-20 | Platform-specific Exec without guard | 🔵 Cross-platform | diff --git a/.agents/skills/msbuild-antipatterns/references/incremental-build-inputs-outputs.md b/.agents/skills/msbuild-antipatterns/references/incremental-build-inputs-outputs.md new file mode 100644 index 00000000..7c54447b --- /dev/null +++ b/.agents/skills/msbuild-antipatterns/references/incremental-build-inputs-outputs.md @@ -0,0 +1,30 @@ +# Incremental Build: Inputs and Outputs on Custom Targets + +Custom targets **must** specify `Inputs` and `Outputs` attributes so MSBuild can skip them when up-to-date. Without both attributes, the target runs on every build. + +```xml + + + + + + + + + + + + + +``` + +**Key points:** +- **`Inputs`** should include `$(MSBuildProjectFile)` plus any source files that drive generation +- **`Outputs`** should use `$(IntermediateOutputPath)` so generated files go in `obj/` and are managed by MSBuild +- **`FileWrites`** registration ensures `dotnet clean` removes the generated file +- **`Compile` inclusion** adds the generated file to compilation without requiring it at evaluation time + +See the `incremental-build` skill for deep guidance on diagnosing broken incremental builds, FileWrites tracking, and Visual Studio's Fast Up-to-Date Check. diff --git a/.agents/skills/msbuild-antipatterns/references/private-assets.md b/.agents/skills/msbuild-antipatterns/references/private-assets.md new file mode 100644 index 00000000..e9414eb5 --- /dev/null +++ b/.agents/skills/msbuild-antipatterns/references/private-assets.md @@ -0,0 +1,22 @@ +# PrivateAssets for Analyzers and Build Tools + +Analyzer and build-tool packages should always use `PrivateAssets="all"` to prevent them from flowing as transitive dependencies to consumers of your library. + +```xml + + + + + + + + + +``` + +**Packages that almost always need `PrivateAssets="all"`:** +- Roslyn analyzers (`*.Analyzers`, `*.CodeFixes`) +- Source generators +- SourceLink packages (`Microsoft.SourceLink.*`) +- Versioning tools (`MinVer`, `Nerdbank.GitVersioning`) +- Build-only tools (`Microsoft.DotNet.ApiCompat`, etc.) diff --git a/.agents/skills/msbuild-modernization/SKILL.md b/.agents/skills/msbuild-modernization/SKILL.md new file mode 100644 index 00000000..1fafbf16 --- /dev/null +++ b/.agents/skills/msbuild-modernization/SKILL.md @@ -0,0 +1,506 @@ +--- +description: 'Guide for modernizing and migrating MSBuild project files to SDK-style format. Only activate in MSBuild/.NET build context. USE FOR: converting legacy .csproj/.vbproj with verbose XML to SDK-style, migrating packages.config to PackageReference, removing Properties/AssemblyInfo.cs in favor of auto-generation, eliminating explicit lists via implicit globbing, consolidating shared settings into Directory.Build.props. Indicators of legacy projects: ToolsVersion attribute, , .csproj files > 50 lines for simple projects. DO NOT USE FOR: projects already in SDK-style format, non-.NET build systems (npm, Maven, CMake), .NET Framework projects that cannot move to SDK-style. INVOKES: dotnet try-convert, upgrade-assistant tools.' +metadata: + github-path: plugins/dotnet-msbuild/skills/msbuild-modernization + github-pinned: v1.0.0 + github-ref: refs/tags/v1.0.0 + github-repo: https://github.com/dotnet/skills + github-tree-sha: 2156bdd7b83c0c3489bd9dd2f06cdfb583c28a7f +name: msbuild-modernization +--- +# MSBuild Modernization: Legacy to SDK-style Migration + +## Identifying Legacy vs SDK-style Projects + +**Legacy indicators:** + +- `` +- Explicit file lists (`` for every `.cs` file) +- `ToolsVersion` attribute on `` element +- `packages.config` file present +- `Properties\AssemblyInfo.cs` with assembly-level attributes + +**SDK-style indicators:** + +- `` attribute on root element +- Minimal content — a simple project may be 10–15 lines +- No explicit file includes (implicit globbing) +- `` items instead of `packages.config` + +**Quick check:** if a `.csproj` is more than 50 lines for a simple class library or console app, it is likely legacy format. + +```xml + + + + + + Debug + AnyCPU + Library + MyLibrary + MyLibrary + v4.7.2 + 512 + true + + + + +``` + +```xml + + + + net472 + + +``` + +## Migration Checklist: Legacy → SDK-style + +### Step 1: Replace Project Root Element + +**BEFORE:** + +```xml + + + + + + +``` + +**AFTER:** + +```xml + + + +``` + +Remove the XML declaration, `ToolsVersion`, `xmlns`, and both `` lines. The `Sdk` attribute replaces all of them. + +### Step 2: Set TargetFramework + +**BEFORE:** + +```xml + + v4.7.2 + +``` + +**AFTER:** + +```xml + + net472 + +``` + +**TFM mapping table:** + +| Legacy `TargetFrameworkVersion` | SDK-style `TargetFramework` | +|---------------------------------|-----------------------------| +| `v4.6.1` | `net461` | +| `v4.7.2` | `net472` | +| `v4.8` | `net48` | +| (migrating to .NET 6) | `net6.0` | +| (migrating to .NET 8) | `net8.0` | + +### Step 3: Remove Explicit File Includes + +**BEFORE:** + +```xml + + + + + + + + + + + + + + +``` + +**AFTER:** + +Delete all of these `` and `` item groups entirely. SDK-style projects include them automatically via implicit globbing. + +**Exception:** keep explicit entries only for files that need special metadata or reside outside the project directory: + +```xml + + + +``` + +### Step 4: Remove AssemblyInfo.cs + +**BEFORE** (`Properties\AssemblyInfo.cs`): + +```csharp +using System.Reflection; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("MyLibrary")] +[assembly: AssemblyDescription("A useful library")] +[assembly: AssemblyCompany("Contoso")] +[assembly: AssemblyProduct("MyLibrary")] +[assembly: AssemblyCopyright("Copyright © Contoso 2024")] +[assembly: ComVisible(false)] +[assembly: Guid("...")] +[assembly: AssemblyVersion("1.2.0.0")] +[assembly: AssemblyFileVersion("1.2.0.0")] +``` + +**AFTER** (in `.csproj`): + +```xml + + MyLibrary + A useful library + Contoso + MyLibrary + Copyright © Contoso 2024 + 1.2.0 + +``` + +Delete `Properties\AssemblyInfo.cs` — the SDK auto-generates assembly attributes from these properties. + +**Alternative:** if you prefer to keep `AssemblyInfo.cs`, disable auto-generation: + +```xml + + false + +``` + +### Step 5: Migrate packages.config → PackageReference + +**BEFORE** (`packages.config`): + +```xml + + + + + + +``` + +**AFTER** (in `.csproj`): + +```xml + + + + + +``` + +Delete `packages.config` after migration. + +**Migration options:** + +- **Visual Studio:** right-click `packages.config` → *Migrate packages.config to PackageReference* +- **CLI:** `dotnet migrate-packages-config` or manual conversion +- **Binding redirects:** SDK-style projects auto-generate binding redirects — remove the `` section from `app.config` if present + +### Step 6: Remove Unnecessary Boilerplate + +Delete all of the following — the SDK provides sensible defaults: + +```xml + + + + + + + Debug + AnyCPU + {...} + Library + Properties + 512 + true + true + + + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + + + + + + + + + + +``` + +**Keep** only properties that differ from SDK defaults (e.g., `Exe`, `` if it differs from the assembly name, custom ``). + +### Step 7: Enable Modern Features + +After migration, consider enabling modern C# features: + +```xml + + net8.0 + enable + enable + latest + +``` + +- `enable` — enables nullable reference type analysis +- `enable` — auto-imports common namespaces (.NET 6+) +- `latest` — uses the latest C# language version (or specify e.g. `12.0`) + +## Complete Before/After Example + +**BEFORE** (legacy — 65 lines): + +```xml + + + + + Debug + AnyCPU + {12345678-1234-1234-1234-123456789ABC} + Library + Properties + MyLibrary + MyLibrary + v4.7.2 + 512 + true + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + + + + + + + + + + + + + +``` + +**AFTER** (SDK-style — 11 lines): + +```xml + + + net472 + + + + + + +``` + +## Common Migration Issues + +**Embedded resources:** files not in a standard location may need explicit includes: + +```xml + + + +``` + +**Content files with CopyToOutputDirectory:** these still need explicit entries: + +```xml + + + + +``` + +**Multi-targeting:** change the element name from singular to plural: + +```xml + +net8.0 + + +net472;net8.0 +``` + +**WPF/WinForms projects:** use the appropriate SDK or properties: + +```xml + + + + + + + true + + true + + +``` + +**Test projects:** use the standard SDK with test framework packages: + +```xml + + + net8.0 + false + + + + + + + +``` + +## Central Package Management Migration + +Centralizes NuGet version management across a multi-project solution. 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. + +**Step 1:** Create `Directory.Packages.props` at the repository root with `true` and `` items for all packages. + +**Step 2:** Remove `Version` from each project's `PackageReference`: + +```xml + + + + + +``` + +## Directory.Build Consolidation + +Identify properties repeated across multiple `.csproj` files and move them to shared files. + +**`Directory.Build.props`** (for properties — placed at repo or src root): + +```xml + + + net8.0 + enable + enable + true + Contoso + Copyright © Contoso 2024 + + +``` + +**`Directory.Build.targets`** (for targets/tasks — placed at repo or src root): + +```xml + + + + + +``` + +**Keep in individual `.csproj` files** only what is project-specific: + +```xml + + + Exe + MyApp + + + + + + +``` + +## Tools and Automation + +| Tool | Usage | +|------|-------| +| `dotnet try-convert` | Automated legacy-to-SDK conversion. Install: `dotnet tool install -g try-convert` | +| .NET Upgrade Assistant | Full migration including API changes. Install: `dotnet tool install -g upgrade-assistant` | +| Visual Studio | Right-click `packages.config` → *Migrate packages.config to PackageReference* | +| Manual migration | Often cleanest for simple projects — follow the checklist above | + +**Recommended approach:** + +1. Run `try-convert` for a first pass +2. Review and clean up the output manually +3. Build and fix any issues +4. Enable modern features (nullable, implicit usings) +5. Consolidate shared settings into `Directory.Build.props` diff --git a/.agents/skills/nuget-trusted-publishing/SKILL.md b/.agents/skills/nuget-trusted-publishing/SKILL.md new file mode 100644 index 00000000..7db69167 --- /dev/null +++ b/.agents/skills/nuget-trusted-publishing/SKILL.md @@ -0,0 +1,164 @@ +--- +description: 'Set up NuGet trusted publishing (OIDC) on a GitHub Actions repo — replaces long-lived API keys with short-lived tokens. USE FOR: trusted publishing, NuGet OIDC, keyless NuGet publish, migrate from NuGet API key, NuGet/login, secure NuGet publishing. DO NOT USE FOR: publishing to private feeds or Azure Artifacts (OIDC is nuget.org only). INVOKES: shell (powershell or bash), edit, create, ask_user for guided repo setup.' +metadata: + github-path: plugins/dotnet/skills/nuget-trusted-publishing + github-pinned: v1.0.0 + github-ref: refs/tags/v1.0.0 + github-repo: https://github.com/dotnet/skills + github-tree-sha: aa50fe419fe01a7d558953b9b01284fd3d8e64b9 +name: nuget-trusted-publishing +--- +# NuGet Trusted Publishing Setup + +Set up [NuGet trusted publishing](https://learn.microsoft.com/en-us/nuget/nuget-org/trusted-publishing) on a GitHub Actions repo. Replaces long-lived API keys with OIDC-based short-lived tokens — no secrets to rotate or leak. + +## Prerequisites + +- **GitHub Actions** — this skill covers GitHub Actions setup only +- **nuget.org account** — the user needs access to create trusted publishing policies + +## When to Use This Skill + +Use this skill when: +- Setting up trusted publishing for a NuGet package +- Migrating from `secrets.NUGET_API_KEY` to OIDC-based publishing +- Asked about keyless or secure NuGet publishing +- Creating a new NuGet publish workflow from scratch +- Asked to "remove NuGet API key" or "use NuGet/login" +- Setting up publishing for a dotnet tool, MCP server, or template package +- Asked about `NuGet/login@v1` or `id-token: write` + +## Safety Rules + +> ⚠️ **Bail-out rule**: If any phase fails after one fix attempt on an infrastructure/auth issue, stop and ask the user. Don't loop on environment problems. + +> ⚠️ **Never delete or overwrite without confirmation**: Removing API key secrets, deleting tags/releases, removing workflow steps, or changing package IDs. NuGet package IDs are permanent — mistakes can't be undone. + +## Process + +> **Fast-path for greenfield repos**: When the user has a simple setup (one packable project, no existing publish workflow), don't gate on multi-turn assessment. Combine phases: create the workflow immediately, include nuget.org policy guidance, local pack recommendation, and filename-matching warning all in one response. The full phased process below is for complex or migration scenarios. + +### Phase 1: Assess + +Inspect the repo and report findings before making any changes. + +1. **Find and classify packable projects** — check `.csproj` files **and `Directory.Build.props`** (package metadata is often set repo-wide). Classify in this order (earlier matches win): + - `Template` → **Template** + - `McpServer` → **MCP server** (also a dotnet tool) + - `true` → **Dotnet tool** + - Class library (`IsPackable=true` or no `OutputType`) → **Library** + - `Exe` with `true` → **Application package** (not a tool, but still publishable) + - `Exe` without `PackAsTool` or `IsPackable` → Not packable by default (ask user if they intend to publish it) + +2. **Validate structure** for each project's type: + + | Type | Required | + |------|----------| + | All | `PackageId`, `Version` (in .csproj or Directory.Build.props) | + | Dotnet tool | `PackAsTool` (required); `ToolCommandName` (optional but recommended — defaults to assembly name) | + | MCP server | `PackageType=McpServer`, `.mcp/server.json` included in package | + | Template | `PackageType=Template`, `.template.config/template.json` under content dir | + +3. **Find existing publish workflows** in `.github/workflows/` — look for `dotnet nuget push`, `nuget push`, or `dotnet pack`. + +4. **Check version consistency** — for MCP servers, verify `.csproj` `` matches both `server.json` version fields (root `version` and `packages[].version`). Flag any mismatch. + +5. **Report findings** to the user: classification, missing properties, version mismatches, existing workflows. For multi-project repos, note whether one workflow or separate workflows per package are needed. Offer to fix gaps — use `ask_user` before modifying project files. + +> ❌ See [references/package-types.md](references/package-types.md) for per-type details and required properties. + +### Phase 2: Local Verification + +Pack and verify locally before touching nuget.org — publishing errors waste a permanent version number. + +> ⚠️ **Always mention this step**, even if you defer running it. Tell the user: "Before your first publish, run `dotnet pack -c Release -o ./artifacts` to verify the .nupkg is created correctly." + +1. `dotnet pack -c Release -o ./artifacts` — verify `.nupkg` is created +2. For tools/MCP servers: install from `./artifacts`, run `--help`, uninstall +3. For libraries: inspect the `.nupkg` contents (it's a zip) + +### Phase 3: nuget.org Policy + +This phase requires the user to act on nuget.org — guide them with exact values. + +1. Determine the **repo owner**, **repo name**, and the **workflow filename** that will publish. + + > ❌ The policy requires the **exact workflow filename** (e.g., `publish.yml` or `publish.yaml`) — just the filename, no path prefix. Matching is case-insensitive. Don't use the workflow `name:` field. + +2. Guide the user to create the trusted publishing policy: + > Go to [**nuget.org/account/trustedpublishing**](https://www.nuget.org/account/trustedpublishing) → **Add policy** + > + > - **Repository Owner**: `{owner}` + > - **Repository**: `{repo}` + > - **Workflow File**: `{filename}.yml` + > - **Environment**: `release` *(only if the workflow uses `environment:`; leave blank otherwise)* + + Policy ownership: the user chooses individual account or organization. Org-owned policies apply to all packages owned by that org. + + For **private repos**: policy is "temporarily active" for 7 days — becomes permanent after the first successful publish. + +3. Guide the user to create a **GitHub Environment** (recommended but optional — provides secret scoping + approval gates): + > Repo **Settings** → **Environments** → **New environment** → `release` + > + > Add environment secret: **Name** = `NUGET_USER`, **Value** = nuget.org username (NOT email) + + Optional: add **Required reviewers** for an approval gate. + +> ⚠️ Wait for the user to confirm they've created the policy **before asking them to remove old API keys/secrets or before attempting to run/publish with the workflow**. Drafting or showing the workflow file itself is OK before confirmation. + +### Phase 4: Workflow Setup + +Create or modify the publish workflow. **The workflow must always be created or shown in your response** — you may draft/show it even if the nuget.org policy is not yet confirmed, but do not guide the user to actually run/publish or remove old secrets until after confirmation. + +**Greenfield**: Create `publish.yml` from the template in [references/publish-workflow.md](references/publish-workflow.md). Adapt .NET version, project path, and environment name. Ensure your output explicitly mentions `id-token: write` and `NuGet/login@v1`. + +**Migration** (existing workflow with API key): Modify in place — + +1. **Add OIDC permission and environment** to the publishing job: + ```yaml + jobs: + publish: + environment: release + permissions: + id-token: write # Required — without this, NuGet/login fails with 403 + contents: read # Explicit — setting permissions overrides defaults + ``` + +2. **Add the NuGet login step** before push: + ```yaml + - name: NuGet login (OIDC) + id: login + uses: NuGet/login@v1 + with: + user: ${{ secrets.NUGET_USER }} # nuget.org profile name, NOT email + ``` + +3. **Replace the API key** in the push step: + ```yaml + --api-key ${{ steps.login.outputs.NUGET_API_KEY }} --skip-duplicate + ``` + +4. **Verify**: Ask the user to trigger a publish and confirm the package appears on nuget.org. + +> ❌ **Don't delete the old API key secret** until trusted publishing is verified. Removing it is a one-way door — wait for confirmation. + +## Troubleshooting + +| Problem | Cause | Fix | +|---------|-------|-----| +| `NuGet/login` 403 | Missing `id-token: write` | Add to job permissions | +| "no matching policy" | Workflow filename mismatch | Verify exact filename on nuget.org | +| Push unauthorized | Package not owned by policy account | Check policy owner on nuget.org | +| Token expired | Login step >1hr before push | Move `NuGet/login` closer to push | +| "temporarily active" policy | Private repo, first publish pending | Publish within 7 days | +| `already_exists` on push | Re-running same version | Add `--skip-duplicate` | +| GitHub Release 422 | Duplicate release for tag | Delete conflicting release (confirm first) | +| Re-run uses wrong YAML | `gh run rerun` replays original commit's YAML | Delete obstacle, re-run — never re-tag | + +> ⚠️ If any blocker persists after one fix attempt, **stop and ask the user**. + +## References + +- **Package type details**: [references/package-types.md](references/package-types.md) — detection logic, required properties, minimal .csproj examples +- **Publish workflow template**: [references/publish-workflow.md](references/publish-workflow.md) — complete tag-triggered workflow ready to adapt +- **Microsoft docs**: [NuGet Trusted Publishing](https://learn.microsoft.com/en-us/nuget/nuget-org/trusted-publishing) diff --git a/.agents/skills/nuget-trusted-publishing/references/package-types.md b/.agents/skills/nuget-trusted-publishing/references/package-types.md new file mode 100644 index 00000000..4c21bd6e --- /dev/null +++ b/.agents/skills/nuget-trusted-publishing/references/package-types.md @@ -0,0 +1,256 @@ +# NuGet Package Type Reference + +Structural requirements for each NuGet package type. The agent uses this to validate a repo's packaging setup before configuring trusted publishing. + +## Detection Logic + +Inspect `.csproj` files (and `Directory.Build.props` if present) for these MSBuild properties: + +``` +1. Has Template? → Template package +2. Has McpServer? → MCP server (also a dotnet tool) +3. Has true? → Dotnet tool +4. Has true or no OutputType? → NuGet library +5. Has Exe + true? → Application package +6. Has Exe without PackAsTool or IsPackable? → Not packable by default (ask user) +``` + +Check in order — MCP servers have `PackAsTool` too, so `PackageType` must be checked first. + +## NuGet Library + +The most common case. A class library consumed via `PackageReference`. + +### Required Properties + +| Property | Example | Notes | +|----------|---------|-------| +| `PackageId` | `Contoso.Utilities` | Defaults to `AssemblyName` if omitted | +| `Version` | `0.1.0` | Start with 0.x for initial development | + +### Recommended Properties + +| Property | Purpose | +|----------|---------| +| `Authors` | Package author(s) | +| `Description` | Shown on nuget.org | +| `PackageTags` | Discoverability | +| `PackageReadmeFile` | README displayed on nuget.org | +| `PackageLicenseExpression` | SPDX license identifier | +| `RepositoryUrl` | Link back to source | +| `PublishRepositoryUrl` | Enables source-link integration | + +### Including README in Package + +`PackageReadmeFile` alone isn't enough — you must also include the file in the package: + +```xml + + README.md + + + + + + +``` + +### Minimal .csproj + +```xml + + + net9.0 + Contoso.Utilities + 0.1.0 + Contoso + Utility library for Contoso apps + README.md + + + + + +``` + +### Pack Command + +```bash +dotnet pack -c Release +``` + +## Dotnet Tool + +A console app distributed as a global or local tool via `dotnet tool install`. + +### Required Properties + +| Property | Example | Notes | +|----------|---------|-------| +| `OutputType` | `Exe` | Must be an executable | +| `PackAsTool` | `true` | Marks this as a tool package | +| `PackageId` | `contoso-cli` | Tool package identifier | +| `Version` | `0.1.0` | Package version (or set in Directory.Build.props) | + +### Recommended Properties + +| Property | Example | Notes | +|----------|---------|-------| +| `ToolCommandName` | `contoso` | Command users type; defaults to assembly name | +| `PackageOutputPath` | `./nupkg` | Where .nupkg is written | +| `PackageReadmeFile` | `README.md` | Shown on nuget.org | + +### Minimal .csproj + +```xml + + + Exe + net9.0 + true + contoso + contoso-cli + README.md + + + + + +``` + +### Pack Command + +```bash +dotnet pack -c Release +``` + +## MCP Server + +A dotnet tool that implements the Model Context Protocol. Distributed the same way as a dotnet tool but with additional metadata for MCP client discovery. + +### Naming Convention + +Follow the established pattern for MCP server packages: +- **PackageId**: `{github-username}.{domain}.mcp` (e.g., `lewing.helix.mcp`) +- **server.json `name`**: `io.github.{username}/{packageid}` (e.g., `io.github.lewing/lewing.helix.mcp`) + +### Required Properties + +Everything from Dotnet Tool, plus: + +| Property | Example | Notes | +|----------|---------|-------| +| `PackageType` | `McpServer` | NuGet recognizes this as an MCP server | + +### Recommended Properties + +| Property | Example | Notes | +|----------|---------|-------| +| `McpServerJsonTemplateFile` | `.mcp/server.json` | MCP client discovery metadata | + +### Required Files + +| File | Purpose | +|------|---------| +| `.mcp/server.json` | MCP server descriptor for client discovery | + +The `.mcp/server.json` must be included in the package: + +```xml + + + +``` + +### Minimal server.json + +```json +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json", + "name": "io.github.contoso/contoso.services.mcp", + "description": "MCP server for Contoso services", + "version": "0.1.0", + "packages": [ + { + "registryType": "nuget", + "registryBaseUrl": "https://api.nuget.org", + "identifier": "contoso.services.mcp", + "version": "0.1.0", + "transport": { "type": "stdio" } + } + ] +} +``` + +> ⚠️ **Version sync**: The `version` fields in `server.json` MUST match the `` in your `.csproj`. Update both when bumping versions. + +> ⚠️ **nuget.org MCP Server tab**: nuget.org auto-generates MCP install config from your `server.json`. If your tool requires a subcommand (e.g., `my-tool mcp`), the generated config may omit it. Ensure your tool defaults to MCP server mode when invoked with no arguments, or uses a `--yes` flag for non-interactive acceptance. + +### Minimal .csproj + +```xml + + + Exe + net9.0 + true + McpServer + contoso.services.mcp + contoso-mcp + .mcp/server.json + README.md + + + + + + +``` + +## Template Package + +A package containing `dotnet new` templates. + +### Required Properties + +| Property | Example | Notes | +|----------|---------|-------| +| `PackageType` | `Template` | NuGet recognizes this as a template package | +| `PackageId` | `Contoso.Templates` | Template package identifier | + +### Required Files + +| File | Purpose | +|------|---------| +| `content/*/.template.config/template.json` | Template definition — one per template | + +The `template.json` must include at minimum: `identity`, `name`, `shortName`, `tags.type` (`item` or `project`). + +### Minimal .csproj + +```xml + + + Template + Contoso.Templates + 0.1.0 + Contoso project templates + + + true + false + content + true + + + + + +``` + +## Common Gotchas + +- **`IsPackable` defaults**: Class libraries default to `true`, console apps to `false`. Console apps can still be published as NuGet packages by setting `true` — they just won't be installable via `dotnet tool install` unless `PackAsTool` is also set. +- **`Directory.Build.props`**: Package metadata may be set at the repo root — always check there too. +- **Multi-project repos**: A repo may contain multiple packable projects of different types. Each needs its own trusted publishing workflow or a matrix build. +- **`GeneratePackageOnBuild`**: If `true`, `dotnet build` also produces the `.nupkg`. The workflow should use `dotnet pack` explicitly for clarity. diff --git a/.agents/skills/nuget-trusted-publishing/references/publish-workflow.md b/.agents/skills/nuget-trusted-publishing/references/publish-workflow.md new file mode 100644 index 00000000..d74311c9 --- /dev/null +++ b/.agents/skills/nuget-trusted-publishing/references/publish-workflow.md @@ -0,0 +1,102 @@ +# Publish Workflow Template + +Complete tag-triggered GitHub Actions workflow for publishing NuGet packages with trusted publishing. Copy and adapt to your repo. + +## Template + +```yaml +name: Publish to NuGet + +on: + push: + tags: + - 'v*' # Triggers on version tags: v1.0.0, v1.2.3-preview.1, etc. + +jobs: + publish: + runs-on: ubuntu-latest + environment: release # Uses release environment for secret scoping + protection rules + permissions: + id-token: write # Required for OIDC token (NuGet trusted publishing) + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '9.0.x' # Adjust to your target framework + + - name: Extract version from tag + id: version + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT + + - name: Validate version matches project + run: | + PROJECT_VERSION=$(sed -n 's:.*\(.*\).*:\1:p' path/to/YourProject.csproj) + if [ "$PROJECT_VERSION" != "${{ steps.version.outputs.VERSION }}" ]; then + echo "::error::Tag version (${{ steps.version.outputs.VERSION }}) doesn't match project version ($PROJECT_VERSION)" + exit 1 + fi + + - name: Pack + run: dotnet pack path/to/YourProject.csproj -c Release -o ./artifacts + + - name: NuGet login (OIDC) + id: login + uses: NuGet/login@v1 + with: + user: ${{ secrets.NUGET_USER }} # nuget.org profile name (NOT email) + + - name: Push to NuGet + run: dotnet nuget push ./artifacts/*.nupkg --api-key ${{ steps.login.outputs.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate +``` + +## Customization Points + +| Item | What to change | +|------|---------------| +| `dotnet-version` | Match your `TargetFramework` | +| `path/to/YourProject.csproj` | Path to your packable project | +| Version extraction `sed` | Adjust for `Directory.Build.props` or .NET 10 file-based apps (`#:property Version=`) | +| `--skip-duplicate` | Keeps push idempotent — safe for re-runs and matrix builds | + +## Release Process + +Once the workflow is committed, the publish process is: + +```bash +# 1. Bump version in .csproj (and server.json for MCP servers) +# 2. Commit +git add -A && git commit -m "Bump version to 0.1.0" +# 3. Tag and push +git tag v0.1.0 +git push origin main --tags +# 4. Workflow runs automatically, publishes to nuget.org +``` + +## Optional: GitHub Release Step + +Add after the push step if you want GitHub Releases with the `.nupkg` attached. Note: this requires changing `contents: read` to `contents: write` in the job permissions. + +```yaml + - name: Create GitHub Release + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2 + with: + files: ./artifacts/*.nupkg + generate_release_notes: true +``` + +> ⚠️ **Consider omitting this step entirely.** Creating GitHub Releases separately (manually or via `gh release create` in a different workflow) avoids 422 `already_exists` conflicts and keeps the publish workflow focused on NuGet. If any release step fails, it blocks NuGet publishing too. + +> ⚠️ **Don't use `ncipollo/release-action` AND `gh release create` for the same tag** — this causes HTTP 422 `already_exists` errors. + +> ⚠️ **`gh run rerun` replays the original YAML** from the tag commit, not from `main`. If the workflow fails due to a release conflict, delete the conflicting release and re-run — don't delete the tag and re-tag (NuGet package IDs are permanent). + +## CI vs Publish Separation + +Keep your CI workflow (build + test on PR/push) separate from the publish workflow (tag-triggered). This gives you: +- CI runs on every PR without publishing +- Publish only runs on deliberate version tags +- Different permission scopes (CI doesn't need `id-token: write`) diff --git a/.agents/skills/resolve-project-references/SKILL.md b/.agents/skills/resolve-project-references/SKILL.md new file mode 100644 index 00000000..c01cc96b --- /dev/null +++ b/.agents/skills/resolve-project-references/SKILL.md @@ -0,0 +1,63 @@ +--- +description: Guide for interpreting ResolveProjectReferences time in MSBuild performance summaries. Only activate in MSBuild/.NET build context. Activate when ResolveProjectReferences appears as the most expensive target and developers are trying to optimize it directly. Explains that the reported time includes wait time for dependent project builds and is misleading. Guides users to focus on task self-time instead. Do not activate for general build performance -- use build-perf-diagnostics instead. +metadata: + github-path: plugins/dotnet-msbuild/skills/resolve-project-references + github-pinned: v1.0.0 + github-ref: refs/tags/v1.0.0 + github-repo: https://github.com/dotnet/skills + github-tree-sha: 783fa203cf9f3c1f6192af07fc313e0e255e4d94 +name: resolve-project-references +--- +# Misleading ResolveProjectReferences Time + +Prevent misguided optimization of `ResolveProjectReferences` by explaining that its reported time is wall-clock wait time, not CPU work. + +## When to Use + +- `ResolveProjectReferences` appears as the most expensive target in the Target Performance Summary +- A developer is trying to optimize `ResolveProjectReferences` directly +- Build performance analysis shows a single target consuming 50-80% of total build time + +## When Not to Use + +- General build performance optimization (use `build-perf-diagnostics` instead) +- The bottleneck is clearly a different target (e.g., `Csc`, `ResolveAssemblyReference`) +- The user has not yet captured a binlog or performance summary + +## Inputs + +| Input | Required | Description | +|-------|----------|-------------| +| Build log or binlog | Yes | A diagnostic build log or binlog containing the Target Performance Summary | + +## Workflow + +### Step 1: Confirm the misleading symptom + +Verify that `ResolveProjectReferences` appears as the top target in the **Target** Performance Summary. This is the misleading metric. + +### Step 2: Explain why it is misleading + +The reported time includes **waiting for dependent projects to build** while the MSBuild node is yielded (see dotnet/msbuild#3135). During this wait, the node may be doing useful work on other projects. The target itself does very little work. + +### Step 3: Redirect to task self-time + +Guide the user to use the **Task** Performance Summary instead: + +```bash +dotnet msbuild build.binlog -noconlog -fl "-flp:v=diag;logfile=full.log;performancesummary" +grep "Task Performance Summary" -A 50 full.log +``` + +Focus on self-time of actual tasks: + +- **Csc**: see `build-perf-diagnostics` skill (Section 2: Roslyn Analyzers) +- **ResolveAssemblyReference**: see `build-perf-diagnostics` skill (Section 1: RAR) +- **Copy**: see `build-perf-diagnostics` skill (Section 4: File I/O) +- **Serialization bottlenecks**: see `build-parallelism` skill + +## Validation + +- [ ] Task Performance Summary was used instead of Target Performance Summary +- [ ] `ResolveProjectReferences` was not set as the optimization target +- [ ] A concrete task (e.g., `Csc`, `Copy`, `ResolveAssemblyReference`) was identified as the true bottleneck diff --git a/.agents/skills/run-tests/SKILL.md b/.agents/skills/run-tests/SKILL.md new file mode 100644 index 00000000..4837090a --- /dev/null +++ b/.agents/skills/run-tests/SKILL.md @@ -0,0 +1,245 @@ +--- +description: 'Runs .NET tests with dotnet test. Use when user says "run tests", "run my tests", "run these tests", "execute tests", "dotnet test", "test filter", "filter by category", "filter by class", "combine filters", "run only specific tests", "integration tests", "unit tests", "tests not running", "hang timeout", "blame-hang", "blame-crash", "crash dump", "TRX report", "TRX", "test report", "generate TRX", "TUnit", "treenode-filter", "target framework", "multi-TFM", or needs to detect the test platform (VSTest or Microsoft.Testing.Platform), identify the test framework, apply test filters, or troubleshoot test execution failures. Covers MSTest, xUnit, NUnit, and TUnit across both VSTest and MTP platforms. Also use for --filter-class, --filter-trait, --report-trx, --logger trx, --blame-hang-timeout, and other platform-specific filter and reporting syntax. DO NOT USE FOR: writing or generating test code, CI/CD pipeline configuration, or debugging failing test logic.' +metadata: + github-path: plugins/dotnet-test/skills/run-tests + github-pinned: v1.0.0 + github-ref: refs/tags/v1.0.0 + github-repo: https://github.com/dotnet/skills + github-tree-sha: 1df99bb3ac1d06d88693057255ae159d8458ae52 +name: run-tests +--- +# Run .NET Tests + +Detect the test platform and framework, run tests, and apply filters using `dotnet test`. + +## When to Use + +- User wants to run tests in a .NET project +- User needs to run a subset of tests using filters +- User needs help detecting which test platform (VSTest vs MTP) or framework is in use +- User wants to understand the correct filter syntax for their setup + +## When Not to Use + +- User needs to write or generate test code (use `writing-mstest-tests` for MSTest, or general coding assistance for other frameworks) +- User needs to migrate from VSTest to MTP (use `migrate-vstest-to-mtp`) +- User wants to iterate on failing tests without rebuilding (use `mtp-hot-reload`) +- User needs CI/CD pipeline configuration (use CI-specific skills) +- User needs to debug a test (use debugging skills) + +## Inputs + +| Input | Required | Description | +|-------|----------|-------------| +| Project or solution path | No | Path to the test project (.csproj) or solution (.sln). Defaults to current directory. | +| Filter expression | No | Filter expression to select specific tests | +| Target framework | No | Target framework moniker to run against (e.g., `net8.0`) | + +## Critical Rules — Avoid Cross-Platform Mistakes + +These are the most common agent mistakes. Internalize before proceeding: + +| Rule | Why | +|------|-----| +| **Do NOT use `--logger trx`** for MTP projects | MTP uses `--report-trx` (requires the TrxReport extension package) | +| **Do NOT use `--report-trx`** for VSTest projects | VSTest uses `--logger trx` | +| **Do NOT use `-- --arg`** on .NET SDK 10+ | SDK 10+ passes MTP args directly: `dotnet test --project . --report-trx` | +| **Do NOT omit `--`** on .NET SDK 8/9 with MTP | SDK 8/9 requires the separator: `dotnet test -- --report-trx` | +| **Do NOT use `--filter "ClassName=..."`** with xUnit v3 on MTP | xUnit v3 on MTP uses `--filter-class`, `--filter-method`, `--filter-trait` | +| **Do NOT use bare positional path** on SDK 10+ | Use `--project ` or `--solution ` instead | +| **Do NOT use `--blame`** for MTP projects | MTP uses `--blame-crash` and `--blame-hang-timeout` separately (each requires its extension package) | +| **Do NOT use `--collect "Code Coverage"`** for MTP | MTP uses `--coverage` (requires the CodeCoverage extension package) | + +## Workflow + +### Quick Reference + +| Platform | SDK | Command pattern | +|----------|-----|----------------| +| VSTest | Any | `dotnet test [] [--filter ] [--logger trx]` | +| MTP | 8 or 9 | `dotnet test [] -- ` | +| MTP | 10+ | `dotnet test --project ` | + +**Detection files to always check** (in order): `global.json` -> `.csproj` -> `Directory.Build.props` -> `Directory.Packages.props` + +### Step 1: Detect the test platform and framework + +1. Run `dotnet --version` in the project directory to determine the SDK version. This accounts for `global.json` SDK pinning. +2. Read `global.json` — on .NET SDK 10+, `"test": { "runner": "Microsoft.Testing.Platform" }` is the **authoritative MTP signal**. If present, the project uses MTP and SDK 10+ syntax (no `--` separator). +3. Read `.csproj`, `Directory.Build.props`, **and** `Directory.Packages.props` for framework packages and MTP properties. **Always check all three files** — MTP properties are frequently set in `Directory.Build.props` rather than individual `.csproj` files. +4. For full detection logic (SDK 8/9 signals, framework identification), see the `platform-detection` skill. + +**What to look for in each file:** + +| File | Look for | Indicates | +|------|----------|-----------| +| `global.json` | `"test": { "runner": "Microsoft.Testing.Platform" }` | MTP on SDK 10+ | +| `global.json` | `"sdk": { "version": "..." }` | SDK version (determines `--` separator behavior) | +| `.csproj` | `true` | MTP on SDK 8/9 | +| `.csproj` | `MSTest`, `xunit.v3`, `NUnit`, `TUnit` packages | Framework identity | +| `.csproj` | `Microsoft.NET.Test.Sdk` + test adapter | VSTest (unless overridden by MTP signals above) | +| `.csproj` | `` (plural) | Multi-TFM — may need `--framework` | +| `Directory.Build.props` | `true` | MTP on SDK 8/9 (often set here, not in .csproj) | +| `Directory.Packages.props` | Centrally managed test package versions | Framework identity for CPM repos | + +**Quick detection summary:** + +| Signal | Means | +|--------|-------| +| `global.json` has `"test": { "runner": "Microsoft.Testing.Platform" }` | **MTP on SDK 10+** — pass args directly, no `--` | +| `true` in csproj or Directory.Build.props | **MTP on SDK 8/9** — pass args after `--` | +| Neither signal present | **VSTest** | + +### Step 2: Run tests + +#### VSTest (any .NET SDK version) + +```bash +dotnet test [ | | | | ] +``` + +Common flags: + +| Flag | Description | +|------|-------------| +| `--framework ` | Target a specific framework in multi-TFM projects (e.g., `net8.0`) | +| `--no-build` | Skip build, use previously built output | +| `--filter ` | Run selected tests (see [Step 3](#step-3-run-filtered-tests)) | +| `--logger trx` | Generate TRX results file | +| `--collect "Code Coverage"` | Collect code coverage using Microsoft Code Coverage (built-in, always available) | +| `--blame` | Enable blame mode to detect tests that crash the host | +| `--blame-crash` | Collect a crash dump when the test host crashes | +| `--blame-hang-timeout ` | Abort test if it hangs longer than duration (e.g., `5min`) | +| `-v ` | Verbosity: `quiet`, `minimal`, `normal`, `detailed`, `diagnostic` | + +#### MTP with .NET SDK 8 or 9 + +With `true`, `dotnet test` bridges to MTP but uses VSTest-style argument parsing. MTP-specific arguments must be passed after `--`: + +```bash +dotnet test [ | | | | ] -- +``` + +#### MTP with .NET SDK 10+ + +With the `global.json` runner set to `Microsoft.Testing.Platform`, `dotnet test` natively understands MTP arguments without `--`: + +```bash +dotnet test + [--project ] + [--solution ] + [--test-modules ] + [] +``` + +Examples: + +```bash +# Run all tests in a project +dotnet test --project path/to/MyTests.csproj + +# Run all tests in a directory containing a project +dotnet test --project path/to/ + +# Run all tests in a solution (sln, slnf, slnx) +dotnet test --solution path/to/MySolution.sln + +# Run all tests in a directory containing a solution +dotnet test --solution path/to/ + +# Run with MTP flags +dotnet test --project path/to/MyTests.csproj --report-trx --blame-hang-timeout 5min +``` + +> **Note**: The .NET 10+ `dotnet test` syntax does **not** accept a bare positional argument like the VSTest syntax. Use `--project`, `--solution`, or `--test-modules` to specify the target. + +#### Common MTP flags + +These flags apply to MTP on both SDK versions. On SDK 8/9, pass after `--`; on SDK 10+, pass directly. + +**Built-in flags (always available):** + +| Flag | Description | +|------|-------------| +| `--no-build` | Skip build, use previously built output | +| `--framework ` | Target a specific framework in multi-TFM projects | +| `--results-directory ` | Directory for test result output | +| `--diagnostic` | Enable diagnostic logging for the test platform | +| `--diagnostic-output-directory ` | Directory for diagnostic log output | + +**Extension-dependent flags (require the corresponding extension package to be registered):** + +| Flag | Requires | Description | +|------|----------|-------------| +| `--filter ` | Framework-specific (not all frameworks support this) | Run selected tests (see [Step 3](#step-3-run-filtered-tests)) | +| `--report-trx` | `Microsoft.Testing.Extensions.TrxReport` | Generate TRX results file | +| `--report-trx-filename ` | `Microsoft.Testing.Extensions.TrxReport` | Set TRX output filename | +| `--blame-hang-timeout ` | `Microsoft.Testing.Extensions.HangDump` | Abort test if it hangs longer than duration (e.g., `5min`) | +| `--blame-crash` | `Microsoft.Testing.Extensions.CrashDump` | Collect a crash dump when the test host crashes | +| `--coverage` | `Microsoft.Testing.Extensions.CodeCoverage` | Collect code coverage using Microsoft Code Coverage | + +> Some frameworks (e.g., MSTest) bundle common extensions by default. Others may require explicit package references. If a flag is not recognized, check that the corresponding extension package is referenced in the project. + +#### Alternative MTP invocations + +MTP test projects are standalone executables. Beyond `dotnet test`, they can be run directly: + +```bash +# Build and run +dotnet run --project + +# Run a previously built DLL +dotnet exec + +# Run the executable directly (Windows) + +``` + +These alternative invocations accept MTP command line arguments directly (no `--` separator needed). + +### Step 3: Run filtered tests + +See the `filter-syntax` skill for the complete filter syntax for each platform and framework combination. Key points: + +- **VSTest** (MSTest, xUnit v2, NUnit): `dotnet test --filter ` with `=`, `!=`, `~`, `!~` operators +- **MTP -- MSTest and NUnit**: Same `--filter` syntax as VSTest; pass after `--` on SDK 8/9, directly on SDK 10+ +- **MTP -- xUnit v3**: Uses `--filter-class`, `--filter-method`, `--filter-trait` (not VSTest expression syntax) +- **MTP -- TUnit**: Uses `--treenode-filter` with path-based syntax + +## Validation + +- [ ] Test platform (VSTest or MTP) was correctly identified +- [ ] Test framework (MSTest, xUnit, NUnit, TUnit) was correctly identified +- [ ] Correct `dotnet test` invocation was used for the detected platform and SDK version +- [ ] Filter expressions used the syntax appropriate for the platform and framework +- [ ] Test results were clearly reported to the user + +## Common Pitfalls + +| Pitfall | Solution | +|---------|----------| +| Missing `Microsoft.NET.Test.Sdk` in a VSTest project | Tests won't be discovered. Add `` | +| Using VSTest `--filter` syntax with xUnit v3 on MTP | xUnit v3 on MTP uses `--filter-class`, `--filter-method`, etc. -- not the VSTest expression syntax | +| Passing MTP args without `--` on .NET SDK 8/9 | Before .NET 10, MTP args must go after `--`: `dotnet test -- --report-trx` | +| Using `-- --arg` separator on .NET SDK 10+ | SDK 10+ passes MTP args directly — do NOT use `--` separator | +| Using `--logger trx` for MTP or `--report-trx` for VSTest | Each platform has its own TRX flag — check the Critical Rules table | +| Only checking `.csproj` for MTP signals | Always check `Directory.Build.props` and `Directory.Packages.props` too — MTP properties are frequently set there | +| Using bare positional path argument on SDK 10+ | SDK 10+ requires named flags: `--project ` or `--solution ` | + +## Troubleshooting + +Common error messages and how to resolve them: + +| Error | Cause | Fix | +|-------|-------|-----| +| `No test is available` or `No test matches the given testcase filter` | Wrong filter syntax for the platform/framework, or tests not discovered | Verify filter syntax matches the platform (see `filter-syntax` skill). For discovery issues, check that the test SDK and adapter packages are installed | +| `The --report-trx option is unrecognized` | MTP extension package not referenced, or using MTP flag on a VSTest project | Add `` for MTP, or use `--logger trx` for VSTest | +| `The --blame-hang-timeout option is unrecognized` | Missing HangDump extension on MTP | Add `` | +| `error NETSDK1045: The current .NET SDK does not support targeting .NET X.0` | SDK version in `global.json` doesn't match the project's target framework | Update `global.json` SDK version or install the required SDK | +| `The test runner process exited with non-zero exit code` | MTP test host crashed or test failure | Run with `--blame-crash` (MTP) or `--blame` (VSTest) to collect a crash dump for diagnosis | +| `No test source files were found` / `No test project found` | `dotnet test` can't find a test project in the given path | Specify the path explicitly: `dotnet test ` (VSTest) or `dotnet test --project ` (SDK 10+) | +| Tests discovered but 0 executed | Filter expression matches no tests | Double-check filter property names and values. Common typo: `TestCategory` (MSTest) vs `Category` (NUnit) vs trait syntax (xUnit) | +| Using `--` for MTP args on .NET SDK 10+ | On .NET 10+, MTP args are passed directly: `dotnet test --project . --blame-hang-timeout 5min` — do NOT use `-- --blame-hang-timeout` | +| Multi-TFM project runs tests for all frameworks | Use `--framework ` to target a specific framework | +| `global.json` runner setting ignored | Requires .NET 10+ SDK. On older SDKs, use `` MSBuild property instead | +| TUnit `--treenode-filter` not recognized | TUnit is MTP-only. On .NET SDK 10+ use `dotnet test`; on older SDKs use `dotnet run` since VSTest-mode `dotnet test` does not support TUnit | diff --git a/.agents/skills/test-anti-patterns/SKILL.md b/.agents/skills/test-anti-patterns/SKILL.md new file mode 100644 index 00000000..e05f2d5b --- /dev/null +++ b/.agents/skills/test-anti-patterns/SKILL.md @@ -0,0 +1,145 @@ +--- +description: Quick pragmatic detection-focused review of .NET test code for anti-patterns that undermine reliability and diagnostic value. Use when asked to audit test quality, investigate flaky or coupled tests, find duplication or magic values, or when tests pass but don't actually verify anything. Best for identifying and prioritizing issues in existing tests with severity-ranked findings and targeted remediation guidance. Catches assertion gaps, swallowed exceptions, always-true assertions, flakiness indicators, test coupling, over-mocking, naming issues, magic values, duplicate tests, and structural problems. Do NOT use for direct MSTest API rewrites or implementation-only fixes (for example swapped Assert.AreEqual argument order or converting `DynamicData` from `IEnumerable` to `ValueTuple`) — use writing-mstest-tests instead. For a deep formal audit based on academic test smell taxonomy, use exp-test-smell-detection instead. Works with MSTest, xUnit, NUnit, and TUnit. +metadata: + github-path: plugins/dotnet-test/skills/test-anti-patterns + github-pinned: v1.0.0 + github-ref: refs/tags/v1.0.0 + github-repo: https://github.com/dotnet/skills + github-tree-sha: 2b9ee0e14e82d7c47681cf48171e650a21ba7532 +name: test-anti-patterns +--- +# Test Anti-Pattern Detection + +Quick, pragmatic analysis of .NET test code for anti-patterns and quality issues that undermine test reliability, maintainability, and diagnostic value. + +## When to Use + +- User asks to review test quality or find test smells +- User wants to know why tests are flaky or unreliable +- User asks "are my tests good?" or "what's wrong with my tests?" +- User requests a test audit or test code review +- User wants to improve existing test code + +## When Not to Use + +- User wants to write new tests from scratch (use `writing-mstest-tests`) +- User wants direct implementation fixes in MSTest code rather than a diagnostic review (use `writing-mstest-tests`) +- User asks to fix swapped `Assert.AreEqual` argument order (use `writing-mstest-tests`) +- User asks to convert `DynamicData` from `IEnumerable` to `ValueTuple` (use `writing-mstest-tests`) +- User wants to run or execute tests (use `run-tests`) +- User wants to migrate between test frameworks or versions (use migration skills) +- User wants to measure code coverage (out of scope) +- User wants a deep formal test smell audit with academic taxonomy and extended catalog (use `exp-test-smell-detection`) + +## Inputs + +| Input | Required | Description | +|-------|----------|-------------| +| Test code | Yes | One or more test files or classes to analyze | +| Production code | No | The code under test, for context on what tests should verify | +| Specific concern | No | A focused area like "flakiness" or "naming" to narrow the review | + +## Workflow + +### Step 1: Gather the test code + +Read the test files the user wants reviewed. If the user points to a directory or project, scan for all test files using the framework-specific markers in the `dotnet-test-frameworks` skill (e.g., `[TestClass]`, `[Fact]`, `[Test]`). + +If production code is available, read it too -- this is critical for detecting tests that are coupled to implementation details rather than behavior. + +### Step 2: Scan for anti-patterns + +Check each test file against the anti-pattern catalog below. Report findings grouped by severity. + +#### Critical -- Tests that give false confidence + +| Anti-Pattern | What to Look For | +|---|---| +| **No assertions** | Test methods that execute code but never assert anything. A passing test without assertions proves nothing. | +| **Swallowed exceptions** | `try { ... } catch { }` or `catch (Exception)` without rethrowing or asserting. Failures are silently hidden. | +| **Assert in catch block only** | `try { Act(); } catch (Exception ex) { Assert.Fail(ex.Message); }` -- use `Assert.ThrowsException` or equivalent instead. The test passes when no exception is thrown even if the result is wrong. | +| **Always-true assertions** | `Assert.IsTrue(true)`, `Assert.AreEqual(x, x)`, or conditions that can never fail. | +| **Commented-out assertions** | Assertions that were disabled but the test still runs, giving the illusion of coverage. | + +#### High -- Tests likely to cause pain + +| Anti-Pattern | What to Look For | +|---|---| +| **Flakiness indicators** | `Thread.Sleep(...)`, `Task.Delay(...)` for synchronization, `DateTime.Now`/`DateTime.UtcNow` without abstraction, `Random` without a seed, environment-dependent paths. | +| **Test ordering dependency** | Static mutable fields modified across tests, `[TestInitialize]` that doesn't fully reset state, tests that fail when run individually but pass in suite (or vice versa). | +| **Over-mocking** | More mock setup lines than actual test logic. Verifying exact call sequences on mocks rather than outcomes. Mocking types the test owns. For a deep mock audit, use `exp-mock-usage-analysis`. | +| **Implementation coupling** | Testing private methods via reflection, asserting on internal state, verifying exact method call counts on collaborators instead of observable behavior. | +| **Broad exception assertions** | `Assert.ThrowsException(...)` instead of the specific exception type. Also: `[ExpectedException(typeof(Exception))]`. | + +#### Medium -- Maintainability and clarity issues + +| Anti-Pattern | What to Look For | +|---|---| +| **Poor naming** | Test names like `Test1`, `TestMethod`, names that don't describe the scenario or expected outcome. Good: `Add_NegativeNumber_ThrowsArgumentException`. | +| **Magic values** | Unexplained numbers or strings in arrange/assert: `Assert.AreEqual(42, result)` -- what does 42 mean? | +| **Duplicate tests** | Three or more test methods with near-identical bodies that differ only in a single input value. Should be data-driven (`[DataRow]`, `[Theory]`, `[TestCase]`). For a detailed duplication analysis, use `exp-test-maintainability`. Note: Two tests covering distinct boundary conditions (e.g., zero vs. negative) are NOT duplicates -- separate tests for different edge cases provide clearer failure diagnostics and are a valid practice. | +| **Giant tests** | Test methods exceeding ~30 lines or testing multiple behaviors at once. Hard to diagnose when they fail. | +| **Assertion messages that repeat the assertion** | `Assert.AreEqual(expected, actual, "Expected and actual are not equal")` adds no information. Messages should describe the business meaning. | +| **Missing AAA separation** | Arrange, Act, Assert phases are interleaved or indistinguishable. | + +#### Low -- Style and hygiene + +| Anti-Pattern | What to Look For | +|---|---| +| **Unused test infrastructure** | `[TestInitialize]`/`[SetUp]` that does nothing, test helper methods that are never called. | +| **IDisposable not disposed** | Test creates `HttpClient`, `Stream`, or other disposable objects without `using` or cleanup. | +| **Console.WriteLine debugging** | Leftover `Console.WriteLine` or `Debug.WriteLine` statements used during test development. | +| **Inconsistent naming convention** | Mix of naming styles in the same test class (e.g., some use `Method_Scenario_Expected`, others use `ShouldDoSomething`). | + +### Step 3: Calibrate severity honestly + +Before reporting, re-check each finding against these severity rules: + +- **Critical/High**: Only for issues that cause tests to give false confidence or be unreliable. A test that always passes regardless of correctness is Critical. Flaky shared state is High. +- **Medium**: Only for issues that actively harm maintainability -- 5+ nearly-identical tests, truly meaningless names like `Test1`. +- **Low**: Cosmetic naming mismatches, minor style preferences, assertion messages that could be better. When in doubt, rate Low. +- **Not an issue**: Separate tests for distinct boundary conditions (zero vs. negative vs. null). Explicit per-test setup instead of `[TestInitialize]` (this *improves* isolation). Tests that are short and clear but could theoretically be consolidated. + +IMPORTANT: If the tests are well-written, say so clearly up front. Do not inflate severity to justify the review. A review that finds zero Critical/High issues and only minor Low suggestions is a valid and valuable outcome. Lead with what the tests do well. + +### Step 4: Report findings + +Present findings in this structure: + +1. **Summary** -- Total issues found, broken down by severity (Critical / High / Medium / Low). If tests are well-written, lead with that assessment. +2. **Critical and High findings** -- List each with: + - The anti-pattern name + - The specific location (file, method name, line) + - A brief explanation of why it's a problem + - A concrete fix (show before/after code when helpful) +3. **Medium and Low findings** -- Summarize in a table unless the user wants full detail +4. **Positive observations** -- Call out things the tests do well (sealed class, specific exception types, data-driven tests, clear AAA structure, proper use of fakes, good naming). Don't only report negatives. + +### Step 5: Prioritize recommendations + +If there are many findings, recommend which to fix first: + +1. **Critical** -- Fix immediately, these tests may be giving false confidence +2. **High** -- Fix soon, these cause flakiness or maintenance burden +3. **Medium/Low** -- Fix opportunistically during related edits + +## Validation + +- [ ] Every finding includes a specific location (not just a general warning) +- [ ] Every Critical/High finding includes a concrete fix +- [ ] Report covers all categories (assertions, isolation, naming, structure) +- [ ] Positive observations are included alongside problems +- [ ] Recommendations are prioritized by severity + +## Common Pitfalls + +| Pitfall | Solution | +|---------|----------| +| Reporting style issues as critical | Naming and formatting are Medium/Low, never Critical | +| Suggesting rewrites instead of targeted fixes | Show minimal diffs -- change the assertion, not the whole test | +| Flagging intentional design choices | If `Thread.Sleep` is in an integration test testing actual timing, that's not an anti-pattern. Consider context. | +| Inventing false positives on clean code | If tests follow best practices, say so. A review finding "0 Critical, 0 High, 1 Low" is perfectly valid. Don't inflate findings to justify the review. | +| Flagging separate boundary tests as duplicates | Two tests for zero and negative inputs test different edge cases. Only flag as duplicates when 3+ tests have truly identical bodies differing by a single value. | +| Rating cosmetic issues as Medium | Naming mismatches (e.g., method name says `ArgumentException` but asserts `ArgumentOutOfRangeException`) are Low, not Medium -- the test still works correctly. | +| Ignoring the test framework | xUnit uses `[Fact]`/`[Theory]`, NUnit uses `[Test]`/`[TestCase]`, MSTest uses `[TestMethod]`/`[DataRow]` -- use correct terminology | +| Missing the forest for the trees | If 80% of tests have no assertions, lead with that systemic issue rather than listing every instance | diff --git a/.agents/skills/writing-mstest-tests/SKILL.md b/.agents/skills/writing-mstest-tests/SKILL.md new file mode 100644 index 00000000..8f1a8381 --- /dev/null +++ b/.agents/skills/writing-mstest-tests/SKILL.md @@ -0,0 +1,355 @@ +--- +description: Best practices for writing new MSTest 3.x/4.x unit tests and implementing concrete fixes in existing MSTest code. Use when the user asks to write, create, implement, repair, or modernize tests (including fix-it prompts such as 'something seems off, fix issues'). Primary fit for direct code changes like correcting swapped Assert.AreEqual argument order, replacing outdated assertion patterns, and converting DynamicData from IEnumerable to ValueTuple-based data sets. Covers modern assertions, data-driven tests, test lifecycle, MSTest.Sdk, sealed classes, Assert.Throws, DynamicData with ValueTuples, TestContext, and conditional execution. Do NOT use for broad test quality audits, flaky-test investigations, or test smell detection reports — use test-anti-patterns instead. +metadata: + github-path: plugins/dotnet-test/skills/writing-mstest-tests + github-pinned: v1.0.0 + github-ref: refs/tags/v1.0.0 + github-repo: https://github.com/dotnet/skills + github-tree-sha: 02ca0ad8517efa911ef1748d70b6942bcbaff7d2 +name: writing-mstest-tests +--- +# Writing MSTest Tests + +Help users write effective, modern unit tests with MSTest 3.x/4.x using current APIs and best practices. + +## When to Use + +- User wants to write new MSTest unit tests +- User wants to improve or modernize existing MSTest tests by implementing concrete fixes +- User asks about MSTest assertion APIs, data-driven patterns, or test lifecycle +- User needs help fixing a specific MSTest test bug or failing assertion +- User asks to fix swapped `Assert.AreEqual` argument order (expected first, actual second) +- User asks to convert `DynamicData` from `IEnumerable` to ValueTuple-based data + +## When Not to Use + +- User needs a test quality audit, anti-pattern detection, or flaky-test investigation (use `test-anti-patterns`) +- User needs to run or execute tests (use the `run-tests` skill) +- User needs to upgrade from MSTest v1/v2 to v3 (use `migrate-mstest-v1v2-to-v3`) +- User needs to upgrade from MSTest v3 to v4 (use `migrate-mstest-v3-to-v4`) +- User needs CI/CD pipeline configuration +- User is using xUnit, NUnit, or TUnit (not MSTest) + +## Inputs + +| Input | Required | Description | +|-------|----------|-------------| +| Code under test | No | The production code to be tested | +| Existing test code | No | Current tests to fix, update, or modernize | +| Test scenario description | No | What behavior the user wants to test | + +## Workflow + +### Step 1: Determine project setup + +Check the test project for MSTest version and configuration: + +- If using `MSTest.Sdk` (``): modern setup, all features available +- If using `MSTest` metapackage: modern setup (MSTest 3.x+) +- If using `MSTest.TestFramework` + `MSTest.TestAdapter`: check version for feature availability + +Recommend MSTest.Sdk or the MSTest metapackage for new projects: + +```xml + + + + net9.0 + + +``` + +When using `MSTest.Sdk`, put the version in `global.json` instead of the project file so all test projects get bumped together: + +```json +{ + "msbuild-sdks": { + "MSTest.Sdk": "3.8.2" + } +} +``` + +```xml + + + + net9.0 + + + + + +``` + +### Step 2: Write test classes following conventions + +Apply these structural conventions: + +- **Seal test classes** with `sealed` for performance and design clarity +- Use `[TestClass]` on the class and `[TestMethod]` on test methods +- Follow the **Arrange-Act-Assert** (AAA) pattern +- Name tests using `MethodName_Scenario_ExpectedBehavior` +- Use separate test projects with naming convention `[ProjectName].Tests` + +```csharp +[TestClass] +public sealed class OrderServiceTests +{ + [TestMethod] + public void CalculateTotal_WithDiscount_ReturnsReducedPrice() + { + // Arrange + var service = new OrderService(); + var order = new Order { Price = 100m, DiscountPercent = 10 }; + + // Act + var total = service.CalculateTotal(order); + + // Assert + Assert.AreEqual(90m, total); + } +} +``` + +### Step 3: Use modern assertion APIs + +Use the correct assertion for each scenario. Prefer `Assert` class methods over `StringAssert` or `CollectionAssert` where both exist. + +#### Equality and null checks + +```csharp +Assert.AreEqual(expected, actual); // Value equality +Assert.AreSame(expected, actual); // Reference equality +Assert.IsNull(value); +Assert.IsNotNull(value); +``` + +#### Exception testing -- use `Assert.Throws` instead of `[ExpectedException]` + +```csharp +// Synchronous +var ex = Assert.ThrowsExactly(() => service.Process(null)); +Assert.AreEqual("input", ex.ParamName); + +// Async +var ex = await Assert.ThrowsExactlyAsync( + async () => await service.ProcessAsync(null)); +``` + +- `Assert.Throws` matches `T` or any derived type +- `Assert.ThrowsExactly` matches only the exact type `T` + +#### Collection assertions + +```csharp +Assert.Contains(expectedItem, collection); +Assert.DoesNotContain(unexpectedItem, collection); +var single = Assert.ContainsSingle(collection); // Returns the single element +Assert.HasCount(3, collection); +Assert.IsEmpty(collection); +Assert.IsNotEmpty(collection); +``` + +Replace generic `Assert.IsTrue` with specialized assertions -- they give better failure messages: + +| Instead of | Use | +|---|---| +| `Assert.IsTrue(list.Count > 0)` | `Assert.IsNotEmpty(list)` | +| `Assert.IsTrue(list.Count() == 3)` | `Assert.HasCount(3, list)` | +| `Assert.IsTrue(x != null)` | `Assert.IsNotNull(x)` | +| `list.Single(predicate)` + `Assert.IsNotNull` | `Assert.ContainsSingle(list)` | +| `Assert.IsTrue(list.Contains(item))` | `Assert.Contains(item, list)` | + +#### String assertions + +```csharp +Assert.Contains("expected", actualString); +Assert.StartsWith("prefix", actualString); +Assert.EndsWith("suffix", actualString); +Assert.MatchesRegex(@"\d{3}-\d{4}", phoneNumber); +``` + +#### Type assertions + +```csharp +// MSTest 3.x -- out parameter +Assert.IsInstanceOfType(result, out var typed); +typed.Handle(); + +// MSTest 4.x -- returns directly +var typed = Assert.IsInstanceOfType(result); +``` + +#### Comparison assertions + +```csharp +Assert.IsGreaterThan(lowerBound, actual); +Assert.IsLessThan(upperBound, actual); +Assert.IsInRange(actual, low, high); +``` + +### Step 4: Use data-driven tests for multiple inputs + +#### DataRow for inline values + +```csharp +[TestMethod] +[DataRow(1, 2, 3)] +[DataRow(0, 0, 0, DisplayName = "Zeros")] +[DataRow(-1, 1, 0)] +public void Add_ReturnsExpectedSum(int a, int b, int expected) +{ + Assert.AreEqual(expected, Calculator.Add(a, b)); +} +``` + +#### DynamicData with ValueTuples (preferred for complex data) + +Prefer `ValueTuple` return types over `IEnumerable` for type safety: + +```csharp +[TestMethod] +[DynamicData(nameof(DiscountTestData))] +public void ApplyDiscount_ReturnsExpectedPrice(decimal price, int percent, decimal expected) +{ + var result = PriceCalculator.ApplyDiscount(price, percent); + Assert.AreEqual(expected, result); +} + +// ValueTuple -- preferred (MSTest 3.7+) +public static IEnumerable<(decimal price, int percent, decimal expected)> DiscountTestData => +[ + (100m, 10, 90m), + (200m, 25, 150m), + (50m, 0, 50m), +]; +``` + +When you need metadata per test case, use `TestDataRow`: + +```csharp +public static IEnumerable> DiscountTestDataWithMetadata => +[ + new((100m, 10, 90m)) { DisplayName = "10% discount" }, + new((200m, 25, 150m)) { DisplayName = "25% discount" }, + new((50m, 0, 50m)) { DisplayName = "No discount" }, +]; +``` + +### Step 5: Handle test lifecycle correctly + +- **Always initialize in the constructor** -- this enables `readonly` fields and works correctly with nullability analyzers (fields are guaranteed non-null after construction) +- Use `[TestInitialize]` **only** for async initialization, combined with the constructor for sync parts +- Use `[TestCleanup]` for cleanup that must run even on failure +- Inject `TestContext` via constructor (MSTest 3.6+) + +```csharp +[TestClass] +public sealed class RepositoryTests +{ + private readonly TestContext _testContext; + private readonly FakeDatabase _db; // readonly -- guaranteed by constructor + + public RepositoryTests(TestContext testContext) + { + _testContext = testContext; + _db = new FakeDatabase(); // sync init in ctor + } + + [TestInitialize] + public async Task InitAsync() + { + // Use TestInitialize ONLY for async setup + await _db.SeedAsync(); + } + + [TestCleanup] + public void Cleanup() => _db.Reset(); +} +``` + +#### Execution order + +1. `[AssemblyInitialize]` -- once per assembly +2. `[ClassInitialize]` -- once per class +3. Per test: + - With `TestContext` property injection: Constructor -> set `TestContext` property -> `[TestInitialize]` + - With constructor injection of `TestContext`: Constructor (receives `TestContext`) -> `[TestInitialize]` +4. Test method +5. `[TestCleanup]` -> `DisposeAsync` -> `Dispose` -- per test +6. `[ClassCleanup]` -- once per class +7. `[AssemblyCleanup]` -- once per assembly + +### Step 6: Apply cancellation and timeout patterns + +Always use `TestContext.CancellationToken` with `[Timeout]`: + +```csharp +[TestMethod] +[Timeout(5000)] +public async Task FetchData_ReturnsWithinTimeout() +{ + var result = await _client.GetDataAsync(_testContext.CancellationToken); + Assert.IsNotNull(result); +} +``` + +### Step 7: Use advanced features where appropriate + +#### Retry flaky tests (MSTest 3.9+) + +Use only for genuinely flaky external dependencies (network, file system), not to paper over race conditions or shared state issues. + +```csharp +[TestMethod] +[Retry(3)] +public void ExternalService_EventuallyResponds() { } +``` + +#### Conditional execution (MSTest 3.10+) + +```csharp +[TestMethod] +[OSCondition(OperatingSystems.Windows)] +public void WindowsRegistry_ReadsValue() { } + +[TestMethod] +[CICondition(ConditionMode.Exclude)] +public void LocalOnly_InteractiveTest() { } +``` + +#### Parallelization + +```csharp +[assembly: Parallelize(Workers = 4, Scope = ExecutionScope.MethodLevel)] + +[TestClass] +[DoNotParallelize] // Opt out specific classes +public sealed class DatabaseIntegrationTests { } +``` + +## Validation + +- [ ] Test classes are `sealed` +- [ ] Test methods follow `MethodName_Scenario_ExpectedBehavior` naming +- [ ] `Assert.ThrowsExactly` used instead of `[ExpectedException]` +- [ ] Specialized assertions used instead of `Assert.IsTrue` (e.g., `Assert.IsNotNull`, `Assert.AreEqual`) +- [ ] DynamicData uses ValueTuple return types instead of `IEnumerable` +- [ ] Sync initialization done in the constructor, not `[TestInitialize]` +- [ ] `TestContext.CancellationToken` passed to async calls in tests with `[Timeout]` +- [ ] Project builds with zero errors and all tests pass + +## Common Pitfalls + +| Pitfall | Solution | +|---------|----------| +| `Assert.AreEqual(actual, expected)` -- swapped arguments | Always put expected first: `Assert.AreEqual(expected, actual)`. Failure messages show "Expected: X, Actual: Y" so wrong order makes messages confusing | +| `[ExpectedException]` -- obsolete, cannot assert message | Use `Assert.Throws` or `Assert.ThrowsExactly` | +| `items.Single()` -- unclear exception on failure | Use `Assert.ContainsSingle(items)` for better failure messages | +| Hard cast `(MyType)result` -- unclear exception | Use `Assert.IsInstanceOfType(result)` | +| `IEnumerable` for DynamicData | Use `IEnumerable<(T1, T2, ...)>` ValueTuples for type safety | +| Sync setup in `[TestInitialize]` | Initialize in the constructor instead -- enables `readonly` fields and satisfies nullability analyzers | +| `CancellationToken.None` in async tests | Use `TestContext.CancellationToken` for cooperative timeout | +| `public TestContext? TestContext { get; set; }` | Drop the `?` -- MSTest suppresses CS8618 for this property | +| `TestContext TestContext { get; set; } = null!` | Remove `= null!` -- unnecessary, MSTest handles assignment | +| Non-sealed test classes | Seal test classes by default for performance | From c9ed3fe8a230d26b648de99c2c839f456585eb8c Mon Sep 17 00:00:00 2001 From: Kazuki Ota <117221407+kaota_microsoft@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:20:33 +0900 Subject: [PATCH 3/3] Add 8 more recommended dotnet/skills (test deps, AOT, MTP, build/perf) Follow-up to the initial skill install. Adds Tier A+B skills relevant to ReactiveProperty: - dotnet-test/dotnet-test-frameworks and dotnet-test/filter-syntax: reference data that the already-installed test-anti-patterns and run-tests skills load - dotnet-upgrade/dotnet-aot-compat: trim/AOT compatibility for the library (relevant for Blazor wasm trimming and AOT consumers) - dotnet-test/migrate-vstest-to-mtp and migrate-mstest-v3-to-v4: test runner and MSTest version modernization - dotnet-diag/analyzing-dotnet-performance: perf anti-pattern auditing to complement microbenchmarking - dotnet-msbuild/binlog-failure-analysis and incremental-build: multi-target build failure diagnosis and incremental-build correctness All pinned to dotnet/skills@v1.0.0 at project scope under .agents/skills. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../analyzing-dotnet-performance/SKILL.md | 196 ++++++++ .../references/async-patterns.md | 112 +++++ .../references/collections-and-linq.md | 207 ++++++++ .../references/critical-patterns.md | 280 +++++++++++ .../references/io-and-serialization.md | 124 +++++ .../references/memory-and-strings.md | 205 ++++++++ .../references/regex-patterns.md | 95 ++++ .../references/structural-patterns.md | 38 ++ .../skills/binlog-failure-analysis/SKILL.md | 114 +++++ .agents/skills/dotnet-aot-compat/SKILL.md | 266 ++++++++++ .../dotnet-aot-compat/references/polyfills.md | 43 ++ .../skills/dotnet-test-frameworks/SKILL.md | 122 +++++ .agents/skills/filter-syntax/SKILL.md | 177 +++++++ .agents/skills/incremental-build/SKILL.md | 223 +++++++++ .../skills/migrate-mstest-v3-to-v4/SKILL.md | 470 ++++++++++++++++++ .agents/skills/migrate-vstest-to-mtp/SKILL.md | 343 +++++++++++++ 16 files changed, 3015 insertions(+) create mode 100644 .agents/skills/analyzing-dotnet-performance/SKILL.md create mode 100644 .agents/skills/analyzing-dotnet-performance/references/async-patterns.md create mode 100644 .agents/skills/analyzing-dotnet-performance/references/collections-and-linq.md create mode 100644 .agents/skills/analyzing-dotnet-performance/references/critical-patterns.md create mode 100644 .agents/skills/analyzing-dotnet-performance/references/io-and-serialization.md create mode 100644 .agents/skills/analyzing-dotnet-performance/references/memory-and-strings.md create mode 100644 .agents/skills/analyzing-dotnet-performance/references/regex-patterns.md create mode 100644 .agents/skills/analyzing-dotnet-performance/references/structural-patterns.md create mode 100644 .agents/skills/binlog-failure-analysis/SKILL.md create mode 100644 .agents/skills/dotnet-aot-compat/SKILL.md create mode 100644 .agents/skills/dotnet-aot-compat/references/polyfills.md create mode 100644 .agents/skills/dotnet-test-frameworks/SKILL.md create mode 100644 .agents/skills/filter-syntax/SKILL.md create mode 100644 .agents/skills/incremental-build/SKILL.md create mode 100644 .agents/skills/migrate-mstest-v3-to-v4/SKILL.md create mode 100644 .agents/skills/migrate-vstest-to-mtp/SKILL.md diff --git a/.agents/skills/analyzing-dotnet-performance/SKILL.md b/.agents/skills/analyzing-dotnet-performance/SKILL.md new file mode 100644 index 00000000..f2a4a7cc --- /dev/null +++ b/.agents/skills/analyzing-dotnet-performance/SKILL.md @@ -0,0 +1,196 @@ +--- +description: Scans .NET code for ~50 performance anti-patterns across async, memory, strings, collections, LINQ, regex, serialization, and I/O with tiered severity classification. Use when analyzing .NET code for optimization opportunities, reviewing hot paths, or auditing allocation-heavy patterns. +metadata: + github-path: plugins/dotnet-diag/skills/analyzing-dotnet-performance + github-pinned: v1.0.0 + github-ref: refs/tags/v1.0.0 + github-repo: https://github.com/dotnet/skills + github-tree-sha: 99d4c31a643f2211a09cb860977861cce4b29933 +name: analyzing-dotnet-performance +--- +# .NET Performance Patterns + +Scan C#/.NET code for performance anti-patterns and produce prioritized findings with concrete fixes. Patterns sourced from the official .NET performance blog series, distilled to customer-actionable guidance. + +## When to Use + +- Reviewing C#/.NET code for performance optimization opportunities +- Auditing hot paths for allocation-heavy or inefficient patterns +- Systematic scan of a codebase for known anti-patterns before release +- Second-opinion analysis after manual performance review + +## When Not to Use + +- **Algorithmic complexity analysis** — this skill targets API usage patterns, not algorithm design +- **Code not on a hot path** with no performance requirements — avoid premature optimization + +## Inputs + +| Input | Required | Description | +|-------|----------|-------------| +| Source code | Yes | C# files, code blocks, or repository paths to scan | +| Hot-path context | Recommended | Which code paths are performance-critical | +| Target framework | Recommended | .NET version (some patterns require .NET 8+) | +| Scan depth | Optional | `critical-only`, `standard` (default), or `comprehensive` | + +## Workflow + +### Step 1: Load Reference Files (if available) + +Try to load `references/critical-patterns.md` and the topic-specific reference files listed below. These contain detailed detection recipes and grep commands. + +**If reference files are not found** (e.g., in a sandboxed environment or when the skill is embedded as instructions only), **skip file loading and proceed directly to Step 3** using the scan recipes listed inline below. Do not spend time searching the filesystem for reference files — if they aren't at the expected relative path, they aren't available. + +### Step 2: Detect Code Signals and Select Topic Recipes + +Scan the code for signals that indicate which pattern categories to check. If reference files were loaded, use their `## Detection` sections. Otherwise, use the inline recipes in Step 3. + +| Signal in Code | Topic | +|----------------|-------| +| `async`, `await`, `Task`, `ValueTask` | Async patterns | +| `Span<`, `Memory<`, `stackalloc`, `ArrayPool`, `string.Substring`, `.Replace(`, `.ToLower()`, `+=` in loops, `params ` | Memory & strings | +| `Regex`, `[GeneratedRegex]`, `Regex.Match`, `RegexOptions.Compiled` | Regex patterns | +| `Dictionary<`, `List<`, `.ToList()`, `.Where(`, `.Select(`, LINQ methods, `static readonly Dictionary<` | Collections & LINQ | +| `JsonSerializer`, `HttpClient`, `Stream`, `FileStream` | I/O & serialization | + +Always check structural patterns (unsealed classes) regardless of signals. + +**Scan depth controls scope:** +- `critical-only`: Only critical patterns (deadlocks, >10x regressions) +- `standard` (default): Critical + detected topic patterns +- `comprehensive`: All pattern categories + +### Step 3: Scan and Report + +**For files under 500 lines, read the entire file first** — you'll spot most patterns faster than running individual grep recipes. Use grep to confirm counts and catch patterns you might miss visually. + +For each relevant pattern category, run the detection recipes below. Report exact counts, not estimates. + +**Core scan recipes** (run these when reference files aren't available): +``` +# Strings & memory +grep -n '\.IndexOf(\"' FILE # Missing StringComparison +grep -n '\.Substring(' FILE # Substring allocations +grep -En '\.(StartsWith|EndsWith|Contains)\s*\(' FILE # Missing StringComparison +grep -n '\.ToLower()\|\.ToUpper()' FILE # Culture-sensitive + allocation +grep -n '\.Replace(' FILE # Chained Replace allocations +grep -n 'params ' FILE # params array allocation + +# Collections & LINQ +grep -n '\.Select\|\.Where\|\.OrderBy\|\.GroupBy' FILE # LINQ on hot path +grep -n '\.All\|\.Any' FILE # LINQ on string/char +grep -n 'new Dictionary<\|new List<' FILE # Per-call allocation +grep -n 'static readonly Dictionary<' FILE # FrozenDictionary candidate + +# Regex +grep -n 'RegexOptions.Compiled' FILE # Compiled regex budget +grep -n 'new Regex(' FILE # Per-call regex +grep -n 'GeneratedRegex' FILE # Positive: source-gen regex + +# Structural +grep -n 'public class \|internal class ' FILE # Unsealed classes +grep -n 'sealed class' FILE # Already sealed +grep -n ': IEquatable' FILE # Positive: struct equality +``` + +**Rules:** +- Run every relevant recipe for the detected pattern categories +- **Emit a scan execution checklist** before classifying findings — list each recipe and the hit count +- A result of **0 hits** is valid and valuable (confirms good practice) +- If reference files were loaded, also run their `## Detection` recipes + +**Verify-the-Inverse Rule:** For absence patterns, always count both sides and report the ratio (e.g., "N of M classes are sealed"). The ratio determines severity — 0/185 is systematic, 12/15 is a consistency fix. + +### Step 3b: Cross-File Consistency Check + +If an optimized pattern is found in one file, check whether sibling files (same directory, same interface, same base class) use the un-optimized equivalent. Flag as 🟡 Moderate with the optimized file as evidence. + +### Step 3c: Compound Allocation Check + +After running scan recipes, look for these multi-allocation patterns that single-line recipes miss: + +1. **Branched `.Replace()` chains:** Methods that call `.Replace()` across multiple `if/else` branches — report total allocation count across all branches, not just per-line. +2. **Cross-method chaining:** When a public method delegates to another method that itself allocates intermediates (e.g., A calls B which does 3 regex replaces, then A calls C), report the total chain cost as one finding. +3. **Compound `+=` with embedded allocating calls:** Lines like `result += $"...{Foo().ToLower()}"` are 2+ allocations (interpolation + ToLower + concatenation) — flag the compound cost, not just the `.ToLower()`. +4. **`string.Format` specificity:** Distinguish resource-loaded format strings (not fixable) from compile-time literal format strings (fixable with interpolation). Enumerate the actionable sites. + +### Step 4: Classify and Prioritize Findings + +Assign each finding a severity: + +| Severity | Criteria | Action | +|----------|----------|--------| +| 🔴 **Critical** | Deadlocks, crashes, security vulnerabilities, >10x regression | Must fix | +| 🟡 **Moderate** | 2-10x improvement opportunity, best practice for hot paths | Should fix on hot paths | +| ℹ️ **Info** | Pattern applies but code may not be on a hot path | Consider if profiling shows impact | + +**Prioritization rules:** +1. If the user identified hot-path code, elevate all findings in that code to their maximum severity +2. If hot-path context is unknown, report 🔴 Critical findings unconditionally; report 🟡 Moderate findings with a note: _"Impactful if this code is on a hot path"_ +3. Never suggest micro-optimizations on code that is clearly not performance-sensitive + +**Scale-based severity escalation:** +When the same pattern appears across many instances, escalate severity: +- 1-10 instances of the same anti-pattern → report at the pattern's base severity +- 11-50 instances → escalate ℹ️ Info patterns to 🟡 Moderate +- 50+ instances → escalate to 🟡 Moderate with elevated priority; flag as a codebase-wide systematic issue + +Always report exact counts (from scan recipes), not estimates or agent summaries. + +### Step 5: Generate Findings + +**Keep findings compact.** Each finding is one short block — not an essay. Group by severity (🔴 → 🟡 → ℹ️), not by file. + +Format per finding: + +``` +#### ID. Title (N instances) +**Impact:** one-line impact statement +**Files:** file1.cs:L1, file2.cs:L2, ... (list locations, don't build tables) +**Fix:** one-line description of the change (e.g., "Add `StringComparison.Ordinal` parameter") +**Caveat:** only if non-obvious (version requirement, correctness risk) +``` + +**Rules for compact output:** +- **No ❌/✅ code blocks** for trivial fixes (adding a keyword, parameter, or type change). A one-line fix description suffices. +- **Only include code blocks** for non-obvious transformations (e.g., replacing a LINQ chain with a foreach loop, or hoisting a closure). +- **File locations as inline comma-separated list**, not a table. Use `File.cs:L42` format. +- **No explanatory prose** beyond the Impact line — the severity icon already conveys urgency. +- **Merge related findings** that share the same fix (e.g., all `.ToLower()` calls go in one finding, not split by file). +- **Positive findings** in a bullet list, not a table. One line per pattern: `✅ Pattern — evidence`. + +End with a summary table and disclaimer: + +```markdown +| Severity | Count | Top Issue | +|----------|-------|-----------| +| 🔴 Critical | N | ... | +| 🟡 Moderate | N | ... | +| ℹ️ Info | N | ... | + +> ⚠️ **Disclaimer:** These results are generated by an AI assistant and are non-deterministic. Findings may include false positives, miss real issues, or suggest changes that are incorrect for your specific context. Always verify recommendations with benchmarks and human review before applying changes to production code. +``` + +## Validation + +Before delivering results, verify: + +- [ ] All critical patterns were checked (from reference files or inline recipes) +- [ ] Topic-specific recipes run only when matching signals detected +- [ ] Each finding includes a concrete code fix +- [ ] Scan execution checklist is complete (all recipes run) +- [ ] Summary table included at end + +## Common Pitfalls + +| Pitfall | Correct Approach | +|---------|-----------------| +| Flagging every `Dictionary` as needing `FrozenDictionary` | Only flag if the dictionary is never mutated after construction | +| Suggesting `Span` in async methods | Use `Memory` in async code; `Span` only in sync hot paths | +| Reporting LINQ outside hot paths | Only flag LINQ in identified hot paths or tight loops; LINQ is acceptable in code that runs infrequently. Since .NET 7, LINQ Min/Max/Sum/Average are vectorized — blanket bans on LINQ are misguided | +| Suggesting `ConfigureAwait(false)` in app code | Only applicable in library code; not primarily a performance concern | +| Recommending `ValueTask` everywhere | Only for hot paths with frequent synchronous completion | +| Flagging `new HttpClient()` in DI services | Check if `IHttpClientFactory` is already in use | +| Suggesting `[GeneratedRegex]` for dynamic patterns | Only flag when the pattern string is a compile-time literal | +| Suggesting `CollectionsMarshal.AsSpan` broadly | Only for ultra-hot paths with benchmarked evidence; adds complexity and fragility | +| Suggesting `unsafe` code for micro-optimizations | Avoid `unsafe` except where absolutely necessary — do not recommend it for micro-optimizations that don't matter. Safe alternatives like `Span`, `stackalloc` in safe context, and `ArrayPool` cover the vast majority of performance needs | diff --git a/.agents/skills/analyzing-dotnet-performance/references/async-patterns.md b/.agents/skills/analyzing-dotnet-performance/references/async-patterns.md new file mode 100644 index 00000000..88cb211f --- /dev/null +++ b/.agents/skills/analyzing-dotnet-performance/references/async-patterns.md @@ -0,0 +1,112 @@ +# Async & Concurrency Patterns + +### Don't Expose Async Wrappers for Sync Methods +🟡 **AVOID** wrapping sync methods with `Task.Run` in libraries | .NET Core+ + +❌ +```csharp +public Task ComputeHashAsync(byte[] data) => + Task.Run(() => ComputeHash(data)); +``` +✅ +```csharp +public int ComputeHash(byte[] data) { /* CPU-bound work */ } +// Consumer decides: var hash = await Task.Run(() => lib.ComputeHash(data)); +``` + +**Impact: Eliminates unnecessary thread pool queue/dequeue overhead per call.** + +### Don't Expose Sync Wrappers for Async Methods +🟡 **AVOID** creating sync wrappers that block on async implementations | .NET Core+ + +❌ +```csharp +public string GetData() => GetDataAsync().Result; +``` +✅ +```csharp +public async Task GetDataAsync() { /* ... */ } +``` + +**Impact: Prevents deadlocks and thread pool starvation from hidden sync-over-async blocking.** + +### Use ValueTask for Hot Paths with Frequent Sync Completion +🟡 **DO** use `ValueTask` on hot paths where sync completion is common | .NET Core 2.1+ + +❌ +```csharp +public async Task ReadAsync(Memory buffer) +{ + if (_bufferedCount > 0) + return ReadFromBuffer(buffer.Span); + return await ReadAsyncCore(buffer); +} +``` +✅ +```csharp +public ValueTask ReadAsync(Memory buffer) +{ + if (_bufferedCount > 0) + return new ValueTask(ReadFromBuffer(buffer.Span)); + return new ValueTask(ReadAsyncCore(buffer)); +} +``` + +**Impact: Eliminates Task\ allocation on synchronous completion — the struct stores results inline.** + +### Use Channels for Producer/Consumer +🟡 **DO** use `System.Threading.Channels` for producer-consumer patterns | .NET Core 3.0+ + +❌ +```csharp +var queue = new BlockingCollection(); +var item = queue.Take(); +``` +✅ +```csharp +var channel = Channel.CreateUnbounded(); + +// Producer +await channel.Writer.WriteAsync(item); + +// Consumer +await foreach (var item in channel.Reader.ReadAllAsync()) + Process(item); +``` + +**Impact: ~25% faster, ~95% fewer GC collections vs manual approaches.** + +### Avoid False Sharing with Thread-Local State +🟡 **AVOID** adjacent mutable fields written by different threads | .NET 7+ + +❌ +```csharp +class SharedCounters +{ + public long Counter1; + public long Counter2; +} +``` +✅ +```csharp +[StructLayout(LayoutKind.Explicit, Size = 128)] +struct PaddedCounter +{ + [FieldOffset(0)] public long Value; +} +``` + +**Impact: Eliminates cross-core cache invalidation — can improve multi-threaded throughput by 10x+.** + +## Detection + +Scan recipes for async anti-patterns. Run these and report exact counts. + +```bash +# async void methods (correctness issue — crashes on exception) +grep -rn --include='*.cs' 'async void' --exclude-dir=bin --exclude-dir=obj . | grep -v 'event' | wc -l +``` + +### Patterns Requiring Manual Review + +- **Sync-over-async** (`.Result`, `.Wait()`): `.Result` matches any property named Result — needs type context to confirm it's `Task.Result` diff --git a/.agents/skills/analyzing-dotnet-performance/references/collections-and-linq.md b/.agents/skills/analyzing-dotnet-performance/references/collections-and-linq.md new file mode 100644 index 00000000..575038ee --- /dev/null +++ b/.agents/skills/analyzing-dotnet-performance/references/collections-and-linq.md @@ -0,0 +1,207 @@ +# Collections & LINQ Patterns + +### Use FrozenDictionary/FrozenSet for Read-Heavy Lookup Tables +🟡 **DO** use `FrozenDictionary`/`FrozenSet` for collections created once and read many times | .NET 8+ + +❌ +```csharp +private static readonly Dictionary s_statusCodes = new() +{ + ["OK"] = 200, ["NotFound"] = 404, ["InternalServerError"] = 500 +}; +``` +✅ +```csharp +private static readonly FrozenDictionary s_statusCodes = + new Dictionary + { + ["OK"] = 200, ["NotFound"] = 404, ["InternalServerError"] = 500 + }.ToFrozenDictionary(); +``` + +**Impact: ~50% faster lookups than Dictionary, ~14x faster than ImmutableDictionary.** + +### Use Dictionary Alternate Lookup for Span-Based Keys +🟡 **DO** use `GetAlternateLookup>()` to avoid string allocation on lookups | .NET 9+ + +❌ +```csharp +string key = headerLine.Substring(0, colonIndex); +if (s_dict.TryGetValue(key, out int value)) { /* ... */ } +``` +✅ +```csharp +var lookup = s_dict.GetAlternateLookup>(); +ReadOnlySpan key = headerLine.AsSpan(0, colonIndex); +if (lookup.TryGetValue(key, out int value)) { } +``` + +**Impact: Avoids string allocation per lookup — especially valuable in parser/protocol hot paths.** + +### Use CollectionsMarshal.GetValueRefOrNullRef for Lookup-and-Update +🟡 **DO** use `CollectionsMarshal.GetValueRefOrAddDefault` for dictionary update patterns | .NET 6+ + +❌ +```csharp +_counts.TryGetValue(key, out int count); +_counts[key] = count + 1; +``` +✅ +```csharp +ref int count = ref CollectionsMarshal.GetValueRefOrAddDefault(_counts, key, out _); +count++; +``` + +**Impact: ~48% faster for lookup-and-update patterns (95µs → 49µs).** + +### Use Collection Expressions [] for Zero-Allocation Span Creation +🟡 **DO** use collection expressions for `Span` targets | C# 12 / .NET 8+ + +❌ +```csharp +int[] values = new int[] { a, b, c, d }; +``` +✅ +```csharp +Span values = [a, b, c, d]; +ReadOnlySpan daysInMonth = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; +``` + +**Impact: Zero heap allocation for span-targeted collection expressions.** + +### Use EnsureCapacity on List/Stack/Queue Before Bulk Adds +🟡 **DO** call `EnsureCapacity` before bulk insertions | .NET 6+ + +❌ +```csharp +var list = new List(); +for (int i = 0; i < 10000; i++) + list.Add(i); +``` +✅ +```csharp +var list = new List(); +list.EnsureCapacity(10000); +for (int i = 0; i < 10000; i++) + list.Add(i); +``` + +**Impact: Reduces reallocations and array copies during bulk operations.** + +### Use TryGetNonEnumeratedCount for Pre-Sizing +🟡 **DO** use `TryGetNonEnumeratedCount` to pre-size destination collections | .NET 6+ + +❌ +```csharp +var results = new List(); +foreach (var item in source) + results.Add(Transform(item)); +``` +✅ +```csharp +var results = source.TryGetNonEnumeratedCount(out int count) + ? new List(count) + : new List(); +foreach (var item in source) + results.Add(Transform(item)); +``` + +**Impact: Avoids O(n) enumeration for counting; eliminates resizing allocations.** + +### Hoist Static Data Out of Method Bodies +🟡 **AVOID** creating collections with static/deterministic data inside method bodies | .NET Core+ + +❌ +```csharp +public string Convert(long number) +{ + var groupsMap = new Dictionary> + { + { 1_000_000_000, n => $"{Convert(n)} billion" }, + { 1_000_000, n => $"{Convert(n)} million" }, + { 1_000, n => $"{Convert(n)} thousand" }, + }; +} +``` +✅ +```csharp +private static readonly FrozenDictionary> s_groupsMap = + new Dictionary> + { + { 1_000_000_000, n => $"{Convert(n)} billion" }, + { 1_000_000, n => $"{Convert(n)} million" }, + { 1_000, n => $"{Convert(n)} thousand" }, + }.ToFrozenDictionary(); + +public string Convert(long number) +{ + // ... use s_groupsMap +} +``` + +**Impact: Eliminates collection + internal storage + closure allocations per call. For a Dictionary with N entries, saves ~N+3 allocations per invocation.** + +### Add Overloads to Avoid params Array Allocation +🟡 **DO** add 1- and 2-argument overloads for methods that accept `params T[]`, or use `params ReadOnlySpan` on .NET 9+ | .NET Core+ + +❌ +```csharp +public static string Transform(this string input, params IStringTransformer[] transformers) => + transformers.Aggregate(input, (current, t) => t.Transform(current)); + +"hello".Transform(To.TitleCase); +``` +✅ +```csharp +// Option A: Explicit overloads for common arities +public static string Transform(this string input, IStringTransformer transformer) => + transformer.Transform(input); + +public static string Transform(this string input, IStringTransformer t1, IStringTransformer t2) => + t2.Transform(t1.Transform(input)); + +public static string Transform(this string input, params IStringTransformer[] transformers) => + transformers.Aggregate(input, (current, t) => t.Transform(current)); + +// Option B (.NET 9+ / C# 13): params ReadOnlySpan — zero heap allocation +public static string Transform(this string input, params ReadOnlySpan transformers) +{ + foreach (var t in transformers) + input = t.Transform(input); + return input; +} +``` + +**Impact: Eliminates one array allocation per call for the common 1- and 2-argument cases. `params ReadOnlySpan` eliminates it for all arities.** + +## Detection + +Scan recipes for collection and LINQ anti-patterns. Run these and report exact counts. + +```bash +# Static Dictionary not using FrozenDictionary (read-only after init) +grep -rn --include='*.cs' 'static readonly Dictionary<' --exclude-dir=bin --exclude-dir=obj . | wc -l + +# Static FrozenDictionary (already optimized — verify the inverse) +grep -rn --include='*.cs' 'static readonly FrozenDictionary<' --exclude-dir=bin --exclude-dir=obj . | wc -l + +# Per-call List allocation (inside method bodies, not static/readonly fields) +grep -rn --include='*.cs' 'new List<' --exclude-dir=bin --exclude-dir=obj . | grep -v 'static\|readonly' | wc -l + +# Per-call Dictionary allocation (inside method bodies, not static/readonly fields) +grep -rn --include='*.cs' 'new Dictionary<' --exclude-dir=bin --exclude-dir=obj . | grep -v 'static\|readonly' | wc -l + +# StringComparer.CurrentCulture usage (almost always wrong in library code — use Ordinal) +grep -rn --include='*.cs' 'StringComparer.CurrentCulture' --exclude-dir=bin --exclude-dir=obj . | wc -l + +# LINQ chains in extension/hot-path files (.Select, .Where, .Cast, .Take, .Aggregate) +grep -rn --include='*.cs' -E '\.(Select|Where|Cast|Take|Aggregate)\(' --exclude-dir=bin --exclude-dir=obj . | wc -l +``` + +For the LINQ chain recipe: any hit in a file whose name ends in `Extensions.cs`, `Formatter.cs`, or implements a method called from a public extension method is a hot-path candidate. Inspect each hit in these files and flag LINQ chains that allocate delegates, enumerators, or intermediate collections on every call. Hits in localization converters or one-time initialization are lower priority. + +### Patterns Requiring Manual Review + +- **ContainsKey + indexer double-lookup**: Requires verifying the same key is used in a subsequent indexer access — multi-line/multi-statement context +- **LINQ on hot paths**: The LINQ chain recipe above catches call sites, but distinguishing hot-path from cold-path requires context. Prioritize hits in `*Extensions.cs` and `*Formatter.cs` files, which are typically called on every user invocation +- **`new Dictionary/List<` in method bodies vs fields**: The grep heuristic (`grep -v 'static\|readonly'`) catches most cases but may include false positives from field initializers without `static`/`readonly` — spot-check flagged lines diff --git a/.agents/skills/analyzing-dotnet-performance/references/critical-patterns.md b/.agents/skills/analyzing-dotnet-performance/references/critical-patterns.md new file mode 100644 index 00000000..63490a86 --- /dev/null +++ b/.agents/skills/analyzing-dotnet-performance/references/critical-patterns.md @@ -0,0 +1,280 @@ +# Critical .NET Performance Anti-Patterns + +17 patterns that cause deadlocks, order-of-magnitude regressions, or excessive allocations. + +## Async / Tasks + +### Never Block on Async (Sync-over-Async) +🔴 **AVOID** | .NET Core+ + +❌ +```csharp +public string GetData() + => GetDataAsync().Result; +``` +✅ +```csharp +public async Task GetDataAsync() + => await GetDataInternalAsync(); +``` +**Impact: Deadlocks or thread pool starvation; wastes threads, destroys scalability.** + +### Never Await a ValueTask Multiple Times +🔴 **AVOID** | .NET Core 2.1+ + +❌ +```csharp +ValueTask vt = SomeMethodAsync(); +int a = await vt; +int b = await vt; +``` +✅ +```csharp +int result = await SomeMethodAsync(); +``` +**Impact: Undefined behavior — silent data corruption or exceptions.** + +## Memory / Allocation + +### Use Span\ / AsSpan Instead of Substring for Slicing +🔴 **DO** | .NET Core 2.1+ + +❌ +```csharp +string sub = input.Substring(5, 10); +``` +✅ +```csharp +ReadOnlySpan sub = input.AsSpan(5, 10); +``` +**Impact: Eliminates per-slice allocations; 2-4x faster via vectorization.** + +### Use ArrayPool\ for Temporary Buffers +🔴 **DO** | .NET Core+ + +❌ +```csharp +byte[] buf = new byte[4096]; +``` +✅ +```csharp +byte[] buf = ArrayPool.Shared.Rent(4096); +Process(buf); +ArrayPool.Shared.Return(buf); +``` +**Impact: Dramatically reduces GC pressure for buffer-heavy workloads.** + +### Avoid stackalloc in Loops +🔴 **AVOID** | .NET 5+ + +❌ +```csharp +for (int i = 0; i < 10_000; i++) + Span buf = stackalloc byte[1024]; +``` +✅ +```csharp +Span buf = stackalloc byte[1024]; +for (int i = 0; i < 10_000; i++) { Process(buf); } +``` +**Impact: StackOverflowException — unrecoverable, no catch possible.** + +### Avoid Boxing Value Types +🔴 **AVOID** | .NET 6+ + +❌ +```csharp +string s = string.Format("{0}.{1}", major, minor); +``` +✅ +```csharp +string s = $"{major}.{minor}"; +``` +**Impact: When replacing `string.Format` with C# 10+ interpolation, typical improvements are ~40% faster with significantly less allocation. Actual gains vary by call site.** + +## Strings + +### Use StringComparison.Ordinal for Non-Linguistic Comparisons +🔴 **DO** | .NET Core+ + +❌ +```csharp +bool found = text.IndexOf("Content-Type") >= 0; +``` +✅ +```csharp +bool found = text.Contains("Content-Type", StringComparison.Ordinal); +``` +**Impact: 2-3x faster; OrdinalIgnoreCase hash codes ~3.3x faster.** + +### Use AsSpan Instead of Substring +🔴 **DO** | .NET Core 2.1+ + +❌ +```csharp +int val = int.Parse(str.Substring(5, 3)); +``` +✅ +```csharp +int val = int.Parse(str.AsSpan(5, 3)); +``` +**Impact: Eliminates one string allocation per parse operation.** + +## Regular Expressions + +### Use Source-Generated Regex [GeneratedRegex] +🔴 **ALWAYS** use `[GeneratedRegex]` for all static regex patterns | .NET 7+ + +❌ +```csharp +private static readonly Regex s_re = + new(@"\w+@\w+\.\w+", RegexOptions.Compiled); +``` +✅ +```csharp +[GeneratedRegex(@"\w+@\w+\.\w+")] +private static partial Regex EmailRegex(); +``` +**Impact: Always beneficial or neutral for static patterns — near-zero startup, better throughput, and required for AOT/trimming scenarios.** + +### Avoid Nested Quantifiers (Catastrophic Backtracking) +🔴 **AVOID** | .NET Core+ + +❌ +```csharp +var r = new Regex(@"^(\w+)+$"); +``` +✅ +```csharp +var r = new Regex(@"^\w+$", RegexOptions.NonBacktracking); +``` +**Impact: Can hang process indefinitely on crafted input.** + +### Use TryGetValue Instead of ContainsKey + Indexer +🔴 **DO** | .NET Core+ + +❌ +```csharp +if (dict.ContainsKey(key)) + Use(dict[key]); +``` +✅ +```csharp +if (dict.TryGetValue(key, out var value)) + Use(value); +``` +**Impact: ~2x faster (50% reduction in lookup time).** + +### Avoid LINQ in Hot Paths +🔴 **AVOID** | .NET Core+ + +❌ +```csharp +bool found = items.Any(x => x.Name == target); +``` +✅ +```csharp +bool found = false; +foreach (var item in items) + if (item.Name == target) { found = true; break; } +``` +**Impact: Eliminates 1-3 allocations per call; measurable in tight loops.** + +### Don't Iterate IEnumerable Multiple Times +🔴 **AVOID** | .NET Core+ + +❌ +```csharp +foreach (Type t in types) { Validate(t); } +_types = types.ToArray(); +``` +✅ +```csharp +Type[] arr = types.ToArray(); +foreach (Type t in arr) { Validate(t); } +_types = arr; +``` +**Impact: Halves enumeration cost; prevents bugs from re-executing deferred queries.** + +## JSON Serialization + +### Use System.Text.Json Source Generator +🔴 **DO** | .NET 6+ + +❌ +```csharp +string json = JsonSerializer.Serialize(post); +``` +✅ +```csharp +[JsonSerializable(typeof(BlogPost))] +internal partial class AppJsonCtx : JsonSerializerContext { } +string json = JsonSerializer.Serialize(post, AppJsonCtx.Default.BlogPost); +``` +**Impact: 37-44% faster; enables trimming and Native AOT.** + +### Cache JsonSerializerOptions +🔴 **DO** | .NET 5+ + +❌ +```csharp +JsonSerializer.Serialize(obj, new JsonSerializerOptions()); +``` +✅ +```csharp +private static readonly JsonSerializerOptions s_opts = new(); +JsonSerializer.Serialize(obj, s_opts); +``` +**Impact: Up to 592x slower without caching (.NET 6); always cache or use defaults.** + +## Networking + +### Reuse HttpClient Instances +🔴 **DO** | .NET Core 2.1+ + +❌ +```csharp +using var client = new HttpClient(); +await client.GetStringAsync(url); +``` +✅ +```csharp +private static readonly HttpClient s_http = new(new SocketsHttpHandler +{ PooledConnectionLifetime = TimeSpan.FromMinutes(5) }); +await s_http.GetStringAsync(url); +``` +**Impact: Prevents socket exhaustion; 6-12x faster concurrent HTTPS.** + +## General + +### Use SearchValues\ for Repeated Set Searches +🔴 **DO** | .NET 8+ + +❌ +```csharp +int pos = text.IndexOfAny("ABCDEF".ToCharArray()); +``` +✅ +```csharp +private static readonly SearchValues s_hex = SearchValues.Create("ABCDEF"); +int pos = text.AsSpan().IndexOfAny(s_hex); +``` +**Impact: 2-10x faster for chars; 10-30x faster for multi-string (.NET 9+).** + +## Detection + +Scan recipes for critical anti-patterns. Run these and report exact counts of issues found in each case. + +```bash +# .IndexOf(string) without StringComparison (culture-aware, 2-3x slower) +grep -rn --include='*.cs' -E '\.IndexOf\("[^"]+"\)' --exclude-dir=bin --exclude-dir=obj . | wc -l + +# .Substring( calls (allocates new string — consider AsSpan) +grep -rn --include='*.cs' '\.Substring(' --exclude-dir=bin --exclude-dir=obj . | wc -l + +# .StartsWith/.EndsWith without StringComparison (culture-aware, 2-3x slower) +grep -rn --include='*.cs' -E '\.(StartsWith|EndsWith)\("[^"]+"\)' --exclude-dir=bin --exclude-dir=obj . | wc -l + +# .Contains(string) without StringComparison — NOTE: will also match collection .Contains() calls; filter to string receivers +grep -rn --include='*.cs' -E '\.Contains\("[^"]+"\)' --exclude-dir=bin --exclude-dir=obj . | wc -l +``` diff --git a/.agents/skills/analyzing-dotnet-performance/references/io-and-serialization.md b/.agents/skills/analyzing-dotnet-performance/references/io-and-serialization.md new file mode 100644 index 00000000..8ff904c5 --- /dev/null +++ b/.agents/skills/analyzing-dotnet-performance/references/io-and-serialization.md @@ -0,0 +1,124 @@ +# I/O, Serialization & General Patterns + +### Use HttpCompletionOption.ResponseHeadersRead for Streaming +🟡 **DO** use `ResponseHeadersRead` when downloading large responses | .NET Core 3.0+ + +❌ +```csharp +var response = await client.GetAsync(uri); +``` +✅ +```csharp +using var response = await client.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead); +using var stream = await response.Content.ReadAsStreamAsync(); +await stream.CopyToAsync(destinationStream); +``` + +**Impact: ~2x faster for large downloads (10MB+), dramatically reduced memory usage.** + +### Use Async FileStream Operations +🟡 **DO** use `FileStream` with `useAsync: true` for scalable file I/O | .NET 6+ + +❌ +```csharp +using var fs = new FileStream(path, FileMode.Open); +``` +✅ +```csharp +await using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, + FileShare.Read, bufferSize: 4096, useAsync: true); + +byte[] buffer = new byte[1024]; +while (await fs.ReadAsync(buffer) != 0) { /* process */ } +``` + +**Impact: Up to 3x faster async reads; allocation reduced from megabytes to hundreds of bytes.** + +### Use Memory\ Overloads for Stream.ReadAsync/WriteAsync +🟡 **DO** use `Memory`-based stream overloads instead of `byte[]` overloads | .NET 5+ + +❌ +```csharp +await stream.ReadAsync(buffer, 0, buffer.Length); +await stream.WriteAsync(buffer, 0, buffer.Length); +``` +✅ +```csharp +await stream.ReadAsync(buffer.AsMemory()); +await stream.WriteAsync(buffer.AsMemory()); +``` + +**Impact: Eliminates ~72 KB allocation per 1,000 read/write pairs on NetworkStream.** + +### Use Span-Based TryFormat for Number Formatting +🟡 **DO** use `TryFormat` to format numbers into `Span` buffers | .NET Core 2.1+ + +❌ +```csharp +string formatted = value.ToString(); +destination.Write(formatted); +``` +✅ +```csharp +Span buffer = stackalloc char[20]; +if (value.TryFormat(buffer, out int charsWritten)) + destination.Write(buffer[..charsWritten]); +``` + +**Impact: Int32.ToString() ~2x faster in .NET Core 2.1, Int32 parsing ~5x faster in .NET Core 3.0.** + +### Use static readonly for Runtime Devirtualization +🟡 **DO** store implementations in `static readonly` fields for JIT devirtualization | .NET Core 3.0+ + +❌ +```csharp +private static Base s_impl = new DerivedImpl(); +s_impl.Process(); +``` +✅ +```csharp +private static readonly Base s_impl = new DerivedImpl(); +s_impl.Process(); + +private static readonly bool s_feature = + Environment.GetEnvironmentVariable("Feature") == "1"; +``` + +**Impact: Virtual call eliminated entirely — can be inlined to zero overhead. Dead code elimination in tier 1.** + +### Avoid Explicit Static Constructors — Use Field Initializers +🟡 **AVOID** explicit `static` constructors when field initializers suffice | .NET Core 3.0+ + +❌ +```csharp +class Foo +{ + static readonly int s_value; + static Foo() { s_value = ComputeValue(); } +} +``` +✅ +```csharp +class Foo +{ + static readonly int s_value = ComputeValue(); +} +``` + +**Impact: Enables better JIT optimization and reduces potential lock overhead on static method access.** + +## Detection + +Scan recipes for I/O and serialization anti-patterns. Run these and report exact counts. + +```bash +# new HttpClient() (socket exhaustion risk) +grep -rn --include='*.cs' 'new HttpClient(' --exclude-dir=bin --exclude-dir=obj . | wc -l + +# new JsonSerializerOptions() not cached (592x slower in .NET 6) +grep -rn --include='*.cs' 'new JsonSerializerOptions' --exclude-dir=bin --exclude-dir=obj . | grep -v 'static\|readonly' | wc -l +``` + +### Patterns Requiring Manual Review + +- **`JsonSerializer.Serialize/Deserialize` without source-gen context**: Can't determine from grep if a context parameter is passed diff --git a/.agents/skills/analyzing-dotnet-performance/references/memory-and-strings.md b/.agents/skills/analyzing-dotnet-performance/references/memory-and-strings.md new file mode 100644 index 00000000..4c6d78be --- /dev/null +++ b/.agents/skills/analyzing-dotnet-performance/references/memory-and-strings.md @@ -0,0 +1,205 @@ +# Memory & String Patterns + +### Use ReadOnlySpan\ for Constant Byte Data +🟡 **DO** assign constant byte arrays to `ReadOnlySpan` | .NET 5+ + +❌ +```csharp +byte[] data = new byte[] { 0x48, 0x65, 0x6C, 0x6C, 0x6F }; +``` +✅ +```csharp +ReadOnlySpan data = [0x48, 0x65, 0x6C, 0x6C, 0x6F]; +ReadOnlySpan primes = [2, 3, 5, 7, 11, 13]; +``` + +**Impact: ~100x faster access than static byte[] field, zero allocation.** + +### Use stackalloc for Small Temporary Buffers +🟡 **DO** use `stackalloc` for small, fixed-size temporary buffers | .NET Core+ + +❌ +```csharp +char[] buffer = new char[64]; +guid.TryFormat(buffer, out int written); +``` +✅ +```csharp +Span buffer = stackalloc char[64]; +guid.TryFormat(buffer, out int written); +``` + +**Impact: Zero heap allocation, no GC pressure, instant alloc/dealloc.** + +### Use Span.TryWrite for Allocation-Free Interpolation +🟡 **DO** use `MemoryExtensions.TryWrite` to format into `Span` buffers | .NET 6+ + +❌ +```csharp +string formatted = $"Date: {dt:R}"; +destination.Write(formatted); +``` +✅ +```csharp +Span buffer = stackalloc char[64]; +buffer.TryWrite($"Date: {dt:R}", out int charsWritten); +``` + +**Impact: Zero heap allocation for formatting operations.** + +### Use Span.Split() for Zero-Allocation Splitting +🟡 **DO** use `MemoryExtensions.Split` for allocation-free string splitting | .NET 9+ + +❌ +```csharp +string[] parts = input.Split(','); +``` +✅ +```csharp +foreach (Range range in input.AsSpan().Split(',')) +{ + ReadOnlySpan segment = input.AsSpan(range); +} +``` + +**Impact: 208 bytes → 0 bytes per split, 2x faster.** + +### Use UTF8 String Literals (u8 suffix) +🟡 **DO** use the `u8` suffix for compile-time UTF8 `ReadOnlySpan` | .NET 7+ + +❌ +```csharp +byte[] header = Encoding.UTF8.GetBytes("Content-Type"); +``` +✅ +```csharp +ReadOnlySpan header = "Content-Type"u8; +``` + +**Impact: 17ns → 0.006ns — eliminates runtime transcoding entirely.** + +### Use ReadOnlySpan\ Pattern Matching with switch +🟡 **DO** use `switch` on `ReadOnlySpan` for allocation-free string matching | C# 11+ + +❌ +```csharp +switch (attr.Value.Trim()) { case "preserve": /* ... */ break; } +``` +✅ +```csharp +switch (attr.Value.AsSpan().Trim()) +{ + case "preserve": return Preserve; + case "default": return Default; +} +``` + +**Impact: Eliminates string allocation from Trim() in switch-based dispatch.** + +### Use params ReadOnlySpan\ to Eliminate Array Allocations +🟡 **DO** add `params ReadOnlySpan` overloads to library methods | C# 13 / .NET 9+ + +❌ +```csharp +public static void Log(params string[] messages) { /* ... */ } +Log("Starting", "Processing", "Done"); +``` +✅ +```csharp +public static void Log(params ReadOnlySpan messages) { /* ... */ } +Log("Starting", "Processing", "Done"); +``` + +**Impact: Eliminates params array allocation. E.g., Path.Join with 5+ segments saves 64 bytes per call.** + +### Avoid Chained String-Returning Operations +🟡 **AVOID** chains of 3+ string-returning method calls that each allocate intermediates | .NET Core+ + +**Pattern 1: Chained .Replace() calls** + +❌ +```csharp +string result = input.Replace("a", "b").Replace("c", "d").Replace("e", "f"); +``` +✅ +```csharp +var sb = new StringBuilder(input.Length); +// single pass replacing all patterns +``` + +**Pattern 2: Chained Regex.Replace() calls** + +❌ +```csharp +public static string Underscore(this string input) => + Regex3.Replace(Regex2.Replace(Regex1.Replace(input, "$1_$2"), "$1_$2"), "_").ToLower(); +``` +✅ +```csharp +return string.Create(totalLength, state, (span, s) => { /* write directly */ }); +``` + +**Pattern 3: += string concatenation in loops** + +❌ +```csharp +string result = ""; +foreach (var part in parts) + result += separator + part; +``` +✅ +```csharp +var sb = new StringBuilder(); +foreach (var part in parts) + sb.Append(separator).Append(part); +return sb.ToString(); +``` + +**Impact: Eliminates N-1 intermediate string allocations per chain. For `+=` in loops, eliminates O(n²) total allocation.** + +### Cache char.ToString() for Known Character Sets +🟡 **DO** cache `char.ToString()` results when the set of characters is small and known | .NET Core+ + +❌ +```csharp +return symbol.ToString(); + +foreach (var prefix in UnitPrefixes) + input = input.Replace(prefix.Value.Name, prefix.Key.ToString()); +``` +✅ +```csharp +private static readonly FrozenDictionary s_charStrings = + new Dictionary + { + ['k'] = "k", ['M'] = "M", ['G'] = "G", + }.ToFrozenDictionary(); + +return s_charStrings[symbol]; +``` + +**Impact: Eliminates one string allocation per char.ToString() call. Significant when called in loops or on hot paths.** + +## Detection + +Scan recipes for memory and string anti-patterns. Run these and report exact counts. + +```bash +# .ToLower()/.ToUpper() without culture parameter (allocates + culture-sensitive) +grep -rn --include='*.cs' -E '\.(ToLower|ToUpper)\(\)' --exclude-dir=bin --exclude-dir=obj . | wc -l + +# Chained .Replace( calls (3+ on one line — intermediate string allocations) +grep -rn --include='*.cs' '\.Replace(.*\.Replace(.*\.Replace(' --exclude-dir=bin --exclude-dir=obj . | wc -l + +# params in method signatures (array allocation per call) +grep -rn --include='*.cs' 'params ' --exclude-dir=bin --exclude-dir=obj . | wc -l + +# LINQ on strings — .All/.Any on IEnumerable (replace with foreach loop) +grep -rn --include='*.cs' -E '\.(All|Any)\(char\.' --exclude-dir=bin --exclude-dir=obj . | wc -l +``` + +### Patterns Requiring Manual Review + +- **Boxing via string.Format**: Can't determine argument types from grep — needs type analysis +- **`+=` string concatenation in loops**: `+=` matches all types (int, list, event, string) — needs type context to confirm string +- **`char.ToString()`**: Requires knowing the variable type is `char` — not reliably greppable diff --git a/.agents/skills/analyzing-dotnet-performance/references/regex-patterns.md b/.agents/skills/analyzing-dotnet-performance/references/regex-patterns.md new file mode 100644 index 00000000..940a1b66 --- /dev/null +++ b/.agents/skills/analyzing-dotnet-performance/references/regex-patterns.md @@ -0,0 +1,95 @@ +# Regex Patterns + +### Choose the Right Regex Engine Mode +🟡 **DO** use `[GeneratedRegex]` for all static regex patterns, but never remove `NonBacktracking` if present | .NET 7+ + +❌ +```csharp +var r = new Regex(dynamicPattern, RegexOptions.Compiled); +``` +✅ +```csharp +[GeneratedRegex("pattern")] +private static partial Regex MyRegex(); + +var safe = new Regex(untrustedPattern, RegexOptions.NonBacktracking); + +var oneOff = new Regex("pattern"); +``` + +**Impact: Source generator is always beneficial for static patterns. NonBacktracking prevents O(2^N) worst case — never remove it if present.** + +### Use IsMatch When You Only Need a Boolean Result +🟡 **DO** use `IsMatch` instead of `Match(...).Success` | .NET 7+ + +❌ +```csharp +bool found = Regex.Match(input, pattern).Success; +``` +✅ +```csharp +bool found = Regex.IsMatch(input, pattern); +``` + +**Impact: Avoids Match object allocation; with NonBacktracking, ~3x faster by skipping capture computation.** + +### Use Regex.Count/EnumerateMatches Instead of Matches +🟡 **DO** use `Count()` and `EnumerateMatches()` for allocation-free match processing | .NET 7+ + +❌ +```csharp +int count = 0; +Match m = regex.Match(text); +while (m.Success) { count++; m = m.NextMatch(); } +``` +✅ +```csharp +int count = regex.Count(text); + +foreach (ValueMatch m in Regex.EnumerateMatches(text, @"\b\w+\b")) +{ + ReadOnlySpan word = text.AsSpan(m.Index, m.Length); +} +``` + +**Impact: ~3x faster than Match/NextMatch with NonBacktracking. Zero allocations for both Count and EnumerateMatches.** + +### Use Span-Based Regex APIs for Allocation-Free Matching +🟡 **DO** use `ReadOnlySpan` overloads for regex matching on spans | .NET 7+ + +❌ +```csharp +string sub = largeBuffer.Substring(start, length); +bool found = Regex.IsMatch(sub, pattern); +``` +✅ +```csharp +ReadOnlySpan text = largeBuffer.AsSpan(start, length); +foreach (ValueMatch m in Regex.EnumerateMatches(text, @"\b\w+\b")) +{ + ReadOnlySpan word = text.Slice(m.Index, m.Length); +} +``` + +**Impact: Eliminates string allocations when working with spans — particularly valuable in high-throughput parsing pipelines.** + +## Detection + +Scan recipes for regex anti-patterns. Run these and report exact counts. + +```bash +# Compiled regex count (startup cost budget — compare ratio to GeneratedRegex) +grep -rn --include='*.cs' 'RegexOptions.Compiled' --exclude-dir=bin --exclude-dir=obj . | wc -l + +# GeneratedRegex count (already optimized — verify the inverse) +grep -rn --include='*.cs' 'GeneratedRegex' --exclude-dir=bin --exclude-dir=obj . | wc -l + +# Uncached new Regex() calls (construction cost per call) +grep -rn --include='*.cs' 'new Regex(' --exclude-dir=bin --exclude-dir=obj . | wc -l +``` + +When `RegexOptions.Compiled` appears inside a class constructor or field initializer of an instantiated class (not a static singleton), count how many instances of that class are created at startup to determine total compiled regex budget. For example, if a `Rule` class compiles a regex in its constructor and 122 rules are registered, that is 122 compiled regexes at startup. + +### Patterns Requiring Manual Review + +- **`new Regex(` uncached**: Field assignment may span multiple lines — grep on one line is unreliable. Verify that matched instances are stored in `static readonly` fields or `[GeneratedRegex]`. diff --git a/.agents/skills/analyzing-dotnet-performance/references/structural-patterns.md b/.agents/skills/analyzing-dotnet-performance/references/structural-patterns.md new file mode 100644 index 00000000..9cf41b28 --- /dev/null +++ b/.agents/skills/analyzing-dotnet-performance/references/structural-patterns.md @@ -0,0 +1,38 @@ +# Structural Patterns + +Patterns detected by the **absence** of a keyword or interface. These require codebase-wide counting scans, not single-file matching. + +### Seal Classes for Devirtualization +🟡 **DO** seal all leaf classes (those not subclassed) | .NET Core 3.0+ + +Sealing lets the JIT devirtualize/inline virtual calls and use pointer comparison for type checks. Every non-abstract, non-static class that is not subclassed should be sealed. + +**Detection:** This is an absence pattern — scan for classes that are NOT sealed. + +```bash +# Count unsealed (non-abstract, non-static) classes +grep -rn --include='*.cs' -E '^\s*((public|internal|private|protected|file)\s+)?(partial\s+)?class ' --exclude-dir=bin --exclude-dir=obj . | grep -v 'sealed' | grep -v 'abstract' | grep -v 'static' | wc -l + +# Count already-sealed classes (verify the inverse) +grep -rn --include='*.cs' 'sealed class' --exclude-dir=bin --exclude-dir=obj . | wc -l +``` + +**Exclusions:** Do not seal classes that are subclassed elsewhere in the codebase. Identifying base classes requires manual review — grep for `: ClassName` patterns and cross-reference, but expect false positives from interface implementations and generic constraints. + +❌ +```csharp +internal class MyHandler : Base +{ public override int Run() => 42; } +``` +✅ +```csharp +internal sealed class MyHandler : Base +{ public override int Run() => 42; } +``` + +**Impact: Virtual calls up to 500x faster; type checks ~25x faster. Severity scales with count.** + +**Scale-based severity:** +- 1-10 unsealed leaf classes → ℹ️ Info +- 11-50 unsealed leaf classes → 🟡 Moderate +- 50+ unsealed leaf classes → 🟡 Moderate (elevated priority) diff --git a/.agents/skills/binlog-failure-analysis/SKILL.md b/.agents/skills/binlog-failure-analysis/SKILL.md new file mode 100644 index 00000000..aaa9e883 --- /dev/null +++ b/.agents/skills/binlog-failure-analysis/SKILL.md @@ -0,0 +1,114 @@ +--- +description: 'Analyze MSBuild binary logs to diagnose build failures by replaying binlogs to searchable text logs. Only activate in MSBuild/.NET build context. USE FOR: build errors that are unclear from console output, diagnosing cascading failures across multi-project builds, tracing MSBuild target execution order, investigating common errors like CS0246 (type not found), MSB4019 (imported project not found), NU1605 (package downgrade), MSB3277 (version conflicts), and ResolveProjectReferences failures. Requires an existing .binlog file. DO NOT USE FOR: generating binlogs (use binlog-generation), build performance analysis (use build-perf-diagnostics), non-MSBuild build systems. INVOKES: dotnet msbuild binlog replay, grep, cat, head, tail for log analysis.' +metadata: + github-path: plugins/dotnet-msbuild/skills/binlog-failure-analysis + github-pinned: v1.0.0 + github-ref: refs/tags/v1.0.0 + github-repo: https://github.com/dotnet/skills + github-tree-sha: dea43446699a1f7263359a184f0c8d7d2655b0d4 +name: binlog-failure-analysis +--- +# Analyzing MSBuild Failures with Binary Logs + +Use MSBuild's built-in **binlog replay** to convert binary logs into searchable text logs, then analyze with standard tools (`grep`, `cat`, `head`, `tail`, `find`). + +## Build Error Investigation (Primary Workflow) + +### Step 1: Replay the binlog to text logs + +Replay produces multiple focused log files in one pass: + +```bash +dotnet msbuild build.binlog -noconlog \ + -fl -flp:v=diag;logfile=full.log;performancesummary \ + -fl1 -flp1:errorsonly;logfile=errors.log \ + -fl2 -flp2:warningsonly;logfile=warnings.log +``` + +> **PowerShell note:** Use `-flp:"v=diag;logfile=full.log;performancesummary"` (quoted semicolons). + +### Step 2: Read the errors + +```bash +cat errors.log +``` + +This gives all errors with file paths, line numbers, error codes, and project context. + +### Step 3: Search for context around specific errors + +```bash +# Find all occurrences of a specific error code with surrounding context +grep -n -B2 -A2 "CS0246" full.log + +# Find which projects failed to compile +grep -i "CoreCompile.*FAILED\|Build FAILED\|error MSB" full.log + +# Find project build order and results +grep "done building project\|Building with" full.log | head -50 +``` + +### Step 4: Detect cascading failures + +Projects that never reached `CoreCompile` failed because a dependency failed, not their own code: + +```bash +# List all projects that ran CoreCompile +grep 'Target "CoreCompile"' full.log | grep -oP 'project "[^"]*"' + +# Compare against projects that had errors to identify cascading failures +grep "project.*FAILED" full.log +``` + +### Step 5: Examine project files for root causes + +```bash +# Read the .csproj of the failing project +cat path/to/Services/Services.csproj + +# Check PackageReference and ProjectReference entries +grep -n "PackageReference\|ProjectReference" path/to/Services/Services.csproj +``` + +**Write your diagnosis as soon as you have enough information.** Do not over-investigate. + +## Additional Workflows + +### Performance Investigation +```bash +# The PerformanceSummary is at the end of full.log +tail -100 full.log # shows target/task timing summary +grep "Target Performance Summary\|Task Performance Summary" -A 50 full.log +``` + +### Dependency/Evaluation Issues +```bash +# Check evaluation properties +grep -i "OutputPath\|IntermediateOutputPath\|TargetFramework" full.log | head -30 +# Check item groups +grep "PackageReference\|ProjectReference" full.log | head -30 +``` + +## Replay reference + +| Command | Purpose | +|---------|---------| +| `dotnet msbuild X.binlog -noconlog -fl -flp:v=diag;logfile=full.log;performancesummary` | Full diagnostic log with perf summary | +| `dotnet msbuild X.binlog -noconlog -fl -flp:errorsonly;logfile=errors.log` | Errors only | +| `dotnet msbuild X.binlog -noconlog -fl -flp:warningsonly;logfile=warnings.log` | Warnings only | +| `grep -n "PATTERN" full.log` | Search for patterns in the replayed log | +| `dotnet msbuild -pp:preprocessed.xml Proj.csproj` | Preprocess — inline all imports into one file | + +## Generating a binlog (only if none exists) + +```bash +dotnet build /bl:build.binlog +``` + +## Common error patterns + +1. **CS0246 / "type not found"** → Missing PackageReference — check the .csproj +2. **MSB4019 / "imported project not found"** → SDK install or global.json issue +3. **NU1605 / "package downgrade"** → Version conflict in package graph +4. **MSB3277 / "version conflicts"** → Binding redirect or version alignment issue +5. **Project failed at ResolveProjectReferences** → Cascading failure from a dependency diff --git a/.agents/skills/dotnet-aot-compat/SKILL.md b/.agents/skills/dotnet-aot-compat/SKILL.md new file mode 100644 index 00000000..9d7b1dd8 --- /dev/null +++ b/.agents/skills/dotnet-aot-compat/SKILL.md @@ -0,0 +1,266 @@ +--- +description: 'Make .NET projects compatible with Native AOT and trimming by systematically resolving IL trim/AOT analyzer warnings. USE FOR: making projects AOT-compatible, fixing trimming warnings, resolving IL warnings (IL2026, IL2070, IL2067, IL2072, IL3050), adding DynamicallyAccessedMembers annotations, enabling IsAotCompatible. DO NOT USE FOR: publishing native AOT binaries, optimizing binary size, replacing reflection-heavy libraries with alternatives. INVOKES: no tools — pure knowledge skill.' +metadata: + github-path: plugins/dotnet-upgrade/skills/dotnet-aot-compat + github-pinned: v1.0.0 + github-ref: refs/tags/v1.0.0 + github-repo: https://github.com/dotnet/skills + github-tree-sha: 087a813c48a2ebb70fa8e3553164ebe57e67b4bc +name: dotnet-aot-compat +--- +# dotnet-aot-compat + +Make .NET projects compatible with Native AOT and trimming by systematically resolving all IL trim/AOT analyzer warnings. + +## When to Use This Skill + +- **"Make this project AOT-compatible"** +- **"Fix trimming warnings"** or **"fix IL warnings"** +- **"Resolve IL2070 / IL2067 / IL2072 / IL2026 / IL3050 warnings"** +- **"Add DynamicallyAccessedMembers annotations"** +- **"Enable IsAotCompatible in my .csproj"** +- **"My project has trim analyzer warnings after upgrading to net8.0"** +- **"Annotate reflection code for the trimmer"** + +## When Not to Use This Skill + +Do not use this skill when the project exclusively targets .NET Framework (net4x), which does not support the trim/AOT analyzers. + +## Prerequisites + +An existing .NET project targeting net8.0 or later (or multi-targeting with at least one net8.0+ TFM) and the corresponding .NET SDK installed. + +## Background: What AOT Compatibility Means + +Native AOT and the IL trimmer perform static analysis to determine what code is reachable. Reflection can break this analysis because the trimmer can't see what types/members are accessed at runtime. The `IsAotCompatible` property enables analyzers that flag these issues as build warnings (ILXXXX codes). + +## Critical Rules + +### ❌ Never suppress warnings incorrectly + +- **NEVER** use `#pragma warning disable` for IL warnings. It hides warnings from the Roslyn analyzer at build time, but the IL linker and AOT compiler still see the issue. The code will fail at trim/publish time. +- **NEVER** use `[UnconditionalSuppressMessage]`. It tells both the analyzer AND the linker to ignore the warning, meaning the trimmer cannot verify safety. Raising an error at build time is always preferable to hiding the issue and having it silently break at runtime. + +### 💡 Preferred approaches + +- **Prefer** `[DynamicallyAccessedMembers]` annotations to flow type information through the call chain. +- **Prefer** refactoring to eliminate patterns that break annotation flow (e.g., boxing `Type` through `object[]`). +- **Use** `[RequiresUnreferencedCode]` / `[RequiresDynamicCode]` / `[RequiresAssemblyFiles]` to mark methods as fundamentally incompatible with trimming, propagating the requirement to callers. This surfaces the issue clearly rather than hiding it — callers must explicitly acknowledge the incompatibility. + +### Annotation flow is key + +The trimmer tracks `[DynamicallyAccessedMembers]` annotations through assignments, parameter passing, and return values. If this flow is broken (e.g., by boxing a `Type` into `object`, storing in an untyped collection, or casting through interfaces), the trimmer loses track and warns. The fix is to preserve the flow, not suppress the warning. + +## Step-by-Step Procedure + +> **Do not explore the codebase up-front.** The build warnings tell you exactly which files and lines need changes. Follow a tight loop: **build → pick a warning → open that file at that line → apply the fix recipe → rebuild**. Reading or analyzing source files beyond what a specific warning points you to is wasted effort and leads to timeouts. Let the compiler guide you. +> +> ❌ Do NOT run `find`, `ls`, or `grep` to understand the project structure before building. Do NOT read README, docs, or architecture files. Your first action should be Step 1 (enable AOT analysis), then build. + +### Step 1: Enable AOT analysis in the .csproj + +Add `IsAotCompatible`. If the project doesn't exclusively target net8.0+, add a TFM condition (AOT analysis requires net8.0+): + +```xml + + true + +``` + +This automatically sets `EnableTrimAnalyzer=true` and `EnableAotAnalyzer=true` for compatible TFMs. For multi-targeting projects (e.g., `netstandard2.0;net8.0`), the condition ensures no `NETSDK1210` warnings on older TFMs. + +### Step 2: Build and collect warnings + +```bash +dotnet build -f --no-incremental 2>&1 | grep 'IL[0-9]\{4\}' +``` + +Sort and deduplicate. Common warning codes: +- **IL2070**: Reflection call on a `Type` parameter missing `[DynamicallyAccessedMembers]` +- **IL2067**: Passing an unannotated `Type` to a method expecting `[DynamicallyAccessedMembers]` +- **IL2072**: Return value or extracted value missing annotation (often from unboxing) +- **IL2057**: `Type.GetType(string)` with a non-constant argument +- **IL2026**: Calling a method marked `[RequiresUnreferencedCode]` +- **IL2050**: P/invoke method with COM marshalling parameters +- **IL2075**: Return value flows into reflection without annotation +- **IL2091**: Generic argument missing `[DynamicallyAccessedMembers]` required by constraint +- **IL3000**: `Assembly.Location` returns empty string in single-file/AOT apps +- **IL3050**: Calling a method marked `[RequiresDynamicCode]` + +### Step 3: Triage warnings by code (do NOT read every file) + +Group the warnings from Step 2 by warning code and count them. **Do not open individual files yet.** Identify the top 1-2 patterns by count — these drive your fix strategy: + +| Pattern | Typical fix | +|---------|-------------| +| Many IL2026 + IL3050 from `JsonSerializer` | **Go to Strategy C immediately** — create a `JsonSerializerContext`, then batch-update all call sites | +| IL2070/IL2087 on `Type` parameters | Add `[DynamicallyAccessedMembers]` to the innermost method, then cascade outward | +| IL2067 passing unannotated `Type` | Annotate the parameter at the source | + +**In most real projects, IL2026/IL3050 from JsonSerializer dominate.** Start with Strategy C unless the warning breakdown clearly shows otherwise. After the batch JSON fix, handle remaining warnings with Strategies A–B. Only use Strategy D as a last resort. + +### Step 4: Fix warnings iteratively (innermost first) + +Work from the **innermost** reflection call outward. Each fix may cascade new warnings to callers. + +**Stay warning-driven.** For each warning, open only the file and line the compiler reported, identify the pattern, apply the matching fix recipe below, and move on. Do not scan the codebase for similar patterns or try to understand the full architecture — fix what the compiler tells you, rebuild, and let new warnings guide the next change. Fix a small batch of warnings (5-10), then rebuild immediately to check progress. + +**Use sub-agents when available.** If you can launch sub-agents (e.g., via a `task` tool), dispatch **multiple sub-agents in parallel** to edit different files simultaneously. Keep the main loop focused on building, parsing warnings, and dispatching — delegate actual file edits to sub-agents. For batch JSON updates, give each sub-agent 5-10 files to update in one prompt. **After 2 build-fix cycles, dispatch all remaining file edits to sub-agents in parallel — do not continue fixing files sequentially.** Example: + +> Update these files to use source-generated JSON: `src/Models/Resource.Serialization.cs`, `src/Models/Identity.Serialization.cs`, `src/Models/Plan.Serialization.cs`. In each file, replace `JsonSerializer.Serialize(writer, value)` with `JsonSerializer.Serialize(writer, value, MyProjectJsonContext.Default.TypeName)` and `JsonSerializer.Deserialize(ref reader)` with `JsonSerializer.Deserialize(ref reader, MyProjectJsonContext.Default.TypeName)`. Only edit the JsonSerializer call sites. + +#### Strategy A: Add `[DynamicallyAccessedMembers]` (preferred) + +When a method uses reflection on a `Type` parameter, annotate the parameter to tell the trimmer what members are needed: + +```csharp +using System.Diagnostics.CodeAnalysis; + +// Before (warns IL2070): +void Process(Type t) { + var method = t.GetMethod("Foo"); // trimmer can't verify +} + +// After (clean): +void Process([DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] Type t) { + var method = t.GetMethod("Foo"); // trimmer preserves public methods +} +``` + +When you annotate a parameter, **all callers** must now pass properly annotated types. This cascades outward — follow each caller and annotate or refactor as needed. **The caller's annotation must include at least the same member types as the callee's.** If the callee requires `PublicConstructors | NonPublicConstructors`, the caller must specify the same or a superset — using only `NonPublicConstructors` will produce IL2091. + +#### Strategy B: Refactor to preserve annotation flow + +When annotation flow is broken by boxing (storing `Type` in `object`, `object[]`, or untyped collections), **refactor** to pass the `Type` directly: + +```csharp +// BROKEN: Type boxed into object[], annotation lost +void Process(object[] args) { + Type t = (Type)args[0]; // IL2072: annotation lost through boxing + Evaluate(t, ...); +} + +// FIXED: Pass Type as a separate, annotated parameter +void Process( + object[] args, + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] Type calleeType, + ...) { + Evaluate(calleeType, ...); // annotation flows cleanly +} +``` + +Common patterns that break flow and how to fix them: +- **`object[]` parameter bags**: Extract the `Type` into a dedicated annotated parameter +- **Dictionary/List storage**: Use a typed field with annotation instead +- **Interface indirection**: Add annotation to the interface method's parameter +- **Property with boxing getter**: Annotate the property's return type + +#### Strategy C: Source-generated JSON serialization (batch fix) + +When most warnings are IL2026/IL3050 from `JsonSerializer.Serialize`/`Deserialize`, this is a single mechanical fix applied in bulk: + +1. **Collect affected types** — grep for all `JsonSerializer.Serialize` and `JsonSerializer.Deserialize` call sites. Extract the type being serialized (the `` in `Deserialize`, or the runtime type of the object in `Serialize`). + +2. **Create one `JsonSerializerContext`** with `[JsonSerializable]` for every type found. **Skip types from external packages** (e.g., `ResponseError` from `Azure.Core`) — they won't source-generate for types you don't own. Handle external types separately via Gotcha #1 below. + +```csharp +[JsonSerializerContext] +[JsonSerializable(typeof(ManagedServiceIdentity))] +[JsonSerializable(typeof(SystemData))] +// ... one attribute per type YOU OWN +// Do NOT add types from external packages (e.g., ResponseError) +internal partial class MyProjectJsonContext : JsonSerializerContext { } +``` + +3. **Batch-update all call sites** — do not read each file individually. Apply the pattern mechanically: + - `JsonSerializer.Serialize(obj)` → `JsonSerializer.Serialize(obj, MyProjectJsonContext.Default.TypeName)` + - `JsonSerializer.Deserialize(json)` → `JsonSerializer.Deserialize(json, MyProjectJsonContext.Default.TypeName)` + + Find and update all call sites in one pass: + ```bash + # Find all files with JsonSerializer calls + grep -rl 'JsonSerializer\.\(Serialize\|Deserialize\)' src/ --include='*.cs' + ``` + Then use sequential `edit` calls to apply the same transformation to every matching file. **Do not use `sed` for C# code** — generics like `Deserialize()` have angle brackets and nested parentheses that sed will mangle. + +4. **Build once** to verify. Remaining warnings will be non-serialization issues — handle those with Strategies A–B or D. + +#### Strategy D: `[RequiresUnreferencedCode]` (last resort) + +When a method fundamentally requires arbitrary reflection that cannot be statically described: + +```csharp +[RequiresUnreferencedCode("Loads plugins by name using Assembly.Load")] +public void LoadPlugin(string assemblyName) { + var asm = Assembly.Load(assemblyName); + // ... +} +``` + +This propagates to callers — they must also be annotated with `[RequiresUnreferencedCode]`. Use sparingly; it marks the entire call chain as trim-incompatible. + +### Step 5: Rebuild and repeat + +After each small batch of fixes (5-10 warnings), rebuild with `--no-incremental` and check for new warnings. **Do not attempt to fix all warnings before rebuilding** — frequent rebuilds catch mistakes early and reveal cascading warnings. Fixes cascade — annotating an inner method may surface warnings in its callers. Repeat until `0 Warning(s)`. + +### Step 6: Validate all TFMs + +Build all target frameworks to ensure: +- **0 IL warnings** on net8.0+ TFMs +- **No NETSDK1210 warnings** (the `IsAotCompatible` condition handles this) +- **Clean builds** on older TFMs (netstandard2.0, net472, etc.) + +```bash +dotnet build # builds all TFMs +``` + +## Stop Signals + +- **Do not analyze more than 2-3 representative files per warning pattern.** After identifying the fix for a pattern, apply it to all matching files without reading each one first. +- **Start fixing after one build.** Do not do a second analysis pass — begin implementing fixes for the most common warning pattern immediately after Step 3 triage. +- Stop after achieving **0 IL warnings** for net8.0+ TFMs. Don't optimize or refactor already-clean annotations. +- If a warning requires **architectural refactoring** beyond annotation flow fixes (e.g., replacing an entire serialization layer), document it and stop — don't rewrite large subsystems. +- Limit to **3 build-fix iterations** per warning. If annotation flow doesn't resolve it after 3 attempts, escalate to `[RequiresUnreferencedCode]`. +- Don't chase warnings in **third-party dependencies** you can't modify. Note them and move on. +- If the user asked a scoped question (e.g., "fix warnings in this file"), don't expand to the entire project. + +## Polyfills for Older TFMs + +For multi-targeting projects that include netstandard2.0 or net472, you need polyfills for `DynamicallyAccessedMembersAttribute` and related types. See [references/polyfills.md](references/polyfills.md). + +## Common Gotchas + +1. **External types without AOT-safe serialization**: When a type comes from a dependency you can't modify (e.g., `ResponseError` from `Azure.Core`) and it lacks a source-generated serializer, `Options.GetConverter()` is reflection-based and will produce IL warnings. First check if the type implements `IJsonModel` (common in Azure SDK) — if so, bypass `JsonSerializer` entirely: + +```csharp +// Before (IL2026 — JsonSerializer uses reflection): +JsonSerializer.Serialize(writer, errorValue); + +// After (AOT-safe — uses IJsonModel directly): +((IJsonModel)errorValue).Write(writer, ModelReaderWriterOptions.Json); + +// For deserialization: +var error = ((IJsonModel)new ResponseError()).Create(ref reader, ModelReaderWriterOptions.Json); +``` + +Do **not** add the external type to your `JsonSerializerContext` — it won't source-generate for types you don't own. If the type doesn't implement `IJsonModel`, write a custom `JsonConverter` with manual `Utf8JsonReader`/`Utf8JsonWriter` logic and register it via `[JsonSourceGenerationOptions]` on your context. + +2. **Serialization libraries**: Most reflection-based serializers (e.g., `Newtonsoft.Json`, `XmlSerializer`) are not AOT-compatible. Migrate to a source-generation-based serializer such as `System.Text.Json` with a `JsonSerializerContext`. If migration is not feasible, mark the serialization call site with `[RequiresUnreferencedCode]`. + +3. **Shared projects / projitems**: When source is shared between multiple projects via ``, annotations added to shared code affect ALL consuming projects. Verify that all consumers still build cleanly. + +## References + +[Limitations](https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/?tabs=windows%2Cnet8#limitations-of-native-aot-deployment) +[Conceptual: Understanding trimming](https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/trimming-concepts) +[How-to: trim compat](https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/fixing-warnings) + +## Checklist + +- [ ] Added `` with TFM condition to .csproj +- [ ] Built with AOT analyzers enabled (net8.0+ TFM) +- [ ] Fixed all IL warnings via annotations or refactoring +- [ ] No `#pragma warning disable` or `[UnconditionalSuppressMessage]` used for any IL warning +- [ ] Polyfills present for older TFMs if needed +- [ ] All target frameworks build with 0 warnings +- [ ] Verified shared/linked source doesn't break sibling projects diff --git a/.agents/skills/dotnet-aot-compat/references/polyfills.md b/.agents/skills/dotnet-aot-compat/references/polyfills.md new file mode 100644 index 00000000..a577f2e2 --- /dev/null +++ b/.agents/skills/dotnet-aot-compat/references/polyfills.md @@ -0,0 +1,43 @@ +# Polyfills for Older TFMs + +`DynamicallyAccessedMembersAttribute` shipped in .NET 5. For projects targeting netstandard2.0 or net472, you need a polyfill. The trimmer recognizes the attribute by name, so a local copy works: + +```csharp +#if !NET +namespace System.Diagnostics.CodeAnalysis +{ + [AttributeUsage(AttributeTargets.Field | AttributeTargets.ReturnValue | + AttributeTargets.GenericParameter | AttributeTargets.Parameter | + AttributeTargets.Property, Inherited = false)] + internal sealed class DynamicallyAccessedMembersAttribute : Attribute + { + public DynamicallyAccessedMembersAttribute(DynamicallyAccessedMemberTypes memberTypes) + => MemberTypes = memberTypes; + public DynamicallyAccessedMemberTypes MemberTypes { get; } + } + + [Flags] + internal enum DynamicallyAccessedMemberTypes + { + None = 0, + PublicParameterlessConstructor = 0x0001, + PublicConstructors = 0x0002 | PublicParameterlessConstructor, + NonPublicConstructors = 0x0004, + PublicMethods = 0x0008, + NonPublicMethods = 0x0010, + PublicFields = 0x0020, + NonPublicFields = 0x0040, + PublicNestedTypes = 0x0080, + NonPublicNestedTypes = 0x0100, + PublicProperties = 0x0200, + NonPublicProperties = 0x0400, + PublicEvents = 0x0800, + NonPublicEvents = 0x1000, + Interfaces = 0x2000, + All = ~None // Discouraged — prefer specific flags + } +} +#endif +``` + +Similarly for `RequiresUnreferencedCodeAttribute` and `UnconditionalSuppressMessageAttribute` if needed on older TFMs. diff --git a/.agents/skills/dotnet-test-frameworks/SKILL.md b/.agents/skills/dotnet-test-frameworks/SKILL.md new file mode 100644 index 00000000..b63e4330 --- /dev/null +++ b/.agents/skills/dotnet-test-frameworks/SKILL.md @@ -0,0 +1,122 @@ +--- +description: Reference data for .NET test framework detection patterns, assertion APIs, skip annotations, setup/teardown methods, and common test smell indicators across MSTest, xUnit, NUnit, and TUnit. DO NOT USE directly — loaded by test analysis skills (test-anti-patterns, exp-test-smell-detection, exp-assertion-quality, exp-test-maintainability, exp-test-tagging) when they need framework-specific lookup tables. +metadata: + github-path: plugins/dotnet-test/skills/dotnet-test-frameworks + github-pinned: v1.0.0 + github-ref: refs/tags/v1.0.0 + github-repo: https://github.com/dotnet/skills + github-tree-sha: e63b5feebdab4ff15d5e7c961b082222bfb74afa +name: dotnet-test-frameworks +user-invocable: false +--- +# .NET Test Framework Reference + +Language-specific detection patterns for .NET test frameworks (MSTest, xUnit, NUnit, TUnit). + +## Test File Identification + +| Framework | Test class markers | Test method markers | +| --------- | ------------------ | ------------------- | +| MSTest | `[TestClass]` | `[TestMethod]`, `[DataTestMethod]` | +| xUnit | *(none — convention-based)* | `[Fact]`, `[Theory]` | +| NUnit | `[TestFixture]` | `[Test]`, `[TestCase]`, `[TestCaseSource]` | +| TUnit | `[ClassDataSource]` | `[Test]` | + +## Assertion APIs by Framework + +| Category | MSTest | xUnit | NUnit | +| -------- | ------ | ----- | ----- | +| Equality | `Assert.AreEqual` | `Assert.Equal` | `Assert.That(x, Is.EqualTo(y))` | +| Boolean | `Assert.IsTrue` / `Assert.IsFalse` | `Assert.True` / `Assert.False` | `Assert.That(x, Is.True)` | +| Null | `Assert.IsNull` / `Assert.IsNotNull` | `Assert.Null` / `Assert.NotNull` | `Assert.That(x, Is.Null)` | +| Exception | `Assert.Throws()` / `Assert.ThrowsExactly()` | `Assert.Throws()` | `Assert.That(() => ..., Throws.TypeOf())` | +| Collection | `CollectionAssert.Contains` | `Assert.Contains` | `Assert.That(col, Has.Member(x))` | +| String | `StringAssert.Contains` | `Assert.Contains(str, sub)` | `Assert.That(str, Does.Contain(sub))` | +| Type | `Assert.IsInstanceOfType` | `Assert.IsAssignableFrom` | `Assert.That(x, Is.InstanceOf())` | +| Inconclusive | `Assert.Inconclusive()` | *skip via `[Fact(Skip)]`* | `Assert.Inconclusive()` | +| Fail | `Assert.Fail()` | `Assert.Fail()` (.NET 10+) | `Assert.Fail()` | + +Third-party assertion libraries: `Should*` (Shouldly), `.Should()` (FluentAssertions / AwesomeAssertions), `Verify()` (Verify). + +## Sleep/Delay Patterns + +| Pattern | Example | +| ------- | ------- | +| Thread sleep | `Thread.Sleep(2000)` | +| Task delay | `await Task.Delay(1000)` | +| SpinWait | `SpinWait.SpinUntil(() => condition, timeout)` | + +## Skip/Ignore Annotations + +| Framework | Annotation | With reason | +| --------- | ---------- | ----------- | +| MSTest | `[Ignore]` | `[Ignore("reason")]` | +| xUnit | `[Fact(Skip = "reason")]` | *(reason is required)* | +| NUnit | `[Ignore("reason")]` | *(reason is required)* | +| TUnit | `[Skip("reason")]` | *(reason is required)* | +| Conditional | `#if false` / `#if NEVER` | *(no reason possible)* | + +## Exception Handling — Idiomatic Alternatives + +When a test uses `try`/`catch` to verify exceptions, suggest the framework-native alternative: + +**MSTest:** + +```csharp +// Instead of try/catch (matches exact type): +var ex = Assert.ThrowsExactly( + () => processor.ProcessOrder(emptyOrder)); +Assert.AreEqual("Order must contain at least one item", ex.Message); + +// Or (also matches derived types): +var ex = Assert.Throws( + () => processor.ProcessOrder(emptyOrder)); +Assert.AreEqual("Order must contain at least one item", ex.Message); +``` + +**xUnit:** + +```csharp +var ex = Assert.Throws( + () => processor.ProcessOrder(emptyOrder)); +Assert.Equal("Order must contain at least one item", ex.Message); +``` + +**NUnit:** + +```csharp +var ex = Assert.Throws( + () => processor.ProcessOrder(emptyOrder)); +Assert.That(ex.Message, Is.EqualTo("Order must contain at least one item")); +``` + +## Mystery Guest — Common .NET Patterns + +| Smell indicator | What to look for | +| --------------- | ---------------- | +| File system | `File.ReadAllText`, `File.Exists`, `File.WriteAllBytes`, `Directory.GetFiles`, `Path.Combine` with hard-coded paths | +| Database | `SqlConnection`, `DbContext` (without in-memory provider), `SqlCommand` | +| Network | `HttpClient` without `HttpMessageHandler` override, `WebRequest`, `TcpClient` | +| Environment | `Environment.GetEnvironmentVariable`, `Environment.CurrentDirectory` | +| Acceptable | `MemoryStream`, `StringReader`, `InMemory` database providers, custom `DelegatingHandler` | + +## Integration Test Markers + +Recognize these as integration tests (adjust smell severity accordingly): + +- Class name contains `Integration`, `E2E`, `EndToEnd`, or `Acceptance` +- `[TestCategory("Integration")]` (MSTest) +- `[Trait("Category", "Integration")]` (xUnit) +- `[Category("Integration")]` (NUnit) +- Project name ending in `.IntegrationTests` or `.E2ETests` + +## Setup/Teardown Methods + +| Framework | Setup | Teardown | +| --------- | ----- | -------- | +| MSTest | `[TestInitialize]` or constructor | `[TestCleanup]` or `IDisposable.Dispose` / `IAsyncDisposable.DisposeAsync` | +| xUnit | constructor | `IDisposable.Dispose` / `IAsyncDisposable.DisposeAsync` | +| NUnit | `[SetUp]` | `[TearDown]` | +| MSTest (class) | `[ClassInitialize]` | `[ClassCleanup]` | +| NUnit (class) | `[OneTimeSetUp]` | `[OneTimeTearDown]` | +| xUnit (class) | `IClassFixture` | fixture's `Dispose` | diff --git a/.agents/skills/filter-syntax/SKILL.md b/.agents/skills/filter-syntax/SKILL.md new file mode 100644 index 00000000..638f1eec --- /dev/null +++ b/.agents/skills/filter-syntax/SKILL.md @@ -0,0 +1,177 @@ +--- +description: 'Reference data for test filter syntax across all platform and framework combinations: VSTest --filter expressions, MTP filters for MSTest/NUnit/xUnit v3/TUnit, and VSTest-to-MTP filter translation. DO NOT USE directly — loaded by run-tests, mtp-hot-reload, and migrate-vstest-to-mtp when they need filter syntax.' +metadata: + github-path: plugins/dotnet-test/skills/filter-syntax + github-pinned: v1.0.0 + github-ref: refs/tags/v1.0.0 + github-repo: https://github.com/dotnet/skills + github-tree-sha: 852508c90bde5e11124f12fd97ec579a1bd5a65d +name: filter-syntax +user-invocable: false +--- +# Test Filter Syntax Reference + +Filter syntax depends on the **platform** and **test framework**. + +## VSTest filters (MSTest, xUnit v2, NUnit on VSTest) + +```bash +dotnet test --filter +``` + +Expression syntax: `[|&]` + +**Operators:** + +| Operator | Meaning | +|----------|---------| +| `=` | Exact match | +| `!=` | Not exact match | +| `~` | Contains | +| `!~` | Does not contain | + +**Combinators:** `|` (OR), `&` (AND). Parentheses for grouping: `(A|B)&C` + +**Supported properties by framework:** + +| Framework | Properties | +|-----------|-----------| +| MSTest | `FullyQualifiedName`, `Name`, `ClassName`, `Priority`, `TestCategory` | +| xUnit | `FullyQualifiedName`, `DisplayName`, `Traits` | +| NUnit | `FullyQualifiedName`, `Name`, `Priority`, `TestCategory` | + +An expression without an operator is treated as `FullyQualifiedName~`. + +**Examples (VSTest):** + +```bash +# Run tests whose name contains "LoginTest" +dotnet test --filter "Name~LoginTest" + +# Run a specific test class +dotnet test --filter "ClassName=MyNamespace.MyTestClass" + +# Run tests in a category +dotnet test --filter "TestCategory=Integration" + +# Exclude a category +dotnet test --filter "TestCategory!=Slow" + +# Combine: class AND category +dotnet test --filter "ClassName=MyNamespace.MyTestClass&TestCategory=Unit" + +# Either of two classes +dotnet test --filter "ClassName=MyNamespace.ClassA|ClassName=MyNamespace.ClassB" +``` + +## MTP filters — MSTest and NUnit + +MSTest and NUnit on MTP use the **same `--filter` syntax** as VSTest (same properties, operators, and combinators). The only difference is how the flag is passed: + +```bash +# .NET SDK 8/9 (after --) +dotnet test -- --filter "Name~LoginTest" + +# .NET SDK 10+ (direct) +dotnet test --filter "Name~LoginTest" +``` + +## MTP filters — xUnit (v3) + +xUnit v3 on MTP uses **framework-specific filter flags** instead of the generic `--filter` expression: + +| Flag | Description | +|------|-------------| +| `--filter-class "name"` | Run all tests in a given class | +| `--filter-not-class "name"` | Exclude all tests in a given class | +| `--filter-method "name"` | Run a specific test method | +| `--filter-not-method "name"` | Exclude a specific test method | +| `--filter-namespace "name"` | Run all tests in a namespace | +| `--filter-not-namespace "name"` | Exclude all tests in a namespace | +| `--filter-trait "name=value"` | Run tests with a matching trait | +| `--filter-not-trait "name=value"` | Exclude tests with a matching trait | + +Multiple values can be specified with a single flag: `--filter-class Foo Bar`. + +```bash +# .NET SDK 8/9 +dotnet test -- --filter-class "MyNamespace.LoginTests" + +# .NET SDK 10+ +dotnet test --filter-class "MyNamespace.LoginTests" + +# Combine: namespace + trait +dotnet test --filter-namespace "MyApp.Tests.Integration" --filter-trait "Category=Smoke" +``` + +### xUnit v3 query filter language + +For complex expressions, use `--filter-query` with a path-segment syntax: + +```text +////[traitName=traitValue] +``` + +Each segment matches against: assembly name, namespace, class name, method name. Use `*` for "match all" in any segment. Documentation: + +```shell +# xUnit.net v3 MTP — using query language (assembly/namespace/class/method[trait]) +dotnet test -- --filter-query "/*/*/*IntegrationTests*/*[Category=Smoke]" +``` + +## MTP filters — TUnit + +TUnit uses `--treenode-filter` with a path-based syntax: + +```text +--treenode-filter "////" +``` + +Wildcards (`*`) are supported in any segment. Filter operators can be appended to test names for property-based filtering. + +| Operator | Meaning | +|----------|---------| +| `*` | Wildcard match | +| `=` | Exact property match (e.g., `[Category=Unit]`) | +| `!=` | Exclude property value | +| `&` | AND (combine conditions) | +| `\|` | OR (within a segment, requires parentheses) | + +**Examples (TUnit):** + +```bash +# All tests in a class +dotnet run --treenode-filter "/*/*/LoginTests/*" + +# A specific test +dotnet run --treenode-filter "/*/*/*/AcceptCookiesTest" + +# By namespace prefix (wildcard) +dotnet run --treenode-filter "/*/MyProject.Tests.Api*/*/*" + +# By custom property +dotnet run --treenode-filter "/*/*/*/*[Category=Smoke]" + +# Exclude by property +dotnet run --treenode-filter "/*/*/*/*[Category!=Slow]" + +# OR across classes +dotnet run --treenode-filter "/*/*/(LoginTests)|(SignupTests)/*" + +# Combined: namespace + property +dotnet run --treenode-filter "/*/MyProject.Tests.Integration/*/*/*[Priority=Critical]" +``` + +## VSTest → MTP filter translation (for migration) + +**MSTest, NUnit, and xUnit.net v2 (with `YTest.MTP.XUnit2`)**: The VSTest `--filter` syntax is identical on both VSTest and MTP. No changes needed. + +**xUnit.net v3 (native MTP)**: xUnit.net v3 does NOT support the VSTest `--filter` syntax on MTP. Translate filters using xUnit.net v3's native options: + +| VSTest `--filter` syntax | xUnit.net v3 MTP equivalent | Notes | +|---|---|---| +| `FullyQualifiedName~ClassName` | `--filter-class *ClassName*` | Wildcards required for substring match | +| `FullyQualifiedName=Ns.Class.Method` | `--filter-method Ns.Class.Method` | Exact match on fully qualified method | +| `Name=MethodName` | `--filter-method *MethodName*` | Wildcards for substring match | +| `Category=Value` (trait) | `--filter-trait "Category=Value"` | Filter by trait name/value pair | +| Complex expressions | `--filter-query "expr"` | Uses xUnit.net query filter language (see above) | diff --git a/.agents/skills/incremental-build/SKILL.md b/.agents/skills/incremental-build/SKILL.md new file mode 100644 index 00000000..b0258502 --- /dev/null +++ b/.agents/skills/incremental-build/SKILL.md @@ -0,0 +1,223 @@ +--- +description: 'Guide for optimizing MSBuild incremental builds. Only activate in MSBuild/.NET build context. USE FOR: builds slower than expected on subsequent runs, ''nothing changed but it rebuilds anyway'', diagnosing why targets re-execute unnecessarily, fixing broken no-op builds. Covers 8 common causes: missing Inputs/Outputs on custom targets, volatile properties in output paths (timestamps/GUIDs), file writes outside tracked Outputs, missing FileWrites registration, glob changes, Visual Studio Fast Up-to-Date Check (FUTDC) issues. Key diagnostic: look for ''Building target completely'' vs ''Skipping target'' in binlog. DO NOT USE FOR: first-time build slowness (use build-perf-baseline), parallelism issues (use build-parallelism), evaluation-phase slowness (use eval-performance), non-MSBuild build systems. INVOKES: dotnet build /bl, binlog replay with diagnostic verbosity.' +metadata: + github-path: plugins/dotnet-msbuild/skills/incremental-build + github-pinned: v1.0.0 + github-ref: refs/tags/v1.0.0 + github-repo: https://github.com/dotnet/skills + github-tree-sha: 6685f79b018f97107d8a7fefe49afad0d4b7ea14 +name: incremental-build +--- +## How MSBuild Incremental Build Works + +MSBuild's incremental build mechanism allows targets to be skipped when their outputs are already up to date, dramatically reducing build times on subsequent runs. + +- **Targets with `Inputs` and `Outputs` attributes**: MSBuild compares the timestamps of all files listed in `Inputs` against all files listed in `Outputs`. If every output file is newer than every input file, the target is skipped entirely. +- **Without `Inputs`/`Outputs`**: The target runs every time the build is invoked. This is the default behavior and the most common cause of slow incremental builds. +- **`Incremental` attribute on targets**: Targets can explicitly opt in or out of incremental behavior. Setting `Incremental="false"` forces the target to always run, even if `Inputs` and `Outputs` are specified. +- **Timestamp-based comparison**: MSBuild uses file system timestamps (last write time) to determine staleness. It does not use content hashes. This means touching a file (updating its timestamp without changing content) will trigger a rebuild. + +```xml + + + + + + + + + +``` + +## Why Incremental Builds Break (Top Causes) + +1. **Missing Inputs/Outputs on custom targets** — Without both attributes, the target always runs. This is the single most common cause of unnecessary rebuilds. + +2. **Volatile properties in Outputs path** — If the output path includes something that changes between builds (e.g., a timestamp, build number, or random GUID), MSBuild will never find the previous output and will always rebuild. + +3. **File writes outside of tracked Outputs** — If a target writes files that aren't listed in its `Outputs`, MSBuild doesn't know about them. The target may be skipped (because its declared outputs are up to date), but downstream targets may still be triggered. + +4. **Missing FileWrites registration** — Files created during the build but not registered in the `FileWrites` item group won't be cleaned by `dotnet clean`. Over time, stale files can confuse incremental checks. + +5. **Glob changes** — When you add or remove source files, the item set (e.g., `@(Compile)`) changes. Since these items feed into `Inputs`, the set of inputs changes and triggers a rebuild. This is expected behavior but can be surprising. + +6. **Property changes** — Properties that feed into `Inputs` or `Outputs` paths (e.g., `$(Configuration)`, `$(TargetFramework)`) will cause rebuilds when changed. Switching between Debug and Release is a full rebuild by design. + +7. **NuGet package updates** — Changing a package version updates `project.assets.json` and potentially many resolved assembly paths. This changes the inputs to `ResolveAssemblyReferences` and `CoreCompile`, triggering a rebuild. + +8. **Build server VBCSCompiler cache invalidation** — The Roslyn compiler server (`VBCSCompiler`) caches compilation state. If the server is recycled (timeout, crash, or manual kill), the next build may be slower even though MSBuild's incremental checks pass, because the compiler must repopulate its in-memory caches. + +## Diagnosing "Why Did This Rebuild?" + +Use binary logs (binlogs) to understand exactly why targets ran instead of being skipped. + +### Step-by-step using binlog + +1. **Build twice with binlogs** to capture the incremental build behavior: + ```shell + dotnet build /bl:first.binlog + dotnet build /bl:second.binlog + ``` + The first build establishes the baseline. The second build is the one you want to be incremental. Analyze `second.binlog`. + +2. **Replay the second binlog** to a diagnostic text log: + ```shell + dotnet msbuild second.binlog -noconlog -fl -flp:v=diag;logfile=second-full.log;performancesummary + ``` + Then search for targets that actually executed: + ```bash + grep 'Building target\|Target.*was not skipped' second-full.log + ``` + In a perfectly incremental build, most targets should be skipped. + +3. **Inspect non-skipped targets** by looking for their execution messages in the diagnostic log. Check for "out of date" messages that indicate why a target ran. + +4. **Look for key messages** in the binlog: + - `"Building target 'X' completely"` — means MSBuild found no outputs or all outputs are missing; this is a full target execution. + - `"Building target 'X' incrementally"` — means some (but not all) outputs are out of date. + - `"Skipping target 'X' because all output files are up-to-date"` — target was correctly skipped. + +5. **Search for "is newer than output"** messages to find the specific input file that triggered the rebuild: + ```bash + grep "is newer than output" second-full.log + ``` + This reveals exactly which input file's timestamp caused MSBuild to consider the target out of date. + +### Additional diagnostic techniques + +- Compare `first.binlog` and `second.binlog` side by side in the MSBuild Structured Log Viewer to see what changed. +- Use `grep 'Target Performance Summary' -A 30 second-full.log` to see which targets consumed the most time in the second build — these are your optimization targets. +- Check for targets with zero-duration that still ran — they may have unnecessary dependencies causing them to execute. + +## FileWrites and Clean Build + +The `FileWrites` item group is MSBuild's mechanism for tracking files generated during the build. It powers `dotnet clean` and helps maintain correct incremental behavior. + +- **`FileWrites` item**: Register any file your custom targets create so that `dotnet clean` knows to remove them. Without this, generated files accumulate across builds and may confuse incremental checks. +- **`FileWritesShareable` item**: Use this for files that are shared across multiple projects (e.g., shared generated code). These files are tracked but not deleted if other projects still reference them. +- **If not registered**: Files accumulate in the output and intermediate directories. `dotnet clean` won't remove them, and they may cause stale data issues or confuse up-to-date checks. + +### Pattern for registering generated files + +Add generated files to `FileWrites` inside the target that creates them: + +```xml + + + + + + + + + +``` + +## Visual Studio Fast Up-to-Date Check + +Visual Studio has its own up-to-date check (Fast Up-to-Date Check, or FUTDC) that is separate from MSBuild's `Inputs`/`Outputs` mechanism. Understanding the difference is critical for diagnosing "it rebuilds in VS but not on the command line" issues. + +- **VS FUTDC is faster** because it runs in-process and checks a known set of items without invoking MSBuild at all. It compares timestamps of well-known item types (Compile, Content, EmbeddedResource, etc.) against the project's primary output. +- **It can be wrong** if your project uses custom build actions, custom targets that generate files, or non-standard item types that FUTDC doesn't know about. +- **Disable FUTDC** to force Visual Studio to use MSBuild's full incremental check: + ```xml + + true + + ``` +- **Diagnose FUTDC decisions** by viewing the Output window in VS: go to **Tools → Options → Projects and Solutions → SDK-Style Projects** and set **Up-to-date Checks** logging level to **Verbose** or above. FUTDC will log exactly which file it considers out of date. +- **Common VS FUTDC issues**: + - Custom build actions not registered with the FUTDC system + - `CopyToOutputDirectory` items that are newer than the last build + - Items added dynamically by targets that FUTDC doesn't evaluate + - `Content` or `None` items with `CopyToOutputDirectory="PreserveNewest"` that have been modified + +## Making Custom Targets Incremental + +The following is a complete example of a well-structured incremental custom target: + +```xml + + + + + + + + +``` + +**Key points in this example:** + +- **`Inputs` includes `$(MSBuildProjectFile)`**: This ensures the target reruns if the project file itself changes (e.g., a property that affects generation is modified). +- **`Inputs` includes `@(ConfigInput)`**: The actual source files that drive generation. +- **`Outputs` uses `$(IntermediateOutputPath)`**: Generated files go in the `obj/` directory, which is managed by MSBuild and cleaned automatically. +- **`BeforeTargets="CoreCompile"`**: The generated file is available before the compiler runs. +- **`FileWrites` registration**: Ensures `dotnet clean` removes the generated file. +- **`Compile` inclusion**: Adds the generated file to the compilation without requiring it to exist at evaluation time. + +### Common mistakes to avoid + +```xml + + + + + + + + + + + + + + + + + + +``` + +## Performance Summary and Preprocess + +MSBuild provides built-in tools to understand what's running and why. + +- **`/clp:PerformanceSummary`** — Appends a summary at the end of the build showing time spent in each target and task. Use this to quickly identify the most expensive operations: + ```shell + dotnet build /clp:PerformanceSummary + ``` + This shows a table of targets sorted by cumulative time, making it easy to spot targets that shouldn't be running in an incremental build. + +- **`/pp:preprocess.xml`** — Generates a single XML file with all imports inlined, showing the fully evaluated project. This is invaluable for understanding what targets, properties, and items are defined and where they come from: + ```shell + dotnet msbuild /pp:preprocess.xml + ``` + Search the preprocessed output to find where `Inputs` and `Outputs` are defined for any target, or to understand the full chain of imports. + +- Use both together to understand what's running (`PerformanceSummary`) and what's imported (`/pp`), then cross-reference with binlog analysis for a complete picture. + +## Common Fixes + +- **Always add `Inputs` and `Outputs` to custom targets** — This is the single most impactful change for incremental build performance. Without both attributes, the target runs every time. +- **Use `$(IntermediateOutputPath)` for generated files** — Files in `obj/` are tracked by MSBuild's clean infrastructure and won't leak between configurations. +- **Register generated files in `FileWrites`** — Ensures `dotnet clean` removes them and prevents stale file accumulation. +- **Avoid volatile data in build** — Don't embed timestamps, random values, or build counters in file paths or generated content unless you have a deliberate strategy for managing staleness. If you must use volatile data, isolate it to a single file with minimal downstream impact. +- **Use `Returns` instead of `Outputs` when you need to pass items without creating incremental build dependency** — `Outputs` serves double duty: it defines the incremental check AND the items returned from the target. If you only need to pass items to calling targets without affecting incrementality, use `Returns` instead: + ```xml + + ... + + + ... + ``` diff --git a/.agents/skills/migrate-mstest-v3-to-v4/SKILL.md b/.agents/skills/migrate-mstest-v3-to-v4/SKILL.md new file mode 100644 index 00000000..7c56818b --- /dev/null +++ b/.agents/skills/migrate-mstest-v3-to-v4/SKILL.md @@ -0,0 +1,470 @@ +--- +description: 'Fix build errors and breaking changes after upgrading MSTest from v3 to v4, or plan a complete MSTest v3-to-v4 migration. Use when user says "upgrade to MSTest v4", "MSTest 4 migration", "MSTest v4 breaking changes", "tests don''t compile after upgrading MSTest", or has errors CS0507, CS0103, CS1061, CS1615 after updating MSTest packages from 3.x to 4.x. USE FOR: Execute to ExecuteAsync, CallerInfo constructor on TestMethodAttribute, sealed custom attributes, ClassCleanupBehavior removal, TestContext.Properties Contains to ContainsKey, Assert.ThrowsException to ThrowsExactly, Assert.IsInstanceOfType out parameter removal, ExpectedExceptionAttribute removal, TestTimeout enum removal, [TestMethod("name")] to DisplayName syntax, TreatDiscoveryWarningsAsErrors, TestContext.TestName in ClassInitialize, MSTest.Sdk MTP changes, dropped TFMs (net6.0/net7.0 to net8.0+). DO NOT USE FOR: migrating from MSTest v1/v2 to v3 (use migrate-mstest-v1v2-to-v3 first), migrating between test frameworks, or general .NET upgrades.' +metadata: + github-path: plugins/dotnet-test/skills/migrate-mstest-v3-to-v4 + github-pinned: v1.0.0 + github-ref: refs/tags/v1.0.0 + github-repo: https://github.com/dotnet/skills + github-tree-sha: 3b7094e42ae389b19d1e5770749e42dfcb2bf8e8 +name: migrate-mstest-v3-to-v4 +--- +# MSTest v3 -> v4 Migration + +Migrate a test project from MSTest v3 to MSTest v4. The outcome is a project using MSTest v4 that builds cleanly, passes tests, and accounts for every source-incompatible and behavioral change. MSTest v4 is **not binary compatible** with MSTest v3 -- any library compiled against v3 must be recompiled against v4. + +## When to Use + +- Upgrading `MSTest.TestFramework`, `MSTest.TestAdapter`, or `MSTest` metapackage from 3.x to 4.x +- Upgrading `MSTest.Sdk` from 3.x to 4.x +- Fixing build errors after updating to MSTest v4 packages +- Resolving behavioral changes in test execution after upgrading to MSTest v4 +- Updating custom `TestMethodAttribute` or `ConditionBaseAttribute` implementations for v4 + +## When Not to Use + +- The project already uses MSTest v4 and builds cleanly -- migration is done +- Upgrading from MSTest v1 or v2 -- use `migrate-mstest-v1v2-to-v3` first, then return here +- The project does not use MSTest +- Migrating between test frameworks (e.g., MSTest to xUnit or NUnit) + +## Inputs + +| Input | Required | Description | +|-------|----------|-------------| +| Project or solution path | Yes | The `.csproj`, `.sln`, or `.slnx` entry point containing MSTest test projects | +| Build command | No | How to build (e.g., `dotnet build`, a repo build script). Auto-detect if not provided | +| Test command | No | How to run tests (e.g., `dotnet test`). Auto-detect if not provided | + +## Response Guidelines + +- **Always identify the current version first**: Before recommending any migration steps, explicitly state the current MSTest version detected in the project (e.g., "Your project uses MSTest v3 (3.8.0)"). This confirms you've read the project files and grounds the migration advice. +- **Focused fix requests** (user has specific compilation errors after upgrading): Address only the relevant breaking changes from Step 3. **Always provide concrete fixed code** using the user's actual types and method names — show a complete, copy-pasteable code snippet, not just a description of what to change. For custom `TestMethodAttribute` subclasses, show the full fixed class including CallerInfo propagation to the base constructor. Mention any related analyzer that could have caught this earlier (e.g., MSTEST0006 for ExpectedException). Do not walk through the entire migration workflow. +- **"What to expect" questions** (user asks about breaking changes before upgrading): Present ALL major breaking changes from the Step 3 quick-lookup table -- not just the ones visible in the current code. For each, provide a one-line fix summary. Also mention key behavioral changes from Step 4 (especially TestCase.Id history impact and TreatDiscoveryWarningsAsErrors default). If project code is available, highlight which changes apply directly. +- **Full migration requests** (user wants complete migration): Follow the complete workflow below. +- **Behavioral/runtime symptom reports** (user describes test execution differences without build errors): Match described symptoms to the behavioral changes table in Step 4. Provide targeted, symptom-specific advice. Mention other behavioral changes the user should watch for. Do not walk through source breaking changes unless the user also has build errors. +- **CI/test-discovery issues** (tests not discovered, vstest.console stopped working, CI pipeline failures after upgrading): Focus on 4.5 (MSTest.Sdk defaults to MTP mode, which does not include Microsoft.NET.Test.Sdk -- needed for vstest.console) and 4.4 (TreatDiscoveryWarningsAsErrors). Explain the root cause clearly and give both fix options (add Microsoft.NET.Test.Sdk package or switch to `dotnet test`). Do not walk through the full migration workflow. +- **Explanatory questions** (user asks "is this a known change?", "what else should I watch out for?"): Explain the relevant changes and advise. Mention related changes the user might encounter next. Do not prescribe a full migration procedure. + +## Workflow + +> **Commit strategy:** Commit at each logical boundary -- after updating packages (Step 2), after resolving source breaking changes (Step 3), after addressing behavioral changes (Step 4). This keeps each commit focused and reviewable. + +### Step 1: Assess the project + +1. Identify the current MSTest version by checking package references for `MSTest`, `MSTest.TestFramework`, `MSTest.TestAdapter`, or `MSTest.Sdk` in `.csproj`, `Directory.Build.props`, or `Directory.Packages.props`. +2. Confirm the project is on MSTest v3 (3.x). If on v1 or v2, use `migrate-mstest-v1v2-to-v3` first. +3. Check target framework(s) -- MSTest v4 drops support for .NET Core 3.1 through .NET 7. Supported target frameworks are: **net8.0**, **net9.0**, **net462** (.NET Framework 4.6.2+), **uap10.0.16299** (UWP), **net9.0-windows10.0.17763.0** (modern UWP), and **net8.0-windows10.0.18362.0** (WinUI). +4. Check for custom `TestMethodAttribute` subclasses -- these require changes in v4. +5. Check for usages of `ExpectedExceptionAttribute` -- removed in v4 (deprecated since v3 with analyzer MSTEST0006). +6. Check for usages of `Assert.ThrowsException` (deprecated) -- removed in v4. +7. Run a clean build to establish a baseline of existing errors/warnings. + +### Step 2: Update packages to MSTest v4 + +**If using the MSTest metapackage:** + +```xml + +``` + +**If using individual packages:** + +```xml + + +``` + +**If using MSTest.Sdk:** + +```xml + +``` + +Run `dotnet restore`, then `dotnet build`. Collect all errors for Step 3. + +### Step 3: Resolve source breaking changes + +Work through compilation errors systematically. Use this quick-lookup table to identify all applicable changes, then apply each fix: + +| Error / Pattern in code | Breaking change | Fix | +|---|---|---| +| Custom `TestMethodAttribute` overrides `Execute` | Execute removed | Change to `ExecuteAsync` returning `Task` (3.1) | +| `[TestMethod("name")]` or custom attribute constructor | CallerInfo params added | Use `DisplayName = "name"` named param; propagate CallerInfo in subclasses (3.2) | +| `ClassCleanupBehavior.EndOfClass` | Enum removed | Remove argument: just `[ClassCleanup]` (3.3) | +| `TestContext.Properties.Contains("key")` | `Properties` is `IDictionary` | Change to `ContainsKey("key")` (3.4) | +| `[Timeout(TestTimeout.Infinite)]` | `TestTimeout` enum removed | Replace with `[Timeout(int.MaxValue)]` (3.5) | +| `TestContext.ManagedType` | Property removed | Use `FullyQualifiedTestClassName` (3.6) | +| `Assert.AreEqual(a, b, "msg {0}", arg)` | Message+params overloads removed | Use string interpolation: `$"msg {arg}"` (3.7) | +| `Assert.ThrowsException(...)` | Renamed | Replace with `Assert.ThrowsExactly(...)` or `Assert.Throws(...)` (3.7) | +| `Assert.IsInstanceOfType(obj, out var t)` | Out parameter removed | Use `var t = Assert.IsInstanceOfType(obj)` (3.7) | +| `[ExpectedException(typeof(T))]` | Attribute removed | Move assertion into test body: `Assert.ThrowsExactly(() => ...)` (3.8) | +| Project targets net5.0, net6.0, or net7.0 | TFM dropped | Change to net8.0 or net9.0 (3.9) | + +> **Important**: Scan the entire project for ALL patterns above before starting fixes. Multiple breaking changes often coexist in the same project. + +#### 3.1 TestMethodAttribute.Execute -> ExecuteAsync + +If you have custom `TestMethodAttribute` subclasses that override `Execute`, change to `ExecuteAsync`. This change was made because the v3 synchronous `Execute` API caused deadlocks when test code used `async`/`await` internally -- the synchronous wrapper would block the thread while the async operation needed that same thread to complete. + +```csharp +// Before (v3) +public sealed class MyTestMethodAttribute : TestMethodAttribute +{ + public override TestResult[] Execute(ITestMethod testMethod) + { + // custom logic + return result; + } +} + +// After (v4) -- Option A: wrap synchronous logic with Task.FromResult +public sealed class MyTestMethodAttribute : TestMethodAttribute +{ + public override Task ExecuteAsync(ITestMethod testMethod) + { + // custom logic (synchronous) + return Task.FromResult(result); + } +} + +// After (v4) -- Option B: make properly async +public sealed class MyTestMethodAttribute : TestMethodAttribute +{ + public override async Task ExecuteAsync(ITestMethod testMethod) + { + // custom async logic + return await base.ExecuteAsync(testMethod); + } +} +``` + +Use `Task.FromResult` when your override logic is purely synchronous. Use `async`/`await` when you call `base.ExecuteAsync` or other async methods. + +#### 3.2 TestMethodAttribute CallerInfo constructor + +`TestMethodAttribute` now uses `[CallerFilePath]` and `[CallerLineNumber]` parameters in its constructor. + +**If you inherit from TestMethodAttribute**, propagate caller info to the base class: + +```csharp +public class MyTestMethodAttribute : TestMethodAttribute +{ + public MyTestMethodAttribute( + [CallerFilePath] string callerFilePath = "", + [CallerLineNumber] int callerLineNumber = -1) + : base(callerFilePath, callerLineNumber) + { + } +} +``` + +**If you use `[TestMethodAttribute("Custom display name")]`**, switch to the named parameter syntax: + +```csharp +// Before (v3) +[TestMethodAttribute("Custom display name")] + +// After (v4) +[TestMethodAttribute(DisplayName = "Custom display name")] +``` + +#### 3.3 ClassCleanupBehavior enum removed + +The `ClassCleanupBehavior` enum is removed. In v3, this enum controlled whether class cleanup ran at end of class (`EndOfClass`) or end of assembly (`EndOfAssembly`). In v4, class cleanup always runs at end of class. Remove the enum argument: + +```csharp +// Before (v3) +[ClassCleanup(ClassCleanupBehavior.EndOfClass)] +public static void ClassCleanup(TestContext testContext) { } + +// After (v4) +[ClassCleanup] +public static void ClassCleanup(TestContext testContext) { } +``` + +If you previously used `ClassCleanupBehavior.EndOfAssembly`, move that cleanup logic to an `[AssemblyCleanup]` method instead. + +#### 3.4 TestContext.Properties type change + +`TestContext.Properties` changed from `IDictionary` to `IDictionary`. Update any `Contains` calls to `ContainsKey`: + +```csharp +// Before (v3) +testContext.Properties.Contains("key"); + +// After (v4) +testContext.Properties.ContainsKey("key"); +``` + +#### 3.5 TestTimeout enum removed + +The `TestTimeout` enum (with only `TestTimeout.Infinite`) is removed. Replace with `int.MaxValue`: + +```csharp +// Before (v3) +[Timeout(TestTimeout.Infinite)] + +// After (v4) +[Timeout(int.MaxValue)] +``` + +#### 3.6 TestContext.ManagedType removed + +The `TestContext.ManagedType` property is removed. Use `TestContext.FullyQualifiedTestClassName` instead. + +#### 3.7 Assert API signature changes + +- **Message + params removed**: Assert methods that accepted both `message` and `object[]` parameters now accept only `message`. Use string interpolation instead of format strings: + +```csharp +// Before (v3) +Assert.AreEqual(expected, actual, "Expected {0} but got {1}", expected, actual); + +// After (v4) +Assert.AreEqual(expected, actual, $"Expected {expected} but got {actual}"); +``` + +- **Assert.ThrowsException renamed**: The `Assert.ThrowsException` APIs are renamed. Use `Assert.ThrowsExactly` (strict type match) or `Assert.Throws` (accepts derived exception types): + +```csharp +// Before (v3) +Assert.ThrowsException(() => DoSomething()); + +// After (v4) -- exact type match (same behavior as old ThrowsException) +Assert.ThrowsExactly(() => DoSomething()); + +// After (v4) -- also catches derived exception types +Assert.Throws(() => DoSomething()); +``` + +- **Assert.IsInstanceOfType out parameter changed**: `Assert.IsInstanceOfType(x, out var t)` changes to `var t = Assert.IsInstanceOfType(x)`: + +```csharp +// Before (v3) +Assert.IsInstanceOfType(obj, out var typed); + +// After (v4) +var typed = Assert.IsInstanceOfType(obj); +``` + +- **Assert.AreEqual for IEquatable\ removed**: If you get generic type inference errors, explicitly specify the type argument as `object`. + +#### 3.8 ExpectedExceptionAttribute removed + +The `[ExpectedException]` attribute is removed in v4. In MSTest 3.2, the `MSTEST0006` analyzer was introduced to flag `[ExpectedException]` usage and suggest migrating to `Assert.ThrowsExactly` while still on v3 (a non-breaking change). In v4, the attribute is gone entirely. Migrate to `Assert.ThrowsExactly`: + +```csharp +// Before (v3) +[ExpectedException(typeof(InvalidOperationException))] +[TestMethod] +public void TestMethod() +{ + MyCall(); +} + +// After (v4) +[TestMethod] +public void TestMethod() +{ + Assert.ThrowsExactly(() => MyCall()); +} +``` + +**When the test has setup code before the throwing call**, wrap only the throwing call in the lambda -- keep Arrange/Act separation clear: + +```csharp +// Before (v3) +[ExpectedException(typeof(ArgumentNullException))] +[TestMethod] +public void Validate_NullInput_Throws() +{ + var service = new ValidationService(); + service.Validate(null); // throws here +} + +// After (v4) +[TestMethod] +public void Validate_NullInput_Throws() +{ + var service = new ValidationService(); + Assert.ThrowsExactly(() => service.Validate(null)); +} +``` + +**For async test methods**, use `Assert.ThrowsExactlyAsync`: + +```csharp +// Before (v3) +[ExpectedException(typeof(HttpRequestException))] +[TestMethod] +public async Task FetchData_BadUrl_Throws() +{ + await client.GetAsync("https://localhost:0"); +} + +// After (v4) +[TestMethod] +public async Task FetchData_BadUrl_Throws() +{ + await Assert.ThrowsExactlyAsync( + () => client.GetAsync("https://localhost:0")); +} +``` + +**If `[ExpectedException]` used the `AllowDerivedTypes` property**, use `Assert.ThrowsAsync` (base type matching) instead of `Assert.ThrowsExactlyAsync` (exact type matching). + +#### 3.9 Dropped target frameworks + +MSTest v4 supports: **net8.0**, **net9.0**, **net462** (.NET Framework 4.6.2+), **uap10.0.16299** (UWP), **net9.0-windows10.0.17763.0** (modern UWP), and **net8.0-windows10.0.18362.0** (WinUI). All other frameworks are dropped -- including net5.0, net6.0, net7.0, and netcoreapp3.1. + +If the test project targets an unsupported framework, update `TargetFramework`: + +```xml + +net6.0 + + +net8.0 +``` + +#### 3.10 Unfolding strategy moved to TestMethodAttribute + +The `UnfoldingStrategy` property (introduced in MSTest 3.7) has moved from individual data source attributes (`DataRowAttribute`, `DynamicDataAttribute`) to `TestMethodAttribute`. + +#### 3.11 ConditionBaseAttribute.ShouldRun renamed + +The `ConditionBaseAttribute.ShouldRun` property is renamed to `IsConditionMet`. + +#### 3.12 Internal/removed types + +Several types previously public are now internal or removed: + +- `MSTestDiscoverer`, `MSTestExecutor`, `AssemblyResolver`, `LogMessageListener` +- `TestExecutionManager`, `TestMethodInfo`, `TestResultExtensions` +- `UnitTestOutcomeExtensions`, `GenericParameterHelper` +- `ITestMethod` in PlatformServices assembly (the one in TestFramework is unchanged) + +If your code references any of these, find alternative approaches or remove the dependency. + +### Step 4: Address behavioral changes + +These changes won't cause build errors but may affect test runtime behavior. + +| Symptom | Cause | Fix | +|---|---|---| +| Tests show as new in Azure DevOps / test history lost | `TestCase.Id` generation changed (4.3) | No code fix; history will re-baseline | +| `TestContext.TestName` throws in `[ClassInitialize]` | v4 enforces lifecycle scope (4.2) | Move access to `[TestInitialize]` or test methods | +| Tests not discovered / discovery failures | `TreatDiscoveryWarningsAsErrors` now true (4.4) | Fix warnings, or set to false in .runsettings | +| Tests hang that didn't before | AppDomain disabled by default (4.1) | Set `DisableAppDomain` to false in .runsettings `RunConfiguration` | +| vstest.console can't find tests with MSTest.Sdk | MSTest.Sdk defaults to MTP; `Microsoft.NET.Test.Sdk` only added in VSTest mode (4.5) | Add explicit package reference or switch to `dotnet test` | +| New warnings from analyzers | Analyzer severities upgraded (4.6) | Fix warnings or suppress in .editorconfig | + +#### 4.1 DisableAppDomain defaults to true + +AppDomains are disabled by default. On .NET Framework, when running inside testhost (the default for `dotnet test` and VS), MSTest re-enables AppDomains automatically. If you need to explicitly control AppDomain isolation, set it via `.runsettings`: + +```xml + + + false + + +``` + +#### 4.2 TestContext throws when used incorrectly + +MSTest v4 now throws when accessing test-specific properties in the wrong lifecycle stage: + +- `TestContext.FullyQualifiedTestClassName` -- cannot be accessed in `[AssemblyInitialize]` +- `TestContext.TestName` -- cannot be accessed in `[AssemblyInitialize]` or `[ClassInitialize]` + +**Fix**: Move any code that accesses `TestContext.TestName` from `[ClassInitialize]` to `[TestInitialize]` or individual test methods, where per-test context is available. Do not replace `TestName` with `FullyQualifiedTestClassName` as a workaround -- they have different semantics. + +#### 4.3 TestCase.Id generation changed + +The generation algorithm for `TestCase.Id` has changed to fix long-standing bugs. This may affect Azure DevOps test result tracking (e.g., test failure tracking over time). There is no code fix needed, but be aware of test result history discontinuity. + +#### 4.4 TreatDiscoveryWarningsAsErrors defaults to true + +v4 uses stricter defaults. Discovery warnings are now treated as errors, which means tests that previously ran despite discovery issues may now fail entirely. If you see unexpected test failures after upgrading (not build errors, but tests not being discovered), check for discovery warnings. To restore v3 behavior while you investigate: + +```xml + + + false + + +``` + +> **Recommended**: Fix the underlying discovery warnings rather than suppressing this setting. + +#### 4.5 MSTest.Sdk and vstest.console compatibility + +MSTest.Sdk defaults to Microsoft.Testing.Platform (MTP) mode. In MTP mode, MSTest.Sdk does **not** add a reference to `Microsoft.NET.Test.Sdk` -- it only adds it in VSTest mode. This is not a v4-specific change; it applies to MSTest.Sdk v3 as well. Without `Microsoft.NET.Test.Sdk`, `vstest.console` cannot discover or run tests and will silently find zero tests. This commonly surfaces during migration when a CI pipeline uses `vstest.console` but the project uses MSTest.Sdk in its default MTP mode. + +**Option A -- Switch to VSTest mode**: Set the `UseVSTest` property. MSTest.Sdk will then automatically add `Microsoft.NET.Test.Sdk`: + +```xml + + + net8.0 + true + + +``` + +**Option B -- Switch CI to `dotnet test`**: Replace `vstest.console` invocations in your CI pipeline with `dotnet test`. This works natively with MTP and is the recommended long-term approach for MSTest.Sdk projects. + +If you need VSTest during a transition period, Option A works without changing CI pipelines. + +#### 4.6 Analyzer severity changes + +Multiple analyzers have been upgraded from Info to Warning by default: + +- MSTEST0001, MSTEST0007, MSTEST0017, MSTEST0023, MSTEST0024, MSTEST0025 +- MSTEST0030, MSTEST0031, MSTEST0032, MSTEST0035, MSTEST0037, MSTEST0045 + +Review and fix any new warnings, or suppress them in `.editorconfig` if intentional. + +### Step 5: Verify + +1. Run `dotnet build` -- confirm zero errors and review any new warnings +2. Run `dotnet test` -- confirm all tests pass +3. Compare test results (pass/fail counts) to the pre-migration baseline +4. If using Azure DevOps test tracking, be aware that `TestCase.Id` changes may affect history continuity +5. Check that no tests were silently dropped due to stricter discovery + +## Validation + +- [ ] All MSTest packages updated to 4.x +- [ ] Project builds with zero errors +- [ ] All tests pass with `dotnet test` +- [ ] Custom `TestMethodAttribute` subclasses updated for `ExecuteAsync` and CallerInfo +- [ ] `ExpectedExceptionAttribute` replaced with `Assert.ThrowsExactly` +- [ ] `Assert.ThrowsException` replaced with `Assert.ThrowsExactly` (or `Assert.Throws`) +- [ ] `ClassCleanupBehavior` enum usages removed +- [ ] `TestContext.Properties.Contains` updated to `ContainsKey` +- [ ] All target frameworks are net8.0+, net9.0, net462+, uap10.0.16299, or WinUI +- [ ] Behavioral changes reviewed and addressed +- [ ] No tests were lost during migration (compare test counts) + +## Related Skills + +- `writing-mstest-tests` -- for modern MSTest v4 assertion APIs and test authoring best practices +- `run-tests` -- for running tests after migration + +## Common Pitfalls + +| Pitfall | Solution | +|---------|----------| +| Custom `TestMethodAttribute` still overrides `Execute` | Change to `ExecuteAsync` returning `Task` | +| `TestMethodAttribute("display name")` no longer compiles | Use `TestMethodAttribute(DisplayName = "display name")` | +| `ClassCleanupBehavior` enum not found | Remove the enum argument; `[ClassCleanup]` now always runs at end of class. For end-of-assembly cleanup, use `[AssemblyCleanup]` | +| `TestContext.Properties.Contains` missing | Use `ContainsKey` -- `Properties` is now `IDictionary` | +| `ExpectedException` attribute not found | Replace with `Assert.ThrowsExactly(() => ...)` inside the test body | +| `Assert.ThrowsException` not found | Replace with `Assert.ThrowsExactly` (or `Assert.Throws` for derived types) | +| `Assert.AreEqual` with format string args fails | Use string interpolation: `$"message {value}"` | +| Tests hang that didn't before | AppDomain is disabled by default; on .NET Fx in testhost it is re-enabled automatically | +| Azure DevOps test history breaks | Expected -- `TestCase.Id` generation changed; no code fix, results will re-baseline | +| Discovery warnings now fail the run | `TreatDiscoveryWarningsAsErrors` is true by default; fix the discovery warnings | +| Net6.0/net7.0 targets don't compile | Update to net8.0 -- MSTest v4 supports net8.0, net9.0, net462, uap10.0.16299, modern UWP, and WinUI | diff --git a/.agents/skills/migrate-vstest-to-mtp/SKILL.md b/.agents/skills/migrate-vstest-to-mtp/SKILL.md new file mode 100644 index 00000000..e5ea69f4 --- /dev/null +++ b/.agents/skills/migrate-vstest-to-mtp/SKILL.md @@ -0,0 +1,343 @@ +--- +description: 'Migrates .NET test projects from VSTest to Microsoft.Testing.Platform (MTP). Use when user asks to "migrate to MTP", "switch from VSTest", "enable Microsoft.Testing.Platform", "use MTP runner", or mentions EnableMSTestRunner, EnableNUnitRunner, UseMicrosoftTestingPlatformRunner, or dotnet test exit code 8. Supports MSTest, NUnit, xUnit.net v2 (via YTest.MTP.XUnit2), and xUnit.net v3 (native MTP). Also covers translating xUnit.net v3 MTP filter syntax (--filter-class, --filter-trait, --filter-query). Covers runner enablement, CLI argument translation, Directory.Build.props and global.json configuration, CI/CD pipeline updates, and MTP extension packages. DO NOT USE FOR: migrating between test frameworks (MSTest/xUnit/NUnit), xUnit.net v2 to v3 API migration, MSTest version upgrades (use migrate-mstest-* skills), TFM upgrades, or UWP/WinUI test projects.' +metadata: + github-path: plugins/dotnet-test/skills/migrate-vstest-to-mtp + github-pinned: v1.0.0 + github-ref: refs/tags/v1.0.0 + github-repo: https://github.com/dotnet/skills + github-tree-sha: 315173b0e9ed1ed2da602be633fada8b6b831b0a +name: migrate-vstest-to-mtp +--- +# VSTest -> Microsoft.Testing.Platform Migration + +Migrate a .NET test solution from VSTest to Microsoft.Testing.Platform (MTP). The outcome is a solution where all test projects run on MTP, `dotnet test` works correctly, and CI/CD pipelines are updated. + +> **Important**: Do not mix VSTest-based and MTP-based .NET test projects in the same solution or run configuration -- this is an unsupported scenario. + +## When to Use + +- Switching from VSTest to Microsoft.Testing.Platform for any supported test framework +- Enabling `dotnet run` / `dotnet watch` / direct executable execution for test projects +- Enabling Native AOT or trimmed test execution +- Replacing `vstest.console.exe` with `dotnet test` on MTP +- Updating CI/CD pipelines from the VSTest task to the .NET Core CLI task +- Updating `dotnet test` arguments from VSTest syntax to MTP syntax + +## When Not to Use + +- The project already runs on Microsoft.Testing.Platform -- migration is done +- Migrating between test frameworks (e.g., MSTest to xUnit.net) -- different effort entirely +- The project builds UWP or packaged WinUI test projects -- MTP does not support these yet +- The solution mixes .NET and non-.NET test adapters (e.g., JavaScript or C++ adapters) -- VSTest is required +- Upgrading MSTest versions -- use `migrate-mstest-v1v2-to-v3` or `migrate-mstest-v3-to-v4` + +## Inputs + +| Input | Required | Description | +|-------|----------|-------------| +| Project or solution path | Yes | The `.csproj`, `.sln`, or `.slnx` entry point containing test projects | +| Test framework | No | MSTest, NUnit, xUnit.net v2, or xUnit.net v3. Auto-detected from package references | +| .NET SDK version | No | Determines `dotnet test` integration mode. Auto-detected via `dotnet --version` | +| CI/CD pipeline files | No | Paths to pipeline definitions that invoke `vstest.console` or `dotnet test` | + +## Workflow + +### Step 1: Assess the solution + +1. Identify the test framework for each test project -- see the `platform-detection` skill for the package-to-framework mapping. Key indicators: + - **MSTest**: References `MSTest` or `MSTest.TestAdapter`, or uses `MSTest.Sdk` (with `` not set to `false`). Note: `MSTest.TestFramework` alone is a library dependency, not a test project. + - **NUnit**: References `NUnit3TestAdapter` + - **xUnit.net**: References `xunit` and `xunit.runner.visualstudio` +2. Check the .NET SDK version (`dotnet --version`) -- this determines how `dotnet test` integrates with MTP +3. Check whether a `Directory.Build.props` file exists at the solution or repo root -- all MTP properties should go there for consistency +4. Check for `vstest.console.exe` usage in CI scripts or pipeline definitions +5. Check for VSTest-specific `dotnet test` arguments in CI scripts: `--filter`, `--logger`, `--collect`, `--settings`, `--blame*` +6. Run `dotnet test` to establish a baseline of test pass/fail counts + +### Step 2: Set up Directory.Build.props + +> **Critical**: Set MTP runner properties in `Directory.Build.props` at the solution or repo root whenever possible, rather than per-project. This prevents inconsistent configuration where some projects use VSTest and others use MTP (an unsupported scenario). +> **Note**: MTP also requires test projects to have `Exe`. Only `MSTest.Sdk` sets this automatically. For all other setups (MSTest NuGet packages with `EnableMSTestRunner`, NUnit with `EnableNUnitRunner`, xUnit.net with `YTest.MTP.XUnit2`), prefer setting `Exe` centrally in `Directory.Build.props` with a condition that targets only test projects. If you cannot reliably target only test projects from `Directory.Build.props`, setting `Exe` per-project is an acceptable exception. +> +> **Conditioning in `Directory.Build.props`**: Do NOT use `Condition="'$(IsTestProject)' == 'true'"` -- `IsTestProject` is set by the test SDK targets later in evaluation and is not available when `Directory.Build.props` is imported. Use a property that is available early, such as `MSBuildProjectName`, to target test projects by naming convention. For example, if all test projects end in `.Tests`: +> +> ```xml +> +> Exe +> +> ``` +> +> Adjust the condition (e.g., `.EndsWith('Tests')`, `.Contains('.Test')`) to match the test project naming convention used in the repository. + +### Step 3: Enable the framework-specific MTP runner + +Each framework has its own opt-in property. Add these in `Directory.Build.props` for consistency. + +#### MSTest + +**Option A -- MSTest NuGet packages (3.2.0+):** + +```xml + + true + Exe + +``` + +Ensure the project references MSTest 3.2.0 or later. If the version is already 3.2.0+, no MSTest version upgrade is needed for MTP migration. + +**Option B -- MSTest.Sdk:** + +When using `MSTest.Sdk`, MTP is enabled by default -- no `EnableMSTestRunner` or `OutputType Exe` property is needed (the SDK sets both automatically). The only action is: if the project has `true`, **remove it**. That property forces the project to use VSTest instead of MTP. + +#### NUnit + +Requires `NUnit3TestAdapter` **5.0.0** or later. + +1. Update `NUnit3TestAdapter` to 5.0.0+: + +```xml + +``` + +1. Enable the NUnit runner: + +```xml + + true + Exe + +``` + +#### xUnit.net + +Add a reference to `YTest.MTP.XUnit2` -- this package provides MTP support for xUnit.net v2 projects without requiring an upgrade to xunit.v3. You must also set `OutputType` to `Exe`: + +```xml + +``` + +```xml + + Exe + +``` + +> **Note**: `YTest.MTP.XUnit2` preserves the VSTest `--filter` syntax, so no filter migration is needed for xUnit.net v2. It also supports `--settings` for runsettings (xunit-specific configurations only), `xunit.runner.json`, TRX reporting via `--report-trx`, and `--treenode-filter`. + +#### xUnit.net v3 + +xUnit.net v3 (`xunit.v3` package) has built-in MTP support. Enable it with: + +```xml + + true + +``` + +> **Important**: xUnit.net v3 on MTP does NOT support the VSTest `--filter` syntax. You must translate filters to xUnit.net v3's native filter options (see Step 5). + +### Step 4: Configure dotnet test integration + +The `dotnet test` integration depends on the .NET SDK version. + +#### .NET 10 SDK and later (recommended) + +Use the native MTP mode by adding a `test` section to `global.json`: + +```json +{ + "sdk": { + "version": "10.0.100" + }, + "test": { + "runner": "Microsoft.Testing.Platform" + } +} +``` + +In this mode, `dotnet test` arguments are passed directly -- for example, `dotnet test --report-trx`. + +> **Important**: `global.json` does not support trailing commas. Ensure the JSON is strictly valid. + +#### .NET 9 SDK and earlier + +Use the VSTest mode of `dotnet test` command to run MTP test projects by adding this property in `Directory.Build.props`: + +```xml + + true + +``` + +> **Important**: In this mode, you must use `--` to separate `dotnet test` build arguments from MTP arguments. For example: `dotnet test --no-build -- --list-tests`. + +### Step 5: Update dotnet test command-line arguments + +VSTest-specific arguments must be translated to MTP equivalents. Build-related arguments (`-c`, `-f`, `--no-build`, `--nologo`, `-v`, etc.) are unchanged. + +| VSTest argument | MTP equivalent | Notes | +|-----------------|----------------|-------| +| `--test-adapter-path` | Not applicable | MTP does not use external adapter discovery | +| `--blame` | Not applicable | | +| `--blame-crash` | `--crashdump` | Requires `Microsoft.Testing.Extensions.CrashDump` NuGet package | +| `--blame-crash-dump-type ` | `--crashdump-type ` | Requires CrashDump extension | +| `--blame-hang` | `--hangdump` | Requires `Microsoft.Testing.Extensions.HangDump` NuGet package | +| `--blame-hang-dump-type ` | `--hangdump-type ` | Requires HangDump extension | +| `--blame-hang-timeout ` | `--hangdump-timeout ` | Requires HangDump extension | +| `--collect "Code Coverage;Format=cobertura"` | `--coverage --coverage-output-format cobertura` | Per-extension arguments | +| `-d\|--diag ` | `--diagnostic` | | +| `--filter ` | `--filter ` | Same syntax for MSTest, NUnit, and xUnit.net v2 (with `YTest.MTP.XUnit2`). For xUnit.net v3, see filter migration below | +| `-l\|--logger trx` | `--report-trx` | Requires `Microsoft.Testing.Extensions.TrxReport` NuGet package | +| `--results-directory ` | `--results-directory ` | Same | +| `-s\|--settings ` | `--settings ` | MSTest and NUnit still support `.runsettings` | +| `-t\|--list-tests` | `--list-tests` | Same | +| `-- ` | `--test-parameter` | Applicable only to MSTest and NUnit | + +#### Filter migration + +**MSTest, NUnit, and xUnit.net v2 (with `YTest.MTP.XUnit2`)**: The VSTest `--filter` syntax is identical on both VSTest and MTP. No changes needed. + +**xUnit.net v3 (native MTP)**: xUnit.net v3 does NOT support the VSTest `--filter` syntax on MTP. See the **VSTest → MTP filter translation** section in the `filter-syntax` skill for the complete translation table. Key translation example: + +```shell +# VSTest +dotnet test --filter "FullyQualifiedName~IntegrationTests&Category=Smoke" + +# xUnit.net v3 MTP -- using individual filters (AND behavior) +dotnet test -- --filter-class *IntegrationTests* --filter-trait "Category=Smoke" + +# xUnit.net v3 MTP -- using query language (assembly/namespace/class/method[trait]) +dotnet test -- --filter-query "/*/*/*IntegrationTests*/*[Category=Smoke]" +``` + +> **Note**: When combining `--filter-class` and `--filter-trait`, both conditions must match (AND behavior). For complex expressions, use `--filter-query` with the path-segment syntax. See the [xUnit.net query filter language docs](https://xunit.net/docs/query-filter-language) for full reference. + +### Step 6: Install MTP extension packages (if needed) + +If CI scripts use TRX reporting, crash dumps, or hang dumps, add the corresponding NuGet packages: + +```xml + + + + + + + + + + + +``` + +### Step 7: Update CI/CD pipelines + +#### Azure DevOps + +**If using the VSTest task (`VSTest@3`)**: Replace with the .NET Core CLI task (`DotNetCoreCLI@2`): + +```yaml +# Before (VSTest task) +- task: VSTest@3 + inputs: + testAssemblyVer2: '**/*Tests.dll' + runSettingsFile: 'test.runsettings' + +# After (.NET Core CLI task) +- task: DotNetCoreCLI@2 + displayName: Run tests + inputs: + command: 'test' + arguments: '--no-build --configuration Release' +``` + +**If already using DotNetCoreCLI@2**: Update arguments per Step 5 translations. Remember the `--` separator on .NET 9 and earlier: + +```yaml +- task: DotNetCoreCLI@2 + displayName: Run tests + inputs: + command: 'test' + arguments: '--no-build -- --report-trx --results-directory $(Agent.TempDirectory)' +``` + +#### GitHub Actions + +Update `dotnet test` invocations in workflow files with the same argument translations from Step 5. + +#### Replace vstest.console.exe + +If any script invokes `vstest.console.exe` directly, replace it with `dotnet test`. The test projects are now executables and can also be run directly. + +### Step 8: Handle behavioral differences + +#### Zero tests exit code + +VSTest silently succeeds when zero tests are discovered. MTP fails with **exit code 8**. Options: + +- Pass `--ignore-exit-code 8` when running tests +- Add to `Directory.Build.props`: + +```xml + + $(TestingPlatformCommandLineArguments) --ignore-exit-code 8 + +``` + +- Use environment variable: `TESTINGPLATFORM_EXITCODE_IGNORE=8` + +### Step 9: Remove VSTest-only packages (optional) + +Once migration is complete and verified, remove packages that are only needed for VSTest: + +- `Microsoft.NET.Test.Sdk` -- not needed for MTP (MSTest.Sdk v4 already omits it by default) +- `xunit.runner.visualstudio` -- only needed for VSTest discovery of xUnit.net (not needed when using `YTest.MTP.XUnit2`) +- `NUnit3TestAdapter` VSTest-only features -- the adapter is still needed but only for the MTP runner + +> **Note**: If you need to maintain VSTest compatibility during a transition period, keep these packages. + +### Step 10: Verify + +1. Run `dotnet build` -- confirm zero errors +2. Run `dotnet test` -- confirm all tests pass +3. Compare test pass/fail counts to the pre-migration baseline +4. Run the test executable directly (e.g., `./bin/Debug/net8.0/MyTests.exe`) -- confirm it works +5. Verify CI pipeline produces the expected test result artifacts (TRX files, code coverage, crash dumps) +6. Test that Test Explorer in Visual Studio (17.14+) or VS Code discovers and runs tests + +## Validation + +- [ ] All test projects use MTP runner (no VSTest-only configuration remains) +- [ ] `dotnet build` completes with zero errors +- [ ] `dotnet test` passes all tests and test counts match pre-migration baseline +- [ ] Test executable runs directly (e.g., `./bin/Debug/net8.0/MyTests.exe`) +- [ ] CI pipeline produces expected test result artifacts (TRX files, code coverage, crash dumps) +- [ ] Test Explorer in Visual Studio or VS Code discovers and runs tests +- [ ] No `vstest.console.exe` invocations remain in CI scripts +- [ ] `Exe` is set for all non-MSTest.Sdk test projects + +## Common Pitfalls + +| Pitfall | Solution | +|---------|----------| +| Mixing VSTest and MTP projects in the same solution | Migrate all test projects together -- mixed mode is unsupported | +| `dotnet test` arguments ignored on .NET 9 and earlier | Use `--` to separate build args from MTP args: `dotnet test -- --report-trx` | +| Exit code 8 on CI without failures | MTP fails when zero tests run; use `--ignore-exit-code 8` or fix test discovery | +| MSTest.Sdk v4 + vstest.console no longer works | MSTest.Sdk v4 no longer adds `Microsoft.NET.Test.Sdk` -- add it explicitly or switch to `dotnet test` | +| Missing `Exe` | Required for all setups except MSTest.Sdk (which sets it automatically) | +| Using `Condition="'$(IsTestProject)' == 'true'"` in `Directory.Build.props` | `IsTestProject` is not yet defined when `Directory.Build.props` is evaluated -- use `$(MSBuildProjectName.EndsWith('.Tests'))` (or a similar name-based check) instead | + +## Next Steps + +- Use `run-tests` for running tests on the new MTP platform +- Use `mtp-hot-reload` for iterative test fixing with hot reload on MTP + +## More Info + +- [Test platforms overview](https://learn.microsoft.com/dotnet/core/testing/test-platforms-overview) +- [Migrate from VSTest to Microsoft.Testing.Platform](https://learn.microsoft.com/dotnet/core/testing/migrating-vstest-microsoft-testing-platform) +- [Microsoft.Testing.Platform overview](https://learn.microsoft.com/dotnet/core/testing/microsoft-testing-platform-intro) +- [Testing with dotnet test](https://learn.microsoft.com/dotnet/core/testing/unit-testing-with-dotnet-test) +- [Microsoft.Testing.Platform CLI options](https://learn.microsoft.com/dotnet/core/testing/microsoft-testing-platform-cli-options) +- [Microsoft.Testing.Platform extensions](https://learn.microsoft.com/dotnet/core/testing/unit-testing-platform-extensions)