Fix Runnable lambda and rich-event predicate linking gaps.

Trace execute→Runnable.run arguments via source AST and JdtDataFlowModel void side-effects, and narrow polymorphic events using domain isCorrect() predicates on rich event types.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-15 07:28:53 +02:00
parent dbc22bf1af
commit 9a54616e5f
8 changed files with 977 additions and 6 deletions

View File

@@ -247,7 +247,7 @@ public final class MachineEnumCanonicalizer {
return polymorphicEvents; return polymorphicEvents;
} }
return narrowPolymorphicCandidates( return narrowPolymorphicCandidates(
polymorphicEvents, constraint, eventTypeFqn, machineTransitions, context); polymorphicEvents, constraint, eventTypeFqn, machineTransitions, context, null);
} }
/** /**
@@ -303,7 +303,8 @@ public final class MachineEnumCanonicalizer {
expanded.getConstraint(), expanded.getConstraint(),
machineTypes.eventTypeFqn(), machineTypes.eventTypeFqn(),
machineTransitions, machineTransitions,
context); context,
expanded);
if (!narrowed.equals(expanded.getPolymorphicEvents())) { if (!narrowed.equals(expanded.getPolymorphicEvents())) {
return expanded.toBuilder() return expanded.toBuilder()
.polymorphicEvents(narrowed) .polymorphicEvents(narrowed)
@@ -317,7 +318,8 @@ public final class MachineEnumCanonicalizer {
expanded.getConstraint(), expanded.getConstraint(),
machineTypes.eventTypeFqn(), machineTypes.eventTypeFqn(),
machineTransitions, machineTransitions,
context); context,
expanded);
if (!machineEvents.isEmpty()) { if (!machineEvents.isEmpty()) {
return expanded.toBuilder() return expanded.toBuilder()
.polymorphicEvents(machineEvents) .polymorphicEvents(machineEvents)
@@ -414,7 +416,8 @@ public final class MachineEnumCanonicalizer {
String constraint, String constraint,
String eventTypeFqn, String eventTypeFqn,
List<Transition> machineTransitions, List<Transition> machineTransitions,
CodebaseContext context) { CodebaseContext context,
TriggerPoint trigger) {
List<String> transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn, context); List<String> transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn, context);
if (transitionEvents.isEmpty()) { if (transitionEvents.isEmpty()) {
return List.of(); return List.of();
@@ -425,6 +428,13 @@ public final class MachineEnumCanonicalizer {
if (!filtered.isEmpty()) { if (!filtered.isEmpty()) {
return filtered; return filtered;
} }
if (trigger != null) {
List<String> richFiltered = RichEventPredicateEvaluator.filterEnumConstants(
transitionEvents, constraint, eventTypeFqn, trigger, context);
if (!richFiltered.isEmpty()) {
return richFiltered;
}
}
return List.of(); return List.of();
} }
return transitionEvents.size() == 1 ? transitionEvents : List.of(); return transitionEvents.size() == 1 ? transitionEvents : List.of();
@@ -436,6 +446,17 @@ public final class MachineEnumCanonicalizer {
String eventTypeFqn, String eventTypeFqn,
List<Transition> machineTransitions, List<Transition> machineTransitions,
CodebaseContext context) { CodebaseContext context) {
return narrowPolymorphicCandidates(
current, constraint, eventTypeFqn, machineTransitions, context, null);
}
private static List<String> narrowPolymorphicCandidates(
List<String> current,
String constraint,
String eventTypeFqn,
List<Transition> machineTransitions,
CodebaseContext context,
TriggerPoint trigger) {
List<String> transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn, context); List<String> transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn, context);
List<String> result = current == null ? List.of() : new ArrayList<>(current); List<String> result = current == null ? List.of() : new ArrayList<>(current);
@@ -444,6 +465,23 @@ public final class MachineEnumCanonicalizer {
result, constraint, eventTypeFqn, context); result, constraint, eventTypeFqn, context);
if (!filtered.isEmpty()) { if (!filtered.isEmpty()) {
result = filtered; result = filtered;
} else if (trigger != null) {
List<String> source = !transitionEvents.isEmpty() ? transitionEvents : result;
List<String> richFiltered = RichEventPredicateEvaluator.filterEnumConstants(
source, constraint, eventTypeFqn, trigger, context);
if (!richFiltered.isEmpty()) {
result = richFiltered;
} else if (!transitionEvents.isEmpty()) {
filtered = EnumMemberPredicateEvaluator.filterEnumConstants(
transitionEvents, constraint, eventTypeFqn, context);
if (!filtered.isEmpty()) {
result = filtered;
} else {
result = List.of();
}
} else {
result = List.of();
}
} else if (!transitionEvents.isEmpty()) { } else if (!transitionEvents.isEmpty()) {
filtered = EnumMemberPredicateEvaluator.filterEnumConstants( filtered = EnumMemberPredicateEvaluator.filterEnumConstants(
transitionEvents, constraint, eventTypeFqn, context); transitionEvents, constraint, eventTypeFqn, context);

View File

@@ -0,0 +1,212 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.service.TypeResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.InfixExpression;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.QualifiedName;
import org.eclipse.jdt.core.dom.ReturnStatement;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
/**
* Evaluates boolean predicates on rich/domain event types (e.g. {@code myEvent.isCorrect()})
* and maps matching implementations to machine enum constants via {@code getType()}.
*/
public final class RichEventPredicateEvaluator {
private RichEventPredicateEvaluator() {
}
public static List<String> filterEnumConstants(
List<String> candidates,
String constraint,
String machineEnumFqn,
TriggerPoint trigger,
CodebaseContext context) {
if (candidates == null || candidates.isEmpty() || constraint == null || context == null || trigger == null) {
return List.of();
}
List<EnumMemberPredicateEvaluator.PredicateCall> predicates =
EnumMemberPredicateEvaluator.extractPredicateCalls(constraint);
if (predicates.isEmpty()) {
return List.of();
}
EnumMemberPredicateEvaluator.PredicateCall predicate = predicates.get(0);
String receiverTypeFqn = resolveReceiverTypeFqn(trigger, predicate.receiverName(), context);
if (receiverTypeFqn == null) {
return List.of();
}
Set<String> ownerTypes = new LinkedHashSet<>();
ownerTypes.add(receiverTypeFqn);
ownerTypes.addAll(context.getImplementations(receiverTypeFqn));
Set<String> matched = new LinkedHashSet<>();
for (String ownerFqn : ownerTypes) {
if (!predicateMatches(ownerFqn, predicate, context)) {
continue;
}
String typeConstant = resolveDirectEnumFromPredicateMethod(ownerFqn, "getType", context);
if (typeConstant != null && matchesCandidate(typeConstant, candidates, machineEnumFqn, context)) {
matched.add(canonicalize(typeConstant, machineEnumFqn, context));
}
String directConstant = resolveDirectEnumFromPredicateMethod(ownerFqn, predicate.methodName(), context);
if (directConstant != null && matchesCandidate(directConstant, candidates, machineEnumFqn, context)) {
matched.add(canonicalize(directConstant, machineEnumFqn, context));
}
}
List<String> filtered = new ArrayList<>();
for (String candidate : candidates) {
String canonical = canonicalize(candidate, machineEnumFqn, context);
if (matched.contains(canonical)) {
filtered.add(candidate);
}
}
return filtered;
}
private static String resolveReceiverTypeFqn(TriggerPoint trigger, String receiverName, CodebaseContext context) {
if (trigger.getClassName() == null || trigger.getMethodName() == null || receiverName == null) {
return null;
}
TypeDeclaration owner = context.getTypeDeclaration(trigger.getClassName());
if (owner == null) {
return null;
}
MethodDeclaration method = context.findMethodDeclaration(owner, trigger.getMethodName(), true);
if (method == null) {
return null;
}
TypeResolver typeResolver = new TypeResolver(context);
for (Object parameter : method.parameters()) {
if (parameter instanceof org.eclipse.jdt.core.dom.SingleVariableDeclaration variable
&& receiverName.equals(variable.getName().getIdentifier())) {
return typeResolver.resolveTypeToFqn(variable.getType(), owner.getRoot());
}
}
return null;
}
private static boolean predicateMatches(
String ownerFqn,
EnumMemberPredicateEvaluator.PredicateCall predicate,
CodebaseContext context) {
TypeDeclaration type = context.getTypeDeclaration(ownerFqn);
if (type == null) {
return false;
}
MethodDeclaration method = context.findMethodDeclaration(type, predicate.methodName(), true);
if (method == null || method.getBody() == null) {
return false;
}
Boolean[] result = new Boolean[1];
method.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
if (result[0] == null && node.getExpression() != null) {
result[0] = evaluateBoolean(node.getExpression());
}
return super.visit(node);
}
});
if (result[0] == null) {
return false;
}
return predicate.negated() ? !result[0] : result[0];
}
private static Boolean evaluateBoolean(Expression expression) {
if (expression instanceof org.eclipse.jdt.core.dom.BooleanLiteral literal) {
return literal.booleanValue();
}
if (expression instanceof MethodInvocation invocation
&& "isEvent".equals(invocation.getName().getIdentifier())
&& invocation.getExpression() instanceof MethodInvocation getType
&& "getType".equals(getType.getName().getIdentifier())) {
return true;
}
if (expression instanceof InfixExpression infix && infix.getOperator() == InfixExpression.Operator.EQUALS) {
return true;
}
return null;
}
private static String resolveDirectEnumFromPredicateMethod(
String ownerFqn,
String predicateMethod,
CodebaseContext context) {
TypeDeclaration type = context.getTypeDeclaration(ownerFqn);
if (type == null) {
return null;
}
MethodDeclaration method = context.findMethodDeclaration(type, predicateMethod, true);
if (method == null || method.getBody() == null) {
return null;
}
final String[] constant = new String[1];
method.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
if (constant[0] != null || node.getExpression() == null) {
return super.visit(node);
}
constant[0] = extractEnumConstant(node.getExpression());
return super.visit(node);
}
});
return constant[0];
}
private static String extractEnumConstant(Expression expression) {
if (expression instanceof QualifiedName qualifiedName) {
return qualifiedName.getFullyQualifiedName();
}
if (expression instanceof InfixExpression infix && infix.getOperator() == InfixExpression.Operator.EQUALS) {
String left = extractEnumConstant(infix.getLeftOperand());
if (left != null) {
return left;
}
return extractEnumConstant(infix.getRightOperand());
}
if (expression instanceof MethodInvocation invocation
&& invocation.getExpression() instanceof MethodInvocation getType
&& "getType".equals(getType.getName().getIdentifier())) {
if (invocation.getExpression() instanceof MethodInvocation inner
&& inner.getExpression() instanceof ClassInstanceCreation creation
&& !creation.arguments().isEmpty()) {
return extractEnumConstant((Expression) creation.arguments().get(0));
}
}
return null;
}
private static boolean matchesCandidate(
String constant,
List<String> candidates,
String machineEnumFqn,
CodebaseContext context) {
if (constant == null) {
return false;
}
String canonical = canonicalize(constant, machineEnumFqn, context);
for (String candidate : candidates) {
if (canonical.equals(canonicalize(candidate, machineEnumFqn, context))) {
return true;
}
}
return false;
}
private static String canonicalize(String constant, String machineEnumFqn, CodebaseContext context) {
return MachineEnumCanonicalizer.canonicalizeLabel(constant, machineEnumFqn, context);
}
}

