-
Notifications
You must be signed in to change notification settings - Fork 0
Add XPC2002: duplicate plugin step registration (error) #21
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+746
−4
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
9f09a5a
Add XPC2002: duplicate plugin step registration (error)
mkholt a562883
Address Copilot review on XPC2002
mkholt c0ebf33
Resolve const enum operation values in XPC2002
mkholt 0d16ed1
Resolve const string message members by value in XPC2002
mkholt b30cd2a
Handle undefined enum operation values in XPC2002
mkholt File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
443 changes: 443 additions & 0 deletions
443
...luginCore.SourceGenerator.Tests/DiagnosticTests/DuplicateStepRegistrationAnalyzerTests.cs
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
218 changes: 218 additions & 0 deletions
218
XrmPluginCore.SourceGenerator/Analyzers/DuplicateStepRegistrationAnalyzer.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,218 @@ | ||
| using Microsoft.CodeAnalysis; | ||
| using Microsoft.CodeAnalysis.CSharp; | ||
| using Microsoft.CodeAnalysis.CSharp.Syntax; | ||
| using Microsoft.CodeAnalysis.Diagnostics; | ||
| using System.Collections.Concurrent; | ||
| using System.Collections.Generic; | ||
| using System.Collections.Immutable; | ||
| using System.Linq; | ||
| using XrmPluginCore.SourceGenerator.Helpers; | ||
|
|
||
| namespace XrmPluginCore.SourceGenerator.Analyzers; | ||
|
|
||
| /// <summary> | ||
| /// Reports when a plugin class registers the same entity/operation/stage combination more than once | ||
| /// (via <c>RegisterStep</c> or the legacy <c>RegisterPluginStep</c>). Such a duplicate is rejected at | ||
| /// registration time and would make the source-generated image/wrapper type names — which are derived | ||
| /// from that tuple — ambiguous. | ||
| /// | ||
| /// Registrations are collected per plugin type across every syntax tree (so <c>partial</c> plugins are | ||
| /// aggregated) and duplicates are evaluated at compilation end, where inherited base-plugin | ||
| /// registrations are also considered. Only types deriving from <c>XrmPluginCore.Plugin</c> are analyzed. | ||
| /// </summary> | ||
| [DiagnosticAnalyzer(LanguageNames.CSharp)] | ||
| public class DuplicateStepRegistrationAnalyzer : DiagnosticAnalyzer | ||
| { | ||
| public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => | ||
| ImmutableArray.Create(DiagnosticDescriptors.DuplicateStepRegistration); | ||
|
|
||
| public override void Initialize(AnalysisContext context) | ||
| { | ||
| context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); | ||
| context.EnableConcurrentExecution(); | ||
| context.RegisterCompilationStartAction(OnCompilationStart); | ||
| } | ||
|
|
||
| private static void OnCompilationStart(CompilationStartAnalysisContext context) | ||
| { | ||
| // Accumulate registrations per plugin type across all trees (handles partial declarations). | ||
| var registrationsByType = new ConcurrentDictionary<INamedTypeSymbol, ConcurrentBag<Registration>>(SymbolEqualityComparer.Default); | ||
|
|
||
| context.RegisterSyntaxNodeAction( | ||
| nodeContext => CollectRegistration(nodeContext, registrationsByType), | ||
| SyntaxKind.InvocationExpression); | ||
|
|
||
| context.RegisterCompilationEndAction(endContext => ReportDuplicates(endContext, registrationsByType)); | ||
| } | ||
|
|
||
| private static void CollectRegistration( | ||
| SyntaxNodeAnalysisContext context, | ||
| ConcurrentDictionary<INamedTypeSymbol, ConcurrentBag<Registration>> registrationsByType) | ||
| { | ||
| var invocation = (InvocationExpressionSyntax)context.Node; | ||
|
|
||
| if (!RegisterStepHelper.IsStepRegistrationCall(invocation, out var genericName) || | ||
| genericName.TypeArgumentList.Arguments.Count < 1) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| var classDeclaration = invocation.FirstAncestorOrSelf<ClassDeclarationSyntax>(); | ||
| if (classDeclaration == null || | ||
| context.SemanticModel.GetDeclaredSymbol(classDeclaration) is not INamedTypeSymbol typeSymbol || | ||
| !IsPluginClass(typeSymbol)) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| var entity = context.SemanticModel.GetTypeInfo(genericName.TypeArgumentList.Arguments[0]).Type?.Name; | ||
|
|
||
| var (operationExpr, stageExpr) = RegisterStepHelper.GetOperationAndStageArguments(invocation, context.SemanticModel); | ||
| if (entity == null || operationExpr == null || stageExpr == null) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| var operation = ResolveTupleValue(operationExpr, context.SemanticModel); | ||
| var stage = ResolveTupleValue(stageExpr, context.SemanticModel); | ||
|
|
||
| // Skip registrations whose tuple can't be resolved to concrete values: the generator can't | ||
| // derive a stable name from them either, so keying them would risk false positives. | ||
| if (operation == null || stage == null) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| var location = (RegisterStepHelper.GetGenericName(invocation) ?? (SyntaxNode)invocation).GetLocation(); | ||
| registrationsByType | ||
| .GetOrAdd(typeSymbol, _ => new ConcurrentBag<Registration>()) | ||
| .Add(new Registration(entity, operation, stage, location)); | ||
| } | ||
|
|
||
| private static void ReportDuplicates( | ||
| CompilationAnalysisContext context, | ||
| ConcurrentDictionary<INamedTypeSymbol, ConcurrentBag<Registration>> registrationsByType) | ||
| { | ||
| foreach (var entry in registrationsByType) | ||
| { | ||
| var type = entry.Key; | ||
|
|
||
| // Seed with registrations inherited from source-defined base plugins so a derived class that | ||
| // re-registers an inherited tuple is caught. Base-internal duplicates are reported when the | ||
| // base itself is evaluated, so they are not re-reported here. | ||
| var seen = new HashSet<string>(); | ||
| for (var baseType = type.BaseType; baseType != null && !IsFrameworkPluginBase(baseType); baseType = baseType.BaseType) | ||
| { | ||
| if (registrationsByType.TryGetValue(baseType, out var baseRegistrations)) | ||
| { | ||
| foreach (var registration in baseRegistrations) | ||
| { | ||
| seen.Add(registration.Key); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Order for deterministic reporting (ConcurrentBag is unordered; declarations may span trees). | ||
| var ordered = entry.Value | ||
| .OrderBy(r => r.Location.SourceTree?.FilePath, System.StringComparer.Ordinal) | ||
| .ThenBy(r => r.Location.SourceSpan.Start); | ||
|
|
||
| foreach (var registration in ordered) | ||
| { | ||
| if (!seen.Add(registration.Key)) | ||
| { | ||
| context.ReportDiagnostic(Diagnostic.Create( | ||
| DiagnosticDescriptors.DuplicateStepRegistration, | ||
| registration.Location, | ||
| type.Name, | ||
| registration.Entity, | ||
| registration.Operation, | ||
| registration.Stage)); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Resolves an operation/stage argument to its comparison value, matching what the runtime and | ||
| /// generated wrappers use (the string value for custom messages, <c>value.ToString()</c> for enums). | ||
| /// Constant evaluation drives this so it works uniformly for literals, <c>const</c> locals/fields, | ||
| /// and member-access references (<c>EventOperation.Update</c>, <c>MyMessages.DoThing</c>): | ||
| /// <list type="bullet"> | ||
| /// <item>a constant string (literal, <c>const string</c>, or a <c>const string</c> member) yields | ||
| /// that string value — so two members sharing a value compare equal;</item> | ||
| /// <item>an enum constant (member access or a <c>const</c> enum) is mapped from its underlying | ||
| /// integral value back to the enum member name.</item> | ||
| /// </list> | ||
| /// Returns null when the value can't be resolved to a concrete string. | ||
| /// </summary> | ||
| private static string ResolveTupleValue(ExpressionSyntax expression, SemanticModel semanticModel) | ||
| { | ||
| var constant = semanticModel.GetConstantValue(expression); | ||
| if (!constant.HasValue) | ||
| { | ||
| return null; | ||
| } | ||
|
|
||
| // Custom message name: the runtime and generated wrapper naming key off the string value, not the | ||
| // member identifier, so a const string member (MyMessages.DoThing) resolves to its value. | ||
| if (constant.Value is string stringValue) | ||
| { | ||
| return stringValue; | ||
| } | ||
|
|
||
| // Enum value: GetConstantValue gives the underlying integral value, so map it back to the member | ||
| // name to match EventOperation.Update.ToString(). | ||
| if (semanticModel.GetTypeInfo(expression).Type is INamedTypeSymbol enumType && | ||
| enumType.TypeKind == TypeKind.Enum) | ||
| { | ||
| foreach (var field in enumType.GetMembers().OfType<IFieldSymbol>()) | ||
| { | ||
| if (field.HasConstantValue && Equals(field.ConstantValue, constant.Value)) | ||
| { | ||
| return field.Name; | ||
| } | ||
| } | ||
|
|
||
| // An undefined value (e.g. (EventOperation)999): Enum.ToString() returns the integral text, | ||
| // so use that rather than skipping the registration. | ||
| return constant.Value?.ToString(); | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| private static bool IsPluginClass(INamedTypeSymbol type) | ||
| { | ||
| for (var baseType = type.BaseType; baseType != null; baseType = baseType.BaseType) | ||
| { | ||
| if (IsFrameworkPluginBase(baseType)) | ||
| { | ||
| return true; | ||
| } | ||
| } | ||
|
|
||
| return false; | ||
| } | ||
|
|
||
| private static bool IsFrameworkPluginBase(INamedTypeSymbol type) => | ||
| type.Name == Constants.PluginBaseClassName && | ||
| type.ContainingNamespace?.ToDisplayString() == Constants.PluginNamespace; | ||
|
|
||
| private readonly struct Registration | ||
| { | ||
| public Registration(string entity, string operation, string stage, Location location) | ||
| { | ||
| Entity = entity; | ||
| Operation = operation; | ||
| Stage = stage; | ||
| Location = location; | ||
| } | ||
|
|
||
| public string Entity { get; } | ||
| public string Operation { get; } | ||
| public string Stage { get; } | ||
| public Location Location { get; } | ||
| public string Key => $"{Entity}|{Operation}|{Stage}"; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| # XPC2002: Duplicate plugin step registration | ||
|
|
||
| ## Severity | ||
|
|
||
| Error | ||
|
|
||
| ## Description | ||
|
|
||
| A plugin may register a given **entity / operation / stage** combination at most once. The platform | ||
| keys plugin steps by that combination, so a duplicate is rejected at registration time. It also makes | ||
| the source-generated type-safe image and wrapper types ambiguous, because their names are derived from | ||
| `{PluginClass}{Entity}{Operation}{Stage}`. | ||
|
|
||
| This applies to both `RegisterStep` and the legacy `RegisterPluginStep`. | ||
|
|
||
| ## ❌ Example of violation | ||
|
|
||
| ```csharp | ||
| public class AccountPlugin : Plugin | ||
| { | ||
| public AccountPlugin() | ||
| { | ||
| RegisterStep<Account, IAccountService>( | ||
| EventOperation.Update, ExecutionStage.PostOperation, nameof(IAccountService.HandleA)) | ||
| .WithPreImage(x => x.Name); | ||
|
|
||
| // XPC2002: Account/Update/PostOperation is already registered above | ||
| RegisterStep<Account, IAccountService>( | ||
| EventOperation.Update, ExecutionStage.PostOperation, nameof(IAccountService.HandleB)) | ||
| .WithPreImage(x => x.Revenue); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ## ✅ How to fix | ||
|
|
||
| Collapse the work into a single registration per entity/operation/stage, and let one handler do it: | ||
|
|
||
| ```csharp | ||
| public class AccountPlugin : Plugin | ||
| { | ||
| public AccountPlugin() | ||
| { | ||
| RegisterStep<Account, IAccountService>( | ||
| EventOperation.Update, ExecutionStage.PostOperation, nameof(IAccountService.HandleUpdate)) | ||
| .WithPreImage(x => x.Name, x => x.Revenue); | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| If the two pieces of work are genuinely distinct, use a different stage (e.g. `PreOperation` vs | ||
| `PostOperation`) or a different message, so each registration has a unique combination. | ||
|
|
||
| ## See also | ||
|
|
||
| - [XPC2001: No parameterless constructor found](XPC2001.md) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.