Directory.Packages.props
+2
-0
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 8c4b470..f08c36a 100644
@@ -11,6 +11,8 @@
</ItemGroup>
<ItemGroup>
<PackageVersion Include="Glob" Version="1.1.9" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="11.0.0-rc.3.26522.148" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="11.0.0-rc.3.26522.148" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.9.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="5.9.0" />
<PackageVersion Include="System.CommandLine" Version="2.0.14" />
cli/src/ScanCommand.cs
+11
-0
diff --git a/cli/src/ScanCommand.cs b/cli/src/ScanCommand.cs
index 4ec7e65..f5073ad 100644
@@ -129,6 +129,13 @@ internal sealed class ScanCommand : Command
return [.. result.Tokens.Select(token => new Glob(token.Value))];
}
private static Option<FileInfo> ConfigOption { get; } =
new("--config")
{
Description =
"JSON file containing message publisher and handler registration routes. (Defaults to ./.cfa.json)",
};
public ScanCommand()
: base("scan", "Scan the code for analysis")
{
@@ -137,6 +144,7 @@ internal sealed class ScanCommand : Command
Options.Add(StartFileOption);
Options.Add(IncludeOption);
Options.Add(ExcludeOption);
Options.Add(ConfigOption);
}
internal sealed class ScanAction : AsynchronousCommandLineAction
@@ -146,6 +154,9 @@ internal sealed class ScanCommand : Command
var solutionFile = parseResult.GetRequiredValue(PathArgument);
var options = new ScannerOptions()
{
Messages = parseResult.GetValue(ConfigOption) is { FullName: var configPath }
? MessageOptions.LoadJson(configPath)
: MessageOptions.LoadJson(".cfa.json", optional: true),
IncludePatterns = parseResult.GetValue(IncludeOption) ?? [],
ExcludePatterns = parseResult.GetValue(ExcludeOption) ?? [],
};
cli/src/packages.lock.json
+57
-1
diff --git a/cli/src/packages.lock.json b/cli/src/packages.lock.json
index 28003a0..3cdfb13 100644
@@ -63,11 +63,46 @@
"System.Composition": "10.0.1"
}
},
"Microsoft.Extensions.Configuration": {
"type": "Transitive",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "eKYZzPkX3fQy+NBmU6Jn6i4K43cF7VR0EvEbe2xd+92LSjuW9KeLOG4IK8c1z6eZ0QhAp7t1m94EYrbFtCn6pQ=="
},
"Microsoft.Extensions.Configuration.Binder": {
"type": "Transitive",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "/vkvZf2C7HOlKcxV7LzTnJ0S7kShfZbt+9wDvJpuqEV83ijZOSUf/npwEDFsjpw97EpX/0p0wxpjTUhgJJpk7w==",
"dependencies": {
"Microsoft.Extensions.Configuration": "11.0.0-rc.3.26522.148"
}
},
"Microsoft.Extensions.Configuration.FileExtensions": {
"type": "Transitive",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "6YVPdNAEGVHh3LIapJ0WRtTmZjrDOaEcWpcX/Tr9Wd3p8rHJfqJ1uTKEwKpYf7NAgN24Ovh7RlX07ygdfz9TEw==",
"dependencies": {
"Microsoft.Extensions.Configuration": "11.0.0-rc.3.26522.148",
"Microsoft.Extensions.FileProviders.Physical": "11.0.0-rc.3.26522.148"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "zerXV0GAR9LCSXoSIApbWn+Dq1/T+6vbXMHGduq1LoVQRHT0BXsGQEau0jeLUBUcsoF/NaUT8ADPu8b+eNcIyg=="
},
"Microsoft.Extensions.FileProviders.Physical": {
"type": "Transitive",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "J0HtYwiJ7NFE3WMxKiJnixzYWPZmDGpbq+r6lWzzAF8MxznwW8DgCla6f1Sa76NkRuTFLhelOSA5Y4GIdR0xFQ==",
"dependencies": {
"Microsoft.Extensions.FileSystemGlobbing": "11.0.0-rc.3.26522.148"
}
},
"Microsoft.Extensions.FileSystemGlobbing": {
"type": "Transitive",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "orWLCLpdv+XAyUA7zrrhAUZEHjoUkg4DeyxJIEs9xbg/FnuYhbRpL6msSdH+5WEvrw0TKjP4U0AYIlZx2WqXig=="
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "10.0.1",
@@ -134,7 +169,9 @@
"dependencies": {
"Glob": "[1.1.9, )",
"Microsoft.CodeAnalysis.CSharp.Workspaces": "[5.9.0, )",
"Microsoft.CodeAnalysis.Workspaces.MSBuild": "[5.9.0, )"
"Microsoft.CodeAnalysis.Workspaces.MSBuild": "[5.9.0, )",
"Microsoft.Extensions.Configuration.Json": "[11.0.0-rc.3.26522.148, )",
"Microsoft.Extensions.Options.ConfigurationExtensions": "[11.0.0-rc.3.26522.148, )"
}
},
"Glob": {
@@ -172,6 +209,25 @@
"Microsoft.VisualStudio.SolutionPersistence": "1.0.52",
"System.Composition": "10.0.1"
}
},
"Microsoft.Extensions.Configuration.Json": {
"type": "CentralTransitive",
"requested": "[11.0.0-rc.3.26522.148, )",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "HwU/0bRyQm050kBidTu3wG9PiMh0yjehZ8gtDjYLDkJ9voC88sRTaerGRS945flSIcwfg4UuU2QEZKORBzC2Iw==",
"dependencies": {
"Microsoft.Extensions.Configuration": "11.0.0-rc.3.26522.148",
"Microsoft.Extensions.Configuration.FileExtensions": "11.0.0-rc.3.26522.148"
}
},
"Microsoft.Extensions.Options.ConfigurationExtensions": {
"type": "CentralTransitive",
"requested": "[11.0.0-rc.3.26522.148, )",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "lkISyAw1jD1Ga97B9VpXTpvXm1YLGfApCRx7LTLzfaBk5YbmiB51U58Uumv+ov66ivhUHl0WdQe6MGFdAhIXjw==",
"dependencies": {
"Microsoft.Extensions.Configuration.Binder": "11.0.0-rc.3.26522.148"
}
}
},
"net11.0/linux-x64": {}
core/src/CallGraph.cs
+1
-0
diff --git a/core/src/CallGraph.cs b/core/src/CallGraph.cs
index 8d2f514..d157ba0 100644
@@ -15,4 +15,5 @@ public enum LinkKind
{
Direct,
Abstract,
Event,
}
core/src/CallGraphBuilder.cs
+48
-12
diff --git a/core/src/CallGraphBuilder.cs b/core/src/CallGraphBuilder.cs
index ebf978f..ef9722c 100644
@@ -21,7 +21,7 @@ internal sealed class CallGraphBuilder
{
var builder = new CallGraphBuilder(options, solution, compilations);
builder.FindCallees(origin, cancellationToken);
await builder.FindCallees(origin, cancellationToken);
builder.InvertCallees(origin);
await builder.FindCallers(origin, cancellationToken);
@@ -37,6 +37,8 @@ internal sealed class CallGraphBuilder
private readonly HashSet<ProjectId> includedProjects;
private readonly HashSet<SyntaxTree> includedTrees;
private readonly IReadOnlyCollection<INamedTypeSymbol> types;
private readonly MessageLinks messageLinks;
private readonly HashSet<ISymbol> seenFrom = [with(SymbolEqualityComparer.Default)];
internal ImmutableDictionary<ISymbol, IImmutableList<Call>>.Builder ReachableFromSymbols { get; } =
ImmutableDictionary.CreateBuilder<ISymbol, IImmutableList<Call>>(SymbolEqualityComparer.Default);
@@ -68,9 +70,10 @@ internal sealed class CallGraphBuilder
.SelectMany(compilation => compilation.GetSymbolsWithName(_ => true, SymbolFilter.Type))
.OfType<INamedTypeSymbol>(),
];
messageLinks = new MessageLinks(options.Messages, compilations, includedTrees, solution);
}
internal void FindCallees(ISymbol symbol, CancellationToken cancellationToken)
internal async Task FindCallees(ISymbol symbol, CancellationToken cancellationToken)
{
if (!seenFrom.Add(symbol))
{
@@ -78,12 +81,21 @@ internal sealed class CallGraphBuilder
}
var seenCall = new Dictionary<ISymbol, LinkKind>(SymbolEqualityComparer.Default);
foreach (var handler in await messageLinks.Targets(symbol, cancellationToken))
{
AddCall(handler, LinkKind.Event);
}
foreach (var reference in symbol.DeclaringSyntaxReferences)
{
var syntax = reference.GetSyntax(cancellationToken);
var semanticModelForSyntax = compilations.GetSemanticModel(syntax.SyntaxTree);
var accesses = syntax
.DescendantNodesAndSelf()
.DescendantNodesAndSelf(node =>
node == syntax
|| node is not AnonymousFunctionExpressionSyntax
|| semanticModelForSyntax.GetSymbolInfo(node, cancellationToken).Symbol is not { } handler
|| !messageLinks.IsHandler(handler)
)
.Where(node =>
node
is SimpleNameSyntax
@@ -94,6 +106,10 @@ internal sealed class CallGraphBuilder
);
foreach (var access in accesses)
{
if (messageLinks.IsRegistrationArgument(access))
{
continue;
}
var accessedSymbol = semanticModelForSyntax.GetSymbolInfo(access, cancellationToken).Symbol;
if (accessedSymbol is not (IMethodSymbol or IPropertySymbol))
{
@@ -115,23 +131,23 @@ internal sealed class CallGraphBuilder
}
}
ReachableFromSymbols.Add(symbol, seenCall.Select(call => new Call(call.Key, call.Value)).ToImmutableList());
foreach (var target in seenCall.Keys)
{
await FindCallees(target, cancellationToken);
}
void AddCall(ISymbol target, LinkKind linkKind)
{
if (
!IsDeclaredInSource(target)
|| linkKind == LinkKind.Abstract && target.IsAbstract
|| linkKind is LinkKind.Abstract && target.IsAbstract
|| target is IPropertySymbol property && !HasBody(property, cancellationToken)
)
{
return;
}
if (seenCall.TryAdd(target, linkKind))
{
FindCallees(target, cancellationToken);
}
else if (linkKind is LinkKind.Direct)
if (!seenCall.TryAdd(target, linkKind) && linkKind is LinkKind.Direct)
{
seenCall[target] = LinkKind.Direct;
}
@@ -196,7 +212,16 @@ internal sealed class CallGraphBuilder
}
var seenCall = new Dictionary<ISymbol, LinkKind>(SymbolEqualityComparer.Default);
var references = await SymbolFinder.FindReferencesAsync(origin, solution, cancellationToken);
var references = origin is IMethodSymbol { MethodKind: MethodKind.AnonymousFunction }
? []
: await SymbolFinder.FindReferencesAsync(origin, solution, cancellationToken);
foreach (var publisher in await messageLinks.Callers(origin, references, cancellationToken))
{
if (seenCall.TryAdd(publisher, LinkKind.Event))
{
await FindCallers(publisher, cancellationToken);
}
}
foreach (var referencedSymbol in references)
{
var definition = referencedSymbol.Definition.OriginalDefinition;
@@ -210,9 +235,20 @@ internal sealed class CallGraphBuilder
foreach (var reference in referencedSymbol.Locations)
{
var root = await reference.Document.GetSyntaxRootAsync(cancellationToken);
if (
root is not null
&& messageLinks.IsRegistrationArgument(root.FindNode(reference.Location.SourceSpan))
)
{
continue;
}
var semanticModel = await reference.Document.GetSemanticModelAsync(cancellationToken);
var caller = semanticModel?.GetEnclosingSymbol(reference.Location.SourceSpan.Start, cancellationToken);
while (caller is IMethodSymbol { MethodKind: MethodKind.AnonymousFunction })
while (
caller is IMethodSymbol { MethodKind: MethodKind.AnonymousFunction }
&& !messageLinks.IsHandler(caller)
)
{
caller = caller.ContainingSymbol;
}
@@ -247,7 +283,7 @@ internal sealed class CallGraphBuilder
};
private bool IsDeclaredInSource(ISymbol symbol) =>
!symbol.IsImplicitlyDeclared
(!symbol.IsImplicitlyDeclared || symbol is IMethodSymbol { MethodKind: MethodKind.AnonymousFunction })
&& symbol.DeclaringSyntaxReferences.Any(reference => includedTrees.Contains(reference.SyntaxTree));
private static bool HasBody(IPropertySymbol property, CancellationToken cancellationToken) =>
core/src/Cfa.csproj
+3
-0
diff --git a/core/src/Cfa.csproj b/core/src/Cfa.csproj
index 39ecf65..77b0365 100644
@@ -2,12 +2,15 @@
<PropertyGroup>
<RootNamespace>Cfa</RootNamespace>
<AssemblyName>Cfa</AssemblyName>
<EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Cfa.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Glob" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" />
<PackageReference Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" />
</ItemGroup>
core/src/MessageLinks.cs
+452
-0
diff --git a/core/src/MessageLinks.cs b/core/src/MessageLinks.cs
new file mode 100644
index 0000000..c1676b6
@@ -0,0 +1,452 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.FindSymbols;
using Microsoft.CodeAnalysis.Operations;
using Microsoft.CodeAnalysis.Text;
using Microsoft.Extensions.Options;
namespace Cfa;
internal sealed class MessageLinks
{
private readonly MessageOptions options;
private readonly IReadOnlyCollection<Compilation> compilations;
private readonly HashSet<SyntaxTree> includedTrees;
private readonly Solution solution;
private readonly Dictionary<(MessageRoute Route, string Type, bool Publishers), HashSet<ISymbol>> links = [];
private readonly Dictionary<(SyntaxTree Tree, TextSpan Span), Registration[]> registrations = [];
private readonly Dictionary<(MethodSelector Method, int Argument, int? TypeArgument), IMethodSymbol[]> methods = [];
private readonly IImmutableSet<Document> documents;
public MessageLinks(
MessageOptions options,
IReadOnlyCollection<Compilation> compilations,
HashSet<SyntaxTree> includedTrees,
Solution solution
)
{
this.options = options;
this.compilations = compilations;
this.includedTrees = includedTrees;
this.solution = solution;
documents = includedTrees.Select(solution.GetDocument).OfType<Document>().ToImmutableHashSet();
// Validate API shapes only; call sites are resolved on demand.
foreach (var route in options.MessageRoutes)
{
Validate(route.Publisher.Method, route.Publisher.MessageArgument);
foreach (var rule in route.Registrations)
{
Validate(rule.Method, rule.HandlerArgument, rule.MessageTypeArgument);
}
}
}
private sealed record Registration(MessageRoute Route, ITypeSymbol Message, ISymbol Handler, TextSpan Argument);
public bool IsHandler(ISymbol member) =>
options.MessageRoutes is not []
&& member is IMethodSymbol { MethodKind: MethodKind.AnonymousFunction }
&& member.DeclaringSyntaxReferences.Any(reference =>
reference
.GetSyntax()
.Ancestors()
.OfType<InvocationExpressionSyntax>()
.Any(invocation =>
Registrations(invocation)
.Any(registration => SymbolEqualityComparer.Default.Equals(registration.Handler, member))
)
);
public bool IsRegistrationArgument(SyntaxNode node) =>
options.MessageRoutes is not []
&& node.AncestorsAndSelf()
.TakeWhile(ancestor => ancestor is not AnonymousFunctionExpressionSyntax)
.OfType<ArgumentSyntax>()
.Any(argument =>
argument.Parent?.Parent is InvocationExpressionSyntax invocation
&& Registrations(invocation).Any(registration => registration.Argument == argument.Span)
);
public async Task<IEnumerable<ISymbol>> Targets(ISymbol member, CancellationToken token)
{
var result = new HashSet<ISymbol>(SymbolEqualityComparer.Default);
if (options.MessageRoutes is [])
{
return result;
}
foreach (var reference in member.DeclaringSyntaxReferences)
{
var syntax = await reference.GetSyntaxAsync(token);
var model = compilations.GetSemanticModel(syntax.SyntaxTree);
foreach (
var invocation in syntax
.DescendantNodesAndSelf(node =>
node == syntax
|| node is not AnonymousFunctionExpressionSyntax
|| model.GetSymbolInfo(node, token).Symbol is not { } handler
|| !IsHandler(handler)
)
.OfType<InvocationExpressionSyntax>()
.Where(invocation =>
options.MessageRoutes.Any(route => route.Publisher.Method.Name == InvocationName(invocation))
)
)
{
token.ThrowIfCancellationRequested();
if (
!SymbolEqualityComparer.Default.Equals(ContainingMember(model, invocation, token), member)
|| model.GetOperation(invocation, token) is not IInvocationOperation operation
)
{
continue;
}
foreach (
var route in options.MessageRoutes.Where(route =>
Matches(operation.TargetMethod, route.Publisher.Method)
)
)
{
if (PublishedMessage(operation, route) is { } message)
{
result.UnionWith(await Resolve(route, message, publishers: false, token));
}
}
}
}
return result;
}
public async Task<IEnumerable<ISymbol>> Callers(
ISymbol member,
IEnumerable<ReferencedSymbol> references,
CancellationToken token
)
{
var result = new HashSet<ISymbol>(SymbolEqualityComparer.Default);
if (options.MessageRoutes is [])
{
return result;
}
var candidates = new HashSet<InvocationExpressionSyntax>();
if (member is IMethodSymbol { MethodKind: MethodKind.AnonymousFunction })
{
foreach (var reference in member.DeclaringSyntaxReferences)
{
candidates.UnionWith(
(await reference.GetSyntaxAsync(token)).Ancestors().OfType<InvocationExpressionSyntax>()
);
}
}
else
{
candidates.UnionWith(await ReferencingInvocations(references, token));
}
foreach (
var registration in candidates
.SelectMany(Registrations)
.Where(registration =>
SymbolEqualityComparer.Default.Equals(
registration.Handler.OriginalDefinition,
member.OriginalDefinition
)
)
)
{
result.UnionWith(await Resolve(registration.Route, registration.Message, publishers: true, token));
}
return result;
}
private Registration[] Registrations(InvocationExpressionSyntax syntax)
{
var key = (syntax.SyntaxTree, syntax.Span);
if (registrations.TryGetValue(key, out var found))
{
return found;
}
var result = new List<Registration>();
if (!includedTrees.Contains(syntax.SyntaxTree))
{
return [];
}
var name = InvocationName(syntax);
foreach (var route in options.MessageRoutes)
{
foreach (var rule in route.Registrations.Where(rule => rule.Method.Name == name))
{
var model = compilations.GetSemanticModel(syntax.SyntaxTree);
if (
model.GetOperation(syntax) is not IInvocationOperation operation
|| !Matches(operation.TargetMethod, rule.Method)
)
{
continue;
}
Validate(rule.Method, rule.HandlerArgument, rule.MessageTypeArgument);
var callback = operation.Arguments.FirstOrDefault(argument =>
argument.Parameter?.Ordinal == rule.HandlerArgument
);
if (callback is not { Value: var callbackOperation })
{
continue;
}
var handler = Unwrap(callbackOperation) switch
{
IAnonymousFunctionOperation lambda => lambda.Symbol,
IMethodReferenceOperation method => method.Method,
_ => null,
};
if (
handler is null
|| !handler.DeclaringSyntaxReferences.Any(reference => includedTrees.Contains(reference.SyntaxTree))
)
{
continue;
}
var argument = syntax.ArgumentList.Arguments.First(argument =>
argument.Span.Contains(callbackOperation.Syntax.Span)
);
result.Add(
new(route, operation.TargetMethod.TypeArguments[rule.MessageTypeArgument], handler, argument.Span)
);
}
}
found = [.. result];
registrations.Add(key, found);
return found;
}
private static string InvocationName(InvocationExpressionSyntax invocation) =>
invocation.Expression switch
{
SimpleNameSyntax simple => simple.Identifier.ValueText,
MemberAccessExpressionSyntax access => access.Name.Identifier.ValueText,
MemberBindingExpressionSyntax binding => binding.Name.Identifier.ValueText,
_ => "",
};
private ITypeSymbol? PublishedMessage(IInvocationOperation operation, MessageRoute route)
{
Validate(route.Publisher.Method, route.Publisher.MessageArgument);
var message = Unwrap(
operation
.Arguments.FirstOrDefault(argument => argument.Parameter?.Ordinal == route.Publisher.MessageArgument)
?.Value
)?.Type;
return message is INamedTypeSymbol { IsAbstract: false, TypeKind: TypeKind.Class or TypeKind.Struct }
? message
: null;
}
private async Task<HashSet<ISymbol>> Resolve(
MessageRoute route,
ITypeSymbol message,
bool publishers,
CancellationToken token
)
{
var key = (
route,
$"{message.ContainingAssembly.Identity}:{DocumentationCommentId.CreateReferenceId(message)}",
publishers
);
if (links.TryGetValue(key, out var found))
{
return found;
}
var result = new HashSet<ISymbol>(SymbolEqualityComparer.Default);
if (publishers)
{
foreach (var method in Validate(route.Publisher.Method, route.Publisher.MessageArgument))
{
foreach (var invocation in await ReferencingInvocations(method, token))
{
var model = compilations.GetSemanticModel(invocation.SyntaxTree);
if (
model.GetOperation(invocation, token) is not IInvocationOperation operation
|| !Matches(operation.TargetMethod, route.Publisher.Method)
|| PublishedMessage(operation, route) is not { } published
|| !SameMessageType(published, message)
)
{
continue;
}
var caller = ContainingMember(model, invocation, token);
if (caller is IMethodSymbol or IPropertySymbol)
{
result.Add(caller);
}
}
}
}
else
{
foreach (var rule in route.Registrations)
{
Validate(rule.Method, rule.HandlerArgument, rule.MessageTypeArgument);
}
// Search references to this message type, rather than every registration API call.
foreach (var invocation in await ReferencingInvocations(message.OriginalDefinition, token))
{
foreach (var registration in Registrations(invocation))
{
if (registration.Route == route && SameMessageType(message, registration.Message))
{
result.Add(registration.Handler);
}
}
}
}
links.Add(key, result);
return result;
}
private ISymbol? ContainingMember(
SemanticModel model,
InvocationExpressionSyntax invocation,
CancellationToken token
)
{
var caller = model.GetEnclosingSymbol(invocation.SpanStart, token);
while (caller is IMethodSymbol { MethodKind: MethodKind.AnonymousFunction } && !IsHandler(caller))
{
caller = caller.ContainingSymbol;
}
return caller is IMethodSymbol { AssociatedSymbol: IPropertySymbol property } ? property : caller;
}
private async Task<HashSet<InvocationExpressionSyntax>> ReferencingInvocations(
ISymbol symbol,
CancellationToken token
) =>
await ReferencingInvocations(await SymbolFinder.FindReferencesAsync(symbol, solution, documents, token), token);
private static async Task<HashSet<InvocationExpressionSyntax>> ReferencingInvocations(
IEnumerable<ReferencedSymbol> references,
CancellationToken token
)
{
var result = new HashSet<InvocationExpressionSyntax>();
foreach (var reference in references)
{
foreach (var location in reference.Locations)
{
token.ThrowIfCancellationRequested();
var root = await location.Document.GetSyntaxRootAsync(token);
result.UnionWith(
root!.FindNode(location.Location.SourceSpan).AncestorsAndSelf().OfType<InvocationExpressionSyntax>()
);
}
}
return result;
}
private static bool SameMessageType(ITypeSymbol left, ITypeSymbol right) =>
SymbolEqualityComparer.Default.Equals(left, right)
// A referenced project's type can be represented by a metadata symbol.
|| left.ContainingAssembly.Identity.Equals(right.ContainingAssembly.Identity)
&& DocumentationCommentId.CreateReferenceId(left) is { } id
&& id == DocumentationCommentId.CreateReferenceId(right);
private static IOperation? Unwrap(IOperation? operation) =>
operation switch
{
IConversionOperation { IsImplicit: true } conversion => Unwrap(conversion.Operand),
IDelegateCreationOperation creation => Unwrap(creation.Target),
_ => operation,
};
private static bool Matches(IMethodSymbol method, MethodSelector selector)
{
for (var member = method; member is not null; member = member.OverriddenMethod)
{
if (MatchesDefinition(member, selector))
{
return true;
}
}
foreach (
var contract in method.ContainingType.AllInterfaces.Where(contract =>
MetadataName(contract) == selector.Type
)
)
{
foreach (
var member in contract
.GetMembers(selector.Name)
.OfType<IMethodSymbol>()
.Where(member => MatchesDefinition(member, selector))
)
{
if (
SymbolEqualityComparer.Default.Equals(
method.ContainingType.FindImplementationForInterfaceMember(member)?.OriginalDefinition,
method.OriginalDefinition
)
)
{
return true;
}
}
}
return false;
}
private static bool MatchesDefinition(IMethodSymbol method, MethodSelector selector) =>
method.Name == selector.Name
&& MetadataName(method.ContainingType) == selector.Type
&& (selector.GenericArity is null || method.Arity == selector.GenericArity)
&& (selector.ParameterCount is null || method.Parameters.Length == selector.ParameterCount);
private static string MetadataName(INamedTypeSymbol type) =>
type.ContainingType is { } parent ? $"{MetadataName(parent)}+{type.MetadataName}"
: type.ContainingNamespace.IsGlobalNamespace ? type.MetadataName
: $"{type.ContainingNamespace}.{type.MetadataName}";
private IMethodSymbol[] Validate(MethodSelector selector, int argument, int? typeArgument = null)
{
var key = (selector, argument, typeArgument);
if (methods.TryGetValue(key, out var found))
{
return found;
}
found =
[
.. compilations
.Select(compilation => compilation.GetTypeByMetadataName(selector.Type))
.OfType<INamedTypeSymbol>()
.SelectMany(type => type.GetMembers(selector.Name))
.OfType<IMethodSymbol>()
.Where(method => MatchesDefinition(method, selector))
.GroupBy(method => (method.ContainingAssembly.Identity, method.GetDocumentationCommentId()))
.Select(group =>
group.FirstOrDefault(method => method.DeclaringSyntaxReferences is not []) ?? group.First()
),
];
if (
found is []
|| found.Any(method =>
argument < 0
|| argument >= method.Parameters.Length
|| typeArgument is { } index && (index < 0 || index >= method.Arity)
|| typeArgument is not null && method.Parameters[argument].Type.TypeKind != TypeKind.Delegate
)
)
{
throw new OptionsValidationException(
nameof(MessageOptions),
typeof(MessageOptions),
[
$"Message method '{selector.Type}.{selector.Name}' was not found or its configured argument indices are invalid.",
]
);
}
methods.Add(key, found);
return found;
}
}
core/src/MessageOptions.cs
+88
-0
diff --git a/core/src/MessageOptions.cs b/core/src/MessageOptions.cs
new file mode 100644
index 0000000..96b39f4
@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.IO;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.FileProviders.Physical;
using Microsoft.Extensions.Options;
namespace Cfa;
public sealed class MessageOptions
{
[Required]
[ValidateEnumeratedItems]
public List<MessageRoute> MessageRoutes { get; set; } = [];
public static MessageOptions LoadJson(string path, bool optional = false)
{
var fullPath = Path.GetFullPath(path);
using var files = new PhysicalFileProvider(
Path.GetDirectoryName(fullPath) ?? Environment.CurrentDirectory,
ExclusionFilters.None
);
using var configuration = new ConfigurationManager();
configuration.AddJsonFile(files, Path.GetFileName(fullPath), optional, reloadOnChange: false);
var services = new ServiceCollection();
services.AddOptions<MessageOptions>().Bind(configuration).Validate<MessageOptionsValidator>();
using var provider = services.BuildServiceProvider();
return provider.GetRequiredService<IOptions<MessageOptions>>().Value;
}
}
public sealed class MessageRoute
{
[Required]
[ValidateObjectMembers]
public PublisherRule Publisher { get; set; } = new();
[Required]
#pragma warning disable IL2026 // T[] implements ICollection; MinLength does not need reflection.
[MinLength(1)]
#pragma warning restore IL2026
[ValidateEnumeratedItems]
public RegistrationRule[] Registrations { get; set; } = [];
}
public sealed class PublisherRule
{
[Required]
[ValidateObjectMembers]
public MethodSelector Method { get; set; } = new();
[Range(0, int.MaxValue)]
public int MessageArgument { get; set; }
}
public sealed class RegistrationRule
{
[Required]
[ValidateObjectMembers]
public MethodSelector Method { get; set; } = new();
[Range(0, int.MaxValue)]
public int MessageTypeArgument { get; set; }
[Range(0, int.MaxValue)]
public int HandlerArgument { get; set; }
}
public sealed class MethodSelector
{
[Required]
public string Type { get; set; } = "";
[Required]
public string Name { get; set; } = "";
[Range(0, int.MaxValue)]
public int? GenericArity { get; set; }
[Range(0, int.MaxValue)]
public int? ParameterCount { get; set; }
}
[OptionsValidator]
internal partial class MessageOptionsValidator : IValidateOptions<MessageOptions>;
core/src/ScannerOptions.cs
+1
-0
diff --git a/core/src/ScannerOptions.cs b/core/src/ScannerOptions.cs
index 609dfff..d95584f 100644
@@ -6,6 +6,7 @@ namespace Cfa;
public sealed record ScannerOptions
{
public MessageOptions Messages { get; init; } = new();
public IImmutableList<Glob> IncludePatterns { get; init; } = [];
public IImmutableList<Glob> ExcludePatterns { get; init; } = [];
core/src/packages.lock.json
+56
-1
diff --git a/core/src/packages.lock.json b/core/src/packages.lock.json
index 7ffe8ea..9a449f8 100644
@@ -44,6 +44,25 @@
"System.Composition": "10.0.1"
}
},
"Microsoft.Extensions.Configuration.Json": {
"type": "Direct",
"requested": "[11.0.0-rc.3.26522.148, )",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "HwU/0bRyQm050kBidTu3wG9PiMh0yjehZ8gtDjYLDkJ9voC88sRTaerGRS945flSIcwfg4UuU2QEZKORBzC2Iw==",
"dependencies": {
"Microsoft.Extensions.Configuration": "11.0.0-rc.3.26522.148",
"Microsoft.Extensions.Configuration.FileExtensions": "11.0.0-rc.3.26522.148"
}
},
"Microsoft.Extensions.Options.ConfigurationExtensions": {
"type": "Direct",
"requested": "[11.0.0-rc.3.26522.148, )",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "lkISyAw1jD1Ga97B9VpXTpvXm1YLGfApCRx7LTLzfaBk5YbmiB51U58Uumv+ov66ivhUHl0WdQe6MGFdAhIXjw==",
"dependencies": {
"Microsoft.Extensions.Configuration.Binder": "11.0.0-rc.3.26522.148"
}
},
"Microsoft.NET.ILLink.Tasks": {
"type": "Direct",
"requested": "[11.0.0-rc.3.26522.148, )",
@@ -93,11 +112,46 @@
"System.Composition": "10.0.1"
}
},
"Microsoft.Extensions.Configuration": {
"type": "Transitive",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "eKYZzPkX3fQy+NBmU6Jn6i4K43cF7VR0EvEbe2xd+92LSjuW9KeLOG4IK8c1z6eZ0QhAp7t1m94EYrbFtCn6pQ=="
},
"Microsoft.Extensions.Configuration.Binder": {
"type": "Transitive",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "/vkvZf2C7HOlKcxV7LzTnJ0S7kShfZbt+9wDvJpuqEV83ijZOSUf/npwEDFsjpw97EpX/0p0wxpjTUhgJJpk7w==",
"dependencies": {
"Microsoft.Extensions.Configuration": "11.0.0-rc.3.26522.148"
}
},
"Microsoft.Extensions.Configuration.FileExtensions": {
"type": "Transitive",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "6YVPdNAEGVHh3LIapJ0WRtTmZjrDOaEcWpcX/Tr9Wd3p8rHJfqJ1uTKEwKpYf7NAgN24Ovh7RlX07ygdfz9TEw==",
"dependencies": {
"Microsoft.Extensions.Configuration": "11.0.0-rc.3.26522.148",
"Microsoft.Extensions.FileProviders.Physical": "11.0.0-rc.3.26522.148"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "zerXV0GAR9LCSXoSIApbWn+Dq1/T+6vbXMHGduq1LoVQRHT0BXsGQEau0jeLUBUcsoF/NaUT8ADPu8b+eNcIyg=="
},
"Microsoft.Extensions.FileProviders.Physical": {
"type": "Transitive",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "J0HtYwiJ7NFE3WMxKiJnixzYWPZmDGpbq+r6lWzzAF8MxznwW8DgCla6f1Sa76NkRuTFLhelOSA5Y4GIdR0xFQ==",
"dependencies": {
"Microsoft.Extensions.FileSystemGlobbing": "11.0.0-rc.3.26522.148"
}
},
"Microsoft.Extensions.FileSystemGlobbing": {
"type": "Transitive",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "orWLCLpdv+XAyUA7zrrhAUZEHjoUkg4DeyxJIEs9xbg/FnuYhbRpL6msSdH+5WEvrw0TKjP4U0AYIlZx2WqXig=="
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "10.0.1",
@@ -159,6 +213,7 @@
"System.Composition.Runtime": "10.0.1"
}
}
}
},
"net11.0/linux-x64": {}
}
}
\ No newline at end of file
core/tests/.cfa.json
+50
-0
diff --git a/core/tests/.cfa.json b/core/tests/.cfa.json
new file mode 100644
index 0000000..4b938eb
@@ -0,0 +1,50 @@
{
"messageRoutes": [
{
"publisher": {
"method": {
"type": "Sample.GrainInterfaces.Events.IEventSender",
"name": "Publish",
"genericArity": 1,
"parameterCount": 1
},
"messageArgument": 0
},
"registrations": [
{
"method": {
"type": "Sample.Grains.EventHandlers.EventReceiver`1",
"name": "On",
"genericArity": 1,
"parameterCount": 1
},
"messageTypeArgument": 0,
"handlerArgument": 0
}
]
},
{
"publisher": {
"method": {
"type": "Sample.GrainInterfaces.Commands.ICommandSender",
"name": "Dispatch",
"genericArity": 1,
"parameterCount": 1
},
"messageArgument": 0
},
"registrations": [
{
"method": {
"type": "Sample.Grains.CommandReceiver`3",
"name": "When",
"genericArity": 1,
"parameterCount": 1
},
"messageTypeArgument": 0,
"handlerArgument": 0
}
]
}
]
}
core/tests/CallGraphBuilderTests/BuildCallGraphShould.cs
+99
-1
diff --git a/core/tests/CallGraphBuilderTests/BuildCallGraphShould.cs b/core/tests/CallGraphBuilderTests/BuildCallGraphShould.cs
index d4d58dc..73ea491 100644
@@ -12,6 +12,104 @@ namespace Cfa.Tests.CallGraphBuilderTests;
public sealed class BuildCallGraphShould
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task LinkMessagesAcrossProjectsUnlessTheReceiverProjectIsExcluded(bool excludeReceiver)
{
// Arrange
using var workspace = new AdhocWorkspace();
var sender = workspace.AddProject(
ProjectInfo.Create(
ProjectId.CreateNewId(),
VersionStamp.Default,
"Sender",
"Sender",
LanguageNames.CSharp,
filePath: "/repo/Sender.csproj",
compilationOptions: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary),
metadataReferences: [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)]
)
);
var receiver = workspace.AddProject(
ProjectInfo.Create(
ProjectId.CreateNewId(),
VersionStamp.Default,
"Receiver.Tests",
"Receiver.Tests",
LanguageNames.CSharp,
filePath: "/repo/Receiver.Tests.csproj",
compilationOptions: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary),
projectReferences: [new ProjectReference(sender.Id)],
metadataReferences: [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)]
)
);
workspace.AddDocument(
sender.Id,
"Sender.cs",
SourceText.From(
MessageSource.Contracts
+ """
public class Example
{
public void Start(Sample.GrainInterfaces.Events.IEventSender publisher)
{
publisher.Publish(new Updated());
}
}
"""
)
);
workspace.AddDocument(
receiver.Id,
"Receiver.cs",
SourceText.From(
"""
class Receiver : Sample.Grains.EventHandlers.EventReceiver<Sample.Domain.Message<int>>
{
public Receiver() { On<Updated>(Handle); }
void Handle(Updated evt) { Save(); }
void Save() { }
}
"""
)
);
var solution = workspace.CurrentSolution;
var senderCompilation = (await solution.GetProject(sender.Id)!.GetCompilationAsync()).ShouldNotBeNull();
var receiverCompilation = (await solution.GetProject(receiver.Id)!.GetCompilationAsync()).ShouldNotBeNull();
senderCompilation.GetDiagnostics().ShouldBeEmpty();
receiverCompilation.GetDiagnostics().ShouldBeEmpty();
var start = senderCompilation.GetTypeByMetadataName("Example")!.GetMembers("Start").Single();
var options = new ScannerOptions
{
ExcludePatterns = excludeReceiver ? [new("*Tests*")] : [],
Messages = MessageSource.Options,
};
// Act
var graph = await CallGraphBuilder.BuildCallGraph(
options,
solution,
[senderCompilation, receiverCompilation],
start,
CancellationToken.None
);
// Assert
if (excludeReceiver)
{
graph.CallsTo[start].ShouldBeEmpty();
}
else
{
var handler = graph.CallsTo[start].ShouldHaveSingleItem();
handler.LinkKind.ShouldBe(LinkKind.Event);
handler.Target.Name.ShouldBe("Handle");
graph.CallsTo[handler.Target].ShouldHaveSingleItem().Target.Name.ShouldBe("Save");
graph.CalledFrom[handler.Target].ShouldHaveSingleItem().Target.ShouldBe(start);
}
}
[Fact]
public async Task ExcludeTestProjectMembersButNotProductionFilesNamedTests()
{
@@ -82,7 +180,7 @@ public sealed class BuildCallGraphShould
);
// Act
builder.FindCallees(start, CancellationToken.None);
await builder.FindCallees(start, CancellationToken.None);
await builder.FindCallers(target, CancellationToken.None);
// Assert
core/tests/CallGraphBuilderTests/FindCalleesShould.cs
+400
-0
diff --git a/core/tests/CallGraphBuilderTests/FindCalleesShould.cs b/core/tests/CallGraphBuilderTests/FindCalleesShould.cs
index 994c96a..c2a59b1 100644
@@ -1,5 +1,7 @@
using System.Linq;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
@@ -7,6 +9,404 @@ namespace Cfa.Tests.CallGraphBuilderTests;
public sealed class FindCalleesShould
{
[Fact]
public async Task FollowMessagesPublishedByARegisteredLambdaOnlyWhenThatLambdaIsVisited()
{
// Arrange
var source =
MessageSource.Contracts
+ """
class Receiver : Sample.Grains.EventHandlers.EventReceiver<Sample.Domain.Message<int>>
{
public Receiver(Sample.GrainInterfaces.Events.IEventSender sender)
{
On<Updated>(evt => sender.Publish(new Other()));
On<Other>(Handle);
}
void Handle(Other evt) { }
}
class Example
{
void Start(Sample.GrainInterfaces.Events.IEventSender sender)
{
sender.Publish(new Updated());
}
}
""";
// Act
var (start, calls) = await source.FindCallees();
// Assert
var first = calls[start].ShouldHaveSingleItem();
first.LinkKind.ShouldBe(LinkKind.Event);
first.Target.ShouldBeAssignableTo<IMethodSymbol>().MethodKind.ShouldBe(MethodKind.AnonymousFunction);
var second = calls[first.Target].ShouldHaveSingleItem();
second.Target.Name.ShouldBe("Handle");
second.LinkKind.ShouldBe(LinkKind.Event);
}
[Fact]
public async Task FindRegistrationsThroughAMessageTypeAlias()
{
// Arrange
var source =
"using Notification = Updated;\n"
+ MessageSource.Contracts
+ """
class Example : Sample.Grains.EventHandlers.EventReceiver<Sample.Domain.Message<int>>
{
public Example() { On<Notification>(Handle); }
void Handle(Notification evt) { }
void Start(Sample.GrainInterfaces.Events.IEventSender sender) { sender.Publish(new Updated()); }
}
""";
// Act
var (start, calls) = await source.FindCallees();
// Assert
var handler = calls[start].ShouldHaveSingleItem();
handler.Target.Name.ShouldBe("Handle");
handler.LinkKind.ShouldBe(LinkKind.Event);
}
[Fact]
public async Task LeaveMessageLinksDisabledWithoutRoutes()
{
// Arrange
var source =
MessageSource.Contracts
+ """
class Example : Sample.Grains.EventHandlers.EventReceiver<Sample.Domain.Message<int>>
{
public Example() { On<Updated>(Handle); }
void Handle(Updated evt) { }
void Start(Sample.GrainInterfaces.Events.IEventSender sender) { sender.Publish(new Updated()); }
}
""";
// Act
var (start, calls) = await source.FindCallees(new MessageOptions());
// Assert
calls[start].ShouldNotContain(call => call.LinkKind == LinkKind.Event);
}
[Theory]
[InlineData("Missing", 0)]
[InlineData("Publish", 1)]
public async Task ReportMissingMethodsAndInvalidArgumentIndices(string method, int index)
{
// Arrange
var options = MessageSource.Options;
options.MessageRoutes[0].Publisher.Method.Name = method;
options.MessageRoutes[0].Publisher.MessageArgument = index;
var source = MessageSource.Contracts + "class Example { void Start() { } }";
// Act
var scan = () => source.FindCallees(options);
// Assert
(await scan.ShouldThrowAsync<OptionsValidationException>()).Message.ShouldContain(method);
}
[Fact]
public async Task UseConfiguredArgumentPositionsWithoutSkippingOtherRegistrationArguments()
{
// Arrange
var source = """
class Payload { }
interface Sender { void Send(int key, object message); }
class Example
{
void Register<TContext, TMessage>(int key, System.Action<TMessage> callback) { }
void Handle(Payload message) { }
int Key() => 0;
void Start(Sender sender)
{
Register<object, Payload>(Key(), Handle);
sender.Send(message: new Payload(), key: 0);
}
}
""";
var options = new MessageOptions
{
MessageRoutes =
[
new()
{
Publisher = new()
{
Method = new() { Type = "Sender", Name = "Send" },
MessageArgument = 1,
},
Registrations =
[
new()
{
Method = new() { Type = "Example", Name = "Register" },
MessageTypeArgument = 1,
HandlerArgument = 1,
},
],
},
],
};
// Act
var (start, calls) = await source.FindCallees(options);
// Assert
calls[start].ShouldContain(call => call.Target.Name == "Handle" && call.LinkKind == LinkKind.Event);
calls[start].ShouldContain(call => call.Target.Name == "Key" && call.LinkKind == LinkKind.Direct);
}
[Theory]
[InlineData("On<Updated>(Handle);")]
[InlineData("On<Updated>(evt => Handle(evt));")]
[InlineData("On<Updated>(async evt => { await System.Threading.Tasks.Task.CompletedTask; Handle(evt); });")]
public async Task LinkPublishedEventsToTheirRegisteredHandlers(string registration)
{
// Arrange
var source =
MessageSource.Contracts
+ $$"""
class Receiver : Sample.Grains.EventHandlers.EventReceiver<Sample.Domain.Message<int>>
{
public Receiver() { {{registration}} On<Other>(Ignore); }
void Handle(Updated evt) { Save(); }
void Ignore(Other evt) { }
void Save() { }
}
class Example
{
void Start(Sample.GrainInterfaces.Events.IEventSender publisher)
{
publisher.Publish<int>(message: new Updated());
publisher.Publish<int>(message: new Updated());
}
}
""";
// Act
var (start, calls) = await source.FindCallees();
// Assert
var handler = calls[start].ShouldHaveSingleItem();
handler.LinkKind.ShouldBe(LinkKind.Event);
handler.Target.ContainingType.Name.ShouldBe("Receiver");
calls[handler.Target]
.ShouldHaveSingleItem()
.Target.Name.ShouldBe(registration.Contains("=>") ? "Handle" : "Save");
}
[Theory]
[InlineData("When<Change>(Handle);")]
[InlineData("When<Change>((cmd, aggregate) => Handle(cmd, aggregate));")]
public async Task LinkDispatchedCommandsToTheirRegisteredHandlers(string registration)
{
// Arrange
var source =
MessageSource.Contracts
+ $$"""
class Receiver : Sample.Grains.CommandReceiver<object, int, object>
{
public Receiver() { {{registration}} }
void Handle(Change command, object aggregate) { Save(); }
void Save() { }
}
class Example
{
void Start(Sample.GrainInterfaces.Commands.ICommandSender bus) { bus.Dispatch(new Change()); }
}
""";
// Act
var (start, calls) = await source.FindCallees();
// Assert
var handler = calls[start].Where(call => call.LinkKind == LinkKind.Event).ShouldHaveSingleItem();
calls[handler.Target]
.ShouldHaveSingleItem()
.Target.Name.ShouldBe(registration.Contains("=>") ? "Handle" : "Save");
}
[Fact]
public async Task RecognizePublisherImplementationsAsWellAsTheInterface()
{
// Arrange
var source =
MessageSource.Contracts
+ """
class Publisher : Sample.GrainInterfaces.Events.IEventSender
{
public void Publish<TId>(Sample.Domain.Message<TId> message) { }
}
class Example : Sample.Grains.EventHandlers.EventReceiver<Sample.Domain.Message<int>>
{
public Example() { On<Updated>(Handle); }
void Handle(Updated evt) { }
void Start(Publisher publisher) { publisher.Publish(new Updated()); }
}
""";
// Act
var (start, calls) = await source.FindCallees();
// Assert
calls[start]
.Where(call => call.LinkKind == LinkKind.Event)
.ShouldHaveSingleItem()
.Target.Name.ShouldBe("Handle");
}
[Fact]
public async Task KeepEachPublishersEventTypeSeparate()
{
// Arrange
var source =
MessageSource.Contracts
+ """
class Receiver : Sample.Grains.EventHandlers.EventReceiver<Sample.Domain.Message<int>>
{
public Receiver() { On<Updated>(Handle); On<Other>(Ignore); }
void Handle(Updated evt) { }
void Ignore(Other evt) { }
}
class Example
{
void Start(Sample.GrainInterfaces.Events.IEventSender publisher)
{
publisher.Publish(new Updated());
OtherPublisher(publisher);
}
void OtherPublisher(Sample.GrainInterfaces.Events.IEventSender publisher)
{
publisher.Publish(new Other());
}
}
""";
// Act
var (start, calls) = await source.FindCallees();
// Assert
calls[start]
.Where(call => call.LinkKind == LinkKind.Event)
.ShouldHaveSingleItem()
.Target.Name.ShouldBe("Handle");
var other = calls[start].Where(call => call.Target.Name == "OtherPublisher").ShouldHaveSingleItem().Target;
calls[other].ShouldHaveSingleItem().Target.Name.ShouldBe("Ignore");
}
[Fact]
public async Task NotGuessConcreteEventsFromAnErasedParameter()
{
// Arrange
var source =
MessageSource.Contracts
+ """
class Receiver : Sample.Grains.EventHandlers.EventReceiver<Sample.Domain.Message<int>>
{
public Receiver() { On<Updated>(Handle); }
void Handle(Updated evt) { }
}
class Example
{
void Start(
Sample.GrainInterfaces.Events.IEventSender publisher,
Sample.Domain.Message<int> evt)
{
publisher.Publish(evt);
}
}
""";
// Act
var (start, calls) = await source.FindCallees();
// Assert
calls[start].ShouldBeEmpty();
}
[Fact]
public async Task PreferDirectCallsWhenTheSameHandlerAlsoReceivesAnEvent()
{
// Arrange
var source =
MessageSource.Contracts
+ """
class Example : Sample.Grains.EventHandlers.EventReceiver<Sample.Domain.Message<int>>
{
public Example() { On<Updated>(Handle); }
void Handle(Updated evt) { }
void Start(Sample.GrainInterfaces.Events.IEventSender publisher, Updated evt)
{
publisher.Publish(evt);
Handle(evt);
}
}
""";
// Act
var (start, calls) = await source.FindCallees();
// Assert
var handler = calls[start].ShouldHaveSingleItem();
handler.Target.Name.ShouldBe("Handle");
handler.LinkKind.ShouldBe(LinkKind.Direct);
}
[Fact]
public async Task IgnoreUnrelatedMethodsNamedPublishAndOn()
{
// Arrange
var source =
MessageSource.Contracts
+ """
class Example
{
void On<T>(System.Action<T> handler) { }
void Publish(Updated evt) { }
void Handle(Updated evt) { }
void Register() { On<Updated>(Handle); }
void Start() { Publish(new Updated()); }
}
""";
// Act
var (start, calls) = await source.FindCallees();
// Assert
calls[start].ShouldHaveSingleItem().Target.Name.ShouldBe("Publish");
calls[start].ShouldNotContain(call => call.LinkKind == LinkKind.Event);
}
[Fact]
public async Task NotTreatHandlerRegistrationAsHandlerExecution()
{
// Arrange
var source =
MessageSource.Contracts
+ """
class Receiver : Sample.Grains.EventHandlers.EventReceiver<Sample.Domain.Message<int>>
{
public Receiver() { On<Updated>(evt => Save()); On<Other>(Ignore); }
void Ignore(Other evt) { }
void Save() { }
}
class Example { void Start() { _ = new Receiver(); } }
""";
// Act
var (start, calls) = await source.FindCallees();
// Assert
var constructor = calls[start].ShouldHaveSingleItem().Target;
calls[constructor].ShouldNotContain(call => call.Target.Name == "Save" || call.Target.Name == "Ignore");
}
[Theory]
[InlineData("Value")]
[InlineData("this.Value")]
core/tests/CallGraphBuilderTests/FindCallersShould.cs
+101
-0
diff --git a/core/tests/CallGraphBuilderTests/FindCallersShould.cs b/core/tests/CallGraphBuilderTests/FindCallersShould.cs
index e500184..d3b5944 100644
@@ -8,6 +8,107 @@ namespace Cfa.Tests.CallGraphBuilderTests;
public sealed class FindCallersShould
{
[Theory]
[InlineData("Updated message")]
[InlineData("Sample.Domain.Message<int> message")]
public async Task UseTheConcreteArgumentTypeWhenLookingUpPublishers(string parameter)
{
// Arrange
var source =
MessageSource.Contracts
+ $$"""
class Example : Sample.Grains.EventHandlers.EventReceiver<Sample.Domain.Message<int>>
{
public Example() { On<Updated>(Start); }
void Start(Updated evt) { }
}
class Sender
{
void Publish(Sample.GrainInterfaces.Events.IEventSender sender, {{parameter}})
{
sender.Publish(message);
}
void Unrelated(Sample.GrainInterfaces.Events.IEventSender sender) { sender.Publish(new Other()); }
}
""";
// Act
var (start, callers) = await source.FindCallers();
// Assert
if (parameter == "Updated message")
{
var publisher = callers[start].ShouldHaveSingleItem();
publisher.Target.Name.ShouldBe("Publish");
publisher.LinkKind.ShouldBe(LinkKind.Event);
}
else
{
callers[start].ShouldBeEmpty();
}
}
[Theory]
[InlineData("On<Updated>(Start);")]
[InlineData("On<Updated>(evt => Start(evt));")]
public async Task FindEventPublishersFromTheReceivingMethod(string registration)
{
// Arrange
var source =
MessageSource.Contracts
+ $$"""
class Example : Sample.Grains.EventHandlers.EventReceiver<Sample.Domain.Message<int>>
{
public Example() { {{registration}} }
void Start(Updated evt) { }
}
class Sender
{
void Publish(Sample.GrainInterfaces.Events.IEventSender publisher) { publisher.Publish(new Updated()); }
}
""";
// Act
var (start, callers) = await source.FindCallers();
// Assert
var caller = callers[start].ShouldHaveSingleItem();
if (registration.Contains("=>"))
{
caller.LinkKind.ShouldBe(LinkKind.Direct);
caller = callers[caller.Target].ShouldHaveSingleItem();
}
caller.Target.Name.ShouldBe("Publish");
caller.LinkKind.ShouldBe(LinkKind.Event);
}
[Fact]
public async Task FindCommandPublishersFromTheReceivingMethod()
{
// Arrange
var source =
MessageSource.Contracts
+ """
class Example : Sample.Grains.CommandReceiver<object, int, object>
{
public Example() { When<Change>(Start); }
void Start(Change command, object aggregate) { }
}
class Sender
{
void Send(Sample.GrainInterfaces.Commands.ICommandSender bus) { bus.Dispatch(new Change()); }
}
""";
// Act
var (start, callers) = await source.FindCallers();
// Assert
var caller = callers[start].ShouldHaveSingleItem();
caller.Target.Name.ShouldBe("Send");
caller.LinkKind.ShouldBe(LinkKind.Event);
}
[Theory]
[InlineData("_ = Start;", "int Start { get => 1; set { } }")]
[InlineData("Start = 2;", "int Start { get => 1; set { } }")]
public async Task FindPropertyAccesses(string access, string declaration)
core/tests/CallGraphBuilderTests/MessageSource.cs
+46
-0
diff --git a/core/tests/CallGraphBuilderTests/MessageSource.cs b/core/tests/CallGraphBuilderTests/MessageSource.cs
new file mode 100644
index 0000000..ca141e8
@@ -0,0 +1,46 @@
using System;
using System.IO;
namespace Cfa.Tests.CallGraphBuilderTests;
internal static class MessageSource
{
public static MessageOptions Options =>
MessageOptions.LoadJson(Path.Combine(AppContext.BaseDirectory, ".cfa.json"));
public const string Contracts = """
namespace Sample.Domain
{
public abstract class Message<TId> { }
}
namespace Sample.GrainInterfaces.Events
{
public interface IEventSender
{
void Publish<TId>(Sample.Domain.Message<TId> message);
}
}
namespace Sample.GrainInterfaces.Commands
{
public interface ICommandSender { void Dispatch<T>(T command); }
}
namespace Sample.Grains.EventHandlers
{
public abstract class EventReceiver<T>
{
protected void On<TEvent>(System.Action<TEvent> handler) where TEvent : T { }
protected void On<TEvent>(System.Func<TEvent, System.Threading.Tasks.Task> handler) where TEvent : T { }
}
}
namespace Sample.Grains
{
public abstract class CommandReceiver<TAggregate, TId, TState>
{
protected void When<TCommand>(System.Action<TCommand, TAggregate> handler) { }
}
}
public class Updated : Sample.Domain.Message<int> { }
public class Other : Sample.Domain.Message<int> { }
public class Change { }
""";
}
core/tests/CallGraphBuilderTests/SourceToCallGraphHelper.cs
+16
-5
diff --git a/core/tests/CallGraphBuilderTests/SourceToCallGraphHelper.cs b/core/tests/CallGraphBuilderTests/SourceToCallGraphHelper.cs
index 20fc3f0..d669185 100644
@@ -12,11 +12,12 @@ namespace Cfa.Tests.CallGraphBuilderTests;
internal static class SourceToCallGraphHelper
{
public static async Task<(ISymbol, IImmutableDictionary<ISymbol, IImmutableList<Call>>)> FindCallees(
this string source
this string source,
MessageOptions? messages = null
)
{
var (start, callGraphBuilder) = await source.GetStartSymbolAndCallGraphBuilder();
callGraphBuilder.FindCallees(start, CancellationToken.None);
var (start, callGraphBuilder) = await source.GetStartSymbolAndCallGraphBuilder(messages);
await callGraphBuilder.FindCallees(start, CancellationToken.None);
return (start, callGraphBuilder.ReachableFromSymbols.ToImmutable());
}
@@ -29,7 +30,10 @@ internal static class SourceToCallGraphHelper
return (start, callGraphBuilder.ReachesToSymbols.ToImmutable());
}
private static async Task<(ISymbol, CallGraphBuilder)> GetStartSymbolAndCallGraphBuilder(this string source)
private static async Task<(ISymbol, CallGraphBuilder)> GetStartSymbolAndCallGraphBuilder(
this string source,
MessageOptions? messages = null
)
{
var workspace = new AdhocWorkspace();
var projectInfo = ProjectInfo.Create(
@@ -57,7 +61,14 @@ internal static class SourceToCallGraphHelper
.ShouldNotBeNull()
.GetMembers("Start")
.ShouldHaveSingleItem();
var callGraphBuilder = new CallGraphBuilder(options: new(), workspace.CurrentSolution, [compilation]);
var callGraphBuilder = new CallGraphBuilder(
new ScannerOptions
{
Messages = messages ?? (source.Contains(MessageSource.Contracts) ? MessageSource.Options : new()),
},
workspace.CurrentSolution,
[compilation]
);
return (start, callGraphBuilder);
}
}
core/tests/Cfa.Tests.csproj
+3
-0
diff --git a/core/tests/Cfa.Tests.csproj b/core/tests/Cfa.Tests.csproj
index 7aa48a4..c99358f 100644
@@ -7,6 +7,9 @@
<IsTrimmable>false</IsTrimmable>
</PropertyGroup>
<ItemGroup>
<None Update=".cfa.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../src/Cfa.csproj" />
</ItemGroup>
<ItemGroup>
core/tests/MessageOptionsTests/LoadJsonShould.cs
+78
-0
diff --git a/core/tests/MessageOptionsTests/LoadJsonShould.cs b/core/tests/MessageOptionsTests/LoadJsonShould.cs
new file mode 100644
index 0000000..92d7228
@@ -0,0 +1,78 @@
using System;
using System.IO;
using Microsoft.Extensions.Options;
using Shouldly;
using Xunit;
namespace Cfa.Tests.MessageOptionsTests;
public sealed class LoadJsonShould
{
[Theory]
[InlineData("""{"messageRoutes":[{}]}""")]
[InlineData("""{"messageRoutes":[{"publisher":{"method":{"type":"Bus","name":"Send"},"messageArgument":-1}}]}""")]
public void RejectIncompleteRoutes(string json)
{
// Arrange
var path = Path.GetTempFileName();
try
{
File.WriteAllText(path, json);
// Act
var load = () => MessageOptions.LoadJson(path);
// Assert
load.ShouldThrow<OptionsValidationException>();
}
finally
{
File.Delete(path);
}
}
[Fact]
public void ReportInvalidNestedSettings()
{
// Arrange
var path = Path.GetTempFileName();
try
{
File.WriteAllText(
path,
"""
{
"messageRoutes": [{
"publisher": {
"method": { "type": " ", "name": "", "genericArity": -1, "parameterCount": -1 },
"messageArgument": -1
},
"registrations": [{
"method": { "type": "Receiver", "name": "On" },
"messageTypeArgument": -1,
"handlerArgument": -1
}]
}]
}
"""
);
// Act
var load = () => MessageOptions.LoadJson(path);
// Assert
var error = load.ShouldThrow<OptionsValidationException>();
error.Message.ShouldContain("Type");
error.Message.ShouldContain("Name");
error.Message.ShouldContain("GenericArity");
error.Message.ShouldContain("ParameterCount");
error.Message.ShouldContain("MessageArgument");
error.Message.ShouldContain("MessageTypeArgument");
error.Message.ShouldContain("HandlerArgument");
}
finally
{
File.Delete(path);
}
}
}
core/tests/packages.lock.json
+57
-1
diff --git a/core/tests/packages.lock.json b/core/tests/packages.lock.json
index 7f8f465..4992668 100644
@@ -107,11 +107,46 @@
"resolved": "17.14.1",
"contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg=="
},
"Microsoft.Extensions.Configuration": {
"type": "Transitive",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "eKYZzPkX3fQy+NBmU6Jn6i4K43cF7VR0EvEbe2xd+92LSjuW9KeLOG4IK8c1z6eZ0QhAp7t1m94EYrbFtCn6pQ=="
},
"Microsoft.Extensions.Configuration.Binder": {
"type": "Transitive",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "/vkvZf2C7HOlKcxV7LzTnJ0S7kShfZbt+9wDvJpuqEV83ijZOSUf/npwEDFsjpw97EpX/0p0wxpjTUhgJJpk7w==",
"dependencies": {
"Microsoft.Extensions.Configuration": "11.0.0-rc.3.26522.148"
}
},
"Microsoft.Extensions.Configuration.FileExtensions": {
"type": "Transitive",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "6YVPdNAEGVHh3LIapJ0WRtTmZjrDOaEcWpcX/Tr9Wd3p8rHJfqJ1uTKEwKpYf7NAgN24Ovh7RlX07ygdfz9TEw==",
"dependencies": {
"Microsoft.Extensions.Configuration": "11.0.0-rc.3.26522.148",
"Microsoft.Extensions.FileProviders.Physical": "11.0.0-rc.3.26522.148"
}
},
"Microsoft.Extensions.DependencyInjection": {
"type": "Transitive",
"resolved": "10.0.1",
"contentHash": "zerXV0GAR9LCSXoSIApbWn+Dq1/T+6vbXMHGduq1LoVQRHT0BXsGQEau0jeLUBUcsoF/NaUT8ADPu8b+eNcIyg=="
},
"Microsoft.Extensions.FileProviders.Physical": {
"type": "Transitive",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "J0HtYwiJ7NFE3WMxKiJnixzYWPZmDGpbq+r6lWzzAF8MxznwW8DgCla6f1Sa76NkRuTFLhelOSA5Y4GIdR0xFQ==",
"dependencies": {
"Microsoft.Extensions.FileSystemGlobbing": "11.0.0-rc.3.26522.148"
}
},
"Microsoft.Extensions.FileSystemGlobbing": {
"type": "Transitive",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "orWLCLpdv+XAyUA7zrrhAUZEHjoUkg4DeyxJIEs9xbg/FnuYhbRpL6msSdH+5WEvrw0TKjP4U0AYIlZx2WqXig=="
},
"Microsoft.Extensions.Logging": {
"type": "Transitive",
"resolved": "10.0.1",
@@ -250,7 +285,9 @@
"dependencies": {
"Glob": "[1.1.9, )",
"Microsoft.CodeAnalysis.CSharp.Workspaces": "[5.9.0, )",
"Microsoft.CodeAnalysis.Workspaces.MSBuild": "[5.9.0, )"
"Microsoft.CodeAnalysis.Workspaces.MSBuild": "[5.9.0, )",
"Microsoft.Extensions.Configuration.Json": "[11.0.0-rc.3.26522.148, )",
"Microsoft.Extensions.Options.ConfigurationExtensions": "[11.0.0-rc.3.26522.148, )"
}
},
"Glob": {
@@ -288,6 +325,25 @@
"Microsoft.VisualStudio.SolutionPersistence": "1.0.52",
"System.Composition": "10.0.1"
}
},
"Microsoft.Extensions.Configuration.Json": {
"type": "CentralTransitive",
"requested": "[11.0.0-rc.3.26522.148, )",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "HwU/0bRyQm050kBidTu3wG9PiMh0yjehZ8gtDjYLDkJ9voC88sRTaerGRS945flSIcwfg4UuU2QEZKORBzC2Iw==",
"dependencies": {
"Microsoft.Extensions.Configuration": "11.0.0-rc.3.26522.148",
"Microsoft.Extensions.Configuration.FileExtensions": "11.0.0-rc.3.26522.148"
}
},
"Microsoft.Extensions.Options.ConfigurationExtensions": {
"type": "CentralTransitive",
"requested": "[11.0.0-rc.3.26522.148, )",
"resolved": "11.0.0-rc.3.26522.148",
"contentHash": "lkISyAw1jD1Ga97B9VpXTpvXm1YLGfApCRx7LTLzfaBk5YbmiB51U58Uumv+ov66ivhUHl0WdQe6MGFdAhIXjw==",
"dependencies": {
"Microsoft.Extensions.Configuration.Binder": "11.0.0-rc.3.26522.148"
}
}
}
}