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
<PackageReference Include="Saritasa.Tools.CodeAnalyzers" Version="0.3.0"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference>
<PackageVersion Include="Saritasa.Tools.CodeAnalyzers" Version="0.3.0" />
<PackageReference Include="Saritasa.Tools.CodeAnalyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference>
paket add Saritasa.Tools.CodeAnalyzers --version 0.3.0
#r "nuget: Saritasa.Tools.CodeAnalyzers, 0.3.0"
#:package Saritasa.Tools.CodeAnalyzers@0.3.0
#addin nuget:?package=Saritasa.Tools.CodeAnalyzers&version=0.3.0
#tool nuget:?package=Saritasa.Tools.CodeAnalyzers&version=0.3.0
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";
Navigation Include attributes
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();
}
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.