mirror of
https://github.com/dotnet/skills.git
synced 2026-09-20 09:49:54 +08:00
Add csharp-refactoring and dotnet-breaking-changes dotnet skills
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -14,6 +14,8 @@
|
||||
/tests/dotnet/setup-local-sdk/ @dotnet/roslyn-ide @dotnet/skills-csharp-language-reviewers
|
||||
/plugins/dotnet/skills/csharp-refactoring/ @dotnet/roslyn-ide @dotnet/skills-csharp-language-reviewers
|
||||
/tests/dotnet/csharp-refactoring/ @dotnet/roslyn-ide @dotnet/skills-csharp-language-reviewers
|
||||
/plugins/dotnet/skills/dotnet-breaking-changes/ @dotnet/roslyn-ide @dotnet/skills-csharp-language-reviewers
|
||||
/tests/dotnet/dotnet-breaking-changes/ @dotnet/roslyn-ide @dotnet/skills-csharp-language-reviewers
|
||||
/plugins/dotnet/lsp.json @dotnet/roslyn-ide @dotnet/skills-csharp-language-reviewers
|
||||
|
||||
# dotnet-advanced (advanced .NET development skills)
|
||||
|
||||
@@ -20,4 +20,5 @@ Prerequisites:
|
||||
## Skills
|
||||
|
||||
- [csharp-refactoring](skills/csharp-refactoring/SKILL.md)
|
||||
- [dotnet-breaking-changes](skills/dotnet-breaking-changes/SKILL.md)
|
||||
- [setup-local-sdk](skills/setup-local-sdk/SKILL.md)
|
||||
|
||||
@@ -1,105 +1,192 @@
|
||||
---
|
||||
name: csharp-refactoring
|
||||
description: "Performs safe, behavior-preserving refactoring of C#/.NET code, verified with build, tests, and analyzers. USE FOR: any request to rename, move, extract, split, modernize, or otherwise restructure C# code without changing behavior, including small requests like 'rename X to Y': rename a symbol/type/file across a solution; move a type or static members to another file/namespace/project; extract a method, interface, or base class; pull members up; inline a method or local; split a large class/file; consolidate or de-duplicate copy-pasted code; sync namespaces to folders; modernize to current C# idioms (file-scoped namespaces, primary constructors, collection expressions, target-typed new, pattern matching); or enable nullable reference annotations. DO NOT USE FOR: adding features, fixing bugs, writing new tests, upgrading frameworks or NuGet versions (use dotnet-upgrade), or formatting-only passes (use dotnet format)."
|
||||
description: "Performs safe, behavior-preserving refactoring of C#/.NET code, verified with build, tests, and analyzers. Use when the user wants to refactor, rename, restructure, clean up, modernize, or reorganize C# code WITHOUT changing behavior: rename a symbol/type/file across a solution; move a type or static members to another file/namespace/project; extract a method, interface, or base class; pull members up; inline a method or local; split a large class/file; consolidate or de-duplicate copy-pasted code; sync namespaces to folders; modernize to current C# idioms (file-scoped namespaces, primary constructors, collection expressions, target-typed new, pattern matching); or enable nullable reference annotations. Prefers Roslyn-backed edits over text find/replace, and defers .NET compatibility hazards to the companion dotnet-breaking-changes skill. DO NOT USE FOR: adding features, fixing bugs, writing new tests, upgrading frameworks or NuGet versions (use dotnet-upgrade), or formatting-only passes (use dotnet format)."
|
||||
license: MIT
|
||||
---
|
||||
|
||||
# C# Refactoring (behavior-preserving)
|
||||
|
||||
A refactor changes **structure**, never observable **behavior**. Do the edit with binding-aware tools,
|
||||
then confirm behavior held with a build + the relevant tests. Keep the effort proportional to the change:
|
||||
a one-line local rename does not need the ceremony a public multi-targeted change does.
|
||||
Refactor C#/.NET code so the structure improves but the observable behavior does **not** change. Every
|
||||
refactor is a sequence of small, named, Roslyn-aware transformations, each followed by a build/test
|
||||
gate. If the gate fails, stop and revert — a refactor that changes behavior is a bug, not a refactor.
|
||||
|
||||
## First, is this actually a refactor?
|
||||
## Operation catalog (what "refactoring" means in C#)
|
||||
|
||||
The most valuable thing this skill does is *not* restructure code you were told to restructure — it is
|
||||
catching a request that is **not** behavior-preserving before you run it through a refactor's contract.
|
||||
When the request changes results, decline the refactor framing and handle it honestly:
|
||||
The canonical C# refactoring operations, aligned with Roslyn's IDE refactoring providers:
|
||||
|
||||
- **Framework / NuGet version bump** → not a refactor. Stop this workflow without editing project or
|
||||
package files, explain the reclassification, and redirect to the `dotnet-upgrade` skills. A successful
|
||||
build does not make an upgrade behavior-preserving.
|
||||
- **New feature** (e.g. add a pricing tier, a flag, an endpoint) → a feature, not a refactor. If asked,
|
||||
build it as a feature *with its own tests* and state that the new behavior is intentional; don't claim
|
||||
the feature itself is behavior-preserving. You can still perform a separable structural cleanup around
|
||||
it, but distinguish the refactor from the feature in both the implementation and the final report.
|
||||
- **Bug fix or "simplification" that changes output** (e.g. always charge shipping, bump a discount) →
|
||||
a behavior **change**. It is a legitimate task — do it as an explicit, tested change and update the
|
||||
tests that lock in the new behavior — but only after it is authorized as a behavior change. Under an
|
||||
explicitly behavior-preserving request, leave that edit undone, complete only any separable structural
|
||||
operation, and report the deferred change. Never label the behavior change behavior-preserving.
|
||||
- **A rename/move with a behavior tweak smuggled in** ("rename X, and while you're there bump the rate")
|
||||
→ do the rename/move as the behavior-preserving operation and defer the tweak. Perform the tweak only
|
||||
after the user separately accepts it as a tested behavior change; do not silently turn one
|
||||
"behavior-preserving" task into two edits.
|
||||
- **Rename** a symbol / type / file
|
||||
- **Move** a type / member / file (to another file, namespace, or project)
|
||||
- **Consolidate / de-duplicate** copy-pasted code
|
||||
- **Modernize / simplify** to current C# idioms (the repo's analyzers decide the idiom, not taste)
|
||||
- **Split** a large class / file / assembly
|
||||
- **Extract** a method / class / interface
|
||||
- **Enable nullable** reference annotations
|
||||
- **Inline** a method / local / constant
|
||||
- **Pull up / push down** a member
|
||||
- **Sync namespace** to folder
|
||||
|
||||
Only when the request is genuinely structure-only do you proceed as a refactor.
|
||||
|
||||
## Rename / move by bindings, not text
|
||||
|
||||
The #1 way a "rename" silently corrupts code is editing textual matches (comments, strings, unrelated
|
||||
overloads) instead of real **bindings**. Find every binding reference first, then edit semantically. Use
|
||||
the strongest tool available: an IDE/Roslyn workspace refactoring, then the configured C# LSP
|
||||
(`findReferences`, `goToDefinition`, `incomingCalls`, `rename` code action), then analyzer code-fixes /
|
||||
Roslynator, then compiler-validated edits (edit the true bindings, rebuild, let the compiler flag misses).
|
||||
Plain find/replace only when scope is provably tiny and every hit is verified. Include **every** `partial`
|
||||
declaration, and edit the generator input, never generated (`*.g.cs`) output.
|
||||
|
||||
For the operation → Roslyn-provider mapping and representative PRs, see
|
||||
Each is a single named operation; compose them one step at a time (see Procedure). For the Roslyn
|
||||
provider mapping and representative real PRs, see
|
||||
[references/operation-catalog.md](references/operation-catalog.md).
|
||||
|
||||
## Consolidate toward the existing source of truth
|
||||
## Tooling — C# LSP (Roslyn language server)
|
||||
|
||||
When de-duplicating, preserve the ownership direction stated by the code or request. If `B` duplicates
|
||||
an implementation already owned by `A`, keep `A` canonical and make `B` delegate to it; do not invert
|
||||
the dependency merely because either direction compiles. Preserve public compatibility wrappers when
|
||||
the duplicate surface is shipped, and migrate only in-repo callers that are safe to move.
|
||||
A headless CLI agent usually can't invoke IDE code actions, but it **can** get Roslyn-quality
|
||||
_semantic navigation_ from the C# language server the [`dotnet` plugin in `dotnet/skills`](https://github.com/dotnet/skills/blob/main/plugins/dotnet/lsp.json)
|
||||
declares. It launches through the .NET CLI (`dnx roslyn-language-server --yes --prerelease -- --stdio
|
||||
--autoLoadProjects`) over `.cs`, `.razor`, and `.cshtml` files (prerequisite: a .NET 10 SDK with
|
||||
`dotnet` on `PATH`).
|
||||
|
||||
## Verify proportionally
|
||||
When the LSP is available, prefer its binding-aware operations over textual grep/glob for every
|
||||
reference-finding step in a refactor:
|
||||
|
||||
Confirm behavior is preserved after the edit — scaled to blast radius, not a fixed ceremony:
|
||||
- Where a symbol is defined → **goToDefinition**
|
||||
- All _binding_ references to a symbol → **findReferences** (the reliable rename/move safety net)
|
||||
- What calls a method → **incomingCalls**
|
||||
- Find symbols by name across the workspace → **workspaceSymbol**
|
||||
- A symbol's type / signature / docs → **hover**
|
||||
|
||||
- **Local / private** (method-local or `private` member, one file, single target framework, no public
|
||||
surface, no `partial`/generated/`#if`): skip a separate baseline unless the tree is already suspect.
|
||||
Make the edit, then run the narrowest build and relevant tests once. Let the compiler catch missed
|
||||
references.
|
||||
- **Cross-boundary** (public/shipped symbol, multi-targeted project, `#if`/platform branches, or
|
||||
`partial`/generated code): establish a baseline, then build/test **each** target framework after the
|
||||
edit (a green default build can hide a break on another TFM), and run the hazards check below.
|
||||
These are exact, binding-aware answers — unlike grep they don't match comments, string literals, or
|
||||
unrelated overloads. Reach for the LSP first; fall back to grep only when it is unavailable.
|
||||
|
||||
Use the repo's own build/test workflow when it documents one (`README`/`CONTRIBUTING`, `build.*`, `eng/`,
|
||||
`global.json`, `.github/workflows`); its instructions win over any generic command.
|
||||
## .NET compatibility hazards — inspect first, then load `dotnet-breaking-changes`
|
||||
|
||||
### Typical workflow (one operation)
|
||||
1. Choose one named refactoring operation and keep the step focused on that operation only.
|
||||
2. Find true binding references (`findReferences`/`goToDefinition`/rename) and include all `partial` declarations.
|
||||
3. Establish a baseline first only for a cross-boundary change or a tree not already known green.
|
||||
4. Apply the change via the most semantics-aware tool available; avoid blind find/replace when possible.
|
||||
5. Rebuild and run the relevant tests. If the gate goes red, report the failure and repair or reassess only
|
||||
your edit; never discard unrelated worktree changes.
|
||||
Behavior-preserving edits fail in _.NET-specific_ ways a green test run won't catch. Before you edit,
|
||||
**search the repo** for the surfaces that govern the symbol — don't assume or recite:
|
||||
|
||||
Otherwise:
|
||||
```bash
|
||||
dotnet build # 0 errors
|
||||
dotnet test # stays green; same pass count as before
|
||||
```
|
||||
- Public-API gate: `PublicAPI.Shipped/Unshipped.txt` (PublicApiAnalyzers) and/or `ApiCompat` /
|
||||
`<EnablePackageValidation>` — these are _not_ interchangeable.
|
||||
- `<TargetFrameworks>` and `#if` / platform branches.
|
||||
- `partial` declarations and generated (`*.g.cs`) files.
|
||||
- `InternalsVisibleTo` friend/test assemblies.
|
||||
|
||||
One operation per step; never mix a refactor and a behavior change in the same step. On red, revert — a
|
||||
refactor that changes behavior is a bug, not a refactor.
|
||||
The four bullets above (and the reminders below) are the **floor**: apply them even if the guide skill
|
||||
is not installed. When it _is_ available, **load the `dotnet-breaking-changes` skill** for the full
|
||||
per-surface playbook (it applies to any change, not just refactors, and holds the depth on each
|
||||
surface); it _adds_ compatibility analysis but does **not** replace this skill's safety contract.
|
||||
Refactor-specific reminders: move a public type across assemblies via a `[TypeForwardedTo]` forwarder
|
||||
(a _rename_ needs an `[Obsolete]` shim, not a forwarder); include _every_ `partial` declaration in a
|
||||
rename/move; edit the generator input, not generated output; and treat friend-assembly `internal`
|
||||
members with public-API care.
|
||||
|
||||
## Cross-boundary hazards (only when it touches a boundary)
|
||||
## Stop and escalate (do not silently proceed)
|
||||
|
||||
If — and only if — the change touches a **public** symbol, a **multi-targeted** project, or
|
||||
`partial`/generated code, some breaks won't show up as a failing test. Search the repo for the surface
|
||||
that governs the symbol (don't assume): the public-API gate (`PublicAPI.Shipped/Unshipped.txt` for
|
||||
PublicApiAnalyzers, and/or `ApiCompat`/`<EnablePackageValidation>` — not interchangeable),
|
||||
`<TargetFrameworks>`/`#if` branches, and `InternalsVisibleTo`. Move a public type via a `[TypeForwardedTo]`
|
||||
forwarder; a *rename* needs an `[Obsolete]` shim, not a forwarder. For a provably local/private change,
|
||||
skip these checks.
|
||||
Halt and ask the user before continuing when:
|
||||
|
||||
## Stop and ask when
|
||||
- The **baseline is red** (build or tests already failing) — you can't prove you preserved behavior.
|
||||
- A **public/shipped API** would change and you cannot verify compatibility (no type-forwarder/shim
|
||||
path, and no PublicApiAnalyzers/ApiCompat/package-validation gate available to catch a break).
|
||||
- The request is **not actually behavior-preserving** — it asks you to upgrade a framework/package,
|
||||
add a feature, or fix a bug. Do **not** carry it out under this skill, and **never label such a
|
||||
change "behavior-preserving."** Say it is out of scope and redirect (upgrades → `dotnet-upgrade`;
|
||||
features/fixes → normal dev flow). If a refactor is genuinely a prerequisite, do only that, as a
|
||||
separate step, and stop.
|
||||
- The edit touches **generated, designer, or migration files** (`*.g.cs`, `*.Designer.cs`, EF
|
||||
migrations, source-generator output) — hand-edits there are **overwritten on the next build**;
|
||||
change the source of generation (template/generator input), not the output.
|
||||
- Semantic equivalence depends on **runtime behavior not covered by tests** (reflection, DI wiring,
|
||||
serialization, `dynamic`, P/Invoke) — flag the risk; tests alone won't catch a regression.
|
||||
- The operation can't be done with any tool on the fallback ladder and would require a wide,
|
||||
unverifiable text replace.
|
||||
|
||||
- The baseline is already red (you can't prove you preserved behavior).
|
||||
- A public/shipped API would change and there is no forwarder/shim path and no analyzer/ApiCompat gate.
|
||||
- Equivalence depends on runtime behavior tests don't cover (reflection, DI, serialization, `dynamic`,
|
||||
P/Invoke) — flag it.
|
||||
## When to use
|
||||
|
||||
- "Rename this method/type everywhere safely" / "rename across the solution"
|
||||
- "Extract this block into a method" / "extract an interface from this class"
|
||||
- "Move this type into its own file / into project X / into namespace Y"
|
||||
- "This class is too big — split it" / "consolidate these two near-identical implementations"
|
||||
- "Modernize this file to current C#" / "use file-scoped namespaces and primary constructors"
|
||||
- "Turn on nullable annotations for this project and fix the warnings"
|
||||
- Any request to **restructure / clean up / reorganize** code while keeping behavior identical
|
||||
|
||||
## When NOT to use
|
||||
|
||||
- The change is meant to alter behavior, add a feature, or fix a bug (not a refactor)
|
||||
- Framework/SDK/NuGet version upgrades → `dotnet-upgrade`
|
||||
- Pure formatting/whitespace → `dotnet format`
|
||||
- Writing brand-new tests → `writing-mstest-tests` / `code-testing-agent`
|
||||
|
||||
## Procedure (the safety contract)
|
||||
|
||||
1. **Establish a green baseline.** Build the affected projects and run the relevant tests. Never
|
||||
refactor on a red baseline — you won't be able to tell what you broke.
|
||||
2. **Pick ONE operation from the catalog.** Refactors compose, but each step is a single named
|
||||
operation. Never mix a refactor with a behavior change in the same step.
|
||||
3. **Find the true references first (for rename/move/inline).** Before editing, locate every _binding_
|
||||
reference to the symbol — not textual matches. Prefer the C# LSP: **findReferences** /
|
||||
**goToDefinition** / **incomingCalls** (see **Tooling**) return only true bindings. If the LSP is
|
||||
unavailable, a headless grep finds candidates but also false positives (test method names, comments,
|
||||
strings, unrelated overloads); treat grep as a _candidate list_, then keep only true bindings and let
|
||||
the compiler catch any you missed. Skipping this is the #1 way a "rename" silently corrupts code.
|
||||
4. **Prefer semantics-aware edits over text edits.** The principle is _semantic verification_, not a
|
||||
specific API — use the strongest tool actually available, in this order (fallback ladder):
|
||||
1. An IDE / Roslyn workspace refactoring (rename, move, extract, inline, pull-up) when available —
|
||||
updates all references, `using`s, and partial declarations correctly.
|
||||
2. **The C# LSP (Roslyn language server) from `dotnet/skills`** (see **Tooling**) for a headless
|
||||
agent: drive edits from **findReferences** / **goToDefinition** / **incomingCalls** /
|
||||
**workspaceSymbol** so every touched reference is a true binding, not a textual guess. Some
|
||||
language-server builds also expose a `textDocument/rename` code action that rewrites all
|
||||
references at once — use it when present.
|
||||
3. Analyzer code-fixes / `dotnet format analyzers` / **Roslynator** for idiom modernization and
|
||||
de-duplication, when installed.
|
||||
4. **Compiler-validated, reference-tracked edits** (the common headless fallback): use the LSP (or
|
||||
grep, if no LSP) to enumerate candidates → edit only the true binding references from step 3 →
|
||||
rebuild. The compiler is your safety net: any missed or wrongly-edited reference becomes a build
|
||||
error, not a silent bug.
|
||||
5. Plain find/replace ONLY when scope is provably tiny and every reference is verified — it
|
||||
silently corrupts strings, comments, and unrelated overloads.
|
||||
A CLI agent often won't have a loaded MSBuildWorkspace, but it **can** load the C# LSP above; prefer
|
||||
its semantic answers over grep, and never skip the build/test gate to compensate.
|
||||
5. **Re-gate after every step — across every target framework.** Rebuild + re-run tests; if red, revert
|
||||
this step and reassess. The diff must be behavior-neutral: tests still green, no new warnings (nullable
|
||||
work is the deliberate exception), no new analyzer/API-compat diagnostics, and the intended public
|
||||
contract unchanged. On a multi-targeted project, build/test **each** TFM — a green default build can
|
||||
still be broken on another target.
|
||||
|
||||
```bash
|
||||
dotnet build # 0 errors, on every TargetFramework (add -f <tfm> to check one)
|
||||
dotnet test # must stay green; same pass count as baseline
|
||||
# if the gate fails, revert THIS step and reassess:
|
||||
git restore . # or: git checkout -- <changed files>
|
||||
```
|
||||
6. **Keep the relevant contract stable — and which contract depends on the project type:**
|
||||
- **Library / shipped package:** preserve the .NET public API; move public types across assembly
|
||||
boundaries via type-forwarders or `[Obsolete]` shims, not breaking moves.
|
||||
- **Application / service:** preserve the _external_ contract (HTTP routes, config keys, DB schema,
|
||||
CLI args); internal type surface can move freely.
|
||||
- **Small / private codebase:** preserve behavior + tests; commit granularity can relax.
|
||||
7. **One refactor per commit** for libraries/large repos (keeps review + `git bisect` meaningful);
|
||||
relax for small private codebases.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Target scope: a symbol, file, type, project, or directory.
|
||||
- The operation (from the catalog) — or infer it from the request.
|
||||
- How to build and test the affected projects (solution/proj path, test command).
|
||||
|
||||
## Outputs
|
||||
|
||||
- The applied refactoring as a minimal, behavior-preserving diff.
|
||||
- Proof of safety: before/after build + test results.
|
||||
- A short rationale naming the operation(s) applied.
|
||||
|
||||
## Anti-patterns to avoid
|
||||
|
||||
- Renaming via text replace (hits strings, comments, unrelated overloads).
|
||||
- "Refactor + small fix" in one step (a behavior change masquerading as a refactor).
|
||||
- Moving a public type without a forwarder/shim (silent breaking change) — or refactoring a public
|
||||
surface with no PublicApiAnalyzers/ApiCompat gate to catch a break.
|
||||
- Editing only the TFM/`#if` branch your editor shows, leaving other targets broken.
|
||||
- Renaming a `partial` type without its other declarations, or editing generated (`*.g.cs`) output
|
||||
instead of the generator input.
|
||||
- Skipping the test gate "because it's just a rename."
|
||||
|
||||
## Reference Files
|
||||
|
||||
- **[references/operation-catalog.md](references/operation-catalog.md)** — the full operation taxonomy
|
||||
with Roslyn providers and representative real PRs.
|
||||
**Load when** you need the provider for an operation or more detail on the catalog.
|
||||
|
||||
For .NET compatibility hazards (public API, multi-targeting/`#if`, source-generated/partial code,
|
||||
`InternalsVisibleTo`), load the companion `dotnet-breaking-changes` skill and its `references/` — it
|
||||
holds the depth on each surface and applies to any change, not just refactors.
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
name: dotnet-breaking-changes
|
||||
description: >
|
||||
Keep the observable .NET/C# contract intact when editing: additions count too, and the
|
||||
contract is wider than the file you are editing. Covers the public API surface and the
|
||||
DIFFERENT gates repos use for it (PublicApiAnalyzers vs ApiCompat/package validation),
|
||||
nullable/trimming/AOT annotations as API, multi-targeting and #if branches (behavior per
|
||||
target framework), source-generated and partial code, and InternalsVisibleTo.
|
||||
USE FOR: any edit to a shipped library/NuGet package or cross-assembly/multi-targeted code —
|
||||
adding a public/protected member, overload, or target framework; widening what a public method
|
||||
accepts or returns; renaming, moving, or removing a member; changing a signature, nullability,
|
||||
or attribute; touching a partial or source-generated type; or answering "is this a breaking
|
||||
change?". Applies to features, fixes, and refactors alike.
|
||||
DO NOT USE FOR: framework/SDK/NuGet upgrades (use dotnet-upgrade skills), pure formatting, or a
|
||||
single-target private app with no public/cross-assembly surface.
|
||||
license: MIT
|
||||
---
|
||||
|
||||
# .NET breaking changes: the contract is wider than the file
|
||||
|
||||
When you edit .NET code, the observable contract is usually **larger than the snippet you are
|
||||
looking at**. A change that compiles locally and keeps tests green can still break a downstream
|
||||
consumer, another target framework, a friend assembly, or regenerate away on the next build. This
|
||||
skill names the four hidden surfaces, tells you how to find which ones a repo actually uses, and
|
||||
points to a reference file for each. It is deliberately _not_ refactoring-specific — the same
|
||||
hazards apply to a feature or a bug fix, and to **additions** (a new public member, overload, or
|
||||
target framework) as much as to removals: adding surface creates a permanent contract obligation,
|
||||
and adding behavior to a multi-targeted or partial type must stay correct on every target and
|
||||
survive the next regeneration. For the behavior-preserving refactoring **process**
|
||||
(green baseline → one named op → re-gate), use the `csharp-refactoring` skill; this skill is the
|
||||
compatibility knowledge it (and any other change) taps into.
|
||||
|
||||
## Inspect first — find which surfaces this repo actually has
|
||||
|
||||
Do not recite these checks or assume; **search the repo and adapt to what exists.** The markers
|
||||
present dictate the validation plan; markers absent tell you a surface is not in play.
|
||||
|
||||
```bash
|
||||
# Public-API gate (which one? they are not interchangeable)
|
||||
git ls-files "**/PublicAPI.Shipped.txt" "**/PublicAPI.Unshipped.txt" # PublicApiAnalyzers
|
||||
grep -rl "EnablePackageValidation\|ApiCompat\|Microsoft.DotNet.ApiCompat" --include=*.props --include=*.targets --include=*.csproj .
|
||||
# Multi-targeting and conditional code
|
||||
grep -rl "<TargetFrameworks>" --include=*.csproj --include=*.props .
|
||||
grep -rn "#if " --include=*.cs . # NET*, platform, and custom symbols
|
||||
# Generated / partial code
|
||||
git ls-files "*.g.cs" "*.generated.cs" ; grep -rln "partial class\|partial record\|partial struct" --include=*.cs .
|
||||
# Friend assemblies
|
||||
grep -rn "InternalsVisibleTo" --include=*.cs --include=*.csproj --include=*.props .
|
||||
```
|
||||
|
||||
Then read only the reference(s) for the surfaces you actually found.
|
||||
|
||||
## The four hidden surfaces (summary — depth in references/)
|
||||
|
||||
Public API (1) is the **default** surface to check on any library edit; the other three are
|
||||
**conditional** — pursue them only when the inspect-first markers above show they are in play.
|
||||
|
||||
1. **Public API surface.** In a shipped library, renaming/moving/removing/re-signing a public
|
||||
member — or changing nullability, a generic constraint, or a trimming/AOT attribute — is a
|
||||
breaking change a green test run will not catch. Repos gate this **two different, non-
|
||||
interchangeable ways**: source-level (`PublicApiAnalyzers` + `PublicAPI.*.txt`, which you
|
||||
maintain) and binary/package-level (`ApiCompat` / `<EnablePackageValidation>`). Run/respect the
|
||||
gate that exists; if none exists, review the surface by hand and flag it — do **not** bolt on
|
||||
analyzer infrastructure as a side effect. See `references/public-api.md`.
|
||||
|
||||
2. **Multi-targeting and #if.** Code that multi-targets (`<TargetFrameworks>`) or compiles
|
||||
conditionally (`#if NET8_0_OR_GREATER`, platform/CoreCLR/Mono/NativeAOT branches) can build for
|
||||
the target your editor shows and break one it never invoked. Inspect every branch — including
|
||||
inactive ones — preserve intentional divergence, and re-gate each TFM/RID. See
|
||||
`references/multi-targeting.md`.
|
||||
|
||||
3. **Source-generated and partial code.** A type is often `partial` across several files, and part
|
||||
may be **generated** (source generators, Razor, `*.g.cs`). Include every partial declaration;
|
||||
treat generated files as derived and change the generator input/template unless the repo checks
|
||||
the output in as source. See `references/source-generation.md`.
|
||||
|
||||
4. **InternalsVisibleTo.** `internal` is not private across a solution: test and friend assemblies
|
||||
bind to internals (and, when strong-named, to a public key). An internal rename/move/removal can
|
||||
break a consumer with no reference in the declaring project. See
|
||||
`references/internals-visible-to.md`.
|
||||
|
||||
## Stop and escalate (do not silently proceed)
|
||||
|
||||
- A **public/shipped API** would change and you cannot verify compatibility — no shim/forwarder
|
||||
path and no PublicApiAnalyzers/ApiCompat/package-validation gate to catch a break.
|
||||
- The edit changes an **observable annotation** (nullability, `[DynamicallyAccessedMembers]`,
|
||||
`[RequiresUnreferencedCode]`, generic constraints) on a public member.
|
||||
- The change can only be made consistent across **some but not all** target frameworks or platforms.
|
||||
- The only way to apply it is editing **generated output** that the next build will overwrite.
|
||||
|
||||
## When to use
|
||||
|
||||
- "Is this a breaking change?" / "Will this break the public API / the NuGet package?"
|
||||
- Editing, renaming, or removing a public or `internal` member in a library
|
||||
- Changing a signature, nullability, generic constraint, or trimming/AOT attribute
|
||||
- Editing a multi-targeted project, code under `#if`, or a platform-specific branch
|
||||
- Touching a `partial` type or source-generated code
|
||||
|
||||
## When NOT to use
|
||||
|
||||
- Framework/SDK/NuGet **version upgrades** → the `dotnet-upgrade` plugin (`migrate-*`,
|
||||
`dotnet-aot-compat`, `migrate-nullable-references`)
|
||||
- Pure formatting/whitespace → `dotnet format`
|
||||
- A single-target private app with no public or cross-assembly surface (no hidden contract to break)
|
||||
|
||||
## Reference Files
|
||||
|
||||
- **[references/public-api.md](references/public-api.md)** — the two API gates and why they are not
|
||||
interchangeable, nullable/trimming/AOT as observable API, `[Obsolete]` shims and type-forwarders,
|
||||
suppression baselines. **Load when** editing a public/`internal` member in a library, changing a
|
||||
signature/nullability/attribute, or asked whether something is a breaking change.
|
||||
- **[references/multi-targeting.md](references/multi-targeting.md)** — TFMs, `#if` and platform
|
||||
symbols, RIDs, and how to inspect and re-gate every target. **Load when** the project has
|
||||
`<TargetFrameworks>` or the code uses `#if`.
|
||||
- **[references/source-generation.md](references/source-generation.md)** — partial types, generated
|
||||
output vs checked-in source, editing the generator input. **Load when** the symbol is `partial`
|
||||
or lives in `*.g.cs`/generated files. (See also `dotnet-msbuild/including-generated-files`.)
|
||||
- **[references/internals-visible-to.md](references/internals-visible-to.md)** — friend/test
|
||||
assemblies and strong-name keys. **Load when** the repo has `InternalsVisibleTo` and you touch an
|
||||
`internal` member.
|
||||
|
||||
## Related skills
|
||||
|
||||
- `csharp-refactoring` — the behavior-preserving refactoring process that consumes this knowledge.
|
||||
This guide _adds_ compatibility analysis; it does **not** replace that skill's green-baseline → one
|
||||
named op → re-gate → revert-on-red workflow.
|
||||
- `dotnet-upgrade/migrate-nullable-references`, `dotnet-upgrade/dotnet-aot-compat` — for _adopting_
|
||||
nullable/AOT, not preserving an existing surface.
|
||||
- `dotnet-msbuild/including-generated-files` — MSBuild wiring for generated files.
|
||||
@@ -0,0 +1,42 @@
|
||||
# InternalsVisibleTo — `internal` is not private across the solution
|
||||
|
||||
## Contents
|
||||
|
||||
- The hazard
|
||||
- Find the friend assemblies
|
||||
- Strong-named friends
|
||||
- What to do
|
||||
|
||||
## The hazard
|
||||
|
||||
`internal` limits access to the declaring assembly **unless** the assembly grants friend access via
|
||||
`[assembly: InternalsVisibleTo("Other.Assembly")]`. Test projects and split implementation assemblies use
|
||||
this constantly. So an internal rename, move, signature change, or removal can break a consumer that has
|
||||
**no project reference visible from the declaring project** — the coupling is expressed in an attribute,
|
||||
not a reference graph.
|
||||
|
||||
## Find the friend assemblies
|
||||
|
||||
```bash
|
||||
grep -rn "InternalsVisibleTo" --include=*.cs --include=*.csproj --include=*.props .
|
||||
```
|
||||
|
||||
`InternalsVisibleTo` can live in a `.cs` (`AssemblyInfo`/any file) **or** as an MSBuild
|
||||
`<InternalsVisibleTo>` item in a `.csproj`/`Directory.Build.props`. Enumerate every named friend, then
|
||||
search **those** assemblies for uses of the internal symbol you are changing — not just the declaring
|
||||
project.
|
||||
|
||||
## Strong-named friends
|
||||
|
||||
When the declaring assembly is strong-named, the `InternalsVisibleTo` string includes the friend's full
|
||||
`PublicKey=...`. Renaming or re-signing a friend assembly, or changing keys, breaks the grant. Do not alter
|
||||
the assembly name/key half of the relationship as an incidental part of another change.
|
||||
|
||||
## What to do
|
||||
|
||||
- Treat internal members that friends consume with the **same care as public API**: search all friend
|
||||
assemblies for binding references before renaming/moving/removing.
|
||||
- If the change is large, let the **compiler across the whole solution** (build the friend projects too) be
|
||||
the safety net — a missed reference becomes a build error in the friend project, not a silent break.
|
||||
- Adding a new friend (`InternalsVisibleTo`) to make a change "reachable" is itself a surface change — flag
|
||||
it rather than doing it silently.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Multi-targeting and #if — satisfy every target, preserve intentional divergence
|
||||
|
||||
## Contents
|
||||
|
||||
- Why this is a hidden surface
|
||||
- Inspect every branch (including inactive ones)
|
||||
- Preserve intentional divergence
|
||||
- Re-gate every target
|
||||
|
||||
## Why this is a hidden surface
|
||||
|
||||
A project with `<TargetFrameworks>` (plural) compiles once per TFM, and code under `#if` compiles
|
||||
differently per TFM, per platform, and per custom symbol. Your editor and a default `dotnet build`
|
||||
usually show/exercise **one** active branch. An edit that is correct there can leave another target
|
||||
uncompilable or behaviorally different — and CI (which builds all of them) is where it surfaces.
|
||||
|
||||
Common conditional symbols: framework (`NET8_0_OR_GREATER`, `NETFRAMEWORK`, `NETSTANDARD2_0`), platform
|
||||
(`WINDOWS`, `LINUX`, `OSX`), runtime flavor (`CORECLR`, `MONO`, `NATIVEAOT`), and repo-defined symbols
|
||||
(`FEATURE_*`, `PRIVATE_*`) declared via `<DefineConstants>`.
|
||||
|
||||
## Inspect every branch (including inactive ones)
|
||||
|
||||
Before editing a symbol used under `#if`:
|
||||
|
||||
- Find **all** its declarations/uses across branches — including branches that are inactive for the
|
||||
current TFM. A grep for the symbol crosses `#if` boundaries; the compiler for the active TFM does not.
|
||||
- If the symbol has the same meaning in every branch, apply the equivalent change to each.
|
||||
- Watch for symbols that **only exist** in some branches (e.g. an API available on `net8.0` but polyfilled
|
||||
or absent on `netstandard2.0`).
|
||||
|
||||
## Preserve intentional divergence
|
||||
|
||||
Conditional branches often differ **on purpose** (a fast path on new runtimes, a polyfill on old ones, a
|
||||
platform-specific implementation). Do **not** homogenize them into one shape to "clean up." Preserve the
|
||||
intended per-target behavior; only unify what is genuinely duplicated with identical intent.
|
||||
|
||||
## Re-gate every target
|
||||
|
||||
After the edit, build **and** test each target, not just the default:
|
||||
|
||||
```bash
|
||||
dotnet build # builds every TargetFramework
|
||||
dotnet build -f net472 # force a specific TFM
|
||||
dotnet test -f net8.0 # test a specific TFM
|
||||
# platform/RID-specific code: build/test on (or cross-target for) each supported RID
|
||||
```
|
||||
|
||||
A green default build is **not** proof the other targets are green.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Public API surface — the two gates and how not to break it
|
||||
|
||||
## Contents
|
||||
|
||||
- Two gates, not interchangeable
|
||||
- What counts as a breaking change (including annotations)
|
||||
- Deciding and validating a public change
|
||||
- Preserving identity: `[Obsolete]` shims and type-forwarders
|
||||
- Suppression baselines
|
||||
|
||||
## Two gates, not interchangeable
|
||||
|
||||
.NET repos protect the public surface in **two fundamentally different ways**. Do not treat one as a
|
||||
substitute for the other — a repo may use either, both, or neither, and each catches things the other
|
||||
does not.
|
||||
|
||||
| Gate | What it compares | Where it lives | You maintain |
|
||||
|------|------------------|----------------|--------------|
|
||||
| **PublicApiAnalyzers** (RS0016/RS0017/…) | _Declared source API_ of the current compilation | `PublicAPI.Shipped.txt` + `PublicAPI.Unshipped.txt` per project | **Yes** — you edit the `.txt` files; the analyzer only enforces they match the code |
|
||||
| **ApiCompat / package validation** | _Built assemblies / NuGet package_ against a baseline (previous version, or a contract/ref assembly) | `<EnablePackageValidation>`, `Microsoft.DotNet.ApiCompat.*` in props/targets | Baseline version + suppression file |
|
||||
|
||||
**Practical rule:** run/respect whichever gate the repo already has and update its files or baseline as
|
||||
the change legitimately requires. If **neither** exists, review the public surface by hand and flag the
|
||||
risk to the user — **do not add analyzer or package-validation infrastructure as a side effect** of an
|
||||
unrelated change (that is scope creep and its own kind of breaking change to the build).
|
||||
|
||||
## What counts as a breaking change (including annotations)
|
||||
|
||||
Beyond the obvious rename/remove/move of a public type or member, these are **also** observable and can
|
||||
break consumers or the API gate:
|
||||
|
||||
- Signature changes: parameter type/order, return type, adding a required parameter, `params`, default
|
||||
values, generic arity or **constraints**.
|
||||
- **Nullability** annotations (`string` → `string?`, `[NotNullWhen]`, `[MaybeNull]`) — these are part of
|
||||
the public contract under `#nullable enable`; changing them shifts consumer warnings and the API gate.
|
||||
- **Trimming/AOT** attributes (`[DynamicallyAccessedMembers]`, `[RequiresUnreferencedCode]`,
|
||||
`[RequiresDynamicCode]`, `[UnconditionalSuppressMessage]`) — observable API for trim/AOT consumers.
|
||||
- Accessibility widening/narrowing, `sealed`/`abstract`/`virtual`/`static` changes, `readonly`/`ref`.
|
||||
- Moving a public type to another **assembly** (identity changes even if the name does not).
|
||||
|
||||
Preserve these unless the task is explicitly to change them.
|
||||
|
||||
## Deciding and validating a public change
|
||||
|
||||
1. Determine the project's role: **shipped library/package** (public API matters) vs **app/service**
|
||||
(external contract = HTTP/config/schema, internal surface can move) vs **private/single-target**
|
||||
(behavior + tests only).
|
||||
2. If a gate exists, build/pack and let it run; update `PublicAPI.Unshipped.txt` or the ApiCompat
|
||||
baseline **intentionally**, never to silence a break you did not mean to make.
|
||||
3. If no gate exists in a library, diff the public surface manually (compare declarations, or a
|
||||
generated ref/`.txt`) and surface the delta to the user.
|
||||
|
||||
## Preserving identity: `[Obsolete]` shims and type-forwarders
|
||||
|
||||
- **`[Obsolete]` shim:** keep the old member alongside the new one, forwarding to it, so source consumers
|
||||
keep compiling. Use for renames/relocations _within_ an assembly.
|
||||
- **`[TypeForwardedTo]` type-forwarder:** preserves **binary identity** when a **public type moves to
|
||||
another assembly**. Forwarders solve _cross-assembly moves_; they do **not** help a rename (the name
|
||||
changed) and are unnecessary for moves within the same assembly.
|
||||
|
||||
## Suppression baselines
|
||||
|
||||
`ApiCompatSuppressions` / `GlobalSuppressions` and PublicApiAnalyzers baselines exist so intentional,
|
||||
reviewed changes pass. Update them deliberately with the change; do not blanket-add suppressions to make
|
||||
an edit "pass" — that hides the very break the gate exists to catch.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Source-generated and partial code — edit the source, not the output
|
||||
|
||||
## Contents
|
||||
|
||||
- Partial types span files
|
||||
- Generated vs checked-in
|
||||
- How to change generated behavior
|
||||
|
||||
## Partial types span files
|
||||
|
||||
A `partial class`/`record`/`struct` (and, since C# 13, `partial` properties/methods) is one type split
|
||||
across several files. A rename, move, or member change must include **every** partial declaration, or you
|
||||
get a partial that no longer agrees with itself (duplicate/missing members, mismatched signatures).
|
||||
|
||||
- Find all parts: `grep -rln "partial .*<TypeName>" --include=*.cs` and check the containing folder.
|
||||
- One part is frequently **generated** — the same type name appears in a `*.g.cs`/`*.generated.cs` you did
|
||||
not write.
|
||||
|
||||
## Generated vs checked-in
|
||||
|
||||
Decide which kind of generated file you are looking at:
|
||||
|
||||
- **Regenerated every build** (source generators, Razor `*.razor.g.cs`, XAML, resx designer): editing the
|
||||
output is pointless — the next build overwrites it. Change the **input** (see below).
|
||||
- **Checked-in / committed generated source** (some repos commit generated `.cs`, ref assemblies, or
|
||||
`PublicAPI.*.txt`): treat it as source _for editing_, but there is almost always a **regeneration
|
||||
command** you must re-run so the checked-in copy stays in sync. Editing it by hand and skipping
|
||||
regeneration drifts it from its source of truth.
|
||||
|
||||
Look for markers: a `<auto-generated>` header, `[GeneratedCode]`, an `.editorconfig`
|
||||
`generated_code = true`, or MSBuild items adding the generator (see `dotnet-msbuild/including-generated-files`).
|
||||
|
||||
## How to change generated behavior
|
||||
|
||||
- **Source generator:** change the generator **input** — the attributes/partial declarations/`AdditionalFiles`
|
||||
it reads, or the generator itself — then rebuild and diff the regenerated output.
|
||||
- **Razor/XAML/resx:** edit the `.razor`/`.xaml`/`.resx`, not the `.g.cs`/designer file.
|
||||
- Never hand-edit regenerated output as a shortcut; the change will vanish and the diff will mislead review.
|
||||
Reference in New Issue
Block a user