Saritasa.Tools.CodeAnalyzers 0.3.0

Prefix Reserved
dotnet add package Saritasa.Tools.CodeAnalyzers --version 0.3.0
                    
NuGet\Install-Package Saritasa.Tools.CodeAnalyzers -Version 0.3.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Saritasa.Tools.CodeAnalyzers" Version="0.3.0">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Saritasa.Tools.CodeAnalyzers" Version="0.3.0" />
                    
Directory.Packages.props
<PackageReference Include="Saritasa.Tools.CodeAnalyzers">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Saritasa.Tools.CodeAnalyzers --version 0.3.0
                    
#r "nuget: Saritasa.Tools.CodeAnalyzers, 0.3.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Saritasa.Tools.CodeAnalyzers@0.3.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Saritasa.Tools.CodeAnalyzers&version=0.3.0
                    
Install as a Cake Addin
#tool nuget:?package=Saritasa.Tools.CodeAnalyzers&version=0.3.0
                    
Install as a Cake Tool

C# code analyzers

The tool contains C# code analyzers to prevent specific issues that might occur during development.

How to setup:

Add a package as a reference. See the latest version on NuGet.

<PackageReference Include="Saritasa.Tools.CodeAnalyzers" Version="0.2.2">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>

If your project uses a global package reference (GlobalPackageReference in Directory.Packages.props) and you need to use attributes such as [TrackIncludeRequired], [IncludeRequired], or [Includes] from this package in your code, you still need to add a regular PackageReference to the project. Global package references are designed for analyzers and don't include compile-time assets (the lib assembly), so the attributes won't be available for direct use in code without an explicit PackageReference.

Analyzers

ID Title Severity Category
STAN1000 MediatR request handler must have a return type Warning Design
STAN1001 Line exceeds maximum length Warning Style
STAN1002 Exception message should end with a dot Warning Spelling
STAN1003 Type names should use singular nouns Warning Naming
STAN1004 Word '{0}' has a typo Warning Spelling
STAN1005 Use early return instead of else after return Warning Style
INCL001 Method parameter should require a navigation property Warning Usage
INCL002 Value does not load the required navigation property Warning Usage
INCL003 Method annotates [Includes] but return value does not load the required navigation property Warning Usage
INCL004 Cannot check whether the navigation property is loaded Warning Usage
INCL005 [PassesIncludes] names a member that does not exist Warning Usage
INCL006 [PassesIncludes] is not allowed on this method Warning Usage

STAN1000: Request handler return type

Triggered when a MediatR IRequestHandler<TRequest> implementation does not annotate a return type. Handlers should always return a value to maintain a consistent and testable API.

Code causing a warning
public class TestRequestHandler : IRequestHandler<TestRequest>
{
    public Task Handle(TestRequest request, CancellationToken cancellationToken)
        => throw new NotImplementedException();
}
Code causing no warning
public class TestRequestHandler : IRequestHandler<TestRequest, int>
{
    public Task<int> Handle(TestRequest request, CancellationToken cancellationToken)
        => throw new NotImplementedException();
}

STAN1001: Line length

Triggered when a source line exceeds the configured maximum length. Keeping lines short improves readability and avoids horizontal scrolling.

The default limit is 130 characters.

Code causing a warning
// Line below is 145 characters — exceeds the default limit of 130
public void SomeMethod(string firstArgument, string secondArgument, string thirdArgument, string fourthArgument) { }
Code causing no warning
public void SomeMethod(
    string firstArgument,
    string secondArgument)
{ }

STAN1002: Exception message dot

Triggered when an exception is created with a message argument that does not end with a dot (.). This enforces consistent punctuation in exception messages.

Code causing a warning
throw new InvalidOperationException("Something went wrong");
Code causing no warning
throw new InvalidOperationException("Something went wrong.");
Code fix

A code fix is available: applying it appends a dot to the exception message. You can use this for fixing all warnings at once.


STAN1003: Singular type name

Triggered when a class or interface name used as a Controller or Service contains a plural noun (e.g. UsersController). Type names should use singular forms.

Certain words are allowed to remain plural by default: News, Settings, Options, Analytics, Physics, Mathematics, Statics, Dynamics, Glass, Class, Gas, Bus, Cors, Status.

Additional exceptions can be configured per project:

Code causing a warning
public class UsersController : ControllerBase { }

public interface IOrdersService { }
Code causing no warning
public class UserController : ControllerBase { }

public interface IOrderService { }

// Allowed plural word — no warning
public class SettingsController : ControllerBase { }

