Commit: 0ff7bb5
Parent: e818ad1

Will not walk entire class from primary constructors

Mårten Åsberg committed on 2026-10-28 at 19:39
Assisted-by: GitHub Copilot:gpt-6.1-sol
core/src/CallGraphBuilder.cs +10 -6
diff --git a/core/src/CallGraphBuilder.cs b/core/src/CallGraphBuilder.cs
index ef9722c..a40f8b3 100644
@@ -89,12 +89,15 @@ internal sealed class CallGraphBuilder
{
var syntax = reference.GetSyntax(cancellationToken);
var semanticModelForSyntax = compilations.GetSemanticModel(syntax.SyntaxTree);
var accesses = syntax
.DescendantNodesAndSelf(node =>
node == syntax
|| node is not AnonymousFunctionExpressionSyntax
|| semanticModelForSyntax.GetSymbolInfo(node, cancellationToken).Symbol is not { } handler
|| !messageLinks.IsHandler(handler)
var accesses = MemberSyntax
.ExecutionRoots(symbol, syntax)
.SelectMany(root =>
root.DescendantNodesAndSelf(node =>
node == root
|| node is not AnonymousFunctionExpressionSyntax
|| semanticModelForSyntax.GetSymbolInfo(node, cancellationToken).Symbol is not { } handler
|| !messageLinks.IsHandler(handler)
)
)
.Where(node =>
node
@@ -103,6 +106,7 @@ internal sealed class CallGraphBuilder
or ElementBindingExpressionSyntax
or BaseObjectCreationExpressionSyntax
or ConstructorInitializerSyntax
or PrimaryConstructorBaseTypeSyntax
);
foreach (var access in accesses)
{
core/src/MemberSyntax.cs +51 -0
diff --git a/core/src/MemberSyntax.cs b/core/src/MemberSyntax.cs
new file mode 100644
index 0000000..78a525d
@@ -0,0 +1,51 @@
using System.Collections.Generic;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace Cfa;
internal static class MemberSyntax
{
public static IEnumerable<SyntaxNode> ExecutionRoots(ISymbol member, SyntaxNode declaration)
{
if (
member is not IMethodSymbol { MethodKind: MethodKind.Constructor }
|| declaration is not TypeDeclarationSyntax type
)
{
yield return declaration;
yield break;
}
if (type.BaseList is { } bases)
{
foreach (var initializer in bases.Types.OfType<PrimaryConstructorBaseTypeSyntax>())
{
yield return initializer;
}
}
foreach (var child in type.Members)
{
switch (child)
{
case BaseFieldDeclarationSyntax field
when !field.Modifiers.Any(SyntaxKind.StaticKeyword)
&& !field.Modifiers.Any(SyntaxKind.ConstKeyword):
foreach (var variable in field.Declaration.Variables)
{
if (variable.Initializer is { } initializer)
{
yield return initializer;
}
}
break;
case PropertyDeclarationSyntax { Initializer: { } initializer } property
when !property.Modifiers.Any(SyntaxKind.StaticKeyword):
yield return initializer;
break;
}
}
}
}
core/src/MessageLinks.cs +11 -7
diff --git a/core/src/MessageLinks.cs b/core/src/MessageLinks.cs
index c1676b6..85f22bb 100644
@@ -84,12 +84,15 @@ internal sealed class MessageLinks
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)
var invocation in MemberSyntax
.ExecutionRoots(member, syntax)
.SelectMany(root =>
root.DescendantNodesAndSelf(node =>
node == root
|| node is not AnonymousFunctionExpressionSyntax
|| model.GetSymbolInfo(node, token).Symbol is not { } handler
|| !IsHandler(handler)
)
)
.OfType<InvocationExpressionSyntax>()
.Where(invocation =>
@@ -99,7 +102,8 @@ internal sealed class MessageLinks
{
token.ThrowIfCancellationRequested();
if (
!SymbolEqualityComparer.Default.Equals(ContainingMember(model, invocation, token), member)
!(member is IMethodSymbol { MethodKind: MethodKind.Constructor } && syntax is TypeDeclarationSyntax)
&& !SymbolEqualityComparer.Default.Equals(ContainingMember(model, invocation, token), member)
|| model.GetOperation(invocation, token) is not IInvocationOperation operation
)
{
core/tests/CallGraphBuilderTests/FindCalleesShould.cs +98 -0
diff --git a/core/tests/CallGraphBuilderTests/FindCalleesShould.cs b/core/tests/CallGraphBuilderTests/FindCalleesShould.cs
index c2a59b1..133c09b 100644
@@ -10,6 +10,104 @@ namespace Cfa.Tests.CallGraphBuilderTests;
public sealed class FindCalleesShould
{
[Fact]
public async Task NotTreatPrimaryConstructorMemberBodiesAsConstructorCalls()
{
// Arrange
const string source = """
abstract class Primitive<T>(T value)
{
public T Value { get; } = value;
public override string ToString() => Value!.ToString()!;
public int Computed => Other();
static int Other() => 0;
public class Nested { public void Run() { Other(); } }
}
class Number(int value) : Primitive<int>(value) { }
class Example { void Start() { _ = new Number(1); } }
""";
// Act
var (start, calls) = await source.FindCallees();
// Assert
var number = calls[start].ShouldHaveSingleItem().Target;
var primitive = calls[number].ShouldHaveSingleItem().Target;
primitive.ContainingType.Name.ShouldBe("Primitive");
calls[primitive].ShouldBeEmpty();
}
[Fact]
public async Task FollowOnlyPrimaryConstructorBaseArgumentsAndInstanceInitializers()
{
// Arrange
const string source = """
class Parent { public Parent(int value) { } }
class Service(int value) : Parent(BaseValue(value))
{
public int Field = FieldValue(value);
public int Property { get; } = PropertyValue(value);
public static int Shared = StaticValue();
public int Computed => Ignored();
static int BaseValue(int value) => value;
static int FieldValue(int value) => value;
static int PropertyValue(int value) => value;
static int StaticValue() => 0;
static int Ignored() => 0;
}
class Example { void Start() { _ = new Service(1); } }
""";
// Act
var (start, calls) = await source.FindCallees();
// Assert
var constructor = calls[start].ShouldHaveSingleItem().Target;
calls[constructor]
.Select(call => call.Target.Name)
.ShouldBe([".ctor", "BaseValue", "FieldValue", "PropertyValue"], ignoreOrder: true);
}
[Fact]
public async Task AttributePrimaryConstructorMessagesOnlyToItsInitialization()
{
// 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 Service(int value)
{
public int Initialized { get; } = Send(new Updated()) + value;
public static int Shared = Send(new Other());
public int Computed => Send(new Other());
public void Unrelated() { Send(new Other()); }
static int Send(Sample.Domain.Message<int> message) => 0;
}
class Example { void Start() { _ = new Service(1); } }
""";
var options = MessageSource.Options;
options.MessageRoutes[0].Publisher.Method = new()
{
Type = "Service",
Name = "Send",
ParameterCount = 1,
};
// Act
var (start, calls) = await source.FindCallees(options);
// Assert
var constructor = calls[start].ShouldHaveSingleItem().Target;
var handler = calls[constructor].Where(call => call.LinkKind == LinkKind.Event).ShouldHaveSingleItem();
handler.Target.Name.ShouldBe("Handle");
}
[Fact]
public async Task FollowMessagesPublishedByARegisteredLambdaOnlyWhenThatLambdaIsVisited()
{
// Arrange