diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 794a641..8bc6178 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,5 +6,61 @@ "Bash(dotnet format:*)" ], "deny": [] + }, + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "curl -sS --connect-timeout 2 http://host.docker.internal:53624/hook/stop?ptyId=b3d64ef4-65a0-4cef-9335-f2b15a5faa11" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "curl -sS --connect-timeout 2 http://host.docker.internal:53624/hook/busy?ptyId=b3d64ef4-65a0-4cef-9335-f2b15a5faa11" + } + ] + } + ], + "Notification": [ + { + "matcher": "permission_prompt", + "hooks": [ + { + "type": "command", + "command": "curl -sS --connect-timeout 2 -X POST -H \"Content-Type: application/json\" -d @- http://host.docker.internal:53624/hook/notification?ptyId=b3d64ef4-65a0-4cef-9335-f2b15a5faa11" + } + ] + }, + { + "matcher": "idle_prompt", + "hooks": [ + { + "type": "command", + "command": "curl -sS --connect-timeout 2 -X POST -H \"Content-Type: application/json\" -d @- http://host.docker.internal:53624/hook/notification?ptyId=b3d64ef4-65a0-4cef-9335-f2b15a5faa11" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "WebSearch", + "hooks": [ + { + "type": "command", + "command": "echo \"Tool WebSearch is disabled — web traffic is routed through api.anthropic.com and bypasses the container firewall\" >&2; exit 2" + } + ] + } + ] + }, + "attribution": { + "commit": "\n\nCo-Authored-By: Claude via Conducktor " } -} \ No newline at end of file +} diff --git a/src/DataverseAnalyzer/EntityCollectionEntityNameAnalyzer.cs b/src/DataverseAnalyzer/EntityCollectionEntityNameAnalyzer.cs new file mode 100644 index 0000000..554ffba --- /dev/null +++ b/src/DataverseAnalyzer/EntityCollectionEntityNameAnalyzer.cs @@ -0,0 +1,85 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace DataverseAnalyzer; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class EntityCollectionEntityNameAnalyzer : DiagnosticAnalyzer +{ + private static readonly Lazy LazyRule = new(() => new DiagnosticDescriptor( + "CT0013", + Resources.CT0013_Title, + Resources.CT0013_MessageFormat, + "Usage", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: Resources.CT0013_Description)); + + public static DiagnosticDescriptor Rule => LazyRule.Value; + + public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(Rule); + + public override void Initialize(AnalysisContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeObjectCreation, SyntaxKind.ObjectCreationExpression); + } + + private static void AnalyzeObjectCreation(SyntaxNodeAnalysisContext context) + { + var objectCreation = (ObjectCreationExpressionSyntax)context.Node; + + var typeInfo = context.SemanticModel.GetTypeInfo(objectCreation); + if (!IsEntityCollection(typeInfo.Type)) + return; + + if (HasConstructorArguments(objectCreation)) + return; + + if (HasEntityNameInInitializer(objectCreation)) + return; + + var diagnostic = Diagnostic.Create(Rule, objectCreation.GetLocation()); + context.ReportDiagnostic(diagnostic); + } + + private static bool IsEntityCollection(ITypeSymbol? type) + { + if (type is null) + return false; + + return type.Name == "EntityCollection" && + type.ContainingNamespace?.ToDisplayString() == "Microsoft.Xrm.Sdk"; + } + + private static bool HasConstructorArguments(ObjectCreationExpressionSyntax objectCreation) + { + return objectCreation.ArgumentList is not null && + objectCreation.ArgumentList.Arguments.Count > 0; + } + + private static bool HasEntityNameInInitializer(ObjectCreationExpressionSyntax objectCreation) + { + if (objectCreation.Initializer is null) + return false; + + foreach (var expression in objectCreation.Initializer.Expressions) + { + if (expression is AssignmentExpressionSyntax assignment && + assignment.Left is IdentifierNameSyntax identifier && + identifier.Identifier.ValueText == "EntityName") + return true; + } + + return false; + } +} diff --git a/src/DataverseAnalyzer/Resources.Designer.cs b/src/DataverseAnalyzer/Resources.Designer.cs index 57733dc..e048f19 100644 --- a/src/DataverseAnalyzer/Resources.Designer.cs +++ b/src/DataverseAnalyzer/Resources.Designer.cs @@ -83,5 +83,11 @@ internal static class Resources internal static string CT0012_Description => GetString(nameof(CT0012_Description)); + internal static string CT0013_Title => GetString(nameof(CT0013_Title)); + + internal static string CT0013_MessageFormat => GetString(nameof(CT0013_MessageFormat)); + + internal static string CT0013_Description => GetString(nameof(CT0013_Description)); + private static string GetString(string name) => ResourceManager.GetString(name, CultureInfo.InvariantCulture) ?? name; } \ No newline at end of file diff --git a/src/DataverseAnalyzer/Resources.resx b/src/DataverseAnalyzer/Resources.resx index 16e93d1..fd12c63 100644 --- a/src/DataverseAnalyzer/Resources.resx +++ b/src/DataverseAnalyzer/Resources.resx @@ -172,4 +172,13 @@ Classes containing Azure Function trigger methods should have a short XML summary comment explaining what the function class does. + + EntityCollection should have EntityName set + + + EntityCollection should have EntityName set via the constructor or object initializer + + + EntityCollection instances used with bulk operations (CreateMultiple, UpdateMultiple, DeleteMultiple) require EntityName to be set. Always set EntityName via the constructor parameter or object initializer to prevent runtime errors. + \ No newline at end of file diff --git a/tests/DataverseAnalyzer.Tests/EntityCollectionEntityNameAnalyzerTests.cs b/tests/DataverseAnalyzer.Tests/EntityCollectionEntityNameAnalyzerTests.cs new file mode 100644 index 0000000..1b7dc07 --- /dev/null +++ b/tests/DataverseAnalyzer.Tests/EntityCollectionEntityNameAnalyzerTests.cs @@ -0,0 +1,159 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace DataverseAnalyzer.Tests; + +public sealed class EntityCollectionEntityNameAnalyzerTests +{ + private const string XrmSdkDefinition = """ + namespace Microsoft.Xrm.Sdk + { + public class Entity { } + + public class EntityCollection + { + public EntityCollection() { } + public EntityCollection(string entityName) { EntityName = entityName; } + public string EntityName { get; set; } + } + } + """; + + [Fact] + public async Task ParameterlessConstructorWithoutInitializerShouldTrigger() + { + var source = XrmSdkDefinition + """ + + class TestClass + { + public void TestMethod() + { + var collection = new Microsoft.Xrm.Sdk.EntityCollection(); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0013", diagnostics[0].Id); + } + + [Fact] + public async Task ParameterlessConstructorWithEmptyInitializerShouldTrigger() + { + var source = XrmSdkDefinition + """ + + class TestClass + { + public void TestMethod() + { + var collection = new Microsoft.Xrm.Sdk.EntityCollection { }; + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Single(diagnostics); + Assert.Equal("CT0013", diagnostics[0].Id); + } + + [Fact] + public async Task ParameterlessConstructorWithEntityNameInInitializerShouldNotTrigger() + { + var source = XrmSdkDefinition + """ + + class TestClass + { + public void TestMethod() + { + var collection = new Microsoft.Xrm.Sdk.EntityCollection { EntityName = "account" }; + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task ConstructorWithEntityNameArgumentShouldNotTrigger() + { + var source = XrmSdkDefinition + """ + + class TestClass + { + public void TestMethod() + { + var collection = new Microsoft.Xrm.Sdk.EntityCollection("account"); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task NonXrmEntityCollectionShouldNotTrigger() + { + var source = """ + namespace OtherNamespace + { + public class EntityCollection + { + public EntityCollection() { } + public string EntityName { get; set; } + } + } + + class TestClass + { + public void TestMethod() + { + var collection = new OtherNamespace.EntityCollection(); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Empty(diagnostics); + } + + [Fact] + public async Task MultipleCreationsShouldTriggerForEachViolation() + { + var source = XrmSdkDefinition + """ + + class TestClass + { + public void TestMethod() + { + var a = new Microsoft.Xrm.Sdk.EntityCollection(); + var b = new Microsoft.Xrm.Sdk.EntityCollection(); + } + } + """; + + var diagnostics = await GetDiagnosticsAsync(source); + Assert.Equal(2, diagnostics.Length); + Assert.All(diagnostics, d => Assert.Equal("CT0013", d.Id)); + } + + private static async Task GetDiagnosticsAsync(string source) + { + var syntaxTree = CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.Latest)); + var compilation = CSharpCompilation.Create( + "TestAssembly", + new[] { syntaxTree }, + new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location) }, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var analyzer = new EntityCollectionEntityNameAnalyzer(); + var compilationWithAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer)); + + var diagnostics = await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(); + return diagnostics.Where(d => d.Id == "CT0013").ToArray(); + } +}