Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <noreply@anthropic.com> via Conducktor <conducktor@contextand.com>"
}
}
}
85 changes: 85 additions & 0 deletions src/DataverseAnalyzer/EntityCollectionEntityNameAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -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<DiagnosticDescriptor> 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<DiagnosticDescriptor> 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;
}
}
6 changes: 6 additions & 0 deletions src/DataverseAnalyzer/Resources.Designer.cs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions src/DataverseAnalyzer/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -172,4 +172,13 @@
<data name="CT0012_Description" xml:space="preserve">
<value>Classes containing Azure Function trigger methods should have a short XML summary comment explaining what the function class does.</value>
</data>
<data name="CT0013_Title" xml:space="preserve">
<value>EntityCollection should have EntityName set</value>
</data>
<data name="CT0013_MessageFormat" xml:space="preserve">
<value>EntityCollection should have EntityName set via the constructor or object initializer</value>
</data>
<data name="CT0013_Description" xml:space="preserve">
<value>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.</value>
</data>
</root>
Original file line number Diff line number Diff line change
@@ -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<Diagnostic[]> 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<DiagnosticAnalyzer>(analyzer));

var diagnostics = await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync();
return diagnostics.Where(d => d.Id == "CT0013").ToArray();
}
}
Loading