Skip to content

Commit bf96aa6

Browse files
committed
Added Analyzer project, and analyzer for inadvertent comparisons based on implicit conversion.
1 parent fd50ec4 commit bf96aa6

File tree

8 files changed

+271
-1
lines changed

8 files changed

+271
-1
lines changed
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
using System.Collections.Immutable;
2+
using Microsoft.CodeAnalysis;
3+
using Microsoft.CodeAnalysis.CSharp;
4+
using Microsoft.CodeAnalysis.CSharp.Syntax;
5+
using Microsoft.CodeAnalysis.Diagnostics;
6+
7+
namespace Architect.DomainModeling.Analyzer.Analyzers;
8+
9+
/// <summary>
10+
/// Prevents accidental equality/comparison operator usage between unrelated types, where implicit conversions inadvertently make the operation compile.
11+
/// </summary>
12+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
13+
public sealed class ValueObjectImplicitConversionOnBinaryOperatorAnalyzer : DiagnosticAnalyzer
14+
{
15+
[System.Diagnostics.CodeAnalysis.SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")]
16+
[System.Diagnostics.CodeAnalysis.SuppressMessage("MicrosoftCodeAnalysisReleaseTracking", "RS2008:Enable analyzer release tracking", Justification = "Not yet implemented.")]
17+
private static readonly DiagnosticDescriptor DiagnosticDescriptor = new DiagnosticDescriptor(
18+
id: "ComparisonBetweenUnrelatedValueObjects",
19+
title: "Comparison between unrelated value objects",
20+
messageFormat: "Possible unintended '{0}' comparison between unrelated value objects {1} and {2}. Either compare value objects of the same type, implement a dedicated operator overload, or compare underlying values directly.",
21+
category: "Usage",
22+
defaultSeverity: DiagnosticSeverity.Warning,
23+
isEnabledByDefault: true);
24+
25+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [DiagnosticDescriptor];
26+
27+
public override void Initialize(AnalysisContext context)
28+
{
29+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.ReportDiagnostics);
30+
context.EnableConcurrentExecution();
31+
32+
context.RegisterSyntaxNodeAction(
33+
AnalyzeBinaryExpression,
34+
SyntaxKind.EqualsExpression,
35+
SyntaxKind.NotEqualsExpression,
36+
SyntaxKind.LessThanExpression,
37+
SyntaxKind.LessThanOrEqualExpression,
38+
SyntaxKind.GreaterThanExpression,
39+
SyntaxKind.GreaterThanOrEqualExpression);
40+
}
41+
42+
private static void AnalyzeBinaryExpression(SyntaxNodeAnalysisContext context)
43+
{
44+
if (context.Node is not BinaryExpressionSyntax binaryExpression)
45+
return;
46+
47+
var semanticModel = context.SemanticModel;
48+
var cancellationToken = context.CancellationToken;
49+
50+
var leftTypeInfo = semanticModel.GetTypeInfo(binaryExpression.Left, cancellationToken);
51+
var rightTypeInfo = semanticModel.GetTypeInfo(binaryExpression.Right, cancellationToken);
52+
53+
// Not if either operand is typeless (e.g. null)
54+
if (leftTypeInfo.Type is null || rightTypeInfo.Type is null)
55+
return;
56+
57+
// If either operand was implicitly converted FROM some IValueObject to something else, then the comparison is ill-advised
58+
if (OperandWasImplicitlyConvertedFromIValueObject(leftTypeInfo) || OperandWasImplicitlyConvertedFromIValueObject(rightTypeInfo))
59+
{
60+
var diagnostic = Diagnostic.Create(
61+
DiagnosticDescriptor,
62+
context.Node.GetLocation(),
63+
binaryExpression.OperatorToken.ValueText,
64+
IsNullable(leftTypeInfo.Type, out var nullableUnderlyingType) ? nullableUnderlyingType.Name + '?' : leftTypeInfo.Type.Name,
65+
IsNullable(rightTypeInfo.Type, out nullableUnderlyingType) ? nullableUnderlyingType.Name + '?' : rightTypeInfo.Type.Name);
66+
67+
context.ReportDiagnostic(diagnostic);
68+
}
69+
}
70+
71+
private static bool OperandWasImplicitlyConvertedFromIValueObject(TypeInfo operandTypeInfo)
72+
{
73+
var from = operandTypeInfo.Type;
74+
var to = operandTypeInfo.ConvertedType;
75+
76+
// If no type available or no implicit conversion took place, return false
77+
if (from is null || from.Equals(to, SymbolEqualityComparer.Default))
78+
return false;
79+
80+
// Do not flag nullable lifting (where a nullable and a non-nullable are compared)
81+
// Note that it LOOKS as though the nullable is converted to non-nullable, but the opposite is true
82+
if (IsNullable(to, out var nullableUnderlyingType) && nullableUnderlyingType.Equals(from, SymbolEqualityComparer.Default))
83+
return false;
84+
85+
// Dig through nullables
86+
if (IsNullable(from, out nullableUnderlyingType))
87+
from = nullableUnderlyingType;
88+
if (IsNullable(to, out nullableUnderlyingType))
89+
to = nullableUnderlyingType;
90+
91+
// Backwards compatibility: If converting to ValueObject, then ignore, because the ValueObject base class implements ==(ValueObject, ValueObject)
92+
if (to is { Name: "ValueObject", ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true } } })
93+
return false;
94+
95+
var isConvertedFromIValueObject = from.AllInterfaces.Any(interf =>
96+
interf is { Name: "IValueObject", ContainingNamespace: { Name: "DomainModeling", ContainingNamespace: { Name: "Architect", ContainingNamespace.IsGlobalNamespace: true } } });
97+
98+
return isConvertedFromIValueObject;
99+
}
100+
101+
private static bool IsNullable(ITypeSymbol? potentialNullable, out ITypeSymbol underlyingType)
102+
{
103+
if (potentialNullable is not INamedTypeSymbol { ConstructedFrom.SpecialType: SpecialType.System_Nullable_T } namedTypeSymbol)
104+
{
105+
underlyingType = null!;
106+
return false;
107+
}
108+
109+
underlyingType = namedTypeSymbol.TypeArguments[0];
110+
return true;
111+
}
112+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>netstandard2.0</TargetFramework>
5+
<AssemblyName>Architect.DomainModeling.Analyzer</AssemblyName>
6+
<RootNamespace>Architect.DomainModeling.Analyzer</RootNamespace>
7+
<Nullable>Enable</Nullable>
8+
<ImplicitUsings>Enable</ImplicitUsings>
9+
<LangVersion>13</LangVersion>
10+
<IsPackable>False</IsPackable>
11+
<DevelopmentDependency>True</DevelopmentDependency>
12+
<EnforceExtendedAnalyzerRules>True</EnforceExtendedAnalyzerRules>
13+
</PropertyGroup>
14+
15+
<PropertyGroup>
16+
<!-- IDE0057: Slice can be simplified -->
17+
<NoWarn>IDE0057</NoWarn>
18+
</PropertyGroup>
19+
20+
<ItemGroup>
21+
<InternalsVisibleTo Include="Architect.DomainModeling.Tests" />
22+
</ItemGroup>
23+
24+
<ItemGroup>
25+
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" PrivateAssets="All" />
26+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" PrivateAssets="All" />
27+
</ItemGroup>
28+
29+
</Project>

