Skip to content

Commit b16af0b

Browse files
nkolev92Copilot
andcommitted
Add nullable-enablement skill for repeatable nullable migration work
Covers annotation philosophy (non-null bias), PublicAPI.Shipped.txt update rules, NULL_INC escape hatch criteria, and step-by-step migration workflow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a03e043 commit b16af0b

1 file changed

Lines changed: 205 additions & 0 deletions

File tree

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
---
2+
name: nullable-enablement
3+
description: Enable C# nullable reference types on files that still have `#nullable disable` in the NuGet.Client codebase. Use this skill whenever the user asks to enable nullable, migrate nullable, remove `#nullable disable`, annotate nullability, or fix nullable warnings for any NuGet project or file. Also trigger when the user mentions a GitHub issue about nullable enablement, references PublicAPI.Shipped.txt annotation updates, or says things like "let's do nullable on X" or "enable nullable for these files."
4+
---
5+
6+
# Nullable Enablement Skill
7+
8+
This skill guides the process of enabling C# nullable reference types on files in the NuGet.Client repository that currently opt out with `#nullable disable`. The codebase has nullable globally enabled via `build/common.project.props`, so individual files opt *out* with `#nullable disable` at the top. Migration means removing that directive, annotating types correctly, and updating the public API surface files.
9+
10+
The work is inherently incremental — don't try to migrate an entire project at once. Work in batches of related files, fix cascading warnings, build, and repeat.
11+
12+
## Annotation Philosophy: Prefer Non-Null
13+
14+
The default mindset when annotating is **non-null unless proven otherwise**. Before marking something `?`, ask: "Can I make this non-null instead?" Sometimes a small, non-breaking code change can eliminate nullability — and that's preferable to marking something nullable. But be careful not to replace null problems with empty-value problems.
15+
16+
**Good non-null opportunities:**
17+
- **Use `required`** on internal/private types so the constructor enforces initialization.
18+
- **Set a property in the constructor** instead of relying on callers to set it later.
19+
- **Use `??` or `?? throw`** at assignment sites to guarantee non-null storage.
20+
- **Initialize a field** to a sensible default — but only when the consuming code already handles that default gracefully. Don't initialize to `string.Empty` or `Array.Empty<T>()` if downstream code would silently misbehave with an empty value instead of correctly handling null. Empty should not become the new null.
21+
22+
**When `?` is the right answer:**
23+
- The value is genuinely optional — configuration, cache misses, "not found" semantics.
24+
- The only non-null alternative is a sentinel value (empty string, empty collection) that downstream code doesn't expect and would silently mishandle.
25+
- The type represents something that can legitimately be absent at runtime.
26+
27+
The goal is to shrink the nullable surface area, but honestly — not by hiding nullability behind empty values that shift the bug downstream.
28+
29+
## High-Level Workflow
30+
31+
1. **Identify target files** — find files with `#nullable disable` in the target project.
32+
2. **Pick a batch** — group related files (e.g., a class and its immediate dependencies). Small batches (1–5 files) are easier to review.
33+
3. **For each file:**
34+
a. Remove `#nullable disable`
35+
b. Annotate types (parameters, return types, properties, fields)
36+
c. Update `PublicAPI.Shipped.txt` for both TFMs
37+
d. Fix cascading warnings in dependent files
38+
4. **Build** to verify zero warnings/errors.
39+
5. **Repeat** with the next batch.
40+
41+
## Step-by-Step: Migrating a Single File
42+
43+
### 1. Remove the directive
44+
45+
Delete the `#nullable disable` line (and any blank line it leaves behind).
46+
47+
### 2. Annotate types
48+
49+
Work through every public and internal member with a **non-null bias** — look for opportunities to keep or make things non-null before reaching for `?`:
50+
51+
- **Parameters** → default to non-null. Only mark `?` when the parameter genuinely accepts null by design. If a parameter was oblivious and callers never pass null in practice, annotate it as non-null and add a null check.
52+
- **Properties/fields** → prefer non-null. Can you initialize in the constructor, use a default value, or use `required`? Do that instead of marking `?`.
53+
- **Return types** → prefer non-null. Can the method return an empty collection, `string.Empty`, or a sentinel instead of null? If so, make the return non-null.
54+
- **Only mark `?` when the value is genuinely, unavoidably absent** — optional configuration, cache misses, "not found" semantics, etc.
55+
- **`IEnumerable<T>` and other generics** → annotate the element type too: `IEnumerable<PackageIdentity!>!` in the API file means both the collection and its elements are non-null
56+
57+
### 3. Risk Assessment for Null Checks
58+
59+
When a parameter is annotated as non-null, the **default** is to add a runtime `ArgumentNullException` check if one doesn't already exist. Most of the time, this is the right thing to do:
60+
61+
**Add the check (the common case):**
62+
- The parameter already had a null check — keep it
63+
- The parameter is new, internal, or has no history of null being passed
64+
- The parameter is used in a way that would throw `NullReferenceException` anyway — `ArgumentNullException` is more informative and fail-fast
65+
66+
**`NULL_INC` — rare, last-resort escape hatch:**
67+
68+
`NULL_INC` exists for the narrow case where a shipped public API parameter is practically never null, but you can't be 100% certain no caller passes null, and adding a throw would be a risky behavioral change. This should be **very few** instances across the entire codebase — if you're reaching for `NULL_INC` more than once or twice per PR, you're probably using it too liberally.
69+
70+
The criteria are strict — ALL of these must be true:
71+
- The parameter is on a **shipped public API** (not internal)
72+
- The parameter **never** had a null check
73+
- The value is **practically never null** in real-world usage (it's not an "optional" parameter — callers almost certainly always provide a value)
74+
- But you lack telemetry to be **absolutely certain** no caller passes null
75+
76+
Place the `NULL_INC` as an XML `<remarks>` on the relevant property or as a comment on the constructor, using this format:
77+
```xml
78+
/// <remarks>
79+
/// NULL_INC: Annotated as non-null but no runtime check is enforced in the constructor
80+
/// to avoid introducing a new throw in a previously-permissive code path.
81+
/// Revisit with telemetry to confirm callers never pass null.
82+
/// </remarks>
83+
```
84+
85+
If in doubt between adding a null check and using `NULL_INC`, **add the null check**. The bar for `NULL_INC` is intentionally high.
86+
87+
**Never suppress nullability with `!` (the forgiveness operator) when the value genuinely can be null.** Make the type honest and let callers handle it.
88+
89+
### 4. Common Annotation Patterns
90+
91+
**Constructor-initialized properties:**
92+
```csharp
93+
// Non-null — set in constructor, guaranteed by null check
94+
public PackageIdentity Identity { get; }
95+
96+
// Nullable — no guarantee
97+
public string? Description { get; set; }
98+
```
99+
100+
**`required` keyword for internal types:**
101+
```csharp
102+
// Prefer `required` over `null!` initializer for internal/private types
103+
internal class Options
104+
{
105+
public required string Path { get; init; }
106+
}
107+
```
108+
109+
**TryCreate / TryGet patterns:**
110+
```csharp
111+
public bool TryGetValue(string key, [NotNullWhen(true)] out string? value)
112+
```
113+
- Out parameters need `?`
114+
- Add `[NotNullWhen(true)]` only when it's actually guaranteed non-null on success for ALL code paths
115+
- Callers use `!` after the success guard
116+
117+
**`Debug.Assert(x != null)` + `x!`:**
118+
When the parameter type is non-null and all callers are nullable-enabled, you can remove both the assert and the `!` — they're redundant.
119+
120+
**Covariant return nullability:**
121+
A subclass method returning `byte[]` can override a base returning `byte[]?` — this is valid in C# 9+. Use it when the subclass guarantees non-null.
122+
123+
**IEquatable and Equals:**
124+
```csharp
125+
public bool Equals(MyType? other) // parameter is nullable
126+
public override bool Equals(object? obj) // always nullable
127+
```
128+
129+
## PublicAPI.Shipped.txt Updates
130+
131+
This is the most precision-sensitive part of the migration. Every public type and member has an entry in `PublicAPI.Shipped.txt`. Each project has **two** TFM directories (e.g., `net8.0/` and `net472/`). Both must be updated identically.
132+
133+
### The `~` (oblivious) prefix
134+
135+
Lines starting with `~` represent nullable-oblivious signatures. When you annotate a type, **replace the `~` line in place** with the correctly annotated line. Do NOT add to `PublicAPI.Unshipped.txt`.
136+
137+
### Annotation syntax
138+
139+
| C# type | PublicAPI notation |
140+
|---|---|
141+
| `string` (non-null) | `string!` |
142+
| `string?` | `string?` |
143+
| `byte[]` (non-null) | `byte[]!` |
144+
| `byte[]?` | `byte[]?` |
145+
| `string[]` (non-null array of non-null strings) | `string![]!` |
146+
| `IEnumerable<PackageIdentity>` (non-null) | `System.Collections.Generic.IEnumerable<...PackageIdentity!>!` |
147+
| `Task<bool>` (non-null) | `System.Threading.Tasks.Task<bool>!` |
148+
| Unconstrained generic `T` | `T` (NO `!` — generics don't get annotated) |
149+
| `Func<T>` (non-null) | `Func<T>!` (the `Func` gets `!`, not `T`) |
150+
151+
### Rules
152+
153+
- **Match existing format precisely** — look at other annotated entries in the same file for guidance.
154+
- **Internal types don't need Shipped.txt updates** — only public API surfaces.
155+
- **Never add to Unshipped.txt** for nullable annotation changes — always replace in-place in Shipped.txt.
156+
- **Both TFMs must match** — update `net8.0/PublicAPI.Shipped.txt` and `net472/PublicAPI.Shipped.txt` (or whatever TFMs the project targets) identically.
157+
158+
### Example transformation
159+
160+
Before (oblivious):
161+
```
162+
~NuGet.Protocol.Core.Types.PackageDownloadContext.PackageDownloadContext(NuGet.Protocol.Core.Types.SourceCacheContext sourceCacheContext, string directDownloadDirectory, bool directDownload) -> void
163+
```
164+
165+
After (annotated):
166+
```
167+
NuGet.Protocol.Core.Types.PackageDownloadContext.PackageDownloadContext(NuGet.Protocol.Core.Types.SourceCacheContext! sourceCacheContext, string? directDownloadDirectory, bool directDownload) -> void
168+
```
169+
170+
## Building and Verifying
171+
172+
After each batch of changes, build the affected project to ensure zero warnings/errors:
173+
174+
```
175+
msbuild src\NuGet.Core\<ProjectName>\<ProjectName>.csproj /t:Build /p:Configuration=Release
176+
```
177+
178+
or use the solution filter:
179+
180+
```
181+
msbuild NuGet-Src.slnf /t:Build /p:Configuration=Release
182+
```
183+
184+
Common build errors after nullable migration:
185+
186+
| Error | Fix |
187+
|---|---|
188+
| **RS0016** "Symbol not in PublicAPI" | You added a new annotated entry but didn't remove the `~` line — replace, don't add |
189+
| **RS0017** "Symbol removed" | You removed the `~` line but the replacement doesn't match — check annotation syntax |
190+
| **CS8600–CS8605** nullable warnings | Annotate the type correctly, or add a null check |
191+
| **CS8618** non-nullable field not initialized | Use `required`, add a constructor init, or make the field nullable |
192+
193+
## XLF Files
194+
195+
**Never edit `.xlf` files directly.** They are generated from `.resx` files. If you need to change localized strings, edit the `.resx` file and build — the xlf files will be regenerated automatically.
196+
197+
## Checklist Before Submitting
198+
199+
- [ ] All `#nullable disable` directives removed from target files
200+
- [ ] Types annotated correctly with a non-null bias (prefer non-null, only `?` when genuinely absent)
201+
- [ ] `NULL_INC` remarks added only for the rare cases meeting all criteria (shipped API, practically never null, no telemetry)
202+
- [ ] `PublicAPI.Shipped.txt` updated for ALL TFMs (`~` lines replaced in place)
203+
- [ ] No entries added to `PublicAPI.Unshipped.txt`
204+
- [ ] Project builds with zero warnings/errors
205+
- [ ] No `.xlf` files edited manually

0 commit comments

Comments
 (0)