STAN1004: Spelling

Triggered when a word in an identifier, string literal, or comment is not found in the built-in English dictionary or the configured exclusions list. Uses Hunspell under the hood.

The analyzer checks:

  • Identifiers (class names, method names, variable names, etc.)
  • String literals (regular, interpolated, raw)
  • Single-line and multi-line comments
  • XML documentation comments (///)

The following are automatically ignored: GUIDs, URLs, hex values, file paths, and format strings (e.g. {0}).

Excluding words

Words that are valid for your project can be excluded by adding them (one per line) to an exclusions file.

By default the analyzer looks for a file named spell-checker-exclusions.txt registered as an AdditionalFiles entry. Add the following to Directory.Build.props:

<ItemGroup>
  <AdditionalFiles Include="$(MSBuildThisFileDirectory)dictionaries/spell-checker-exclusions.txt" />
</ItemGroup>

You can choose a different file path and tell the analyzer about it via .editorconfig:

[*.cs]
dotnet_diagnostic.STAN1004.exclusions_file = dictionaries/spell-checker-exclusions.txt

A code fix is available: applying it appends the flagged word to the exclusions file automatically.

Code causing a warning
// "typoo" is not a valid English word

var typoo = "It's string literal with a typoo."; // identifier and string both warned

// It's single line comment with a typoo.

/// <summary>
/// Method with a typoo.
/// </summary>
public void MethodWithTypoo() { }
Code causing no warning
// Correct spelling

var typo = "It's string literal without a typo.";

// It's single line comment without a typo.

/// <summary>
/// Correct method name.
/// </summary>
public void MethodWithCorrectName() { }

// Words in 'spell-checker-exclusions.txt' are also allowed
var linq = "linq"; // "linq" is in the built-in general exclusions list

STAN1005: Early exit

Triggered when an else block follows an if branch that already exits (via return or throw). The else is unnecessary in that case and should be removed to flatten the control flow.

Code causing a warning
if (flag)
{
    return "true";
}
else              // <-- warning here
{
    return "false";
}
if (flag)
{
    throw new InvalidOperationException("Error.");
}
else              // <-- warning here
{
    return "ok";
}
Code causing no warning
if (flag)
{
    return "true";
}
return "false";
if (flag)
{
    // does not exit
    Console.WriteLine();
}
else
{
    return "false";
}
return "other";

Four attributes control which navigation properties are tracked and how inclusion requirements are communicated between methods.

Attribute Target Purpose
[TrackIncludeRequired] Property Marks a navigation property as requiring explicit loading. Only properties with this attribute are checked by INCL rules. Every read of it is checked.
[IncludeRequired("param", "Property")] Method States that the named parameter must have the named property loaded before the method is called. A nested path is written as one dotted string, "Settings.Profile", or one segment per argument, nameof(User.Settings), nameof(UserSettings.Profile). Repeatable.
[Includes("Property")] Method Promises that the method's return value has the named property loaded. Takes a nested path the same way. Repeatable. Set Verify = false to skip the INCL003 check of the method body.
[PassesIncludes] Method, assembly Says that the method hands back the same entity objects it was given, so their includes are kept. Name the parameter they come from: [PassesIncludes(nameof(query))]; naming none means the value in front of the dot. For a library, put it on your assembly: [assembly: PassesIncludes(typeof(Lib.Ext), "Paginate", "query")]. The method must be static and must not change the entity type — see INCL006. System.Linq, EF Core and the collection types are annotated already.

How the analyzer follows a value back to its query is described in the developer documentation.

Example model used in the sections below:

class User
{
    public Organization Organization { get; set; }

    [TrackIncludeRequired]  // Only Profile and Settings are tracked, Organization produces no diagnostics.
    public UserProfile Profile { get; set; }

    [TrackIncludeRequired]
    public UserSettings Settings { get; set; }
}

class UserSettings
{
    [TrackIncludeRequired]
    public UserProfile Profile { get; set; }
}
What counts as a read

Every read of a tracked property is checked on its own, whatever the value in front of it is: a parameter, a local, a lambda parameter or the result of a call. user.Settings.Profile reads two tracked properties, so it needs Settings and Settings.Profile loaded on user; ?. is checked the same way as .. The diagnostic is placed on the property name and its rule depends on where the entities came from:

The entities came from Rule
a parameter of the current method that does not ask for the path INCL001
a query without the Include, or a method of this project that does not promise it INCL002
something the analyzer cannot follow INCL004

These are not reads and are not checked: an assignment to the property (user.Profile = x, object initializers), the entity reading its own property (this.Profile), and a lambda that becomes an expression tree, such as query.Where(u => u.Profile.IsActive), which is translated to SQL and never runs.


INCL001: Navigation property not checked

Triggered when a [TrackIncludeRequired] property is read on a parameter, or a parameter is passed to a method that requires it via [IncludeRequired], and the current method does not ask for the property with a matching [IncludeRequired]. The parameter is found through locals, lambdas and navigation reads too, and the message names the whole path the attribute must ask for:

void SetTimezone(User user)
{
    var settings = user.Settings;   // INCL001: use [IncludeRequired("user", "Settings")]
    settings.Profile.Timezone = "UTC";  // INCL001: use [IncludeRequired("user", "Settings.Profile")]
}

One [IncludeRequired(nameof(user), "Settings.Profile")] fixes both, because a longer path covers a shorter one.

Code causing a warning
void SetTimezone(User user, string timezone)
{
    // INCL001: Profile has [TrackIncludeRequired] but no [IncludeRequired(nameof(user), nameof(User.Profile))] on this method
    user.Profile.Timezone = timezone;
}
Or
 // Uncommenting this line will fix the INCL001 warning.
// [IncludeRequired(nameof(user), nameof(User.Profile))]
void UpdateUserProfile(User user, SaveUserDto dto)
{
    // Does not produce INCL001 because Organization has no [TrackIncludeRequired] attribute.
    user.Organization = dto.Organization;
    // INCL001: the called method captures the `user` argument and has [IncludeRequired] attribute.
    // Use IncludeRequiredAttribute on the current method as well.
    SetTimezone(user, dto.Timezone);
}

[IncludeRequired(nameof(user), nameof(User.Profile))]
void SetTimezone(User user, string timezone)
{
    // Does not produce INCL001 because the method has [IncludeRequired] attribute.
    user.Profile.Timezone = timezone;
}
Fixed
[IncludeRequired(nameof(user), nameof(User.Profile))]
void UpdateUserProfile(User entity, SaveUserDto dto)
{
    user.Organization = dto.Organization;
    SetTimezone(entity, dto.Timezone);
}

[IncludeRequired(nameof(user), nameof(User.Profile))]
void SetTimezone(User user, string timezone)
{
    user.Profile.Timezone = timezone;
}

INCL002: Navigation property not loaded

Triggered when a [TrackIncludeRequired] property is read, or a value is passed to a method that requires a navigation property via [IncludeRequired], and the entities came from a query that does not Include it, or from a method of this project that does not promise it with [Includes]. The value may be a local, a lambda parameter, the result of a call or a navigation of another entity.

The message names the path to load counted from the query, which is not always the path that was read:

var user = await _dbContext.Users.Include(u => u.Settings).FirstAsync(u => u.Id == id);
var timezone = user.Settings.Profile.Timezone;
// INCL002: Navigation property 'UserSettings.Profile' is not loaded for 'user.Settings';
// load 'Settings.Profile' in the query, ...
// Fix: .Include(u => u.Settings).ThenInclude(s => s.Profile)

The analyzer checks the value at the point where it is used. It follows reassignments, if/else branches (the property must be loaded on every branch), intermediate query variables (var query = ...Include(...); var user = await query.FirstAsync();), foreach loops over included collections, reassignments inside loops, LINQ lambdas such as users.Select(u => ...), and dictionaries built with ToDictionary(u => u.Id) / ToDictionaryAsync(u => u.Id) (read via dictionary[id], TryGetValue, GetValueOrDefault, Values or foreach). A dictionary built with an element selector (ToDictionary(u => u.Id, u => ...)) stores other objects, so its values are not considered loaded.

Code causing a warning
[IncludeRequired(nameof(user), nameof(User.Profile))]
void UpdateUserProfile(User entity, SaveUserDto dto)
{
    user.Organization = dto.Organization;
    SetTimezone(entity, dto.Timezone);
}

async Task Handle(SaveUserDto dto)
{
    var user = await _dbContext.Users
        //.Include(u => u.Profile) // Uncommenting this line would fix the INCL002 warning.
        .FirstOrDefaultAsync(u => u.Id == dto.Id);
    // INCL002: the called method requires User.Profile, but it is not Included in the query.
    UpdateUserProfile(user, dto);
}
Fixed
[IncludeRequired(nameof(user), nameof(User.Profile))]
void UpdateUserProfile(User entity, SaveUserDto dto)
{
    user.Organization = dto.Organization;
    SetTimezone(entity, dto.Timezone);
}

async Task Handle(SaveUserDto dto)
{
    var user = await _dbContext.Users
        .Include(u => u.Profile) // Load the property directly.
        .FirstOrDefaultAsync(u => u.Id == dto.Id);

    UpdateUserProfile(user, dto);
}
Code causing a warning
[IncludeRequired(nameof(user), nameof(User.Profile))]
void UpdateUserProfile(User entity, SaveUserDto dto)
{
    user.Organization = dto.Organization;
    SetTimezone(entity, dto.Timezone);
}

async Task Handle2(SaveUserDto dto)
{
    var user = new User
    {
        Id = dto.Id,
        Organization = dto.Organization,
        //Profile = new UserProfile { Timezone = dto.Timezone } // Uncommenting this line would fix the INCL002 warning.
    };
    // INCL002: the called method requires User.Profile, but it is not set.
    UpdateUserProfile(user, dto);
}

Fixed
[IncludeRequired(nameof(user), nameof(User.Profile))]
void UpdateUserProfile(User entity, SaveUserDto dto)
{
    user.Organization = dto.Organization;
    SetTimezone(entity, dto.Timezone);
}

async Task Handle2(SaveUserDto dto)
{
    var user = new User
    {
        Id = dto.Id,
        Organization = dto.Organization,
        Profile = new UserProfile { Timezone = dto.Timezone } // Load the property directly.
    };

    UpdateUserProfile(user, dto);
}
Code causing a warning
[IncludeRequired(nameof(user), nameof(User.Profile))]
void UpdateUserProfile(User entity, SaveUserDto dto)
{
    user.Organization = dto.Organization;
    SetTimezone(entity, dto.Timezone);
}

async Task Handle3(SaveUserDto dto)
{
    var user = await GetUser(dto.Id);
    // INCL002: the called method requires User.Profile, but it is not checked.
    UpdateUserProfile(user, dto);
}
// [Includes(nameof(User.Profile))] // Uncommenting this line would fix the INCL002 warning.
async Task<User> GetUser(int id)
{
    return _dbContext.Users
        .Include(u => u.Profile)
        .FirstOrDefault(u => u.Id == id);
}
Fixed
// Fix 2: annotate the source method with [Includes]
[Includes(nameof(User.Profile))]
async Task<User> GetUser(int id)
{
    return _dbContext.Users
        .Include(u => u.Profile)
        .FirstOrDefault(u => u.Id == id);
}

async Task Handle3(SaveUserDto dto)
{
    var user = await GetUser(dto.Id); // [Includes(nameof(User.Profile))] satisfies the requirement.
    UpdateUserProfile(user, dto);
}

INCL003: [Includes] promise not fulfilled

Triggered when a method annotated with [Includes("Property")] returns a value that does not load the named property via .Include() and .ThenInclude(), an object initializer, or a source method annotated with [Includes]. As for INCL002, the message names the path to load in the query: a method that promises Profile and returns user.Settings needs Settings.Profile loaded on the query of user.

Code causing a warning
[Includes(nameof(User.Profile))]
async Task<User> GetUser(int id)
{
   // INCL003: the method has [Includes] attribute for User.Profile, but Profile is not Included in the query.
    return _dbContext.Users
        //.Include(u => u.Profile) // Uncommenting this line would fix the INCL003 warning.
        .FirstOrDefault(u => u.Id == id);
}
Fixed
[Includes(nameof(User.Profile))]
async Task<User> GetUser(int id)
{
    return _dbContext.Users
        .Include(u => u.Profile) // Load the property directly.
        .FirstOrDefault(u => u.Id == id);
}
Code causing a warning
[Includes(nameof(User.Profile))]
async Task<User> CreateUser(SaveUserDto dto)
{
    // INCL003: the method has [Includes] attribute for User.Profile, but Profile is not set.
    var user = new User
    {
        Id = dto.Id,
        Organization = dto.Organization,
        //Profile = new UserProfile { Timezone = dto.Timezone } // Uncommenting this line would fix the INCL003 warning.
    };
    return user;
}
Fixed
[Includes(nameof(User.Profile))]
Task<User> CreateUser(SaveUserDto dto)
{
    var user = new User
    {
        Id = dto.Id,
        Profile = new UserProfile { Timezone = dto.Timezone }, // Load the property directly.
    };
    return Task.FromResult(user);
}
Disabling verification

When the analyzer cannot follow how the method loads the property (for example, includes are built dynamically or loaded by a helper), set Verify = false. INCL003 is not reported for the method, and callers still rely on the promise.

[Includes(nameof(User.Profile), Verify = false)]
Task<User> GetUser(int id)
{
    return _repository.GetWithIncludesAsync(id, IncludeProfile); // No INCL003.
}

INCL004: Cannot check navigation property

Triggered when the analyzer follows a value back to the query it came from and cannot read one of the steps. The analyzer follows only members annotated with [PassesIncludes]; System.Linq, EF Core and the collection types are annotated already.

These are the cases it does not follow. All of them give INCL004, never a false pass:

Not followed Why
A property of a library type, such as page.Items an annotation can only describe a method. Make the type enumerable instead
Cast<T>, OfType<T> the entity type changes
Entities reached through a navigation property, such as the f of SelectMany(u => u.Friends, (u, f) => ...) they came from u.Friends, not from the query, and carry different includes
A field, such as UpdateUserProfile(this.user, dto) or this.user.Profile nothing in the method says where its value came from
A lambda not passed to a known method, such as Func<User, string> f = u => u.Profile.Timezone nothing says what u holds
Code causing a warning
async Task Handle(SaveUserDto dto)
{
    var page = _dbContext.Users
        .Include(u => u.Profile)
        .Paginate(1); // A library method: the analyzer does not know it keeps the users.

    // INCL004: the analyzer cannot read SomeLib.QueryExtensions.Paginate.
    UpdateUserProfile(page.First(), dto);
}
Fixed

The code fix adds the annotation to NavigationIncludes.cs in the project:

[assembly: PassesIncludes(typeof(SomeLib.QueryExtensions), "Paginate", "query")]

An annotation can only describe a method. When a library hands its entities back through a property instead, such as page.Items, there is nothing to annotate and the value cannot be followed. It works when the type is enumerable, because First(), foreach and the rest are annotated already.

A method in your own project that is not annotated gives INCL002 instead, because it can be read and promises nothing. Annotate it on the method itself:

[PassesIncludes(nameof(query))]
public static IQueryable<User> OnlyActive(this IQueryable<User> query) => query.Where(u => u.IsActive);

INCL005: [PassesIncludes] names nothing

Triggered when an assembly-level [PassesIncludes] names a member or a parameter that does not exist, for example after a library renamed a method. Such an annotation has no effect, so the analyzer would silently stop following the member.

Code causing a warning
// INCL005: SomeLib.QueryExtensions has no member "Paginated".
[assembly: PassesIncludes(typeof(SomeLib.QueryExtensions), "Paginated", "query")]
Fixed
[assembly: PassesIncludes(typeof(SomeLib.QueryExtensions), "Paginate", "query")]

INCL006: [PassesIncludes] is not allowed here

Triggered when a [PassesIncludes] describes a method it may not describe. The annotation is ignored, and this says so where it is written, rather than leaving it to turn up as an INCL004 in another file.

[PassesIncludes] is a promise that the same entity objects come out of the method that went into it. Nothing the analyzer reads can check that, so the two shapes where it can quietly be false are not allowed.

The method must be static. An instance method can change what it was called on, or hand back something it built from a field, and the annotation would look the same. An extension method counts as static, and the value in front of the dot is its first parameter.

public class UserBatch
{
    private readonly List<User> items = new();

    // INCL006: only a static method can be annotated to hand back the entities it was given.
    [PassesIncludes]
    public List<User> GetItems() => items;
}

The entity type must not change. The container may: IQueryable<User> to List<User>, to Task<User>, to a single User.

// INCL006: it would carry entities from 'User' to 'Organization'.
[PassesIncludes(nameof(users))]
public static IEnumerable<Organization> GetOrganizations(IEnumerable<User> users) => ...;

A container of your own counts only if the analyzer can read it, which means implementing IEnumerable<T>. Without that it cannot be told apart from an annotation pointing at an unrelated entity:

// INCL006: 'UserBatch' is not a collection the analyzer can read.
public class UserBatch
{
    public List<User> Items { get; set; }
}

// Fixed: now the entities in it can be seen.
public class UserBatch : IEnumerable<User>
{
    public List<User> Items { get; set; }

    public IEnumerator<User> GetEnumerator() => Items.GetEnumerator();

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
There are no supported framework assets in this package.

Learn more about Target Frameworks and .NET Standard.

This package has no dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.3.0 42 10/20/2026
0.2.0 288 8/5/2026
0.1.0 24,660 11/30/2023