View File

@@ -3116,6 +3116,20 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return; return;
} }
} }
if (expr instanceof MethodInvocation mi
&& "run".equals(mi.getName().getIdentifier())
&& mi.arguments().isEmpty()) {
List<String> callerFrameConstants = CallSiteArgumentResolver.resolveFunctionalRunFromCallerFrame(
mi, path, callGraph, scopeMethod, typeResolver, pathFinder, constantExtractor, callSiteHooks());
if (!callerFrameConstants.isEmpty()) {
for (String constant : callerFrameConstants) {
if (!constants.contains(constant)) {
constants.add(constant);
}
}
return;
}
}
List<Expression> traced = variableTracer.traceVariableAll(expr); List<Expression> traced = variableTracer.traceVariableAll(expr);
for (Expression tracedExpr : traced) { for (Expression tracedExpr : traced) {
addConstantsFromSingleExpression(tracedExpr, constants, path, callGraph, scopeMethod); addConstantsFromSingleExpression(tracedExpr, constants, path, callGraph, scopeMethod);
@@ -3155,6 +3169,11 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
} }
Map<String, String> bindings = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder); Map<String, String> bindings = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder);
if (bindings.isEmpty()) { if (bindings.isEmpty()) {
List<String> runnableResolved = CallSiteArgumentResolver.resolveRunnableLambdaFromPath(
path, callGraph, typeResolver, context, pathFinder, constantExtractor, callSiteHooks());
if (!runnableResolved.isEmpty()) {
return runnableResolved;
}
if (hasMultiClassPolymorphicPath(path) if (hasMultiClassPolymorphicPath(path)
&& polymorphicEvents.stream().noneMatch(this::looksLikeEnumConstant)) { && polymorphicEvents.stream().noneMatch(this::looksLikeEnumConstant)) {
List<String> callerArgumentResolved = resolvePolymorphicEventsFromCallerArgument(path); List<String> callerArgumentResolved = resolvePolymorphicEventsFromCallerArgument(path);
@@ -3593,4 +3612,10 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
results.add(parsed); results.add(parsed);
} }
} }
List<String> resolveRunnableLambdaConstantsForPath(
List<String> path, Map<String, List<CallEdge>> callGraph) {
return CallSiteArgumentResolver.resolveRunnableLambdaFromPath(
path, callGraph, typeResolver, context, pathFinder, constantExtractor, callSiteHooks());
}
} }

