diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java index d48ec50..ea0637d 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java @@ -247,7 +247,7 @@ public final class MachineEnumCanonicalizer { return polymorphicEvents; } return narrowPolymorphicCandidates( - polymorphicEvents, constraint, eventTypeFqn, machineTransitions, context); + polymorphicEvents, constraint, eventTypeFqn, machineTransitions, context, null); } /** @@ -303,7 +303,8 @@ public final class MachineEnumCanonicalizer { expanded.getConstraint(), machineTypes.eventTypeFqn(), machineTransitions, - context); + context, + expanded); if (!narrowed.equals(expanded.getPolymorphicEvents())) { return expanded.toBuilder() .polymorphicEvents(narrowed) @@ -317,7 +318,8 @@ public final class MachineEnumCanonicalizer { expanded.getConstraint(), machineTypes.eventTypeFqn(), machineTransitions, - context); + context, + expanded); if (!machineEvents.isEmpty()) { return expanded.toBuilder() .polymorphicEvents(machineEvents) @@ -414,7 +416,8 @@ public final class MachineEnumCanonicalizer { String constraint, String eventTypeFqn, List machineTransitions, - CodebaseContext context) { + CodebaseContext context, + TriggerPoint trigger) { List transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn, context); if (transitionEvents.isEmpty()) { return List.of(); @@ -425,6 +428,13 @@ public final class MachineEnumCanonicalizer { if (!filtered.isEmpty()) { return filtered; } + if (trigger != null) { + List richFiltered = RichEventPredicateEvaluator.filterEnumConstants( + transitionEvents, constraint, eventTypeFqn, trigger, context); + if (!richFiltered.isEmpty()) { + return richFiltered; + } + } return List.of(); } return transitionEvents.size() == 1 ? transitionEvents : List.of(); @@ -436,6 +446,17 @@ public final class MachineEnumCanonicalizer { String eventTypeFqn, List machineTransitions, CodebaseContext context) { + return narrowPolymorphicCandidates( + current, constraint, eventTypeFqn, machineTransitions, context, null); + } + + private static List narrowPolymorphicCandidates( + List current, + String constraint, + String eventTypeFqn, + List machineTransitions, + CodebaseContext context, + TriggerPoint trigger) { List transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn, context); List result = current == null ? List.of() : new ArrayList<>(current); @@ -444,6 +465,23 @@ public final class MachineEnumCanonicalizer { result, constraint, eventTypeFqn, context); if (!filtered.isEmpty()) { result = filtered; + } else if (trigger != null) { + List source = !transitionEvents.isEmpty() ? transitionEvents : result; + List 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()) { filtered = EnumMemberPredicateEvaluator.filterEnumConstants( transitionEvents, constraint, eventTypeFqn, context); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/RichEventPredicateEvaluator.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/RichEventPredicateEvaluator.java new file mode 100644 index 0000000..21a04f3 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/RichEventPredicateEvaluator.java @@ -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 filterEnumConstants( + List candidates, + String constraint, + String machineEnumFqn, + TriggerPoint trigger, + CodebaseContext context) { + if (candidates == null || candidates.isEmpty() || constraint == null || context == null || trigger == null) { + return List.of(); + } + List 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 ownerTypes = new LinkedHashSet<>(); + ownerTypes.add(receiverTypeFqn); + ownerTypes.addAll(context.getImplementations(receiverTypeFqn)); + + Set 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 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 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); + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java index 8f89408..155c687 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java @@ -3116,6 +3116,20 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return; } } + if (expr instanceof MethodInvocation mi + && "run".equals(mi.getName().getIdentifier()) + && mi.arguments().isEmpty()) { + List 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 traced = variableTracer.traceVariableAll(expr); for (Expression tracedExpr : traced) { addConstantsFromSingleExpression(tracedExpr, constants, path, callGraph, scopeMethod); @@ -3155,6 +3169,11 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } Map bindings = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder); if (bindings.isEmpty()) { + List runnableResolved = CallSiteArgumentResolver.resolveRunnableLambdaFromPath( + path, callGraph, typeResolver, context, pathFinder, constantExtractor, callSiteHooks()); + if (!runnableResolved.isEmpty()) { + return runnableResolved; + } if (hasMultiClassPolymorphicPath(path) && polymorphicEvents.stream().noneMatch(this::looksLikeEnumConstant)) { List callerArgumentResolved = resolvePolymorphicEventsFromCallerArgument(path); @@ -3593,4 +3612,10 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { results.add(parsed); } } + + List resolveRunnableLambdaConstantsForPath( + List path, Map> callGraph) { + return CallSiteArgumentResolver.resolveRunnableLambdaFromPath( + path, callGraph, typeResolver, context, pathFinder, constantExtractor, callSiteHooks()); + } } \ No newline at end of file diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallSiteArgumentResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallSiteArgumentResolver.java index 920d80c..476ca56 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallSiteArgumentResolver.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallSiteArgumentResolver.java @@ -1,10 +1,19 @@ package click.kamil.springstatemachineexporter.analysis.service; 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.ASTVisitor; 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.SimpleName; +import org.eclipse.jdt.core.dom.TypeDeclaration; +import org.eclipse.jdt.core.dom.Block; import java.util.ArrayList; import java.util.List; @@ -62,6 +71,103 @@ public final class CallSiteArgumentResolver { 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 resolveFunctionalRunFromCallerFrame( + MethodInvocation runCall, + List path, + Map> 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 resolveRunnableLambdaFromPath( + List path, + Map> 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 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( String caller, String callee, @@ -110,11 +216,201 @@ public final class CallSiteArgumentResolver { Expression resolvedExpr = hooks.parseExpression(resolved); List constants = new ArrayList<>(); if (resolvedExpr != null) { - constantExtractor.extractConstantsFromExpression(resolvedExpr, constants); + extractSideEffectConstantsFromLambda(resolvedExpr, constantExtractor, constants); + if (constants.isEmpty()) { + constantExtractor.extractConstantsFromExpression(resolvedExpr, constants); + } + } + if (constants.isEmpty() && parsed instanceof LambdaExpression lambda) { + extractSideEffectConstantsFromLambda(lambda, constantExtractor, constants); } if (constants.isEmpty() && FunctionalInterfaceTypes.looksLikeEnumConstant(resolved)) { constants.add(resolved); } return constants; } + + private static void extractSideEffectConstantsFromLambda( + Expression expression, + ConstantExtractor constantExtractor, + List 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 extractRunnableArgumentConstants( + String callerMethod, + String executeMethod, + int paramIndex, + String argValue, + CodebaseContext context, + TypeResolver typeResolver, + ConstantExtractor constantExtractor, + ExpressionHooks hooks) { + List 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 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 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]; + } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/FunctionalInterfaceTypes.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/FunctionalInterfaceTypes.java index 6cbf7a9..3d886e9 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/FunctionalInterfaceTypes.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/FunctionalInterfaceTypes.java @@ -17,7 +17,8 @@ public final class FunctionalInterfaceTypes { if (simple.contains("<")) { 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) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/JdtDataFlowModel.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/JdtDataFlowModel.java index 65942a9..aa5b80f 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/JdtDataFlowModel.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/JdtDataFlowModel.java @@ -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); } + private void collectSideEffectExpressions( + Block body, + List results, + Set visited, + Map paramBindings, + Map 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 findReturnStatements(ASTNode node) { List returns = new ArrayList<>(); node.accept(new ASTVisitor() { diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/RichEventIsCorrectPredicateTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/RichEventIsCorrectPredicateTest.java new file mode 100644 index 0000000..ebdbeed --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/RichEventIsCorrectPredicateTest.java @@ -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 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 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 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; + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/RunnableLambdaDispatchTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/RunnableLambdaDispatchTest.java new file mode 100644 index 0000000..0e8535e --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/RunnableLambdaDispatchTest.java @@ -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 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 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 resolved = engine.resolveRunnableLambdaConstantsForPath(path, graph); + assertThat(resolved).containsExactly("OrderCommand.PAY"); + } +}