dotnet-breaking-changes

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

.NET breaking changes: the contract is wider than the file

.NET 破坏性变更:契约范围比文件更大

When you edit .NET code, the observable contract is usually larger than the snippet in front of you. A change that compiles and keeps tests green can still break a downstream consumer, another target framework, a friend assembly, or regenerate away on the next build. This applies to a feature or a bug fix as much as a refactor, and to additions — a new public member, overload, or target framework — as much as removals: added surface is a permanent contract obligation, and added behavior on a multi-targeted or partial type must stay correct on every target and survive the next regeneration. (For the behavior-preserving refactoring process, use the
csharp-refactoring
skill; this skill is the compatibility knowledge it — and any other change — taps into.)
编辑 .NET 代码时,可观察到的契约通常比眼前的代码片段更大。即使某项变更能够编译并保持测试全绿,它仍可能破坏下游使用者、另一个 target framework、friend assembly,或者在下次构建时因代码重新生成而失效。无论是 feature、bug fix 还是 refactor,也无论是新增还是删除,都是如此:新增的 API surface 意味着永久的契约义务;在 multi-target 或 partial type 上新增的行为,必须在每个 target 上都保持正确,并能经受下一次重新生成。(对于保持行为的 refactoring 流程,请使用
csharp-refactoring
skill;本 skill 提供的正是它以及其他所有变更都会调用的兼容性知识。)

Inspect first — find which surfaces this repo actually has

先检查——找出这个仓库实际具有哪些契约面

The most valuable move is to look before you leap: find which hidden surfaces exist here, then check only those. Don't recite these or assume — the markers present dictate the plan; markers absent tell you a surface is not in play.
bash
undefined
最有价值的动作是先看再改:先找出这里存在哪些隐藏契约面,然后只检查这些契约面。不要照搬这些内容或凭假设行事——存在的标记决定计划;不存在的标记则表明相关契约面不适用。
bash
undefined

Public-API gate (which one? they are NOT interchangeable)

Public-API gate (which one? they are NOT interchangeable)

git ls-files "/PublicAPI.Shipped.txt" "/PublicAPI.Unshipped.txt" # PublicApiAnalyzers grep -rl "EnablePackageValidation|ApiCompat" --include=.props --include=.targets --include=*.csproj .
git ls-files "/PublicAPI.Shipped.txt" "/PublicAPI.Unshipped.txt" # PublicApiAnalyzers grep -rl "EnablePackageValidation|ApiCompat" --include=.props --include=.targets --include=*.csproj .

Multi-targeting and conditional code

Multi-targeting and conditional code

grep -rl "<TargetFrameworks>" --include=.csproj --include=.props . ; grep -rn "#if " --include=*.cs .
grep -rl "<TargetFrameworks>" --include=.csproj --include=.props . ; grep -rn "#if " --include=*.cs .

Generated / partial code

Generated / partial code

git ls-files ".g.cs" ".generated.cs" ; grep -rln "partial class|partial record|partial struct" --include=*.cs .
git ls-files ".g.cs" ".generated.cs" ; grep -rln "partial class|partial record|partial struct" --include=*.cs .

Friend assemblies

Friend assemblies

grep -rn "InternalsVisibleTo" --include=.cs --include=.csproj --include=*.props .

Then read only the reference(s) for the surfaces you actually found. For every `InternalsVisibleTo`
name, check whether that project is in the solution: a friend **not** built here is an external
consumer of every internal it can see, exactly like a public caller, and a green solution build proves
nothing about it.

Before editing, confirm the request's premise is true in the code. For "consolidate", "deduplicate", or
"add X" requests, trace the actual calls: if one type already delegates to the other, or the member
already exists, the work is done — change nothing and report the delegating call (see Stop and escalate).
grep -rn "InternalsVisibleTo" --include=.cs --include=.csproj --include=*.props .

然后只阅读实际找到的契约面对应的 reference(s)。对于每个 `InternalsVisibleTo` 名称,检查该项目是否位于 solution 中:未在此处构建的 friend **也是**它所能看到的每个 internal 的外部 consumer,与 public caller 完全相同;solution build 全绿并不能证明它没有问题。

编辑前,确认请求的前提在代码中属实。对于“consolidate”“deduplicate”或“add X”请求,跟踪实际调用:如果一个 type 已经 delegate 给另一个,或者该 member 已经存在,则工作已经完成——不要做任何修改,并报告 delegate 调用(参见 Stop and escalate)。

The hidden surfaces (depth in references/)