View File

@@ -1,10 +1,19 @@
package click.kamil.springstatemachineexporter.analysis.service; package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge; import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel;
import org.eclipse.jdt.core.dom.ASTNode; import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.Expression; import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.ExpressionStatement;
import org.eclipse.jdt.core.dom.IMethodBinding;
import org.eclipse.jdt.core.dom.LambdaExpression;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.MethodInvocation; import org.eclipse.jdt.core.dom.MethodInvocation;
import org.eclipse.jdt.core.dom.SimpleName; import org.eclipse.jdt.core.dom.SimpleName;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.Block;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -62,6 +71,103 @@ public final class CallSiteArgumentResolver {
return extractConstantsFromCallSiteArgument(argValue, constantExtractor, hooks); return extractConstantsFromCallSiteArgument(argValue, constantExtractor, hooks);
} }
/**
* When {@code task.run()} is seen inside {@code scopeMethod}, walk to the caller frame and extract
* enum/constants from the {@code Runnable} argument bound at the {@code execute(Runnable)} call site.
*/
public static List<String> resolveFunctionalRunFromCallerFrame(
MethodInvocation runCall,
List<String> path,
Map<String, List<CallEdge>> callGraph,
String scopeMethod,
TypeResolver typeResolver,
CallGraphPathFinder pathFinder,
ConstantExtractor constantExtractor,
ExpressionHooks hooks) {
if (path == null || path.size() < 2 || scopeMethod == null || runCall.getExpression() == null) {
return List.of();
}
if (!(runCall.getExpression() instanceof SimpleName runnableParam)) {
return List.of();
}
if (!"run".equals(runCall.getName().getIdentifier()) || !runCall.arguments().isEmpty()) {
return List.of();
}
int scopeIndex = indexOfMethod(path, scopeMethod);
if (scopeIndex <= 0) {
return List.of();
}
String callee = path.get(scopeIndex);
String caller = path.get(scopeIndex - 1);
int paramIndex = typeResolver.getParameterIndex(callee, runnableParam.getIdentifier());
if (paramIndex < 0) {
paramIndex = findRunnableParameterIndex(callee, typeResolver);
}
if (paramIndex < 0) {
return List.of();
}
String argValue = findCallSiteArgument(caller, callee, paramIndex, callGraph, pathFinder);
if (argValue == null) {
return List.of();
}
return extractConstantsFromCallSiteArgument(argValue, constantExtractor, hooks);
}
/**
* When the call path contains {@code java.lang.Runnable.run}, resolve the {@code Runnable} lambda
* passed from the frame two hops earlier (e.g. controller → executor.execute → Runnable.run).
*/
public static List<String> resolveRunnableLambdaFromPath(
List<String> path,
Map<String, List<CallEdge>> callGraph,
TypeResolver typeResolver,
CodebaseContext context,
CallGraphPathFinder pathFinder,
ConstantExtractor constantExtractor,
ExpressionHooks hooks) {
if (path == null || path.size() < 3) {
return List.of();
}
for (int i = 1; i < path.size(); i++) {
if (!isRunnableRunMethod(path.get(i))) {
continue;
}
String executeMethod = path.get(i - 1);
String callerMethod = path.get(i - 2);
int paramIndex = findRunnableParameterIndex(executeMethod, typeResolver);
if (paramIndex < 0) {
paramIndex = 0;
}
String argValue = findCallSiteArgument(callerMethod, executeMethod, paramIndex, callGraph, pathFinder);
List<String> constants = extractRunnableArgumentConstants(
callerMethod, executeMethod, paramIndex, argValue, context, typeResolver, constantExtractor, hooks);
if (!constants.isEmpty()) {
return constants;
}
}
return List.of();
}
private static boolean isRunnableRunMethod(String methodFqn) {
return "java.lang.Runnable.run".equals(methodFqn) || "Runnable.run".equals(methodFqn);
}
private static int findRunnableParameterIndex(String methodFqn, TypeResolver typeResolver) {
if (methodFqn == null || typeResolver == null) {
return -1;
}
for (int i = 0; i < 8; i++) {
String paramType = typeResolver.getParameterType(methodFqn, i);
if (paramType == null) {
break;
}
if (FunctionalInterfaceTypes.isFunctionalInterface(paramType)) {
return i;
}
}
return -1;
}
public static String findCallSiteArgument( public static String findCallSiteArgument(
String caller, String caller,
String callee, String callee,
@@ -110,11 +216,201 @@ public final class CallSiteArgumentResolver {
Expression resolvedExpr = hooks.parseExpression(resolved); Expression resolvedExpr = hooks.parseExpression(resolved);
List<String> constants = new ArrayList<>(); List<String> constants = new ArrayList<>();
if (resolvedExpr != null) { if (resolvedExpr != null) {
extractSideEffectConstantsFromLambda(resolvedExpr, constantExtractor, constants);
if (constants.isEmpty()) {
constantExtractor.extractConstantsFromExpression(resolvedExpr, constants); constantExtractor.extractConstantsFromExpression(resolvedExpr, constants);
} }
}
if (constants.isEmpty() && parsed instanceof LambdaExpression lambda) {
extractSideEffectConstantsFromLambda(lambda, constantExtractor, constants);
}
if (constants.isEmpty() && FunctionalInterfaceTypes.looksLikeEnumConstant(resolved)) { if (constants.isEmpty() && FunctionalInterfaceTypes.looksLikeEnumConstant(resolved)) {
constants.add(resolved); constants.add(resolved);
} }
return constants; return constants;
} }
private static void extractSideEffectConstantsFromLambda(
Expression expression,
ConstantExtractor constantExtractor,
List<String> constants) {
if (!(expression instanceof LambdaExpression lambda)) {
return;
}
ASTNode body = lambda.getBody();
if (body instanceof Expression bodyExpr) {
constantExtractor.extractConstantsFromExpression(bodyExpr, constants);
return;
}
if (!(body instanceof Block block)) {
return;
}
for (Object statement : block.statements()) {
if (statement instanceof ExpressionStatement expressionStatement) {
constantExtractor.extractConstantsFromExpression(expressionStatement.getExpression(), constants);
} else if (statement instanceof org.eclipse.jdt.core.dom.ReturnStatement returnStatement
&& returnStatement.getExpression() != null) {
constantExtractor.extractConstantsFromExpression(returnStatement.getExpression(), constants);
}
}
}
private static List<String> extractRunnableArgumentConstants(
String callerMethod,
String executeMethod,
int paramIndex,
String argValue,
CodebaseContext context,
TypeResolver typeResolver,
ConstantExtractor constantExtractor,
ExpressionHooks hooks) {
List<String> constants = new ArrayList<>();
Expression sourceArg = findCallSiteArgumentExpression(callerMethod, executeMethod, paramIndex, context);
if (sourceArg != null) {
extractConstantsViaDataflow(sourceArg, context, constantExtractor, constants);
}
if (constants.isEmpty() && argValue != null) {
Expression parsed = hooks.parseExpression(argValue);
if (parsed != null) {
extractConstantsViaDataflow(parsed, context, constantExtractor, constants);
if (constants.isEmpty() && parsed instanceof MethodInvocation sideEffect) {
extractSideEffectConstantsFromParsedInvocation(
sideEffect, callerMethod, constantExtractor, constants, context, typeResolver);
}
}
if (constants.isEmpty()) {
constants.addAll(extractConstantsFromCallSiteArgument(argValue, constantExtractor, hooks));
}
}
return constants;
}
private static void extractSideEffectConstantsFromParsedInvocation(
MethodInvocation invocation,
String callerMethodFqn,
ConstantExtractor constantExtractor,
List<String> constants,
CodebaseContext context,
TypeResolver typeResolver) {
if (context == null || typeResolver == null || callerMethodFqn == null || !callerMethodFqn.contains(".")) {
return;
}
String callerClass = callerMethodFqn.substring(0, callerMethodFqn.lastIndexOf('.'));
TypeDeclaration callerType = context.getTypeDeclaration(callerClass);
if (callerType == null || !(invocation.getExpression() instanceof SimpleName receiver)) {
return;
}
String receiverFqn = null;
for (Object field : callerType.getFields()) {
if (field instanceof org.eclipse.jdt.core.dom.FieldDeclaration fieldDeclaration) {
for (Object fragmentObj : fieldDeclaration.fragments()) {
if (fragmentObj instanceof org.eclipse.jdt.core.dom.VariableDeclarationFragment fragment
&& receiver.getIdentifier().equals(fragment.getName().getIdentifier())) {
receiverFqn = typeResolver.resolveTypeToFqn(fieldDeclaration.getType(), callerType.getRoot());
break;
}
}
}
if (receiverFqn != null) {
break;
}
}
if (receiverFqn == null) {
return;
}
MethodDeclaration method = context.findMethodDeclaration(
context.getTypeDeclaration(receiverFqn),
invocation.getName().getIdentifier(),
true);
if (method == null || method.getBody() == null) {
return;
}
for (Object statement : method.getBody().statements()) {
if (statement instanceof ExpressionStatement expressionStatement
&& expressionStatement.getExpression() instanceof MethodInvocation nested) {
for (Object arg : nested.arguments()) {
constantExtractor.extractConstantsFromExpression((Expression) arg, constants);
}
}
}
}
private static void extractConstantsViaDataflow(
Expression expression,
CodebaseContext context,
ConstantExtractor constantExtractor,
List<String> constants) {
Expression effective = unwrapLambdaBody(expression);
if (effective != expression) {
extractConstantsViaDataflow(effective, context, constantExtractor, constants);
if (!constants.isEmpty()) {
return;
}
}
JdtDataFlowModel dataFlow = new JdtDataFlowModel(context);
for (Expression def : dataFlow.getReachingDefinitions(effective)) {
constantExtractor.extractConstantsFromExpression(def, constants);
}
if (constants.isEmpty()) {
constantExtractor.extractConstantsFromExpression(effective, constants);
}
}
private static Expression unwrapLambdaBody(Expression expression) {
if (!(expression instanceof LambdaExpression lambda)) {
return expression;
}
ASTNode body = lambda.getBody();
if (body instanceof Expression bodyExpr) {
return bodyExpr;
}
return expression;
}
static Expression findCallSiteArgumentExpression(
String caller,
String callee,
int paramIndex,
CodebaseContext context) {
if (caller == null || callee == null || paramIndex < 0 || context == null
|| !caller.contains(".") || !callee.contains(".")) {
return null;
}
String callerClass = caller.substring(0, caller.lastIndexOf('.'));
String callerMethod = caller.substring(caller.lastIndexOf('.') + 1);
String calleeClass = callee.substring(0, callee.lastIndexOf('.'));
String calleeMethod = callee.substring(callee.lastIndexOf('.') + 1);
TypeDeclaration callerType = context.getTypeDeclaration(callerClass);
if (callerType == null) {
return null;
}
MethodDeclaration method = context.findMethodDeclaration(callerType, callerMethod, true);
if (method == null || method.getBody() == null) {
return null;
}
final Expression[] found = new Expression[1];
method.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if (found[0] != null || !calleeMethod.equals(node.getName().getIdentifier())) {
return super.visit(node);
}
IMethodBinding binding = node.resolveMethodBinding();
if (binding != null && binding.getDeclaringClass() != null) {
String declaringFqn = binding.getDeclaringClass().getErasure().getQualifiedName();
if (!declaringFqn.equals(calleeClass) && !declaringFqn.endsWith("." + calleeClass)) {
String calleeSimple = calleeClass.substring(calleeClass.lastIndexOf('.') + 1);
if (!declaringFqn.endsWith("." + calleeSimple)) {
return super.visit(node);
}
}
}
if (paramIndex < node.arguments().size()) {
found[0] = (Expression) node.arguments().get(paramIndex);
}
return super.visit(node);
}
});
return found[0];
}
} }

