diff --git a/src/dotnet/Azure.ClientSdk.Analyzers/.editorconfig b/src/dotnet/Azure.ClientSdk.Analyzers/.editorconfig
new file mode 100644
index 00000000000..6ff62a0f2a9
--- /dev/null
+++ b/src/dotnet/Azure.ClientSdk.Analyzers/.editorconfig
@@ -0,0 +1,23 @@
+# The warnings below are found after upgrading to Roslyn 4.7.0
+# It should be able to fix them all. Suppressing them is just a temporary work-around.
+
+[*.cs]
+
+# CS0618: 'IOperation.Children' is obsolete: 'This API has performance penalties, please use ChildOperations instead.
+dotnet_diagnostic.CS0618.severity = none
+
+# RS1031: The diagnostic title should not contain a period, nor any line return character, nor any leading or trailing whitespaces
+dotnet_diagnostic.RS1031.severity = none
+
+# RS1032: The diagnostic message should not contain any line return character nor any leading or trailing whitespaces and should either be a single sentence without a trailing period or a multi-sentences with a trailing period
+dotnet_diagnostic.RS1032.severity = none
+
+# RS2008: Enable analyzer release tracking for the analyzer project containing rule 'XXX'
+dotnet_diagnostic.RS2008.severity = none
+
+# RS1024: Use 'SymbolEqualityComparer' when comparing symbols
+dotnet_diagnostic.RS1024.severity = none
+
+# RS1037: Add "CompilationEnd" custom tag to the diagnostic descriptor used to initialize field 'AZC0011' as it is used to report a compilation end diagnostic
+dotnet_diagnostic.RS1037.severity = none
+
diff --git a/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers.Tests/Azure.ClientSdk.Analyzers.Tests.csproj b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers.Tests/Azure.ClientSdk.Analyzers.Tests.csproj
index 4e207cd1a60..aa76426ffc9 100644
--- a/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers.Tests/Azure.ClientSdk.Analyzers.Tests.csproj
+++ b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers.Tests/Azure.ClientSdk.Analyzers.Tests.csproj
@@ -14,17 +14,17 @@
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
-
-
+
+
diff --git a/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers.Tests/ModelName/AZC0030Tests.cs b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers.Tests/ModelName/AZC0030Tests.cs
new file mode 100644
index 00000000000..4a3e8b1b8ad
--- /dev/null
+++ b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers.Tests/ModelName/AZC0030Tests.cs
@@ -0,0 +1,100 @@
+using System.Threading.Tasks;
+using Azure.ClientSdk.Analyzers.ModelName;
+using Xunit;
+
+using VerifyCS = Azure.ClientSdk.Analyzers.Tests.AzureAnalyzerVerifier<
+ Azure.ClientSdk.Analyzers.ModelName.GeneralSuffixAnalyzer>;
+
+namespace Azure.ClientSdk.Analyzers.Tests.ModelName
+{
+ public class AZC0030Tests
+ {
+ private const string diagnosticId = "AZC0030";
+
+ [Fact]
+ public async Task GoodSuffix()
+ {
+ var test = @"using System.Text.Json;
+namespace Azure.ResourceManager.Models;
+
+public class MonitorContent
+{
+}";
+ await VerifyCS.VerifyAnalyzerAsync(test);
+ }
+
+ [Fact]
+ public async Task ParametersSuffix()
+ {
+ var test = @"using System.Text.Json;
+namespace Azure.ResourceManager
+{
+ public class ResponseParameters
+ {
+ public static ResponseParameters DeserializeResponseParameters(JsonElement element)
+ {
+ return null;
+ }
+ }
+}";
+ var expected = VerifyCS.Diagnostic(diagnosticId).WithSpan(4, 18, 4, 36).WithArguments("ResponseParameters", "Parameters", "'ResponseContent' or 'ResponsePatch'");
+ await VerifyCS.VerifyAnalyzerAsync(test, expected);
+ }
+
+ [Fact]
+ public async Task RequestSuffix()
+ {
+ var test = @"using System.Text.Json;
+namespace Azure.ResourceManager.Models
+{
+ public class DiskOption
+ {
+ public static DiskOption DeserializeDiskOption(JsonElement element)
+ {
+ return null;
+ }
+ }
+}";
+ var expected = VerifyCS.Diagnostic(diagnosticId).WithSpan(4, 18, 4, 28).WithArguments("DiskOption", "Option", "'DiskConfig'");
+ await VerifyCS.VerifyAnalyzerAsync(test, expected);
+ }
+
+ [Fact]
+ public async Task OptionSuffixWithNestedNameSpace()
+ {
+ var test = @"using System.Text.Json;
+namespace Azure.ResourceManager.Models
+{
+ namespace SubTest
+ {
+ public class DiskOption
+ {
+ }
+ }
+}";
+ var expected = VerifyCS.Diagnostic(diagnosticId).WithSpan(6, 22, 6, 32).WithArguments("DiskOption", "Option", "'DiskConfig'");
+ await VerifyCS.VerifyAnalyzerAsync(test, expected);
+ }
+
+ [Fact]
+ public async Task ResponsesSuffix()
+ {
+ var test = @"using System.Text.Json;
+namespace Azure.ResourceManager.Models
+{
+ namespace SubTest
+ {
+ public class CreationResponses
+ {
+ public static CreationResponses DeserializeCreationResponses(JsonElement element)
+ {
+ return null;
+ }
+ }
+ }
+}";
+ var expected = VerifyCS.Diagnostic(diagnosticId).WithSpan(6, 22, 6, 39).WithArguments("CreationResponses", "Responses", "'CreationResults'");
+ await VerifyCS.VerifyAnalyzerAsync(test, expected);
+ }
+ }
+}
diff --git a/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers.Tests/ModelName/AZC0031Tests.cs b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers.Tests/ModelName/AZC0031Tests.cs
new file mode 100644
index 00000000000..1e3391672a8
--- /dev/null
+++ b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers.Tests/ModelName/AZC0031Tests.cs
@@ -0,0 +1,74 @@
+using System.Threading.Tasks;
+using Azure.ClientSdk.Analyzers.ModelName;
+using Xunit;
+
+using VerifyCS = Azure.ClientSdk.Analyzers.Tests.AzureAnalyzerVerifier<
+ Azure.ClientSdk.Analyzers.ModelName.DefinitionSuffixAnalyzer>;
+
+namespace Azure.ClientSdk.Analyzers.Tests.ModelName
+{
+ public class AZC0031Tests
+ {
+ private const string diagnosticId = "AZC0031";
+
+ [Fact]
+ public async Task ModelWithDefinitionSuffix()
+ {
+ var test = @"using System.Text.Json;
+namespace Azure.ResourceManager.Network.Models
+{
+ public partial class AadAuthenticationDefinition
+ {
+ }
+}";
+ var expected = VerifyCS.Diagnostic(diagnosticId).WithSpan(4, 26, 4, 53).WithArguments("AadAuthenticationDefinition", "Definition");
+ await VerifyCS.VerifyAnalyzerAsync(test, expected);
+ }
+
+ [Fact]
+ public async Task ArmResourceIsNotChecked()
+ {
+ var test = @"using System.Text.Json;
+using Azure.ResourceManager;
+namespace Azure.ResourceManager
+{
+ public class ArmResource {
+ }
+}
+namespace Azure.ResourceManager.Network.Models
+{
+ public partial class AadAuthenticationDefinition: ArmResource
+ {
+ public static AadAuthenticationDefinition DeserializeAadAuthenticationDefinition(JsonElement element)
+ {
+ return null;
+ }
+ }
+}";
+ await VerifyCS.VerifyAnalyzerAsync(test);
+ }
+
+ [Fact]
+ public async Task NotCheckIfRemovingSuffixIsAnotherType()
+ {
+ var test = @"using System.Text.Json;
+using Azure.ResourceManager;
+namespace Azure.ResourceManager.Network.Models
+{
+ public class AadAuthentication {
+ }
+
+ public partial class AadAuthenticationDefinition
+ {
+ public static AadAuthenticationDefinition DeserializeAadAuthenticationDefinition(JsonElement element)
+ {
+ return null;
+ }
+ }
+}";
+ await VerifyCS.VerifyAnalyzerAsync(test);
+ }
+
+ }
+}
+
diff --git a/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers.Tests/ModelName/AZC0032Tests.cs b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers.Tests/ModelName/AZC0032Tests.cs
new file mode 100644
index 00000000000..1731ab1975f
--- /dev/null
+++ b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers.Tests/ModelName/AZC0032Tests.cs
@@ -0,0 +1,71 @@
+using System.Threading.Tasks;
+using Xunit;
+
+using VerifyCS = Azure.ClientSdk.Analyzers.Tests.AzureAnalyzerVerifier<
+ Azure.ClientSdk.Analyzers.ModelName.DataSuffixAnalyzer>;
+using Azure.ClientSdk.Analyzers.ModelName;
+
+namespace Azure.ClientSdk.Analyzers.Tests.ModelName
+{
+ public class AZC0032Tests
+ {
+ private const string diagnosticId = "AZC0032";
+
+ [Fact]
+ public async Task ModelClassWithDataSuffix()
+ {
+ var test = @"using System.Text.Json;
+namespace Azure.ResourceManager.Network.Models
+{
+ public partial class AadAuthenticationData
+ {
+ public static AadAuthenticationData DeserializeAadAuthenticationData(JsonElement element)
+ {
+ return null;
+ }
+ }
+}";
+ var expected = VerifyCS.Diagnostic(diagnosticId).WithSpan(4, 26, 4, 47).WithArguments("AadAuthenticationData", "Data");
+ await VerifyCS.VerifyAnalyzerAsync(test, expected);
+ }
+
+ [Fact]
+ public async Task ResourceDataClassesAreNotChecked()
+ {
+ var test = @"using System.Text.Json;
+using Azure.ResourceManager.Models;
+namespace Azure.ResourceManager.Models
+{
+ public class ResourceData {
+ }
+}
+namespace Azure.ResourceManager.Models.Network
+{
+ public partial class AadAuthenticationData: ResourceData
+ {
+ }
+}";
+ await VerifyCS.VerifyAnalyzerAsync(test);
+ }
+
+ [Fact]
+ public async Task TrackedResourceDataClassesAreNotChecked()
+ {
+ var test = @"using System.Text.Json;
+using Azure.ResourceManager.Models;
+namespace Azure.ResourceManager.Models
+{
+ public class TrackedResourceData {
+ }
+}
+namespace Azure.ResourceManager.Network.Models
+{
+ public partial class AadAuthenticationData: TrackedResourceData
+ {
+ }
+}";
+ await VerifyCS.VerifyAnalyzerAsync(test);
+ }
+ }
+}
+
diff --git a/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers.Tests/ModelName/AZC0033Tests.cs b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers.Tests/ModelName/AZC0033Tests.cs
new file mode 100644
index 00000000000..ed6aadc96cf
--- /dev/null
+++ b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers.Tests/ModelName/AZC0033Tests.cs
@@ -0,0 +1,87 @@
+using System.Threading.Tasks;
+using Azure.ClientSdk.Analyzers.ModelName;
+using Microsoft.CodeAnalysis.Testing;
+using Xunit;
+
+using VerifyCS = Azure.ClientSdk.Analyzers.Tests.AzureAnalyzerVerifier<
+ Azure.ClientSdk.Analyzers.ModelName.OperationSuffixAnalyzer>;
+
+namespace Azure.ClientSdk.Analyzers.Tests.ModelName
+{
+ public class AZC0033Tests
+ {
+ private const string diagnosticId = "AZC0033";
+
+ [Fact]
+ public async Task OperationClassIsNotChecked()
+ {
+ var test = @"using System.Text.Json;
+
+using Azure;
+using Azure.ResourceManager;
+namespace Azure
+{
+ public class Operation
+ {
+ }
+ public class Operation
+ {
+ }
+}
+namespace Azure.ResourceManager
+{
+ public class ArmOperation : Operation
+ {
+ }
+ public class ArmOperation : Operation
+ {
+ }
+}
+namespace Azure.ResourceManager.Network.Models
+{
+ public class DnsOperation : Operation
+ {
+ }
+ public class DnsArmOperation : ArmOperation
+ {
+ }
+ public class DnsOperation : Operation
+ {
+ }
+ public class DnsArmOperation : ArmOperation
+ {
+ }
+}";
+ await VerifyCS.VerifyAnalyzerAsync(test);
+ }
+
+ [Fact]
+ public async Task OperationSuffix()
+ {
+ var test = @"using System.Text.Json;
+namespace Azure.ResourceManager.Network
+{
+ public class DnsOperation
+ {
+ public static DnsOperation DeserializeDnsOperation(JsonElement element)
+ {
+ return null;
+ }
+ }
+ public class DnsArmOperation
+ {
+ public static DnsArmOperation DeserializeDnsArmOperation(JsonElement element)
+ {
+ return null;
+ }
+ }
+}";
+ DiagnosticResult[] expected = {
+ VerifyCS.Diagnostic(diagnosticId).WithSpan(4, 18, 4, 30).WithArguments("DnsOperation", "Operation", "DnsData", "DnsInfo"),
+ VerifyCS.Diagnostic(diagnosticId).WithSpan(11, 18, 11, 33).WithArguments("DnsArmOperation", "Operation", "DnsArmData", "DnsArmInfo")
+ };
+ await VerifyCS.VerifyAnalyzerAsync(test, expected);
+ }
+ }
+}
+
diff --git a/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers.Tests/ModelName/SuffixAnalyzerBaseTests.cs b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers.Tests/ModelName/SuffixAnalyzerBaseTests.cs
new file mode 100644
index 00000000000..32be2243ac3
--- /dev/null
+++ b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers.Tests/ModelName/SuffixAnalyzerBaseTests.cs
@@ -0,0 +1,99 @@
+using System.Threading.Tasks;
+using Azure.ClientSdk.Analyzers.ModelName;
+using Xunit;
+
+using VerifyCS = Azure.ClientSdk.Analyzers.Tests.AzureAnalyzerVerifier<
+ Azure.ClientSdk.Analyzers.ModelName.GeneralSuffixAnalyzer>;
+
+namespace Azure.ClientSdk.Analyzers.Tests.ModelName
+{
+ public class SuffixAnalyzerBaseTests
+ {
+ private const string diagnosticId = "AZC0030";
+
+ [Fact]
+ public async Task NonPublicClassIsNotChecked()
+ {
+ var test = @"using System.Text.Json;
+namespace Azure.Test.Models;
+
+class MonitorParameter
+{
+ public static MonitorParameter DeserializeMonitorParameter(JsonElement element)
+ {
+ return null;
+ }
+}";
+ await VerifyCS.VerifyAnalyzerAsync(test);
+ }
+
+ [Theory]
+ [InlineData(@"namespace Azure.Test.Models;
+
+public class MonitorParameter
+{
+}")]
+ [InlineData(@"namespace Azure.Models.Test;
+
+public class MonitorParameter
+{
+}")]
+ public async Task ClassWithoutSerliaizationMethodsButInModelsNamespaceIsChecked(string test)
+ {
+ var expected = VerifyCS.Diagnostic(diagnosticId).WithSpan(3, 14, 3, 30).WithArguments("MonitorParameter", "Parameter", "'MonitorContent' or 'MonitorPatch'");
+ await VerifyCS.VerifyAnalyzerAsync(test, expected);
+ }
+
+ [Fact]
+ public async Task ClassWithSerliazationMethodIsChecked()
+ {
+ var test = @"using System.Text.Json;
+namespace Azure.NotModels;
+
+public class MonitorParameter
+{
+ public static MonitorParameter DeserializeMonitorParameter(JsonElement element)
+ {
+ return null;
+ }
+}";
+ var expected = VerifyCS.Diagnostic(diagnosticId).WithSpan(4, 14, 4, 30).WithArguments("MonitorParameter", "Parameter", "'MonitorContent' or 'MonitorPatch'");
+ await VerifyCS.VerifyAnalyzerAsync(test, expected);
+ }
+
+ [Fact]
+ public async Task ClassWithDeserliazationMethodIsChecked()
+ {
+ var test = @"using System.Text.Json;
+namespace Azure.NotModels;
+
+// workaround since IUtf8JsonSerializable is internally shared
+internal interface IUtf8JsonSerializable
+{
+ void Write(Utf8JsonWriter writer);
+}
+public class MonitorParameter : IUtf8JsonSerializable
+{
+ void IUtf8JsonSerializable.Write(Utf8JsonWriter writer)
+ {
+ return;
+ }
+}";
+ var expected = VerifyCS.Diagnostic(diagnosticId).WithSpan(9, 14, 9, 30).WithArguments("MonitorParameter", "Parameter", "'MonitorContent' or 'MonitorPatch'");
+ await VerifyCS.VerifyAnalyzerAsync(test, expected);
+ }
+
+ [Fact]
+ public async Task EnumIsNotChecked()
+ {
+ var test = @"namespace Azure.ResourceManager.Models;
+
+public enum MonitorParameter
+{
+ One
+}";
+ await VerifyCS.VerifyAnalyzerAsync(test);
+ }
+
+ }
+}
diff --git a/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/AnalyzerUtils.cs b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/AnalyzerUtils.cs
new file mode 100644
index 00000000000..8ac140ccf43
--- /dev/null
+++ b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/AnalyzerUtils.cs
@@ -0,0 +1,99 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Buffers;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Azure.ClientSdk.Analyzers
+{
+ internal class AnalyzerUtils
+ {
+ internal static bool IsNotSdkCode(ISymbol symbol) => !IsSdkCode(symbol);
+
+ internal static bool IsSdkCode(ISymbol symbol)
+ {
+ using var namespaces = symbol.ContainingNamespace.GetAllNamespaces();
+ return IsSdkNamespace(namespaces);
+ }
+
+ internal static bool IsNotSdkCode(SyntaxNode node, SemanticModel model) => !IsSdkCode(node, model);
+
+ internal static bool IsSdkCode(SyntaxNode node, SemanticModel model)
+ {
+ var symbol = model.GetDeclaredSymbol(node);
+ if (symbol != null)
+ {
+ return IsSdkCode(symbol);
+ }
+
+ using var namespaces = GetNamespace(node);
+ return IsSdkNamespace(namespaces);
+ }
+
+ private static bool IsSdkNamespace(Namespaces namespaces) => namespaces.Count >= 2 && namespaces[0] == "Azure" && namespaces[1] != "Core";
+
+ private static Namespaces GetNamespace(SyntaxNode node)
+ {
+ var namespaces = new Namespaces();
+
+ var parent = node.Parent;
+
+ while (parent != null &&
+ parent is not NamespaceDeclarationSyntax
+ && parent is not FileScopedNamespaceDeclarationSyntax)
+ {
+ parent = parent.Parent;
+ }
+
+ if (parent is BaseNamespaceDeclarationSyntax namespaceParent)
+ {
+ namespaces.Add(namespaceParent.Name.ToString());
+
+ while (true)
+ {
+ if (namespaceParent.Parent is not NamespaceDeclarationSyntax parentNamespace)
+ {
+ break;
+ }
+
+ namespaces.Add(namespaceParent.Name.ToString());
+ namespaceParent = parentNamespace;
+ }
+ }
+
+ namespaces.Reverse();
+
+ return namespaces;
+ }
+
+ internal class Namespaces : IDisposable
+ {
+ private int count;
+ private readonly string[] namespaces = ArrayPool.Shared.Rent(10);
+
+ public int Count => this.count;
+
+ public void Add(string name)
+ {
+ this.namespaces[this.count++] = name;
+ }
+
+ public string this[int i]
+ {
+ get => this.namespaces[i];
+ }
+
+ public void Reverse()
+ {
+ Array.Reverse(this.namespaces, 0, this.count);
+ }
+
+ public void Dispose()
+ {
+ ArrayPool.Shared.Return(this.namespaces);
+ }
+ }
+ }
+}
diff --git a/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers.csproj b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers.csproj
index 5d1f12494fa..61603e27c88 100644
--- a/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers.csproj
+++ b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers.csproj
@@ -13,11 +13,13 @@
analyzers/dotnet/cs/
0.1.1
true
+ true
+ AD0001
-
-
+
+
diff --git a/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/Descriptors.cs b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/Descriptors.cs
index 87380df0a26..25016015a76 100644
--- a/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/Descriptors.cs
+++ b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/Descriptors.cs
@@ -12,13 +12,13 @@ internal class Descriptors
#region Guidelines
public static DiagnosticDescriptor AZC0001 = new DiagnosticDescriptor(
nameof(AZC0001), AZC0001Title,
- "Namespace '{0}' shouldn't contain public types. " + AZC0001Title, "Usage", DiagnosticSeverity.Warning, true);
+ "Namespace '{0}' shouldn't contain public types. " + AZC0001Title, DiagnosticCategory.Usage, DiagnosticSeverity.Warning, true);
public static DiagnosticDescriptor AZC0002 = new DiagnosticDescriptor(
nameof(AZC0002),
"DO ensure all service methods, both asynchronous and synchronous, take an optional CancellationToken parameter called 'cancellationToken' or a RequestContext parameter called 'context'.",
"Client method should have an optional CancellationToken called cancellationToken (both name and it being optional matters) or a RequestContext called context as the last parameter.",
- "Usage", DiagnosticSeverity.Warning, isEnabledByDefault: true, description: null,
+ DiagnosticCategory.Usage, DiagnosticSeverity.Warning, isEnabledByDefault: true, description: null,
"https://azure.github.io/azure-sdk/dotnet_introduction.html#dotnet-service-methods-cancellation"
);
@@ -26,7 +26,7 @@ internal class Descriptors
nameof(AZC0003),
"DO make service methods virtual.",
"DO make service methods virtual.",
- "Usage", DiagnosticSeverity.Warning, isEnabledByDefault: true, description: null,
+ DiagnosticCategory.Usage, DiagnosticSeverity.Warning, isEnabledByDefault: true, description: null,
"https://azure.github.io/azure-sdk/dotnet_introduction.html#dotnet-service-methods-virtual"
);
@@ -34,7 +34,7 @@ internal class Descriptors
nameof(AZC0004),
"DO provide both asynchronous and synchronous variants for all service methods.",
"DO provide both asynchronous and synchronous variants for all service methods.",
- "Usage", DiagnosticSeverity.Warning, isEnabledByDefault: true, description: null,
+ DiagnosticCategory.Usage, DiagnosticSeverity.Warning, isEnabledByDefault: true, description: null,
"https://azure.github.io/azure-sdk/dotnet_introduction.html#dotnet-service-methods-sync-and-async"
);
@@ -42,7 +42,7 @@ internal class Descriptors
nameof(AZC0005),
"DO provide protected parameterless constructor for mocking.",
"DO provide protected parameterless constructor for mocking.",
- "Usage", DiagnosticSeverity.Warning, isEnabledByDefault: true, description: null,
+ DiagnosticCategory.Usage, DiagnosticSeverity.Warning, isEnabledByDefault: true, description: null,
"https://azure.github.io/azure-sdk/dotnet_introduction.html#dotnet-client-constructor-for-mocking"
);
@@ -50,7 +50,7 @@ internal class Descriptors
nameof(AZC0006),
"DO provide constructor overloads that allow specifying additional options.",
"A client type should have a public constructor with equivalent parameters that takes a Azure.Core.ClientOptions-derived type as the last argument",
- "Usage", DiagnosticSeverity.Warning, isEnabledByDefault: true, description: null,
+ DiagnosticCategory.Usage, DiagnosticSeverity.Warning, isEnabledByDefault: true, description: null,
"https://azure.github.io/azure-sdk/dotnet_introduction.html#dotnet-client-constructor-overloads"
);
@@ -58,51 +58,51 @@ internal class Descriptors
nameof(AZC0007),
"DO provide a minimal constructor that takes only the parameters required to connect to the service.",
"A client type should have a public constructor with equivalent parameters that doesn't take a Azure.Core.ClientOptions-derived type as the last argument",
- "Usage", DiagnosticSeverity.Warning, isEnabledByDefault: true, description: null,
+ DiagnosticCategory.Usage, DiagnosticSeverity.Warning, isEnabledByDefault: true, description: null,
"https://azure.github.io/azure-sdk/dotnet_introduction.html#dotnet-client-constructor-minimal"
);
public static DiagnosticDescriptor AZC0008 = new DiagnosticDescriptor(
nameof(AZC0008), "ClientOptions should have a nested enum called ServiceVersion",
- "Client type should have a nested enum called ServiceVersion", "Usage", DiagnosticSeverity.Warning, true);
+ "Client type should have a nested enum called ServiceVersion", DiagnosticCategory.Usage, DiagnosticSeverity.Warning, true);
public static DiagnosticDescriptor AZC0009 = new DiagnosticDescriptor(
nameof(AZC0009), "ClientOptions constructors should take a ServiceVersion as their first parameter",
- "ClientOptions constructors should take a ServiceVersion as their first parameter. Default constructor should be overloaded to provide ServiceVersion.", "Usage", DiagnosticSeverity.Warning, true);
+ "ClientOptions constructors should take a ServiceVersion as their first parameter. Default constructor should be overloaded to provide ServiceVersion.", DiagnosticCategory.Usage, DiagnosticSeverity.Warning, true);
public static DiagnosticDescriptor AZC0010 = new DiagnosticDescriptor(
nameof(AZC0010), "ClientOptions constructors should default ServiceVersion to latest supported service version",
- "ClientOptions constructors should default ServiceVersion to latest supported service version", "Usage", DiagnosticSeverity.Warning, true);
+ "ClientOptions constructors should default ServiceVersion to latest supported service version", DiagnosticCategory.Usage, DiagnosticSeverity.Warning, true);
public static DiagnosticDescriptor AZC0011 = new DiagnosticDescriptor(
nameof(AZC0011), "Avoid InternalsVisibleTo to non-test assemblies",
- "Internal visible to product libraries effectively become public API and have to be versioned appropriately", "Usage", DiagnosticSeverity.Warning, true);
+ "Internal visible to product libraries effectively become public API and have to be versioned appropriately", DiagnosticCategory.Usage, DiagnosticSeverity.Warning, true);
public static DiagnosticDescriptor AZC0012 = new DiagnosticDescriptor(
nameof(AZC0012), "Avoid single word type names",
"Single word class names are too generic and have high chance of collision with BCL types or types from other libraries",
- "Usage",
+ DiagnosticCategory.Usage,
DiagnosticSeverity.Warning, true);
public static DiagnosticDescriptor AZC0013 = new DiagnosticDescriptor(
nameof(AZC0013),
"Use TaskCreationOptions.RunContinuationsAsynchronously when instantiating TaskCompletionSource",
"All the task’s continuations are executed synchronously unless TaskCreationOptions.RunContinuationsAsynchronously option is specified. This may cause deadlocks and other threading issues if all \"async\" continuations have to run in the thread that sets the result of a task.",
- "Usage",
+ DiagnosticCategory.Usage,
DiagnosticSeverity.Warning, true);
public static DiagnosticDescriptor AZC0014 = new DiagnosticDescriptor(
nameof(AZC0014),
"Avoid using banned types in public API",
"Types from {0} assemblies should not be exposed as part of public API surface.",
- "Usage",
+ DiagnosticCategory.Usage,
DiagnosticSeverity.Warning, true);
public static DiagnosticDescriptor AZC0015 = new DiagnosticDescriptor(
nameof(AZC0015),
"Unexpected client method return type.",
"Client methods should return Pageable/AsyncPageable/Operation/Task>/Response/Response/Task/Task> or other client class found {0} instead.",
- "Usage",
+ DiagnosticCategory.Usage,
DiagnosticSeverity.Warning, true);
public static DiagnosticDescriptor AZC0016 = new DiagnosticDescriptor(
@@ -134,93 +134,129 @@ internal class Descriptors
nameof(AZC0020),
"Avoid using banned types in public APIs",
"The Azure.Core internal shared source types {0} should not be used outside of the Azure.Core library.",
- "Usage",
+ DiagnosticCategory.Usage,
DiagnosticSeverity.Warning, true);
- #endregion
+ public static readonly DiagnosticDescriptor AZC0030 = new DiagnosticDescriptor(
+ nameof(AZC0030),
+ "Improper model name suffix",
+ "Model name '{0}' ends with '{1}'. Suggest to rename it to {2} or any other appropriate name.",
+ DiagnosticCategory.Naming,
+ DiagnosticSeverity.Warning,
+ true,
+ "Suffix is not recommended. Consider to remove or modify it.");
+
+ public static readonly DiagnosticDescriptor AZC0031 = new DiagnosticDescriptor(
+ nameof(AZC0031),
+ "Improper model name suffix",
+ "Model name '{0}' ends with '{1}'. Suggest to rename it to an appropriate name.",
+ DiagnosticCategory.Naming,
+ DiagnosticSeverity.Warning,
+ true,
+ "Suffix is not recommended. Consider to remove or modify it.");
+
+ public static readonly DiagnosticDescriptor AZC0032 = new DiagnosticDescriptor(
+ nameof(AZC0032),
+ "Improper model name suffix",
+ "Model name '{0}' ends with '{1}'. Suggest to rename it to an appropriate name.",
+ DiagnosticCategory.Naming,
+ DiagnosticSeverity.Warning,
+ true,
+ "Suffix is not recommended. Consider to remove or modify it.");
+
+ public static readonly DiagnosticDescriptor AZC0033 = new DiagnosticDescriptor(
+ nameof(AZC0033),
+ "Improper model name suffix",
+ "Model name '{0}' ends with '{1}'. Suggest to rename it to '{2}' or '{3}', if an appropriate name could not be found.",
+ DiagnosticCategory.Naming,
+ DiagnosticSeverity.Warning,
+ true,
+ "Suffix is not recommended. Consider to remove or modify it.");
+ #endregion
+
#region General
public static DiagnosticDescriptor AZC0100 = new DiagnosticDescriptor(
nameof(AZC0100),
"ConfigureAwait(false) must be used.",
"ConfigureAwait(false) must be used.",
- "Usage",
+ DiagnosticCategory.Usage,
DiagnosticSeverity.Warning, true);
public static DiagnosticDescriptor AZC0101 = new DiagnosticDescriptor(
nameof(AZC0101),
"Use ConfigureAwait(false) instead of ConfigureAwait(true).",
"Use ConfigureAwait(false) instead of ConfigureAwait(true).",
- "Usage",
+ DiagnosticCategory.Usage,
DiagnosticSeverity.Warning, true);
public static DiagnosticDescriptor AZC0102 = new DiagnosticDescriptor(
nameof(AZC0102),
"Do not use GetAwaiter().GetResult().",
"Do not use GetAwaiter().GetResult(). Use the TaskExtensions.EnsureCompleted() extension method instead.",
- "Usage",
+ DiagnosticCategory.Usage,
DiagnosticSeverity.Warning, true);
public static DiagnosticDescriptor AZC0103 = new DiagnosticDescriptor(
nameof(AZC0103),
"Do not wait synchronously in asynchronous scope.",
"Do not use {0} in asynchronous scope. Use await keyword instead.",
- "Usage",
+ DiagnosticCategory.Usage,
DiagnosticSeverity.Warning, true);
public static DiagnosticDescriptor AZC0104 = new DiagnosticDescriptor(
nameof(AZC0104),
"Use EnsureCompleted() directly on asynchronous method return value.",
"Don't use {0}. Call EnsureCompleted() extension method directly on the return value of the asynchronous method that has 'bool async' parameter.",
- "Usage",
+ DiagnosticCategory.Usage,
DiagnosticSeverity.Warning, true);
public static DiagnosticDescriptor AZC0105 = new DiagnosticDescriptor(
nameof(AZC0105),
"DO NOT add 'async' parameter to public methods.",
"DO provide both asynchronous and synchronous variants for all service methods instead of one variant with 'async' parameter.",
- "Usage",
+ DiagnosticCategory.Usage,
DiagnosticSeverity.Warning, true);
public static DiagnosticDescriptor AZC0106 = new DiagnosticDescriptor(
nameof(AZC0106),
"Non-public asynchronous method needs 'async' parameter.",
"Non-public asynchronous method that is called in synchronous scope should have a boolean 'async' parameter.",
- "Usage",
+ DiagnosticCategory.Usage,
DiagnosticSeverity.Warning, true);
public static DiagnosticDescriptor AZC0107 = new DiagnosticDescriptor(
nameof(AZC0107),
"DO NOT call public asynchronous method in synchronous scope.",
"Public asynchronous method shouldn't be called in synchronous scope. Use synchronous version of the method if it is available.",
- "Usage",
+ DiagnosticCategory.Usage,
DiagnosticSeverity.Warning, true);
public static DiagnosticDescriptor AZC0108 = new DiagnosticDescriptor(
nameof(AZC0108),
"Incorrect 'async' parameter value.",
"In {0} scope 'async' parameter for the '{1}' method call should {2}.",
- "Usage",
+ DiagnosticCategory.Usage,
DiagnosticSeverity.Warning, true);
public static DiagnosticDescriptor AZC0109 = new DiagnosticDescriptor(
nameof(AZC0109),
"Misuse of 'async' parameter.",
"'async' parameter in asynchronous method can't be changed and can only be used as an exclusive condition in '?:' operator or conditional statement.",
- "Usage",
+ DiagnosticCategory.Usage,
DiagnosticSeverity.Warning, true);
public static DiagnosticDescriptor AZC0110 = new DiagnosticDescriptor(
nameof(AZC0110),
"DO NOT use await keyword in possibly synchronous scope.",
"Asynchronous method with `async` parameter can be called from both synchronous and asynchronous scopes. 'await' keyword can be safely used either in guaranteed asynchronous scope (i.e. `if (async) {...}`) or if `async` parameter is passed into awaited method. Awaiting on variables, fields, properties, conditional operators or async methods that don't use `async` parameter isn't allowed outside of the guaranteed asynchronous scope.",
- "Usage",
+ DiagnosticCategory.Usage,
DiagnosticSeverity.Warning, true);
public static DiagnosticDescriptor AZC0111 = new DiagnosticDescriptor(
nameof(AZC0111),
"DO NOT use EnsureCompleted in possibly asynchronous scope.",
"Asynchronous method with `async` parameter can be called from both synchronous and asynchronous scopes. 'EnsureCompleted' extension method can be safely used on in guaranteed synchronous scope (i.e. `if (!async) {...}`).",
- "Usage",
+ DiagnosticCategory.Usage,
DiagnosticSeverity.Warning, true);
#endregion
}
diff --git a/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/DiagnosticCategory.cs b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/DiagnosticCategory.cs
new file mode 100644
index 00000000000..736373861e1
--- /dev/null
+++ b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/DiagnosticCategory.cs
@@ -0,0 +1,14 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+namespace Azure.ClientSdk.Analyzers
+{
+ ///
+ /// Diagnostic category.
+ ///
+ public static class DiagnosticCategory
+ {
+ public static readonly string Naming = "Naming";
+ public static readonly string Usage = "Usage";
+ }
+}
diff --git a/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/INameSpaceSymbolExtensions.cs b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/INameSpaceSymbolExtensions.cs
new file mode 100644
index 00000000000..dd2378a1c84
--- /dev/null
+++ b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/INameSpaceSymbolExtensions.cs
@@ -0,0 +1,39 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Text;
+using Microsoft.CodeAnalysis;
+using static Azure.ClientSdk.Analyzers.AnalyzerUtils;
+
+namespace Azure.ClientSdk.Analyzers
+{
+ public static class INamespaceSymbolExtensions
+ {
+ public static StringBuilder GetFullNamespaceName(this INamespaceSymbol namespaceSymbol)
+ {
+ var namespaceName = new StringBuilder();
+ while (namespaceSymbol is { IsGlobalNamespace: false })
+ {
+ if (namespaceName.Length > 0)
+ {
+ namespaceName.Insert(0, '.');
+ }
+ namespaceName.Insert(0, namespaceSymbol.Name);
+ namespaceSymbol = namespaceSymbol.ContainingNamespace;
+ }
+ return namespaceName;
+ }
+
+ internal static Namespaces GetAllNamespaces(this INamespaceSymbol namespaceSymbol)
+ {
+ var namespaces = new Namespaces();
+ while (namespaceSymbol is { IsGlobalNamespace: false })
+ {
+ namespaces.Add(namespaceSymbol.Name);
+ namespaceSymbol = namespaceSymbol.ContainingNamespace;
+ }
+ namespaces.Reverse();
+ return namespaces;
+ }
+ }
+}
diff --git a/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/ModelName/DataSuffixAnalyzer.cs b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/ModelName/DataSuffixAnalyzer.cs
new file mode 100644
index 00000000000..339fb0d9f91
--- /dev/null
+++ b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/ModelName/DataSuffixAnalyzer.cs
@@ -0,0 +1,36 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Text.RegularExpressions;
+
+namespace Azure.ClientSdk.Analyzers.ModelName
+{
+ //
+ ///
+ /// Analyzer to check the model names ending with "Data". Avoid using "Data" as model suffix unless the model derives from ResourceData/TrackedResourceData.
+ ///
+ [DiagnosticAnalyzer(LanguageNames.CSharp)]
+ public class DataSuffixAnalyzer : SuffixAnalyzerBase
+ {
+ private static readonly string[] dataSuffix = new string[] { "Data" };
+
+ public override ImmutableArray SupportedDiagnostics { get { return ImmutableArray.Create(Descriptors.AZC0032); } }
+
+ // unless the model derives from ResourceData/TrackedResourceData
+ protected override bool ShouldSkip(INamedTypeSymbol symbol, SymbolAnalysisContext context) => IsTypeOf(symbol, "Azure.ResourceManager.Models", "ResourceData") ||
+ IsTypeOf(symbol, "Azure.ResourceManager.Models", "TrackedResourceData");
+
+ protected override string[] SuffixesToCatch => dataSuffix;
+
+ protected override Diagnostic GetDiagnostic(INamedTypeSymbol typeSymbol, string suffix, SymbolAnalysisContext context)
+ {
+ var name = typeSymbol.Name;
+ return Diagnostic.Create(Descriptors.AZC0032, context.Symbol.Locations[0],
+ new Dictionary { { "SuggestedName", name.Substring(0, name.Length - suffix.Length) } }.ToImmutableDictionary(), name, suffix);
+ }
+ }
+}
diff --git a/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/ModelName/DefinitionSuffixAnalyzer.cs b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/ModelName/DefinitionSuffixAnalyzer.cs
new file mode 100644
index 00000000000..45062ab471f
--- /dev/null
+++ b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/ModelName/DefinitionSuffixAnalyzer.cs
@@ -0,0 +1,48 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Text;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace Azure.ClientSdk.Analyzers.ModelName
+{
+ ///
+ /// Analyzer to check model names ending with "Definition". Avoid using "Definition" as model suffix unless it's the name of a Resource or
+ /// after removing the suffix it's another type.
+ ///
+ [DiagnosticAnalyzer(LanguageNames.CSharp)]
+ public class DefinitionSuffixAnalyzer : SuffixAnalyzerBase
+ {
+ private static readonly string[] definitionSuffix = new string[] { "Definition" };
+
+ public override ImmutableArray SupportedDiagnostics { get { return ImmutableArray.Create(Descriptors.AZC0031); } }
+
+ // unless the type is a Resource or after removing the suffix it's another type
+ protected override bool ShouldSkip(INamedTypeSymbol symbol, SymbolAnalysisContext context)
+ {
+ if (IsTypeOf(symbol, "Azure.ResourceManager", "ArmResource"))
+ return true;
+
+ var name = symbol.Name;
+ var suggestedName = name.AsSpan().Slice(0, name.Length - "Definition".Length);
+
+ var strBuilder = symbol.ContainingNamespace.GetFullNamespaceName();
+ strBuilder.Append('.').Append(suggestedName.ToArray());
+
+ return context.Compilation.GetTypeByMetadataName(strBuilder.ToString()) is not null;
+ }
+
+ protected override string[] SuffixesToCatch => definitionSuffix;
+
+ protected override Diagnostic GetDiagnostic(INamedTypeSymbol typeSymbol, string suffix, SymbolAnalysisContext context)
+ {
+ var name = typeSymbol.Name;
+ return Diagnostic.Create(Descriptors.AZC0031, context.Symbol.Locations[0],
+ new Dictionary { { "SuggestedName", name.Substring(0, name.Length - suffix.Length) } }.ToImmutableDictionary(), name, suffix);
+ }
+ }
+}
diff --git a/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/ModelName/GeneralSuffixAnalyzer.cs b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/ModelName/GeneralSuffixAnalyzer.cs
new file mode 100644
index 00000000000..ade8cc58d01
--- /dev/null
+++ b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/ModelName/GeneralSuffixAnalyzer.cs
@@ -0,0 +1,51 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Text.RegularExpressions;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace Azure.ClientSdk.Analyzers.ModelName
+{
+ ///
+ /// Analyzer to check general model name suffix issues.
+ ///
+ [DiagnosticAnalyzer(LanguageNames.CSharp)]
+ public class GeneralSuffixAnalyzer : SuffixAnalyzerBase
+ {
+ private static readonly ImmutableHashSet reservedNames = ImmutableHashSet.Create("ErrorResponse");
+
+ // Avoid to use suffixes "Request(s)", "Parameter(s)", "Option(s)", "Response(s)", "Collection"
+ private static readonly string[] generalSuffixes = new string[] { "Request", "Requests", "Response", "Responses", "Parameter", "Parameters", "Option", "Options", "Collection"};
+
+ public override ImmutableArray SupportedDiagnostics { get { return ImmutableArray.Create(Descriptors.AZC0030); } }
+
+ protected override bool ShouldSkip(INamedTypeSymbol symbol, SymbolAnalysisContext context) => reservedNames.Contains(symbol.Name);
+
+ protected override string[] SuffixesToCatch => generalSuffixes;
+ protected override Diagnostic GetDiagnostic(INamedTypeSymbol typeSymbol, string suffix, SymbolAnalysisContext context)
+ {
+ var name = typeSymbol.Name;
+ var suggestedName = GetSuggestedName(name, suffix);
+ return Diagnostic.Create(Descriptors.AZC0030, context.Symbol.Locations[0],
+ new Dictionary { { "SuggestedName", suggestedName } }.ToImmutableDictionary(), name, suffix, suggestedName);
+ }
+
+ private string GetSuggestedName(string originalName, string suffix)
+ {
+ var nameWithoutSuffix = originalName.Substring(0, originalName.Length - suffix.Length);
+ return suffix switch
+ {
+ "Request" or "Requests" => $"'{nameWithoutSuffix}Content'",
+ "Parameter" or "Parameters" => $"'{nameWithoutSuffix}Content' or '{nameWithoutSuffix}Patch'",
+ "Option" or "Options" => $"'{nameWithoutSuffix}Config'",
+ "Response" => $"'{nameWithoutSuffix}Result'",
+ "Responses" => $"'{nameWithoutSuffix}Results'",
+ "Collection" => $"'{nameWithoutSuffix}Group' or '{nameWithoutSuffix}List'",
+ _ => nameWithoutSuffix,
+ };
+ }
+ }
+}
diff --git a/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/ModelName/OperationSuffixAnalyzer.cs b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/ModelName/OperationSuffixAnalyzer.cs
new file mode 100644
index 00000000000..fecdd64be68
--- /dev/null
+++ b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/ModelName/OperationSuffixAnalyzer.cs
@@ -0,0 +1,33 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Collections.Immutable;
+using System.Text.RegularExpressions;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace Azure.ClientSdk.Analyzers.ModelName
+{
+ ///
+ /// Analyzer to check model names ending with "Operation". Avoid using Operation as model suffix unless the model derives from Operation
+ ///
+ [DiagnosticAnalyzer(LanguageNames.CSharp)]
+ public class OperationSuffixAnalyzer : SuffixAnalyzerBase
+ {
+ private static readonly string[] operationSuffix = new string[] { "Operation" };
+
+ public override ImmutableArray SupportedDiagnostics { get { return ImmutableArray.Create(Descriptors.AZC0033); } }
+
+ // Unless the model derivew from Operation
+ protected override bool ShouldSkip(INamedTypeSymbol symbol, SymbolAnalysisContext context) => IsTypeOf(symbol, "Azure", "Operation");
+
+ protected override string[] SuffixesToCatch => operationSuffix;
+ protected override Diagnostic GetDiagnostic(INamedTypeSymbol typeSymbol, string suffix, SymbolAnalysisContext context)
+ {
+ var name = typeSymbol.Name;
+ var nameWithoutSuffix = name.Substring(0, name.Length - suffix.Length);
+ return Diagnostic.Create(Descriptors.AZC0033, context.Symbol.Locations[0],
+ name, suffix, $"{nameWithoutSuffix}Data", $"{nameWithoutSuffix}Info");
+ }
+ }
+}
diff --git a/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/ModelName/SuffixAnalyzerBase.cs b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/ModelName/SuffixAnalyzerBase.cs
new file mode 100644
index 00000000000..8438cf657fa
--- /dev/null
+++ b/src/dotnet/Azure.ClientSdk.Analyzers/Azure.ClientSdk.Analyzers/ModelName/SuffixAnalyzerBase.cs
@@ -0,0 +1,100 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Immutable;
+using System.Linq;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace Azure.ClientSdk.Analyzers.ModelName
+{
+ ///
+ /// Base analyzer to check model name suffixes.
+ ///
+ public abstract class SuffixAnalyzerBase : DiagnosticAnalyzer
+ {
+ protected static readonly string Title = "Improper model name suffix";
+ protected static readonly string Description = "Suffix is not recommended. Consider to remove or modify it.";
+ protected static readonly string GeneralRenamingMessageFormat = "Model name '{0}' ends with '{1}'. Suggest to rename it to an appropriate name.";
+
+ public override ImmutableArray SupportedDiagnostics { get; } = ImmutableArray.Empty;
+
+ public override void Initialize(AnalysisContext context)
+ {
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics);
+ context.EnableConcurrentExecution();
+ context.RegisterSymbolAction(AnalyzeSymbol, SymbolKind.NamedType);
+ }
+
+ private void AnalyzeSymbol(SymbolAnalysisContext context)
+ {
+ var typeSymbol = (INamedTypeSymbol)context.Symbol;
+ if (typeSymbol.DeclaredAccessibility != Accessibility.Public || !IsModelClass(typeSymbol) || AnalyzerUtils.IsNotSdkCode(typeSymbol))
+ return;
+
+ var nameSpan = typeSymbol.Name.AsSpan();
+ foreach (var suffix in SuffixesToCatch)
+ {
+ if (MemoryExtensions.EndsWith(nameSpan, suffix.AsSpan()) && !ShouldSkip(typeSymbol, context))
+ {
+ context.ReportDiagnostic(GetDiagnostic(typeSymbol, suffix, context));
+ return;
+ }
+ }
+ }
+
+ protected abstract bool ShouldSkip(INamedTypeSymbol symbol, SymbolAnalysisContext context);
+ protected abstract string[] SuffixesToCatch { get; }
+ protected abstract Diagnostic GetDiagnostic(INamedTypeSymbol typeSymbol, string suffix, SymbolAnalysisContext context);
+
+ // check if a given symbol is the sub-type of the given type
+ protected bool IsTypeOf(INamedTypeSymbol typeSymbol, string namespaceName, string typeName)
+ {
+ if (typeSymbol is null)
+ return false;
+
+ // check class hierachy
+ for (var classType = typeSymbol; classType is not null; classType = classType.BaseType)
+ {
+ if (classType.Name == typeName && classType.ContainingNamespace.GetFullNamespaceName().ToString() == namespaceName)
+ return true;
+ };
+
+ // check interfaces
+ return typeSymbol.AllInterfaces.Any(i => i.Name == typeName && i.ContainingNamespace.Name == namespaceName);
+ }
+
+ private bool IsModelClass(ITypeSymbol symbol){
+ if (symbol is not ({ TypeKind: TypeKind.Class }))
+ return false;
+
+ // some SDKs could have models without any serialization method but under `Models` namespace
+ // like alertsmanagement\Azure.ResourceManager.AlertsManagement\src\Generated\Models\SmartGroupCollectionGetAllOptions.cs
+ if (HasModelsNamespace(symbol))
+ return true;
+
+ // check serialize interface, TODO: include public serializer interface
+ if (symbol.Interfaces.Any(i => i.Name == "IUtf8JsonSerializable"))
+ return true;
+
+ // check if deserialize method exists, e.g. internal static Foo DeserializeFoo(JsonElement element)
+ if (symbol.GetMembers($"Deserialize{symbol.Name}").Any(m => m is IMethodSymbol method && method is { IsStatic: true } &&
+ method.ReturnType == symbol && method.Parameters.Length == 1 && method.Parameters[0] is { Type.Name: "JsonElement" }))
+ return true;
+
+ return false;
+ }
+
+ private bool HasModelsNamespace(ITypeSymbol typeSymbol)
+ {
+ for (var namespaceSymbol = typeSymbol.ContainingNamespace; namespaceSymbol != null; namespaceSymbol = namespaceSymbol.ContainingNamespace)
+ {
+ if (namespaceSymbol.Name == "Models")
+ return true;
+ }
+ return false;
+ }
+ }
+}
+