DomainModeling.Example/DomainModeling.Example.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
<ItemGroup>
2121
<ProjectReference Include="..\DomainModeling\DomainModeling.csproj" />
22+
<ProjectReference Include="..\DomainModeling.Analyzer\DomainModeling.Analyzer.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
2223
<ProjectReference Include="..\DomainModeling.Generator\DomainModeling.Generator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
2324
</ItemGroup>
2425

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
using Architect.DomainModeling.Tests.IdentityTestTypes;
3+
using Architect.DomainModeling.Tests.WrapperValueObjectTestTypes;
4+
5+
namespace Architect.DomainModeling.Tests.Analyzers;
6+
7+
[SuppressMessage("CodeQuality", "IDE0079:Remove unnecessary suppression", Justification = "False positive.")]
8+
[SuppressMessage("Usage", "ComparisonBetweenUnrelatedValueObjects:Comparison between unrelated value objects", Justification = "Testing presence of warning.")]
9+
public class ValueObjectImplicitConversionOnBinaryOperatorAnalyzerTests
10+
{
11+
// Unfortunately, we always get "unnecessary suppression" even when the warning is successfully suppressed
12+
// All we can do is manually outcomment the suppression temporarily to check that each statement in this file still warns
13+
14+
public static void CompareUnrelatedIdentities_Always_ShouldWarn()
15+
{
16+
_ = (IntId)0 == (FullySelfImplementedIdentity)0;
17+
_ = (IntId)0 != (FullySelfImplementedIdentity)0;
18+
_ = (IntId)0 < (FullySelfImplementedIdentity)0;
19+
_ = (IntId)0 <= (FullySelfImplementedIdentity)0;
20+
_ = (IntId)0 > (FullySelfImplementedIdentity)0;
21+
_ = (IntId)0 >= (FullySelfImplementedIdentity)0;
22+
23+
_ = (IntId?)0 == (FullySelfImplementedIdentity)0;
24+
_ = (IntId?)0 != (FullySelfImplementedIdentity)0;
25+
_ = (IntId?)0 < (FullySelfImplementedIdentity)0;
26+
_ = (IntId?)0 <= (FullySelfImplementedIdentity)0;
27+
_ = (IntId?)0 > (FullySelfImplementedIdentity)0;
28+
_ = (IntId?)0 >= (FullySelfImplementedIdentity)0;
29+
30+
_ = (IntId)0 == (FullySelfImplementedIdentity?)0;
31+
_ = (IntId)0 != (FullySelfImplementedIdentity?)0;
32+
_ = (IntId)0 < (FullySelfImplementedIdentity?)0;
33+
_ = (IntId)0 <= (FullySelfImplementedIdentity?)0;
34+
_ = (IntId)0 > (FullySelfImplementedIdentity?)0;
35+
_ = (IntId)0 >= (FullySelfImplementedIdentity?)0;
36+
37+
_ = (IntId?)0 == (FullySelfImplementedIdentity?)0;
38+
_ = (IntId?)0 != (FullySelfImplementedIdentity?)0;
39+
_ = (IntId?)0 < (FullySelfImplementedIdentity?)0;
40+
_ = (IntId?)0 <= (FullySelfImplementedIdentity?)0;
41+
_ = (IntId?)0 > (FullySelfImplementedIdentity?)0;
42+
_ = (IntId?)0 >= (FullySelfImplementedIdentity?)0;
43+
}
44+
45+
public static void CompareUnrelatedWrapperValueObjects_Always_ShouldWarn()
46+
{
47+
_ = (IntValue)0 == (FullySelfImplementedWrapperValueObject)0;
48+
_ = (IntValue)0 != (FullySelfImplementedWrapperValueObject)0;
49+
_ = (IntValue)0 < (FullySelfImplementedWrapperValueObject)0;
50+
_ = (IntValue)0 <= (FullySelfImplementedWrapperValueObject)0;
51+
_ = (IntValue)0 > (FullySelfImplementedWrapperValueObject)0;
52+
_ = (IntValue)0 >= (FullySelfImplementedWrapperValueObject)0;
53+
54+
#pragma warning disable CS8604 // Possible null reference argument. -- True, but we just want to use this to test an analyzer"
55+
_ = (IntValue?)0 == (FullySelfImplementedWrapperValueObject)0;
56+
_ = (IntValue?)0 != (FullySelfImplementedWrapperValueObject)0;
57+
_ = (IntValue?)0 < (FullySelfImplementedWrapperValueObject)0;
58+
_ = (IntValue?)0 <= (FullySelfImplementedWrapperValueObject)0;
59+
_ = (IntValue?)0 > (FullySelfImplementedWrapperValueObject)0;
60+
_ = (IntValue?)0 >= (FullySelfImplementedWrapperValueObject)0;
61+
62+
_ = (IntValue)0 == (FullySelfImplementedWrapperValueObject?)0;
63+
_ = (IntValue)0 != (FullySelfImplementedWrapperValueObject?)0;
64+
_ = (IntValue)0 < (FullySelfImplementedWrapperValueObject?)0;
65+
_ = (IntValue)0 <= (FullySelfImplementedWrapperValueObject?)0;
66+
_ = (IntValue)0 > (FullySelfImplementedWrapperValueObject?)0;
67+
_ = (IntValue)0 >= (FullySelfImplementedWrapperValueObject?)0;
68+
#pragma warning restore CS8604 // Possible null reference argument.
69+
}
70+
71+
public static void CompareUnrelatedWrapperValueObjectToCoreType_Always_ShouldWarn()
72+
{
73+
_ = (IntValue)0 == 0;
74+
_ = (IntValue)0 != 0;
75+
_ = (IntValue)0 < 0;
76+
_ = (IntValue)0 <= 0;
77+
_ = (IntValue)0 > 0;
78+
_ = (IntValue)0 >= 0;
79+
80+
#pragma warning disable CS8604 // Possible null reference argument. -- True, but we just want to use this to test an analyzer"
81+
_ = (IntValue?)0 == 0;
82+
_ = (IntValue?)0 != 0;
83+
_ = (IntValue?)0 < 0;
84+
_ = (IntValue?)0 <= 0;
85+
_ = (IntValue?)0 > 0;
86+
_ = (IntValue?)0 >= 0;
87+
#pragma warning restore CS8604 // Possible null reference argument.
88+
89+
_ = (IntValue)0 == (int?)0;
90+
_ = (IntValue)0 != (int?)0;
91+
_ = (IntValue)0 < (int?)0;
92+
_ = (IntValue)0 <= (int?)0;
93+
_ = (IntValue)0 > (int?)0;
94+
_ = (IntValue)0 >= (int?)0;
95+
96+
_ = (IntValue?)0 == (int?)0;
97+
_ = (IntValue?)0 != (int?)0;
98+
_ = (IntValue?)0 < (int?)0;
99+
_ = (IntValue?)0 <= (int?)0;
100+
_ = (IntValue?)0 > (int?)0;
101+
_ = (IntValue?)0 >= (int?)0;
102+
}
103+
}

