csharp-review

v2026.09.24

Reviewing C# code - what to flag, and what Roslyn analyzers and nullable reference types already flag for you USE WHEN: you are reviewing, critiquing or auditing existing C# code - a "code review", a "review" of a .cs file, a diff, a PR or a pull request; deciding what to comment on in C#; avoiding false positives on C# code DO NOT USE FOR: writing, explaining or learning C# - use `languages/csharp`; anything the built-in Roslyn analyzers or nullable reference types already report (this skill lists those so you can stay silent about them); ASP.NET Core specifics - use the ASP.NET skills; EF Core mapping - use the ORM skills

GitHub
安装命令
npx skhub add claude-dev-suite/csharp-review
Markdown
SKILL.md

Reviewing C#

A reviewer's leverage is what the toolchain cannot say. C# ships a large analyzer set in the SDK, but two of its most valuable guarantees are opt-in per project — nullable reference types and warnings-as-errors — so the first thing to establish is which of them this project turned on.

The findings that remain cluster around async, disposal, and the places where a compile-time guarantee is erased at runtime.

The snippets are fragments cut down to the defect, not runnable programs.

Already covered - do not spend review on it

DefectReported by
Dereferencing a possibly-null referenceCS8602 — only when <Nullable>enable</Nullable>
An async method with no awaitCS1998 (warning, on by default)
Unawaited task in an async methodCS4014 (warning)
Unused local, unreachable codeCS0219, CS0162
Disposable created and not disposed, simple shapesCA2000 — only if code analysis is enabled
ConfigureAwait missing in a libraryCA2007 — opt-in, off by default in most templates
Sync-over-async (.Result, .Wait())no built-in rule; some analyzer packages add one
String comparison without StringComparisonCA1310 — opt-in

Read the .csproj first. <Nullable>, <TreatWarningsAsErrors> and <AnalysisLevel> decide whether rows 1, 5, 6 and 8 exist at all. A project with nullable disabled has no null guarantees whatsoever, and that is one architectural comment, not a per-line campaign.

The table above dates; the tools do not. Verify a default set with mcp__documentation__fetch_docs on technology dotnet-quality — topics code-analysis-rules, nullable-reference-types. Those entries point at the indexes the tool authors maintain, so they follow the version the project actually pins rather than the one this skill was written against.

The checks that earn their place

async void outside an event handler

async void ProcessAsync() { await work(); }   // exceptions cannot be caught

When you see it: async void on anything that is not an event handler matching a delegate signature.

Ask: where does an exception from this go? An async void method has no task to carry the failure, so a throw reaches the synchronization context and terminates the process. The caller cannot await it or catch it. async Task is the fix, and the shape is easy to miss because it compiles and usually works.

Sync-over-async

var user = GetUserAsync(id).Result;   // blocks; deadlocks on a context with affinity

When you see it: .Result, .Wait(), .GetAwaiter().GetResult() on a task whose continuation may need the calling thread.

Ask: is there a synchronization context here? In ASP.NET Core there is not, so this is "only" thread-pool starvation under load. In WPF, WinForms and classic ASP.NET it is a hard deadlock: the continuation waits for a thread the caller is blocking. The symptom is a hang, not an exception.

A CancellationToken accepted and never passed on

async Task<Data> LoadAsync(CancellationToken ct) {
    return await http.GetAsync(url);      // ct not forwarded
}

When you see it: a method taking a CancellationToken whose body calls other async APIs without it.

Ask: does cancellation actually reach the I/O? The signature promises cooperative cancellation and the body does not deliver it, so a cancelled request keeps its connection and its thread. Nothing reports it — the parameter is "used" as far as the compiler is concerned.

IDisposable created inside a using-less path

var conn = new SqlConnection(cs);      // no using, no dispose
if (!TryOpen(conn)) return;            // leaks on this path

When you see it: any IDisposable assigned to a local without using, especially where an early return or a throw sits between creation and disposal.