View File

@@ -17,7 +17,8 @@ public final class FunctionalInterfaceTypes {
if (simple.contains("<")) { if (simple.contains("<")) {
simple = simple.substring(0, simple.indexOf('<')); simple = simple.substring(0, simple.indexOf('<'));
} }
return "Supplier".equals(simple) || "Function".equals(simple) || "Callable".equals(simple); return "Supplier".equals(simple) || "Function".equals(simple) || "Callable".equals(simple)
|| "Runnable".equals(simple);
} }
public static boolean isLambdaArgument(String argValue) { public static boolean isLambdaArgument(String argValue) {

View File

@@ -253,6 +253,9 @@ public class JdtDataFlowModel implements DataFlowModel {
} }
} }
} }
} else {
collectSideEffectExpressions(
md.getBody(), results, visited, newParamBindings, target.fieldBindings, depth);
} }
} }
} }
@@ -1463,6 +1466,33 @@ public class JdtDataFlowModel implements DataFlowModel {
return findMethodDeclarationInType(mb.getDeclaringClass(), mb); return findMethodDeclarationInType(mb.getDeclaringClass(), mb);
} }
private void collectSideEffectExpressions(
Block body,
List<Expression> results,
Set<ASTNode> visited,
Map<IVariableBinding, Expression> paramBindings,
Map<IVariableBinding, Expression> fieldBindings,
int depth) {
if (body == null) {
return;
}
body.accept(new ASTVisitor() {
@Override
public boolean visit(ExpressionStatement node) {
Expression statementExpr = node.getExpression();
if (statementExpr instanceof MethodInvocation nested) {
for (Object argObj : nested.arguments()) {
results.addAll(getReachingDefinitions(
(Expression) argObj, visited, paramBindings, fieldBindings, depth + 1));
}
}
results.addAll(getReachingDefinitions(
statementExpr, visited, paramBindings, fieldBindings, depth + 1));
return super.visit(node);
}
});
}
private List<ReturnStatement> findReturnStatements(ASTNode node) { private List<ReturnStatement> findReturnStatements(ASTNode node) {
List<ReturnStatement> returns = new ArrayList<>(); List<ReturnStatement> returns = new ArrayList<>();
node.accept(new ASTVisitor() { node.accept(new ASTVisitor() {

View File

@@ -0,0 +1,184 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver.MachineTypes;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.State;
import click.kamil.springstatemachineexporter.model.Transition;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class RichEventIsCorrectPredicateTest {
@Test
void shouldFilterEnumConstantsByRichEventIsCorrectPredicate(@TempDir Path tempDir) throws Exception {
CodebaseContext context = scanProject(tempDir);
List<String> all = List.of(
"com.example.order.OrderCommand.PAY",
"com.example.order.OrderCommand.SHIP",
"com.example.order.OrderCommand.META");
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.service.AbstractOrderService")
.methodName("dispatch")
.build();
List<String> filtered = RichEventPredicateEvaluator.filterEnumConstants(
all, "myEvent.isCorrect()", "com.example.order.OrderCommand", trigger, context);
assertThat(filtered).containsExactly(
"com.example.order.OrderCommand.PAY",
"com.example.order.OrderCommand.SHIP");
}
@Test
void shouldInferPolymorphicEventsForRichEventConstraint(@TempDir Path tempDir) throws Exception {
CodebaseContext context = scanProject(tempDir);
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.service.AbstractOrderService")
.methodName("dispatch")
.event("myEvent.getType()")
.constraint("myEvent.isCorrect()")
.external(true)
.ambiguous(true)
.build();
MachineTypes types = new MachineTypes(null, "com.example.order.OrderCommand");
List<Transition> transitions = List.of(
transition("OrderCommand.PAY", "NEW", "PAID"),
transition("OrderCommand.SHIP", "PAID", "SHIPPED"));
TriggerPoint enriched = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
trigger, types, context, transitions, true);
assertThat(enriched.getPolymorphicEvents())
.containsExactlyInAnyOrder(
"com.example.order.OrderCommand.PAY",
"com.example.order.OrderCommand.SHIP");
assertThat(enriched.isAmbiguous()).isTrue();
}
@Test
void shouldLinkTransitionsWhenConstraintUsesRichEventPredicate(@TempDir Path tempDir) throws Exception {
CodebaseContext context = scanProject(tempDir);
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.className("com.example.service.AbstractOrderService")
.methodName("dispatch")
.event("myEvent.getType()")
.polymorphicEvents(List.of(
"com.example.order.OrderCommand.PAY",
"com.example.order.OrderCommand.SHIP",
"com.example.order.OrderCommand.META"))
.constraint("myEvent.isCorrect()")
.ambiguous(true)
.external(true)
.build())
.build();
Transition pay = transition("OrderCommand.PAY", "NEW", "PAID");
Transition ship = transition("OrderCommand.SHIP", "PAID", "SHIPPED");
AnalysisResult result = AnalysisResult.builder()
.name("com.example.config.OrderStateMachineConfiguration")
.eventTypeFqn("com.example.order.OrderCommand")
.transitions(List.of(pay, ship))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
new TransitionLinkerEnricher().enrich(result, context, null);
TriggerPoint tp = result.getMetadata().getCallChains().get(0).getTriggerPoint();
assertThat(tp.getPolymorphicEvents())
.containsExactlyInAnyOrder(
"com.example.order.OrderCommand.PAY",
"com.example.order.OrderCommand.SHIP");
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(2);
}
private static CodebaseContext scanProject(Path tempDir) throws Exception {
Path javaRoot = tempDir.resolve("src/main/java/com/example");
Files.createDirectories(javaRoot.resolve("order"));
Files.createDirectories(javaRoot.resolve("domain"));
Files.createDirectories(javaRoot.resolve("service"));
Files.writeString(tempDir.resolve("build.gradle"), "plugins { id 'java' }");
Files.writeString(javaRoot.resolve("order/OrderCommand.java"), """
package com.example.order;
public enum OrderCommand { PAY, SHIP, META }
""");
Files.writeString(javaRoot.resolve("domain/DomainEvent.java"), """
package com.example.domain;
import com.example.order.OrderCommand;
public interface DomainEvent {
OrderCommand getType();
boolean isCorrect();
}
""");
Files.writeString(javaRoot.resolve("domain/PayEvent.java"), """
package com.example.domain;
import com.example.order.OrderCommand;
public class PayEvent implements DomainEvent {
@Override
public OrderCommand getType() { return OrderCommand.PAY; }
@Override
public boolean isCorrect() { return true; }
}
""");
Files.writeString(javaRoot.resolve("domain/ShipEvent.java"), """
package com.example.domain;
import com.example.order.OrderCommand;
public class ShipEvent implements DomainEvent {
@Override
public OrderCommand getType() { return OrderCommand.SHIP; }
@Override
public boolean isCorrect() { return true; }
}
""");
Files.writeString(javaRoot.resolve("domain/MetaEvent.java"), """
package com.example.domain;
import com.example.order.OrderCommand;
public class MetaEvent implements DomainEvent {
@Override
public OrderCommand getType() { return OrderCommand.META; }
@Override
public boolean isCorrect() { return false; }
}
""");
Files.writeString(javaRoot.resolve("service/AbstractOrderService.java"), """
package com.example.service;
import com.example.domain.DomainEvent;
import com.example.order.OrderCommand;
public abstract class AbstractOrderService {
protected void dispatch(DomainEvent myEvent) {
if (myEvent.isCorrect()) {
fire(myEvent.getType());
}
}
protected abstract void fire(OrderCommand event);
}
""");
CodebaseContext context = new CodebaseContext();
context.setSourcepath(List.of(tempDir.resolve("src/main/java").toString()));
context.scan(tempDir);
return context;
}
private static Transition transition(String event, String source, String target) {
Transition transition = new Transition();
transition.setEvent(Event.of(event, event));
transition.setSourceStates(List.of(State.of(source, source)));
transition.setTargetStates(List.of(State.of(target, target)));
return transition;
}
}

View File

@@ -0,0 +1,185 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class RunnableLambdaDispatchTest {
@Test
void shouldResolveEventThroughRunnableExecuteLambda() throws IOException {
String controllerSource = """
package com.example.web;
import com.example.service.AbstractOrderService;
import com.example.executor.TaskExecutor;
public class OrderController {
private final TaskExecutor executor;
private final AbstractOrderService orderService;
public OrderController(TaskExecutor executor, AbstractOrderService orderService) {
this.executor = executor;
this.orderService = orderService;
}
public void process() {
executor.execute(() -> orderService.fireEvent());
}
}
""";
String executorSource = """
package com.example.executor;
public class TaskExecutor {
public void execute(Runnable task) {
task.run();
}
}
""";
String serviceSource = """
package com.example.service;
import com.example.domain.OrderCommand;
public abstract class AbstractOrderService {
protected StateMachine machine;
public void fireEvent() {
sendEvent(OrderCommand.PAY);
}
protected void sendEvent(OrderCommand event) {
machine.fire(event);
}
}
class StateMachine {
public void fire(OrderCommand event) {}
}
""";
String enumSource = """
package com.example.domain;
public enum OrderCommand { PAY, SHIP, META }
""";
Path tempDir = Files.createTempDirectory("callgraph_runnable_lambda");
Files.writeString(tempDir.resolve("OrderController.java"), controllerSource);
Files.writeString(tempDir.resolve("TaskExecutor.java"), executorSource);
Files.writeString(tempDir.resolve("AbstractOrderService.java"), serviceSource);
Files.writeString(tempDir.resolve("OrderCommand.java"), enumSource);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.web.OrderController")
.methodName("process")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.service.AbstractOrderService")
.methodName("sendEvent")
.event("event")
.build();
List<CallChain> chains = engine.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
.containsExactly("OrderCommand.PAY");
}
@Test
void shouldResolveRunnableLambdaFromExecuteCallSite() throws IOException {
String controllerSource = """
package com.example.web;
import com.example.service.AbstractOrderService;
import com.example.executor.TaskExecutor;
public class OrderController {
private final TaskExecutor executor;
private final AbstractOrderService orderService;
public OrderController(TaskExecutor executor, AbstractOrderService orderService) {
this.executor = executor;
this.orderService = orderService;
}
public void process() {
executor.execute(() -> orderService.fireEvent());
}
}
""";
String executorSource = """
package com.example.executor;
public class TaskExecutor {
public void execute(Runnable task) {
task.run();
}
}
""";
String serviceSource = """
package com.example.service;
import com.example.domain.OrderCommand;
public abstract class AbstractOrderService {
public void fireEvent() {
sendEvent(OrderCommand.PAY);
}
protected void sendEvent(OrderCommand event) {}
}
""";
String enumSource = """
package com.example.domain;
public enum OrderCommand { PAY, SHIP, META }
""";
Path tempDir = Files.createTempDirectory("callgraph_runnable_resolver");
Files.writeString(tempDir.resolve("OrderController.java"), controllerSource);
Files.writeString(tempDir.resolve("TaskExecutor.java"), executorSource);
Files.writeString(tempDir.resolve("AbstractOrderService.java"), serviceSource);
Files.writeString(tempDir.resolve("OrderCommand.java"), enumSource);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
var graph = engine.buildCallGraph();
assertThat(graph.get("com.example.executor.TaskExecutor.execute"))
.extracting(click.kamil.springstatemachineexporter.analysis.model.CallEdge::getTargetMethod)
.anyMatch(target -> target.endsWith("Runnable.run"));
List<String> path = List.of(
"com.example.web.OrderController.process",
"com.example.executor.TaskExecutor.execute",
graph.get("com.example.executor.TaskExecutor.execute").get(0).getTargetMethod(),
"com.example.service.AbstractOrderService.fireEvent");
List<String> resolved = engine.resolveRunnableLambdaConstantsForPath(path, graph);
assertThat(resolved).containsExactly("OrderCommand.PAY");
}
}