DomainModeling.Tests/DomainModeling.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535

3636
<ItemGroup>
3737
<ProjectReference Include="..\DomainModeling\DomainModeling.csproj" />
38+
<ProjectReference Include="..\DomainModeling.Analyzer\DomainModeling.Analyzer.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
3839
<ProjectReference Include="..\DomainModeling.Generator\DomainModeling.Generator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="true" />
3940
</ItemGroup>
4041

DomainModeling.sln

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@ EndProject
1414
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{08DABA83-2014-4A2F-A584-B5FFA6FEA45D}"
1515
ProjectSection(SolutionItems) = preProject
1616
.editorconfig = .editorconfig
17+
LICENSE = LICENSE
1718
pipeline-publish-preview.yml = pipeline-publish-preview.yml
1819
pipeline-publish-stable.yml = pipeline-publish-stable.yml
1920
pipeline-verify.yml = pipeline-verify.yml
2021
README.md = README.md
21-
LICENSE = LICENSE
2222
EndProjectSection
2323
EndProject
24+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DomainModeling.Analyzer", "DomainModeling.Analyzer\DomainModeling.Analyzer.csproj", "{39B467AB-9E95-47AF-713B-D37A02BD964B}"
25+
EndProject
2426
Global
2527
GlobalSection(SolutionConfigurationPlatforms) = preSolution
2628
Debug|Any CPU = Debug|Any CPU
@@ -43,6 +45,10 @@ Global
4345
{F4035B17-3F4B-4298-A68E-AD3B730A4DB6}.Debug|Any CPU.Build.0 = Debug|Any CPU
4446
{F4035B17-3F4B-4298-A68E-AD3B730A4DB6}.Release|Any CPU.ActiveCfg = Release|Any CPU
4547
{F4035B17-3F4B-4298-A68E-AD3B730A4DB6}.Release|Any CPU.Build.0 = Release|Any CPU
48+
{39B467AB-9E95-47AF-713B-D37A02BD964B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
49+
{39B467AB-9E95-47AF-713B-D37A02BD964B}.Debug|Any CPU.Build.0 = Debug|Any CPU
50+
{39B467AB-9E95-47AF-713B-D37A02BD964B}.Release|Any CPU.ActiveCfg = Release|Any CPU
51+
{39B467AB-9E95-47AF-713B-D37A02BD964B}.Release|Any CPU.Build.0 = Release|Any CPU
4652
EndGlobalSection
4753
GlobalSection(SolutionProperties) = preSolution
4854
HideSolutionNode = FALSE
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<Project>
2+
<ItemGroup>
3+
<Analyzer Include="$(MSBuildThisFileDirectory)../analyzers/dotnet/cs/Architect.DomainModeling.Analyzer.dll" />
4+
</ItemGroup>
5+
</Project>

DomainModeling/DomainModeling.csproj

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ Performance:
5757
Misc improvements:
5858
- Semi-breaking: IIdentity now implements IWrapperValueObject.
5959
- Semi-breaking: I[Utf8][Span]Formattable implementations based on strings have stopped treating null strings as "", as this could cover up mistakes instead of revealing them.
60+
- Feature: An analyzer now warns when '==' or a similar operator is implicitly casting some IValueObject to something else. This avoids accidentally comparing unrelated types.
6061
- Bug fix: Fixed a bug where source-generated records would always generate ToString()/Equals()/GetHashCode(), even if you wrote your own.
6162
- Bug fix: Fixed a bug where source-generated WrapperValueObject/Identity types would not recognize manual member implementations if they were explicit interface implementations.
6263
- Bug fix: Fixed a bug where the DummyBuilder generator struggled with nested types.
@@ -86,8 +87,20 @@ Misc improvements:
8687
<ItemGroup>
8788
<!-- Take build dependency on the generator -->
8889
<ProjectReference Include="..\DomainModeling.Generator\DomainModeling.Generator.csproj" Pack="False" ReferenceOutputAssembly="False" Private="False" />
90+
8991
<!-- Package the generator in the analyzer directory of the NuGet package -->
9092
<None Include="$(MSBuildProjectDirectory)/../DomainModeling.Generator/bin/$(Configuration)/netstandard2.0/Architect.DomainModeling.Generator.dll" Pack="True" PackagePath="analyzers/dotnet/cs" Visible="False" />
9193
</ItemGroup>
9294

95+
<ItemGroup>
96+
<!-- Take build dependency on the analyzer -->
97+
<ProjectReference Include="..\DomainModeling.Analyzer\DomainModeling.Analyzer.csproj" Pack="False" ReferenceOutputAssembly="False" Private="False" />
98+
99+
<!-- Package the analyzer in the analyzers directory -->
100+
<None Include="$(MSBuildProjectDirectory)/../DomainModeling.Analyzer/bin/$(Configuration)/netstandard2.0/Architect.DomainModeling.Analyzer.dll" Pack="True" PackagePath="analyzers/dotnet/cs" Visible="False" />
101+
102+
<!-- Package the targets file in the build directory -->
103+
<None Include="Architect.DomainModeling.targets" Pack="True" PackagePath="build/Architect.DomainModeling.targets" Visible="False" />
104+
</ItemGroup>
105+
93106
</Project>

0 commit comments

Comments
 (0)