Ask: which paths dispose it? CA2000 catches simple shapes only, and is off unless code analysis is enabled. Also check the opposite error: using on an injected HttpClient, which disposes something the container owns.

A struct mutated through a property or a copy

readonly struct Point { public int X { get; init; } }
list[0].X = 5;                          // does not compile for List<T> — but
var p = dict["k"]; p.X = 5;             // mutates a COPY, silently

When you see it: a mutable struct, or any struct retrieved from a collection, a property, or a foreach variable and then modified.

Ask: is this the original or a copy? Value semantics mean every access returns a copy, so the write lands somewhere that is discarded. Making structs readonly turns the mistake into a compile error, which is why the guidance exists.

A nullable warning suppressed rather than answered

var name = user!.Name;                 // `!` asserts; nothing verified it
#nullable disable                      // or the whole file opts out

When you see it: ! (null-forgiving), #nullable disable, or [AllowNull]/[MaybeNull] used to quiet a warning.

Ask: what establishes non-null here? ! is the C# equivalent of a cast: it changes the compiler's belief and nothing about the value. It is legitimate right after a check the compiler cannot see; it is a defect when it stands in for the check itself.

An IEnumerable enumerated more than once

var rows = db.Query(sql);              // deferred, not materialised
if (rows.Any()) return rows.First();   // two round trips, two result sets

When you see it: an IEnumerable/IQueryable parameter or local used in more than one LINQ operation, especially across Any()/Count()/First().

Ask: is this deferred? Each enumeration re-executes the query or the generator, so the cost is silent and the two passes can disagree if the source changed. ToList() at the boundary makes the decision explicit.

DateTime where the kind is not carried

var now = DateTime.Now;                // local, kind=Local
Store(now);                            // read back as Unspecified

When you see it: DateTime.Now, or a DateTime crossing a persistence or serialisation boundary.

Ask: is the offset preserved? DateTimeKind is not stored by most providers, so a round trip returns Unspecified and later arithmetic silently assumes local. DateTimeOffset carries the offset, and DateTime.UtcNow is the minimum.

A captured loop variable in a closure

for (int i = 0; i < n; i++)
    tasks.Add(Task.Run(() => Use(i)));   // all tasks may see the final i

When you see it: a lambda capturing the iteration variable of a three-clause for loop.

Ask: is this a for or a foreach? C# 5 changed foreach so each iteration gets a fresh variable — that case is fixed and not worth a comment. The three-clause for was never changed, and it is the one that still captures a single shared variable.

Config-dependent - read the .csproj and Directory.Build.props

What to readWhy it changes the review
<Nullable>Disabled (still the default in older templates): there are no null guarantees, every reference is implicitly nullable, and the ! findings above do not even apply
<TreatWarningsAsErrors>Without it, CS1998 and CS4014 are warnings in a build log nobody reads
<AnalysisLevel> / <EnableNETAnalyzers>Decides whether the CA rules ran at all
<LangVersion> / TFMrecord, init, file-scoped namespaces, required members each have a floor — do not suggest below it
Whether this is a libraryConfigureAwait(false) matters in libraries and is noise in an ASP.NET Core app, which has no synchronization context

What to say

Anchor the comment to the line, name the condition that triggers the defect, and say what breaks: ".Result here blocks the request thread, and on a context with affinity the continuation needs that same thread, so this hangs rather than throwing" beats "avoid sync-over-async". If you cannot state the input that fails, it is a preference, not a defect.

If <Nullable> is disabled, say that once and stop reporting individual nullability findings — they are all instances of the same decision.

发现
标签

此技能尚未发布标签。

版本
最新版本元数据

版本

v2026.09.24

发布时间

2026年9月24日

分类

未分类

许可证

MIT

源路径

skills/review/csharp

默认分支

main

最新提交

9496306

Tree SHA

fe4e2f1