隐藏的契约面(深入内容见 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. 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 non-interchangeable ways: source-level (
    PublicApiAnalyzers
    +
    PublicAPI.*.txt
    , which you maintain) and binary/package-level (
    ApiCompat
    /
    <EnablePackageValidation>
    ). 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. Move a public type via a
    [TypeForwardedTo]
    forwarder; a rename needs an
    [Obsolete]
    shim, not a forwarder. 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. A friend built outside this repo cannot be updated, so keep the old internal name as an
    [Obsolete]
    forwarder. See
    references/internals-visible-to.md
    .
Public API (1) 是任何 library 编辑时默认需要检查的契约面;其他三个是有条件的——只有当上述先检查标记表明它们确实存在时才继续排查。
  1. Public API。 重命名、移动、删除或更改 public member 的 signature——或者更改 nullability、generic constraint 或 trimming/AOT attribute——都是绿色测试运行无法发现的 breaking change。Repository 通过两种不可互换的方式进行 gate:source-level(
    PublicApiAnalyzers
    +
    PublicAPI.*.txt
    ,由你维护)和 binary/package-level(
    ApiCompat
    /
    <EnablePackageValidation>
    )。遵循仓库已有的 gate;如果没有,则手动审查该 surface 并明确标记——不要顺带添加 analyzer infrastructure。移动 public type 时使用
    [TypeForwardedTo]
    forwarder;rename 需要
    [Obsolete]
    shim,而不是 forwarder。另见
    references/public-api.md
    。
  2. Multi-targeting 和
    #if
    。
    使用 multi-target(
    <TargetFrameworks>
    )或条件编译(
    #if NET8_0_OR_GREATER
    、platform/CoreCLR/Mono/NativeAOT branches)的代码,可能只针对编辑器显示的 target 构建成功,却在从未调用到的 target 上损坏。检查每个 branch——包括未激活的 branch——保留有意设置的差异,并对每个 TFM/RID 重新执行相应 gate。另见
    references/multi-targeting.md
    。
  3. Source-generated 和 partial 代码。 一个 type 经常跨多个文件使用
    partial
    ,其中一部分可能是 generated(source generators、Razor、
    *.g.cs
    )。纳入每个 partial declaration;将 generated files 视为派生内容,除非仓库将 output 作为 source 签入,否则应更改 generator 的 input/template。另见
    references/source-generation.md
    。
  4. InternalsVisibleTo。
    internal
    在整个 solution 中并不等同于 private:test 和 friend assemblies 会绑定到 internals(启用 strong-named 时,还会绑定到 public key)。对 internal 的重命名、移动或删除,可能破坏声明项目中完全没有引用的 consumer。构建在此 repo 之外的 friend 无法更新,因此应将旧 internal 名称保留为
    [Obsolete]
    forwarder。另见
    references/internals-visible-to.md
    。

Decisions a green build will not make for you

绿色构建无法替你做出的决策

Each row below compiles every caller and passes every test, yet breaks a consumer. Do not stop at a green build or test run. Use the safe shape instead.
When the safe shape departs from the literal request — an overload instead of the requested optional parameter, a forwarder kept instead of a deleted or renamed member, or no change at all — say so in your final answer in one or two sentences: what you did instead, and which consumer it would have broken (e.g. "already-compiled callers would throw
MissingMethodException
"). A silent substitution reads as ignoring the request.
Treat that substitution as one atomic deliverable: implement the safe shape, preserve the old contract and shipped baseline, test both old and new entry points, and explain the compatibility reason. After the last edit, inspect the diff or reopen the implementation file before claiming success. A green test run does not prove a failed patch applied when the unchanged fixture already passes those tests.
Do not use labels such as "for binary compatibility" as the whole explanation. Name the exact old entry point that remains, the consumer that still binds to it, and the concrete failure avoided. For example: "Kept
Parse(string?, int)
unchanged because already-compiled consumers still request that exact method and would throw
MissingMethodException
if it disappeared." For source-only breaks, name the affected syntax (
raw:
named arguments, derivation from a newly sealed class, or nullable callers) instead. This causal sentence is part of the implementation, not optional release-note polish.
Tempting changeWhy it breaksSafe shape
Add an optional parameter to a shipped method (
Foo(a)
→
Foo(a, b = null)
)
Binary break: the old signature is gone, so already-compiled consumers throw
MissingMethodException
until they recompile
Keep
Foo(a)
unchanged and add a new overload
Foo(a, b)
with
b
required on that overload; omission continues to select the old method. Record only the new overload
Fix RS0017 by editing or deleting a line in
PublicAPI.Shipped.txt
Shipped lines are the released contract; rewriting one hides the break the gate exists to reportLeave shipped lines alone. Restore the shipped member and record new surface in
PublicAPI.Unshipped.txt
Add a member to a shipped public interfaceEvery existing implementer, including ones in other packages, stops compilingAdd a default interface implementation (requires runtime support: not on
netstandard2.0
or .NET Framework targets), or add a new interface. A default member is callable only through the interface, so also add a public member on any concrete type that must expose it, and record both
Rename a shipped memberSource and binary breakAdd the new name, keep the old one as an
[Obsolete]
forwarder, and record only the new member
Rename or remove an
internal
member when
InternalsVisibleTo
names an assembly not in the solution
That friend was compiled against the old name and cannot be updated from this repo; it fails at bind timeAdd the new name, migrate in-repo callers, keep the old internal name as an
[Obsolete]
forwarder, keep the grant, and name the protected friend in the answer
Rename a parameter of a shipped memberCallers passing it by name (
raw: x
) stop compiling and late-bound/
dynamic
callers fail; positional callers in this repo hide it. PublicAPI analyzers report it as RS0016 + RS0017
Keep the shipped name — no overload can carry a different name for the same signature. Leave it unchanged and edit nothing else; you may offer (not make) a clearer XML
<param>
doc, or the rename at the next major version
Seal a class, make a member non-virtual, or narrow a parameter's nullabilityDerived types, overrides, or nullable callers breakTreat as a breaking change; escalate rather than edit
Change a
const
value or an optional parameter's default
The old value is compiled into callers and persists until they recompilePreserve the shipped constant/default; introduce a separately named member (which may be
static readonly
) or a new overload for new behavior. Replacing an existing
const
with
static readonly
is itself source-breaking
Change a value under
#if
for one target
Only consumers of that target see it; tests edited to match will not catch itTreat as a behavior change for that target and call it out
Whenever you kept an old entry point or departed from the literal request, the final answer must contain this line, filled in — terse bullets such as "preserved for binary consumers" do not satisfy it:
Compatibility: Kept
<exact old signature>
unchanged because
<who still binds to it>
would
<concrete failure>
if it changed.
<What you did instead of the literal request, and why.>
Before finishing a compatibility-preserving edit, make the delta easy to audit:
  1. Name the new entry point or behavior and the exact old entry point that remains.
  2. Name the in-repo caller, generated template, or friend assembly you migrated, plus any external consumer you could not migrate and therefore protected with a forwarder.
  3. State which baseline stayed unchanged and which new surface was recorded.
  4. Report the exact compatibility check that exercised the old entry point as well as the new one.
For a no-change outcome, lead with
No files changed.
and identify the decisive evidence before any general explanation: the existing delegation, shipped signature, parameter name, or nullability line.
When asked "is this a breaking change?" or to review a diff, make no edits unless asked and answer in this shape, scaled to the diff:
  1. One verdict line first (e.g. "Do not merge as-is: N changes break consumers").
  2. One numbered item per consumer-visible change: what changed, binary (already-compiled consumers fail), source (consumers fail to recompile), or behavioral (same signature, different result, possibly on only one target), who breaks, why CI stayed green (a shipped API line rewritten, a test edited to match, or a change the analyzer does not track, such as
    sealed
    or a private
    const
    ), and the safe shape. State each as fact or not at all: a behavioral claim needs a concrete input whose old and new results differ; if unsure, probe both versions with a one-line program — framework parsers already tolerate much (e.g.
    bool.TryParse
    ignores surrounding whitespace), and a no-op rewrite is not a break.
  3. One closing "Not breaking:" line covering every remaining hunk with its reason (internal-only edits, probed no-ops), instead of side notes inside the numbered items.
Cover only surfaces the diff touches; do not add trimming, AOT, or tooling commentary it does not raise.
下表每一行都能编译所有 callers 并通过所有 tests,却会破坏某个 consumer。不要止步于 green build 或 test run。请改用 safe shape。
当 safe shape 偏离字面要求时——例如用 overload 代替请求的 optional parameter、保留 forwarder 而不删除或 rename member,或者完全不修改——需在 final answer 中用一两句话说明:你改用了什么方案,以及它会破坏哪个 consumer(例如,“已编译的 callers 将抛出
MissingMethodException
”)。静默替换会被理解为无视请求。
将该替换视为一个原子交付项:实现 safe shape,保留旧契约和 shipped baseline,同时测试旧、新入口,并解释兼容性原因。最后一次编辑后,检查 diff 或重新打开实现文件,然后再声明成功。Green test run 不能证明失败的 patch 已应用,因为未改动的 fixture 本身可能已经通过这些测试。
不要把“用于 binary compatibility”之类的标签作为全部解释。说明仍然存在的精确旧入口、仍绑定到它的 consumer,以及所避免的具体 failure。例如:“保留
Parse(string?, int)
不变,因为已编译的 consumers 仍会请求该确切 method;如果删除,它们将抛出
MissingMethodException
。”对于仅影响 source 的破坏,应指出受影响的语法(
raw:
named arguments、派生自 newly sealed class,或 nullable callers),而不是笼统说明。这个因果句是实现的一部分,并非可选的 release-note 润色。
诱人的变更为什么会破坏安全方案
为已发布 method 添加 optional parameter(
Foo(a)
→
Foo(a, b = null)
)
Binary break:旧 signature 已消失,因此已编译 consumers 会抛出
MissingMethodException
,直到重新编译
保持
Foo(a)
不变,并添加新 overload
Foo(a, b)
,其中该 overload 的
b
为必填;省略参数时仍选择旧 method。仅记录新 overload
通过编辑或删除
PublicAPI.Shipped.txt
中的一行来修复 RS0017
Shipped lines 是已发布契约;重写其中一行会隐藏 gate 原本要报告的 break不要改动 shipped lines。恢复已发布 member,并在
PublicAPI.Unshipped.txt
中记录新 surface
向已发布的 public interface 添加 member每个现有 implementer,包括其他 package 中的实现,都会停止编译添加 default interface implementation(需要 runtime 支持:
netstandard2.0
或 .NET Framework targets 不支持),或添加新 interface。default member 只能通过 interface 调用,因此还要在任何需要公开它的 concrete type 上添加 public member,并同时记录二者
重命名已发布 memberSource 和 binary break添加新名称,将旧名称保留为
[Obsolete]
forwarder,并且只记录新 member
InternalsVisibleTo
引用了不在 solution 中的 assembly 时,重命名或删除
internal
member
该 friend 针对旧名称编译,且无法从此 repo 更新;它会在 bind 时失败添加新名称,迁移 repo 内 callers,保留旧 internal 名称作为
[Obsolete]
forwarder,保留 grant,并在回答中指出受保护的 friend
重命名已发布 member 的 parameter按名称传参的 callers(
raw: x
)会停止编译,late-bound/
dynamic
callers 也会失败;repo 内的 positional callers 会掩盖此问题。PublicAPI analyzers 会报告 RS0016 + RS0017
保留已发布名称——同一 signature 的 overload 无法使用不同名称。保持其不变且不编辑其他内容;可以提供但不要直接修改更清晰的 XML
<param>
文档,或在下一个 major version 重命名
将 class sealed、将 member 设为 non-virtual,或收窄 parameter 的 nullabilityDerived types、overrides 或 nullable callers 会 break将其视为 breaking change;进行 escalate,而不是直接编辑
更改
const
value 或 optional parameter 的 default
旧值会编译进 callers,并持续到 callers 重新编译保留已发布的 constant/default;为新行为引入单独命名的 member(可以是
static readonly
)或新 overload。用
static readonly
替换现有
const
本身就是 source-breaking
更改某个 target 下
#if
内的 value
只有该 target 的 consumers 会看到变化;为匹配变化而编辑的 tests 不会捕获它将其视为该 target 上的 behavior change,并在结果中明确指出
只要你保留了旧 entry point,或偏离了字面要求,final answer 就必须包含下面这一行并填入实际内容——诸如“为 binary consumers 保留”之类的简短 bullet 并不满足要求:
Compatibility: 保持
<exact old signature>
不变,因为
<who still binds to it>
如果该签名发生变化就会
<concrete failure>
。
<What you did instead of the literal request, and why.>
完成兼容性保留编辑前,应让变更易于审计:
  1. 指出新的 entry point 或 behavior,以及仍然保留的确切旧 entry point。
  2. 指出已迁移的 repo 内 caller、generated template 或 friend assembly,以及无法迁移、因此用 forwarder 保护的任何 external consumer。
  3. 说明哪个 baseline 保持不变,以及记录了哪个新 surface。
  4. 报告同时验证旧 entry point 和新 entry point 的确切 compatibility check。
对于无变更的结果,先以“No files changed.”开头,并在任何一般性解释之前指出决定性证据:已有 delegation、shipped signature、parameter name 或 nullability line。
当被询问**“这是 breaking change 吗?”**或要求审查 diff 时,除非用户明确要求,否则不要编辑,并按以下形式回答(根据 diff 缩放):
  1. 第一行给出结论,例如:“请勿按当前状态合并:有 N 项变更会破坏 consumers”。
  2. 每个 consumer-visible change 使用一个编号项说明:改了什么、属于 binary(已编译 consumers 会失败)、source(consumers 重新编译时会失败)还是 behavioral(signature 相同但结果不同,也可能只发生在某个 target)、谁会 break、CI 为何保持全绿(重写了 shipped API line、编辑 test 以匹配结果,或 analyzer 未跟踪的变更,例如
    sealed
    或 private
    const
    ),以及 safe shape。每项要么陈述事实,要么不要陈述:behavioral claim 需要给出旧结果与新结果不同的具体输入;如果不确定,用一行程序探测两个版本——framework parsers 通常已经容忍许多差异(例如
    bool.TryParse
    会忽略周围空白),而 no-op rewrite 不是 break。
  3. 最后用“Not breaking:”一行概括其余每个 hunk 及其原因(仅 internal 的编辑、已探测为 no-op 的变更),不要把说明塞进编号项中。
只覆盖 diff 实际触及的契约面;不要添加其未涉及的 trimming、AOT 或 tooling 说明。

Verify before reporting

报告前进行验证

Build and test every target framework (
dotnet test -f <tfm>
for each), not only the default. If the project sets
EnablePackageValidation
, run
dotnet pack
— package validation runs only after
Pack
, never on build. Report exactly which commands ran and their result.
构建并测试每一个 target framework(每个都运行
dotnet test -f <tfm>
),而不只测试默认项。如果项目设置了
EnablePackageValidation
,运行
dotnet pack
——package validation 只会在
Pack
之后运行,构建时绝不会运行。准确报告运行了哪些命令及其结果。

Stop and escalate (do not silently proceed)

停止并升级处理(不要静默继续)

  • The requested change is already in place — e.g. the "duplicated" code already delegates to one implementation, or the overload already exists. Say so and change nothing; do not invent a refactor to have something to show.
  • 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 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 you change nothing (the request is already done or has no safe shape), a bare "no" looks like ignoring the request. Make the final answer show the work, in this order: "No files changed." and why; the evidence that decided it (the shipped
PublicAPI.Shipped.txt
line, or the call that already delegates, with its file); who would break, or where the single implementation already lives; what the user can do instead, if anything; and the validation you ran.
  • 请求的变更已经就位——例如所谓“重复”代码已经 delegate 给某个实现,或所需 overload 已经存在。此时应如实说明并且不修改任何内容;不要为了展示工作量而虚构一次 refactor。
  • public/shipped API 将发生变化,并且无法验证兼容性——既没有 shim/forwarder 路径,也没有 PublicApiAnalyzers/ApiCompat/package-validation gate 来捕获破坏。
  • 编辑会改变 public member 上的 observable annotation(nullability、
    [DynamicallyAccessedMembers]
    、
    [RequiresUnreferencedCode]
    、generic constraints)。
  • 变更只能在部分而非全部 target frameworks 或 platforms 上保持一致。
  • 唯一可行的修改方式是编辑下次构建会覆盖的 generated output。
当你没有修改任何内容时(请求已经完成,或不存在安全方案),只回答“No”会显得像是在无视请求。final answer 应按以下顺序展示工作:“No files changed.”及其原因;决定结果的证据(shipped
PublicAPI.Shipped.txt
行,或已经 delegate 的调用及其文件);谁会受到影响,或单一实现已经位于何处;用户可以采取的替代方案(如果有);以及已经运行的验证。

Reference files

Reference files

Load a reference only for a surface the inspect-first step actually found:
  • 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.
  • references/multi-targeting.md — TFMs,
    #if
    and platform symbols, RIDs, and how to inspect and re-gate every target.
  • references/source-generation.md — partial types, generated output vs checked-in source, editing the generator input. (See also
    dotnet-msbuild/including-generated-files
    .)
  • references/internals-visible-to.md — friend/test assemblies and strong-name keys.
仅针对先检查步骤实际找到的契约面加载 reference:
  • references/public-api.md——两种 API gate 及其不可互换的原因、作为 observable API 的 nullable/trimming/AOT、
    [Obsolete]
    shims 和 type-forwarders、suppression baselines。
  • references/multi-targeting.md——TFMs、
    #if
    和 platform symbols、RIDs,以及如何检查并为每个 target 重新执行 gate。
  • references/source-generation.md——partial types、generated output 与 checked-in source 的区别,以及如何编辑 generator input。另见
    dotnet-msbuild/including-generated-files
    。
  • references/internals-visible-to.md——friend/test assemblies 和 strong-name keys。