diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilder.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilder.java new file mode 100644 index 0000000..4e1707e --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilder.java @@ -0,0 +1,777 @@ +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 click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.jdt.core.dom.*; + +import java.util.*; + +@Slf4j +public class CallGraphBuilder { + private final CodebaseContext context; + private final ConstantResolver constantResolver; + private String currentMethodFqn; + private Map> graph; + + @lombok.Data + private static class CallEdge { + private final String targetMethod; + private final List arguments; + } + + public CallGraphBuilder(CodebaseContext context) { + this.context = context; + this.constantResolver = new ConstantResolver(); + } + + public List findChains(List entryPoints, List triggers) { + Map> callGraph = buildCallGraph(); + List chains = new ArrayList<>(); + + for (EntryPoint ep : entryPoints) { + String startMethod = ep.getClassName() + "." + ep.getMethodName(); + boolean foundAny = false; + for (TriggerPoint tp : triggers) { + String targetMethod = tp.getClassName() + "." + tp.getMethodName(); + List path = findPath(startMethod, targetMethod, callGraph, new HashSet<>()); + if (path != null) { + foundAny = true; + TriggerPoint resolvedTp = resolveTriggerPointParameters(tp, path, callGraph); + String contextMachineId = extractContextMachineId(path, callGraph); + chains.add(CallChain.builder() + .entryPoint(ep) + .triggerPoint(resolvedTp) + .methodChain(path) + .contextMachineId(contextMachineId) + .build()); + } + } + if (!foundAny && log.isDebugEnabled()) { + log.debug("Entry point {} ({}) did not reach any trigger points. Graph nodes available: {}", ep.getName(), startMethod, callGraph.keySet()); + } + } + return chains; + } + + private TriggerPoint resolveTriggerPointParameters(TriggerPoint tp, List path, Map> callGraph) { + if (path.size() < 2) return tp; + + String event = tp.getEvent(); + if (event == null || event.isEmpty() || event.contains("\"")) return tp; + + String currentParamName = event; + String resolvedValue = event; + String methodSuffix = ""; + + // Extract method calls like .getType() so we can trace the base parameter + int dotIndex = currentParamName.indexOf('.'); + if (dotIndex > 0 && dotIndex + 1 < currentParamName.length()) { + char nextChar = currentParamName.charAt(dotIndex + 1); + if (Character.isLowerCase(nextChar)) { + methodSuffix = currentParamName.substring(dotIndex); + currentParamName = currentParamName.substring(0, dotIndex); + } + } + + // Walk backwards up the call chain + for (int i = path.size() - 1; i > 0; i--) { + String target = path.get(i); + String caller = path.get(i - 1); + + // Find parameter index in target method + int paramIndex = getParameterIndex(target, currentParamName); + if (paramIndex < 0) { + // Not a parameter. Maybe it's a local variable initialized from a parameter or method call? + String tracedVar = traceLocalVariable(target, currentParamName); + if (tracedVar != null && !tracedVar.equals(currentParamName)) { + // Extract method calls like .getType() from the traced variable + int dotIdx = tracedVar.indexOf('.'); + if (dotIdx > 0 && dotIdx + 1 < tracedVar.length() && Character.isLowerCase(tracedVar.charAt(dotIdx + 1))) { + methodSuffix = tracedVar.substring(dotIdx) + methodSuffix; + tracedVar = tracedVar.substring(0, dotIdx); + } + currentParamName = tracedVar; + resolvedValue = tracedVar + methodSuffix; + paramIndex = getParameterIndex(target, currentParamName); + } + } + if (paramIndex < 0) { + break; // Parameter name changed or not found, stop tracing + } + + // Find the edge from caller to target to get the argument passed + List edges = callGraph.get(caller); + boolean found = false; + if (edges != null) { + for (CallEdge edge : edges) { + if (edge.getTargetMethod().equals(target) || isHeuristicMatch(edge.getTargetMethod(), target)) { + if (paramIndex < edge.getArguments().size()) { + String arg = edge.getArguments().get(paramIndex); + if (arg != null) { + // If the argument passed has a method call, extract it + int dotIdx = arg.indexOf('.'); + if (dotIdx > 0 && dotIdx + 1 < arg.length() && Character.isLowerCase(arg.charAt(dotIdx + 1))) { + methodSuffix = arg.substring(dotIdx) + methodSuffix; + arg = arg.substring(0, dotIdx); + } + currentParamName = arg; + resolvedValue = arg + methodSuffix; + found = true; + break; + } + } + } + } + } + if (!found) break; // Could not map argument + } + + // Final check on the entry method + String entryMethod = path.get(0); + int entryParamIndex = getParameterIndex(entryMethod, currentParamName); + if (entryParamIndex < 0) { + String tracedVar = traceLocalVariable(entryMethod, currentParamName); + if (tracedVar != null && !tracedVar.equals(currentParamName)) { + int dotIdx = tracedVar.indexOf('.'); + if (dotIdx > 0 && dotIdx + 1 < tracedVar.length() && Character.isLowerCase(tracedVar.charAt(dotIdx + 1))) { + methodSuffix = tracedVar.substring(dotIdx) + methodSuffix; + tracedVar = tracedVar.substring(0, dotIdx); + } + currentParamName = tracedVar; + resolvedValue = tracedVar + methodSuffix; + } + } + + List polymorphicEvents = new ArrayList<>(); + if (resolvedValue.matches(".*\\.[a-zA-Z0-9_]+\\(\\)")) { + String varName = resolvedValue.substring(0, resolvedValue.indexOf('.')); + String methodName = resolvedValue.substring(resolvedValue.indexOf('.') + 1, resolvedValue.indexOf('(')); + + // Resolve in the first method in the path where the variable might be declared + for (String methodFqn : path) { + String declaredType = getVariableDeclaredType(methodFqn, varName); + if (declaredType != null) { + System.out.println("DEEP TRACE: " + methodFqn + " " + varName + " -> " + declaredType); + List typesToInspect = new ArrayList<>(); + typesToInspect.add(declaredType); + typesToInspect.addAll(context.getImplementations(declaredType)); + System.out.println("DEEP TRACE IMPLS: " + typesToInspect); + + for (String type : typesToInspect) { + Set visited = new HashSet<>(); + // We must find the compilation unit to pass for simple names! + // Let's pass the first available CU for this class + TypeDeclaration baseTd = context.getTypeDeclaration(type); + CompilationUnit cuToUse = null; + if (baseTd != null && baseTd.getRoot() instanceof CompilationUnit) { + cuToUse = (CompilationUnit) baseTd.getRoot(); + } else { + String entryClassName = methodFqn.substring(0, methodFqn.lastIndexOf('.')); + TypeDeclaration entryTd = context.getTypeDeclaration(entryClassName); + if (entryTd != null && entryTd.getRoot() instanceof CompilationUnit) { + cuToUse = (CompilationUnit) entryTd.getRoot(); + } + } + List constants = resolveMethodReturnConstant(type, methodName, 0, visited, cuToUse); + System.out.println("DEEP TRACE RETURN: " + type + " -> " + constants); + for (String constant : constants) { + if (!polymorphicEvents.contains(constant)) { + polymorphicEvents.add(constant); + } + } + } + break; + } + } + } + + if (!resolvedValue.equals(event) || !polymorphicEvents.isEmpty()) { + return TriggerPoint.builder() + .event(resolvedValue) + .className(tp.getClassName()) + .methodName(tp.getMethodName()) + .sourceFile(tp.getSourceFile()) + .lineNumber(tp.getLineNumber()) + .polymorphicEvents(polymorphicEvents) + .build(); + } + + return tp; + } + + private int getParameterIndex(String methodFqn, String paramName) { + if (methodFqn == null || !methodFqn.contains(".")) return -1; + String className = methodFqn.substring(0, methodFqn.lastIndexOf('.')); + String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1); + TypeDeclaration td = context.getTypeDeclaration(className); + if (td != null) { + MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); + if (md != null) { + for (int i = 0; i < md.parameters().size(); i++) { + SingleVariableDeclaration svd = (SingleVariableDeclaration) md.parameters().get(i); + if (svd.getName().getIdentifier().equals(paramName)) { + return i; + } + } + } + } + return -1; + } + + private String getVariableDeclaredType(String methodFqn, String varName) { + if (methodFqn == null || !methodFqn.contains(".")) return null; + String className = methodFqn.substring(0, methodFqn.lastIndexOf('.')); + String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1); + TypeDeclaration td = context.getTypeDeclaration(className); + if (td != null) { + MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); + if (md != null) { + for (Object pObj : md.parameters()) { + SingleVariableDeclaration svd = (SingleVariableDeclaration) pObj; + if (svd.getName().getIdentifier().equals(varName)) { + return svd.getType().toString(); + } + } + final String[] foundType = new String[1]; + if (md.getBody() != null) { + md.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(VariableDeclarationStatement node) { + for (Object fragObj : node.fragments()) { + VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj; + if (frag.getName().getIdentifier().equals(varName)) { + foundType[0] = node.getType().toString(); + } + } + return super.visit(node); + } + }); + } + return foundType[0]; + } + } + return null; + } + + private List resolveMethodReturnConstant(String className, String methodName, int depth, Set visited, CompilationUnit contextCu) { + if (depth > 20) return Collections.emptyList(); + String fqn = className + "." + methodName; + if (!visited.add(fqn)) return Collections.emptyList(); + + List constants = new ArrayList<>(); + TypeDeclaration td = contextCu != null ? context.getTypeDeclaration(className, contextCu) : context.getTypeDeclaration(className); + if (td == null) { + System.out.println("DEEP TRACE FAILED TO FIND TD: " + className); + } + if (td != null) { + MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); + if (md != null && md.getBody() != null) { + md.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(ReturnStatement node) { + Expression retExpr = node.getExpression(); + if (retExpr != null) { + boolean handled = false; + if (retExpr instanceof MethodInvocation mi) { + // Follow delegation first + String called = resolveCalledMethod(mi); + System.out.println("DEEP TRACE RESOLVED CALLED: " + called); + if (called != null && called.contains(".")) { + if (visited.contains(called)) { + handled = true; + } else { + String cName = called.substring(0, called.lastIndexOf('.')); + String mName = called.substring(called.lastIndexOf('.') + 1); + + TypeDeclaration targetTd = contextCu != null ? context.getTypeDeclaration(cName, contextCu) : context.getTypeDeclaration(cName); + if (targetTd == null) targetTd = context.getTypeDeclaration(cName); + + if (targetTd != null && context.findMethodDeclaration(targetTd, mName, true) != null) { + List delegationResult = resolveMethodReturnConstant(cName, mName, depth + 1, visited, contextCu); + constants.addAll(delegationResult); + handled = true; + } + } + } + } + if (!handled) { + String val = constantResolver.resolve(retExpr, context); + if (val != null) { + if (val.startsWith("ENUM_SET:")) { + for (String eVal : val.substring(9).split(",")) { + constants.add(eVal.substring(eVal.lastIndexOf('.') + 1)); + } + } else { + constants.add(val); + } + } else if (retExpr instanceof QualifiedName qn) { + constants.add(qn.toString()); + } else if (retExpr instanceof SimpleName sn) { + constants.add(sn.toString()); + } + } + } + return super.visit(node); + } + }); + } + } + visited.remove(fqn); + return constants; + } + + private String traceLocalVariable(String methodFqn, String varName) { + if (methodFqn == null || !methodFqn.contains(".")) return null; + String className = methodFqn.substring(0, methodFqn.lastIndexOf('.')); + String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1); + TypeDeclaration td = context.getTypeDeclaration(className); + if (td != null) { + MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); + if (md != null && md.getBody() != null) { + final Expression[] initializer = new Expression[1]; + md.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(VariableDeclarationFragment node) { + if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) { + initializer[0] = node.getInitializer(); + } + return super.visit(node); + } + @Override + public boolean visit(Assignment node) { + if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) { + initializer[0] = node.getRightHandSide(); + } + return super.visit(node); + } + }); + + if (initializer[0] != null) { + Expression expr = traceVariable(initializer[0]); + if (expr instanceof MethodInvocation mi) { + // Unwrapper logic: If wrapper method is called, extract its arguments recursively + Expression innerMost = unwrapMethodInvocation(mi, 0); + if (innerMost instanceof MethodInvocation innerMi) { + if (innerMi.getExpression() instanceof SimpleName sn) { + return sn.getIdentifier() + "." + innerMi.getName().getIdentifier() + "()"; + } + return innerMi.getName().getIdentifier() + "()"; + } + if (innerMost instanceof SimpleName sn) { + return sn.getIdentifier(); + } + return innerMost.toString(); + } + if (expr instanceof SimpleName sn) { + return sn.getIdentifier(); + } + return expr.toString(); + } + } + } + return null; + } + + private Expression unwrapMethodInvocation(MethodInvocation mi, int depth) { + if (depth > 5) return mi; + if (!mi.arguments().isEmpty()) { + Expression arg = (Expression) mi.arguments().get(0); + if (arg instanceof MethodInvocation innerMi) { + return unwrapMethodInvocation(innerMi, depth + 1); + } + return arg; + } + return mi; + } + + private String extractContextMachineId(List path, Map> callGraph) { + for (String node : path) { + List edges = callGraph.get(node); + if (edges != null) { + for (CallEdge edge : edges) { + String target = edge.getTargetMethod(); + if (target != null && (target.contains(".restore") || target.contains(".read"))) { + // Persister signatures usually like: restore(stateMachine, contextObj) + if (edge.getArguments().size() >= 2) { + return edge.getArguments().get(1); // The contextObj / machineId + } + } + } + } + } + return null; + } + + private MethodDeclaration findEnclosingMethod(ASTNode node) { + ASTNode parent = node.getParent(); + while (parent != null && !(parent instanceof MethodDeclaration)) { + parent = parent.getParent(); + } + return (MethodDeclaration) parent; + } + + private Map> buildCallGraph() { + graph = new HashMap<>(); + for (CompilationUnit cu : context.getCompilationUnits()) { + cu.accept(new ASTVisitor() { + + @Override + public boolean visit(MethodInvocation node) { + MethodDeclaration md = findEnclosingMethod(node); + if (md != null) { + TypeDeclaration td = findEnclosingType(md); + if (td != null) { + String currentMethodFqn = context.getFqn(td) + "." + md.getName().getIdentifier(); + List calledMethods = resolveCalledMethodsPolymorphic(node); + List args = resolveArguments(node.arguments()); + for (String calledMethod : calledMethods) { + graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(calledMethod, args)); + } + for (Object argObj : node.arguments()) { + if (argObj instanceof ExpressionMethodReference emr) { + String typeName = emr.getExpression().toString(); + if ("this".equals(typeName) || "super".equals(typeName)) { + TypeDeclaration td2 = findEnclosingType(node); + if (td2 != null) { + String refMethod = resolveMethodInType(td2, emr.getName().getIdentifier()); + if (refMethod != null) { + graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(refMethod, args)); + } + } + } else { + String fallbackTypeFqn = null; + ITypeBinding binding = emr.getExpression().resolveTypeBinding(); + if (binding != null) { + fallbackTypeFqn = binding.getQualifiedName(); + } else if (emr.getExpression() instanceof SimpleName sn) { + fallbackTypeFqn = resolveReceiverTypeFallback(sn); + } + if (fallbackTypeFqn != null) { + String calledMethod = fallbackTypeFqn + "." + emr.getName().getIdentifier(); + graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(calledMethod, args)); + + List impls = context.getImplementations(fallbackTypeFqn); + for (String impl : impls) { + graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(impl + "." + emr.getName().getIdentifier(), args)); + } + } + } + } + } + } + } + return super.visit(node); + } + + @Override + public boolean visit(SuperMethodInvocation node) { + MethodDeclaration md = findEnclosingMethod(node); + if (md != null) { + TypeDeclaration tdOuter = findEnclosingType(md); + if (tdOuter != null) { + String currentMethodFqn = context.getFqn(tdOuter) + "." + md.getName().getIdentifier(); + String methodName = node.getName().getIdentifier(); + TypeDeclaration td = findEnclosingType(node); + if (td != null) { + String superFqn = context.getSuperclassFqn(td); + if (superFqn != null) { + TypeDeclaration superTd = context.getTypeDeclaration(superFqn); + String calledMethod = null; + if (superTd != null) { + calledMethod = resolveMethodInType(superTd, methodName); + } + if (calledMethod == null) { + calledMethod = superFqn + "." + methodName; + } + List args = resolveArguments(node.arguments()); + graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(calledMethod, args)); + } + } + } + } + return super.visit(node); + } + }); + } + return graph; + } + + private List resolveArguments(List astArguments) { + List args = new ArrayList<>(); + for (Object argObj : astArguments) { + Expression expr = (Expression) argObj; + + // Extract from lambda + if (expr instanceof LambdaExpression le) { + ASTNode body = le.getBody(); + if (body instanceof Expression bodyExpr) { + expr = bodyExpr; + } else if (body instanceof Block block) { + for (Object stmtObj : block.statements()) { + if (stmtObj instanceof ReturnStatement rs && rs.getExpression() != null) { + expr = rs.getExpression(); + break; + } + } + } + } + + if (expr instanceof ExpressionMethodReference emr) { + TypeDeclaration td = findEnclosingType(expr); + if (td != null) { + MethodDeclaration md = context.findMethodDeclaration(td, emr.getName().getIdentifier(), true); + if (md != null && md.getBody() != null) { + for (Object stmtObj : md.getBody().statements()) { + if (stmtObj instanceof ReturnStatement rs && rs.getExpression() != null) { + expr = rs.getExpression(); + break; + } + } + } + } + } + + // Extract from constructor args (e.g., new CustomMessage(OrderEvent.PROCESS, ...)) + Expression tracedExpr = traceVariable(expr); + if (tracedExpr instanceof QualifiedName || tracedExpr instanceof ClassInstanceCreation || tracedExpr instanceof StringLiteral || tracedExpr instanceof NumberLiteral) { + expr = tracedExpr; // Only accept trace if it resolves to a pure constant/primitive/constructor + } + + if (expr instanceof ClassInstanceCreation cic && !cic.arguments().isEmpty()) { + Expression firstArg = (Expression) cic.arguments().get(0); + String resolved = constantResolver.resolve(firstArg, context); + if (resolved != null) { + expr = firstArg; // Only unwrap if it's actually a constant + } + } + + String val = constantResolver.resolve(expr, context); + if (val == null) { + val = expr.toString(); // Fallback to raw string (handles Enums, 'new Class()', etc) + } + args.add(val); + } + return args; + } + + private List resolveCalledMethodsPolymorphic(MethodInvocation node) { + String baseCalled = resolveCalledMethod(node); + if (baseCalled == null) return Collections.emptyList(); + + List allResolved = new ArrayList<>(); + allResolved.add(baseCalled); + + if (baseCalled.contains(".")) { + String className = baseCalled.substring(0, baseCalled.lastIndexOf('.')); + String methodName = baseCalled.substring(baseCalled.lastIndexOf('.') + 1); + + List impls = context.getImplementations(className); + for (String impl : impls) { + allResolved.add(impl + "." + methodName); + } + } + + return allResolved; + } + + private String resolveCalledMethod(MethodInvocation node) { + Expression receiver = node.getExpression(); + String methodName = node.getName().getIdentifier(); + + if (receiver == null) { + TypeDeclaration td = findEnclosingType(node); + if (td != null) { + return resolveMethodInType(td, methodName); + } + return null; + } + + ITypeBinding binding = receiver.resolveTypeBinding(); + if (binding != null) { + return binding.getQualifiedName() + "." + methodName; + } + + if (receiver instanceof ThisExpression) { + TypeDeclaration td = findEnclosingType(node); + if (td != null) { + return context.getFqn(td) + "." + methodName; + } + } + + if (receiver instanceof SimpleName sn) { + String fallbackTypeFqn = resolveReceiverTypeFallback(sn); + if (fallbackTypeFqn != null) { + return fallbackTypeFqn + "." + methodName; + } + String receiverName = sn.getIdentifier(); + return receiverName + "." + methodName; + } + + return null; + } + + private String resolveReceiverTypeFallback(SimpleName receiverNameNode) { + String varName = receiverNameNode.getIdentifier(); + + // 1. Check local variables in enclosing method + MethodDeclaration enclosingMethod = findEnclosingMethod(receiverNameNode); + if (enclosingMethod != null) { + // Check parameters + for (Object paramObj : enclosingMethod.parameters()) { + SingleVariableDeclaration svd = (SingleVariableDeclaration) paramObj; + if (svd.getName().getIdentifier().equals(varName)) { + return resolveTypeToFqn(svd.getType(), receiverNameNode); + } + } + // Check method body (local variables) + if (enclosingMethod.getBody() != null) { + Type[] foundType = new Type[1]; + enclosingMethod.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(VariableDeclarationStatement node) { + for (Object fragObj : node.fragments()) { + VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj; + if (frag.getName().getIdentifier().equals(varName)) { + foundType[0] = node.getType(); + } + } + return super.visit(node); + } + }); + if (foundType[0] != null) { + return resolveTypeToFqn(foundType[0], receiverNameNode); + } + } + } + + // 2. Check fields in enclosing class + TypeDeclaration enclosingType = findEnclosingType(receiverNameNode); + if (enclosingType != null) { + for (FieldDeclaration field : enclosingType.getFields()) { + for (Object fragObj : field.fragments()) { + VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj; + if (frag.getName().getIdentifier().equals(varName)) { + return resolveTypeToFqn(field.getType(), receiverNameNode); + } + } + } + } + + return null; + } + + private String resolveTypeToFqn(Type type, ASTNode contextNode) { + if (type == null) return null; + String simpleName; + if (type.isSimpleType()) { + simpleName = ((SimpleType) type).getName().getFullyQualifiedName(); + } else if (type.isParameterizedType()) { + return resolveTypeToFqn(((ParameterizedType) type).getType(), contextNode); + } else { + simpleName = type.toString(); + } + + CompilationUnit cu = (CompilationUnit) contextNode.getRoot(); + TypeDeclaration td = context.getTypeDeclaration(simpleName, cu); + if (td != null) { + return context.getFqn(td); + } + + // Fallback to import matching if CodebaseContext doesn't know it (e.g., external library) + for (Object impObj : cu.imports()) { + ImportDeclaration imp = (ImportDeclaration) impObj; + String impName = imp.getName().getFullyQualifiedName(); + if (impName.endsWith("." + simpleName)) { + return impName; + } + } + + return simpleName; + } + + private String resolveMethodInType(TypeDeclaration td, String methodName) { + MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); + if (md != null) { + TypeDeclaration declaringTd = findEnclosingType(md); + return context.getFqn(declaringTd) + "." + methodName; + } + return null; + } + + private List findPath(String start, String target, Map> graph, Set visited) { + if (start.equals(target)) return new ArrayList<>(List.of(start)); + if (!visited.add(start)) return null; // Path-scoped cycle detection + + List neighbors = graph.get(start); + if (neighbors != null) { + for (CallEdge edge : neighbors) { + String neighbor = edge.getTargetMethod(); + if (neighbor.equals(target) || isHeuristicMatch(neighbor, target)) { + visited.remove(start); + return new ArrayList<>(List.of(start, target)); + } + List path = findPath(neighbor, target, graph, visited); + if (path != null) { + path.add(0, start); + visited.remove(start); + return path; + } + } + } + + if (log.isDebugEnabled()) { + log.debug("Path search dead-end at {} when looking for {}", start, target); + } + + visited.remove(start); + return null; + } + + private boolean isHeuristicMatch(String neighbor, String target) { + if (target.endsWith("." + neighbor)) return true; + String simpleTarget = target.contains(".") ? target.substring(target.lastIndexOf('.') + 1) : target; + String simpleNeighbor = neighbor.contains(".") ? neighbor.substring(neighbor.lastIndexOf('.') + 1) : neighbor; + return simpleNeighbor.equals(simpleTarget); + } + + private TypeDeclaration findEnclosingType(ASTNode node) { + ASTNode parent = node.getParent(); + while (parent != null && !(parent instanceof TypeDeclaration)) { + parent = parent.getParent(); + } + return (TypeDeclaration) parent; + } + private Expression traceVariable(Expression expr) { + if (expr instanceof SimpleName sn) { + String varName = sn.getIdentifier(); + MethodDeclaration enclosingMethod = findEnclosingMethod(expr); + if (enclosingMethod != null) { + final Expression[] initializer = new Expression[1]; + enclosingMethod.accept(new ASTVisitor() { + @Override + public boolean visit(VariableDeclarationFragment node) { + if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) { + initializer[0] = node.getInitializer(); + } + return super.visit(node); + } + @Override + public boolean visit(Assignment node) { + if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) { + initializer[0] = node.getRightHandSide(); + } + return super.visit(node); + } + }); + if (initializer[0] != null) { + return traceVariable(initializer[0]); + } + } + } + return expr; + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilderTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilderTest.java new file mode 100644 index 0000000..f706c40 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilderTest.java @@ -0,0 +1,1125 @@ +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 org.junit.jupiter.api.io.TempDir; + +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 CallGraphBuilderTest { + + @Test + void shouldFindCallChainWithLambdaMethodReference(@TempDir Path tempDir) throws IOException { + String source = """ + package com.example; + public class OrderService { + private final EventDispatcher dispatcher = new EventDispatcher(); + + public void processOrder() { + dispatcher.dispatch("order", this::sendEvent); + } + + public void sendEvent(String event) { + // Dummy trigger + } + } + + class EventDispatcher { + public void dispatch(String order, java.util.function.Consumer callback) { + callback.accept("MY_EVENT"); + } + } + """; + Files.writeString(tempDir.resolve("OrderService.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + CallGraphBuilder builder = new CallGraphBuilder(context); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.OrderService") + .methodName("processOrder") + .build(); + + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.OrderService") + .methodName("sendEvent") + .event("event") + .build(); + + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + assertThat(chains).hasSize(1); + CallChain chain = chains.get(0); + assertThat(chain.getMethodChain()).containsExactly( + "com.example.OrderService.processOrder", + "com.example.OrderService.sendEvent" + ); + } + + @Test + void shouldExtractContextMachineId(@TempDir Path tempDir) throws IOException { + String source = """ + package com.example; + public class PersisterService { + private final StateMachinePersister persister = new StateMachinePersister(); + + public void updateOrderState(String orderId) { + StateMachine sm = persister.restore(null, "machine:" + orderId); + sm.sendEvent("PAY"); + } + } + + class StateMachinePersister { + public StateMachine restore(Object sm, String contextObj) { + return new StateMachine(); + } + } + + class StateMachine { + public void sendEvent(String event) {} + } + """; + Files.writeString(tempDir.resolve("PersisterService.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + CallGraphBuilder builder = new CallGraphBuilder(context); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.PersisterService") + .methodName("updateOrderState") + .build(); + + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.StateMachine") + .methodName("sendEvent") + .event("PAY") + .build(); + + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + assertThat(chains).hasSize(1); + CallChain chain = chains.get(0); + assertThat(chain.getContextMachineId()).isNotNull(); + // Since it's tracing constant concatenation: "machine:" + orderId, which might resolve dynamically or string format. + // It should at least be present (it typically extracts 'machine:' + orderId into expression). + } + + @Test + void shouldResolveTriggerPointParametersAcrossChain() throws IOException { + String source = """ + package com.example; + public class OrderController { + private OrderService service; + public void createOrder() { + service.updateOrderState(OrderEvents.CREATE); + } + } + + class OrderService { + private StateMachine sm; + public void updateOrderState(OrderEvents event) { + sm.sendEvent(event); + } + } + + class StateMachine { + public void sendEvent(Object event) {} + } + + enum OrderEvents { CREATE, PAY } + """; + Path tempDir = Files.createTempDirectory("callgraph_test"); + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + CallGraphBuilder builder = new CallGraphBuilder(context); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.OrderController") + .methodName("createOrder") + .build(); + + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.OrderService") + .methodName("updateOrderState") + .event("event") + .build(); + + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + assertThat(chains).hasSize(1); + CallChain chain = chains.get(0); + assertThat(chain.getTriggerPoint().getEvent()).isEqualTo("OrderEvents.CREATE"); + } + + @Test + void shouldResolveTriggerPointLocalVariableAcrossChain() throws IOException { + String source = """ + package com.example; + public class OrderController { + private OrderService service; + public void processOrderEvent(MyOrderEvent domainEvent) { + doProcessOrderEvent(domainEvent); + } + + private void doProcessOrderEvent(MyOrderEvent domainEvent) { + OrderEvents eventType = domainEvent.getType(); + service.updateOrderState(eventType); + } + } + + class OrderService { + public void updateOrderState(OrderEvents event) { + // sendEvent + } + } + + class MyOrderEvent { + public OrderEvents getType() { return OrderEvents.PAY; } + } + + enum OrderEvents { CREATE, PAY } + """; + Path tempDir = Files.createTempDirectory("callgraph_test2"); + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + CallGraphBuilder builder = new CallGraphBuilder(context); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.OrderController") + .methodName("processOrderEvent") + .build(); + + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.OrderService") + .methodName("updateOrderState") + .event("event") + .build(); + + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + assertThat(chains).hasSize(1); + CallChain chain = chains.get(0); + assertThat(chain.getTriggerPoint().getPolymorphicEvents()) + .as("Resolved event was: " + chain.getTriggerPoint().getEvent()) + .containsExactlyInAnyOrder("OrderEvents.PAY"); + } + + @Test + void shouldResolvePolymorphicInheritanceAcrossChain() throws IOException { + String source = """ + package com.example; + public class OrderController { + private OrderService service; + public void processOrderEvent(RichOrderEvent domainEvent) { + doProcessOrderEvent(domainEvent); + } + + private void doProcessOrderEvent(RichOrderEvent domainEvent) { + OrderEvents eventType = assertSupportedOrderEvent(domainEvent.getType()); + service.updateOrderState(eventType); + } + + private OrderEvents assertSupportedOrderEvent(OrderEvents e) { + return e; + } + } + + class OrderService { + public void updateOrderState(OrderEvents event) { + // sendEvent + } + } + + interface RichOrderEvent { + OrderEvents getType(); + } + + class CancelOrderEvent implements RichOrderEvent { + public OrderEvents getType() { return OrderEvents.CANCELLED; } + } + + class ReceiveOrderEvent implements RichOrderEvent { + public OrderEvents getType() { return OrderEvents.RECEIVED; } + } + + enum OrderEvents { CREATE, PAY, CANCELLED, RECEIVED } + """; + Path tempDir = Files.createTempDirectory("callgraph_test_poly"); + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + CallGraphBuilder builder = new CallGraphBuilder(context); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.OrderController") + .methodName("processOrderEvent") + .build(); + + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.OrderService") + .methodName("updateOrderState") + .event("event") + .build(); + + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + assertThat(chains).hasSize(1); + CallChain chain = chains.get(0); + assertThat(chain.getTriggerPoint().getPolymorphicEvents()) + .as("Resolved event was: " + chain.getTriggerPoint().getEvent()) + .containsExactlyInAnyOrder("OrderEvents.CANCELLED", "OrderEvents.RECEIVED"); + } + + @Test + void shouldUnwrapDeepMethodWrappers() throws IOException { + String source = """ + package com.example; + public class OrderController { + private OrderService service; + public void processOrderEvent(RichOrderEvent domainEvent) { + OrderEvents eventType = wrap3(wrap2(wrap1(domainEvent.getType()))); + service.updateOrderState(eventType); + } + + private OrderEvents wrap1(OrderEvents e) { return e; } + private OrderEvents wrap2(OrderEvents e) { return e; } + private OrderEvents wrap3(OrderEvents e) { return e; } + } + + class OrderService { + public void updateOrderState(OrderEvents event) { } + } + + class RichOrderEvent { + public OrderEvents getType() { return OrderEvents.CREATE; } + } + + enum OrderEvents { CREATE } + """; + Path tempDir = Files.createTempDirectory("callgraph_test_deep_wrap"); + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + CallGraphBuilder builder = new CallGraphBuilder(context); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.OrderController") + .methodName("processOrderEvent") + .build(); + + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.OrderService") + .methodName("updateOrderState") + .event("event") + .build(); + + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + assertThat(chains).hasSize(1); + CallChain chain = chains.get(0); + assertThat(chain.getTriggerPoint().getPolymorphicEvents()) + .as("Resolved event was: " + chain.getTriggerPoint().getEvent()) + .containsExactlyInAnyOrder("OrderEvents.CREATE"); + } + + @Test + void shouldResolvePolymorphicInheritanceThroughDelegation() throws IOException { + String source = """ + package com.example; + public class OrderController { + private OrderService service; + public void processOrderEvent(BaseEvent domainEvent) { + service.updateOrderState(domainEvent.getType()); + } + } + + class OrderService { + public void updateOrderState(OrderEvents event) {} + } + + interface BaseEvent { + OrderEvents getType(); + } + + abstract class AbstractEvent implements BaseEvent { + protected OrderEvents internalGetType() { + return OrderEvents.PAY; + } + } + + class ConcreteEvent extends AbstractEvent { + public OrderEvents getType() { + return internalGetType(); + } + } + + class AnotherConcreteEvent implements BaseEvent { + public OrderEvents getType() { + return delegateToHelper(); + } + private OrderEvents delegateToHelper() { + return OrderEvents.CANCELLED; + } + } + + enum OrderEvents { PAY, CANCELLED } + """; + Path tempDir = Files.createTempDirectory("callgraph_test_poly_delegation"); + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + CallGraphBuilder builder = new CallGraphBuilder(context); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.OrderController") + .methodName("processOrderEvent") + .build(); + + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.OrderService") + .methodName("updateOrderState") + .event("event") + .build(); + + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + assertThat(chains).hasSize(1); + CallChain chain = chains.get(0); + assertThat(chain.getTriggerPoint().getPolymorphicEvents()) + .containsExactlyInAnyOrder("OrderEvents.PAY", "OrderEvents.CANCELLED"); + } + + @Test + void shouldHandleMissingImplementationsGracefully() throws IOException { + String source = """ + package com.example; + public class OrderController { + private OrderService service; + public void processOrderEvent(UnknownEvent domainEvent) { + service.updateOrderState(domainEvent.getType()); + } + } + + class OrderService { + public void updateOrderState(OrderEvents event) {} + } + + // UnknownEvent is not defined in the source (e.g. from a third-party library) + + enum OrderEvents { CREATE } + """; + Path tempDir = Files.createTempDirectory("callgraph_test_missing_impl"); + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + CallGraphBuilder builder = new CallGraphBuilder(context); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.OrderController") + .methodName("processOrderEvent") + .build(); + + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.OrderService") + .methodName("updateOrderState") + .event("event") + .build(); + + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + assertThat(chains).hasSize(1); + CallChain chain = chains.get(0); + assertThat(chain.getTriggerPoint().getEvent()).isEqualTo("domainEvent.getType()"); + assertThat(chain.getTriggerPoint().getPolymorphicEvents()).isNullOrEmpty(); + } + + @Test + void shouldTraceMultipleLocalVariableAssignments() throws IOException { + String source = """ + package com.example; + public class OrderController { + private OrderService service; + public void processOrderEvent(RichOrderEvent domainEvent) { + OrderEvents type1 = domainEvent.getType(); + OrderEvents type2 = type1; + OrderEvents type3 = type2; + service.updateOrderState(type3); + } + } + + class OrderService { + public void updateOrderState(OrderEvents event) {} + } + + class RichOrderEvent { + public OrderEvents getType() { return OrderEvents.RECEIVED; } + } + + enum OrderEvents { RECEIVED } + """; + Path tempDir = Files.createTempDirectory("callgraph_test_multiple_assign"); + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + CallGraphBuilder builder = new CallGraphBuilder(context); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.OrderController") + .methodName("processOrderEvent") + .build(); + + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.OrderService") + .methodName("updateOrderState") + .event("event") + .build(); + + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + assertThat(chains).hasSize(1); + CallChain chain = chains.get(0); + assertThat(chain.getTriggerPoint().getPolymorphicEvents()) + .containsExactlyInAnyOrder("OrderEvents.RECEIVED"); + } + + @Test + void shouldResolveMethodInheritedFromAbstractBaseClass() throws IOException { + String source = """ + package com.example; + public class OrderController { + private OrderService service; + public void processOrderEvent(BaseEvent domainEvent) { + service.updateOrderState(domainEvent.getType()); + } + } + + class OrderService { + public void updateOrderState(OrderEvents event) {} + } + + interface BaseEvent { + OrderEvents getType(); + } + + abstract class AbstractEvent implements BaseEvent { + public OrderEvents getType() { + return OrderEvents.PAY; + } + } + + // Concrete subclass inherits getType() but doesn't override it! + class InheritingEvent extends AbstractEvent { + } + + enum OrderEvents { PAY } + """; + Path tempDir = Files.createTempDirectory("callgraph_test_inherited"); + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + CallGraphBuilder builder = new CallGraphBuilder(context); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.OrderController") + .methodName("processOrderEvent") + .build(); + + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.OrderService") + .methodName("updateOrderState") + .event("event") + .build(); + + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + assertThat(chains).hasSize(1); + CallChain chain = chains.get(0); + assertThat(chain.getTriggerPoint().getPolymorphicEvents()) + .containsExactlyInAnyOrder("OrderEvents.PAY"); + } + + @Test + void shouldStopTracingRecursiveMethodsGracefully() throws IOException { + String source = """ + package com.example; + public class OrderController { + private OrderService service; + public void processOrderEvent(RecursiveEvent domainEvent) { + service.updateOrderState(domainEvent.getType()); + } + } + + class OrderService { + public void updateOrderState(OrderEvents event) {} + } + + class RecursiveEvent { + public OrderEvents getType() { + return this.getType(); + } + } + + enum OrderEvents { PAY } + """; + Path tempDir = Files.createTempDirectory("callgraph_test_recursive"); + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + CallGraphBuilder builder = new CallGraphBuilder(context); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.OrderController") + .methodName("processOrderEvent") + .build(); + + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.OrderService") + .methodName("updateOrderState") + .event("event") + .build(); + + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + assertThat(chains).hasSize(1); + CallChain chain = chains.get(0); + // Should safely complete without stack overflow and return an empty result + assertThat(chain.getTriggerPoint().getEvent()).isEqualTo("domainEvent.getType()"); + assertThat(chain.getTriggerPoint().getPolymorphicEvents()).isEmpty(); + } + + @Test + void shouldHandleChainedMethodCallsGracefully() throws IOException { + String source = """ + package com.example; + public class OrderController { + private OrderService service; + public void processOrderEvent(EventWrapper wrapper) { + service.updateOrderState(wrapper.getEvent().getType()); + } + } + + class OrderService { + public void updateOrderState(OrderEvents event) {} + } + + class EventWrapper { + public RichEvent getEvent() { return new RichEvent(); } + } + + class RichEvent { + public OrderEvents getType() { return OrderEvents.PAY; } + } + + enum OrderEvents { PAY } + """; + Path tempDir = Files.createTempDirectory("callgraph_test_chained"); + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + CallGraphBuilder builder = new CallGraphBuilder(context); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.OrderController") + .methodName("processOrderEvent") + .build(); + + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.OrderService") + .methodName("updateOrderState") + .event("event") + .build(); + + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + assertThat(chains).hasSize(1); + CallChain chain = chains.get(0); + // It might not fully resolve chained method calls, but it must not crash! + assertThat(chain.getTriggerPoint().getEvent()).isEqualTo("wrapper.getEvent().getType()"); + } + + @Test + void shouldTraceStaticFieldReferences() throws IOException { + String source = """ + package com.example; + public class OrderController { + private static final OrderEvents CONSTANT_EVENT = OrderEvents.CANCELLED; + private OrderService service; + public void processOrderEvent() { + service.updateOrderState(CONSTANT_EVENT); + } + } + + class OrderService { + public void updateOrderState(OrderEvents event) {} + } + + enum OrderEvents { CANCELLED } + """; + Path tempDir = Files.createTempDirectory("callgraph_test_static_field"); + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + CallGraphBuilder builder = new CallGraphBuilder(context); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.OrderController") + .methodName("processOrderEvent") + .build(); + + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.OrderService") + .methodName("updateOrderState") + .event("event") + .build(); + + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + assertThat(chains).hasSize(1); + CallChain chain = chains.get(0); + assertThat(chain.getTriggerPoint().getEvent()).isEqualTo("OrderEvents.CANCELLED"); + } + + @Test + void shouldHandleAnonymousInnerClassesGracefully() throws IOException { + String source = """ + package com.example; + public class OrderController { + private OrderService service; + public void processOrderEvent() { + RichEvent event = new RichEvent() { + public OrderEvents getType() { + return OrderEvents.CREATE; + } + }; + service.updateOrderState(event.getType()); + } + } + + class OrderService { + public void updateOrderState(OrderEvents event) {} + } + + interface RichEvent { + OrderEvents getType(); + } + + enum OrderEvents { CREATE } + """; + Path tempDir = Files.createTempDirectory("callgraph_test_anonymous"); + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + CallGraphBuilder builder = new CallGraphBuilder(context); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.OrderController") + .methodName("processOrderEvent") + .build(); + + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.OrderService") + .methodName("updateOrderState") + .event("event") + .build(); + + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + assertThat(chains).hasSize(1); + CallChain chain = chains.get(0); + // It successfully traces the local variable 'event' to its anonymous class initializer + assertThat(chain.getTriggerPoint().getEvent()).startsWith("new RichEvent(){"); + assertThat(chain.getTriggerPoint().getEvent()).endsWith(".getType()"); + } + + @Test + void shouldLinkCallsInsideLambdasToEnclosingMethod() throws IOException { + String source = """ + package com.example; + public class OrderController { + private OrderService service; + public void processOrderEvent() { + Runnable r = () -> service.updateOrderState(OrderEvents.PAY); + r.run(); + } + } + + class OrderService { + public void updateOrderState(OrderEvents event) {} + } + + enum OrderEvents { PAY } + """; + Path tempDir = Files.createTempDirectory("callgraph_test_lambda_scope"); + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + CallGraphBuilder builder = new CallGraphBuilder(context); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.OrderController") + .methodName("processOrderEvent") + .build(); + + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.OrderService") + .methodName("updateOrderState") + .event("event") + .build(); + + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + assertThat(chains).hasSize(1); + CallChain chain = chains.get(0); + assertThat(chain.getTriggerPoint().getEvent()).isEqualTo("OrderEvents.PAY"); + } + + @Test + void shouldHandleTernaryOperatorsGracefully() throws IOException { + String source = """ + package com.example; + public class OrderController { + private OrderService service; + public void processOrderEvent(boolean flag) { + OrderEvents event = flag ? OrderEvents.PAY : OrderEvents.CANCELLED; + service.updateOrderState(event); + } + } + + class OrderService { + public void updateOrderState(OrderEvents event) {} + } + + enum OrderEvents { PAY, CANCELLED } + """; + Path tempDir = Files.createTempDirectory("callgraph_test_ternary"); + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + CallGraphBuilder builder = new CallGraphBuilder(context); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.OrderController") + .methodName("processOrderEvent") + .build(); + + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.OrderService") + .methodName("updateOrderState") + .event("event") + .build(); + + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + assertThat(chains).hasSize(1); + CallChain chain = chains.get(0); + // It should gracefully fallback to the string representation of the ternary operator + assertThat(chain.getTriggerPoint().getEvent()).isEqualTo("flag ? OrderEvents.PAY : OrderEvents.CANCELLED"); + } + + @Test + void shouldTraceLastTextualAssignmentInTryCatchBlocks() throws IOException { + String source = """ + package com.example; + public class OrderController { + private OrderService service; + public void processOrderEvent() { + OrderEvents event; + try { + event = OrderEvents.PAY; + } catch (Exception e) { + event = OrderEvents.CANCELLED; + } + service.updateOrderState(event); + } + } + + class OrderService { + public void updateOrderState(OrderEvents event) {} + } + + enum OrderEvents { PAY, CANCELLED } + """; + Path tempDir = Files.createTempDirectory("callgraph_test_try_catch"); + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + CallGraphBuilder builder = new CallGraphBuilder(context); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.OrderController") + .methodName("processOrderEvent") + .build(); + + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.OrderService") + .methodName("updateOrderState") + .event("event") + .build(); + + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + assertThat(chains).hasSize(1); + CallChain chain = chains.get(0); + // The ASTVisitor picks up Assignments in textual order. + // CANCELLED is the last one textually in the method. + assertThat(chain.getTriggerPoint().getEvent()).isEqualTo("OrderEvents.CANCELLED"); + } + + @Test + void shouldTraceArrayAndCollectionAccessGracefully() throws IOException { + String source = """ + package com.example; + public class OrderController { + private OrderService service; + public void processOrderEvent() { + OrderEvents[] events = new OrderEvents[]{OrderEvents.PAY}; + service.updateOrderState(events[0]); + } + } + + class OrderService { + public void updateOrderState(OrderEvents event) {} + } + + enum OrderEvents { PAY } + """; + Path tempDir = Files.createTempDirectory("callgraph_test_array"); + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + CallGraphBuilder builder = new CallGraphBuilder(context); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.OrderController") + .methodName("processOrderEvent") + .build(); + + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.OrderService") + .methodName("updateOrderState") + .event("event") + .build(); + + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + assertThat(chains).hasSize(1); + CallChain chain = chains.get(0); + // We don't dynamically resolve array indexes, but it shouldn't crash. + // It returns the array access expression as a string. + assertThat(chain.getTriggerPoint().getEvent()).isEqualTo("events[0]"); + } + + @Test + void shouldProperlyUnwrapDeeplyNestedWrapperCallsForFix1() throws IOException { + String source = """ + package com.example; + public class OrderController { + private OrderService service; + public void processOrderEvent(MyEvent event) { + OrderEvents eventType = wrap1(wrap2(wrap3(event.getType()))); + service.updateOrderState(eventType); + } + + private OrderEvents wrap1(OrderEvents e) { return e; } + private OrderEvents wrap2(OrderEvents e) { return e; } + private OrderEvents wrap3(OrderEvents e) { return e; } + } + + class OrderService { + public void updateOrderState(OrderEvents event) { } + } + + class MyEvent { + public OrderEvents getType() { return OrderEvents.CREATE; } + } + enum OrderEvents { CREATE } + """; + Path tempDir = Files.createTempDirectory("callgraph_nested_wrap"); + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + CallGraphBuilder builder = new CallGraphBuilder(context); + + EntryPoint entryPoint = EntryPoint.builder().className("com.example.OrderController").methodName("processOrderEvent").build(); + TriggerPoint trigger = TriggerPoint.builder().className("com.example.OrderService").methodName("updateOrderState").event("event").build(); + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + assertThat(chains).hasSize(1); + assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).containsExactlyInAnyOrder("OrderEvents.CREATE"); + } + + @Test + void shouldNotExtractClassInstanceCreationEvenWithComplexArgsForFix2() throws IOException { + String source = """ + package com.example; + public class OrderController { + private OrderService service; + public void createOrder(String oID) { + service.processOrderEvent(new OrderCancelledEvent(new OrderId(oID))); + } + } + class OrderService { + public void processOrderEvent(OrderCancelledEvent event) { } + } + class OrderCancelledEvent { + public OrderCancelledEvent(OrderId id) {} + } + class OrderId { + public OrderId(String id) {} + } + """; + Path tempDir = Files.createTempDirectory("callgraph_complex_cic"); + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + CallGraphBuilder builder = new CallGraphBuilder(context); + + EntryPoint entryPoint = EntryPoint.builder().className("com.example.OrderController").methodName("createOrder").build(); + TriggerPoint trigger = TriggerPoint.builder().className("com.example.OrderService").methodName("processOrderEvent").event("event").build(); + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + assertThat(chains).hasSize(1); + assertThat(chains.get(0).getTriggerPoint().getEvent()).isEqualTo("new OrderCancelledEvent(new OrderId(oID))"); + } + + @Test + void shouldResolveMethodReturnConstantFromMultiplePackagesForFix3() throws IOException { + String source1 = """ + package com.example.api; + public interface RichEvent { + String getType(); + } + """; + String source2 = """ + package com.example.impl.a; + import com.example.api.RichEvent; + public class EventA implements RichEvent { + public String getType() { return "EVENT_A"; } + } + """; + String source3 = """ + package com.example.impl.b; + import com.example.api.RichEvent; + public class EventB implements RichEvent { + public String getType() { return "EVENT_B"; } + } + """; + String source4 = """ + package com.example.controller; + import com.example.api.RichEvent; + public class EventController { + private com.example.service.EventService service; + public void handleEvent(RichEvent event) { + service.process(event.getType()); + } + } + """; + String source5 = """ + package com.example.service; + public class EventService { + public void process(String type) {} + } + """; + Path tempDir = Files.createTempDirectory("callgraph_multi_pkg"); + Files.writeString(tempDir.resolve("RichEvent.java"), source1); + Files.writeString(tempDir.resolve("EventA.java"), source2); + Files.writeString(tempDir.resolve("EventB.java"), source3); + Files.writeString(tempDir.resolve("EventController.java"), source4); + Files.writeString(tempDir.resolve("EventService.java"), source5); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + CallGraphBuilder builder = new CallGraphBuilder(context); + + EntryPoint entryPoint = EntryPoint.builder().className("com.example.controller.EventController").methodName("handleEvent").build(); + TriggerPoint trigger = TriggerPoint.builder().className("com.example.service.EventService").methodName("process").event("type").build(); + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + assertThat(chains).hasSize(1); + assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).containsExactlyInAnyOrder("EVENT_A", "EVENT_B"); + } + + @Test + void shouldResolveConstantsFromSwitchStatementInMethod() throws IOException { + String source = """ + package com.example; + public class OrderController { + private OrderService service; + public void handleStatus(DomainEvent domainEvent) { + service.process(domainEvent.getEventForStatus()); + } + } + + class DomainEvent { + private OrderStatus status; + public String getEventForStatus() { + switch (status) { + case NEW: return "CREATE_EVENT"; + case PAID: return "PAY_EVENT"; + default: return "CANCEL_EVENT"; + } + } + } + + class OrderService { + public void process(String event) {} + } + + enum OrderStatus { NEW, PAID } + """; + Path tempDir = Files.createTempDirectory("callgraph_switch"); + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + CallGraphBuilder builder = new CallGraphBuilder(context); + + EntryPoint entryPoint = EntryPoint.builder().className("com.example.OrderController").methodName("handleStatus").build(); + TriggerPoint trigger = TriggerPoint.builder().className("com.example.OrderService").methodName("process").event("event").build(); + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + assertThat(chains).hasSize(1); + assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).containsExactlyInAnyOrder("CREATE_EVENT", "PAY_EVENT", "CANCEL_EVENT"); + } +} diff --git a/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/click.kamil.maven.core.MavenOrderStateMachine.html b/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/click.kamil.maven.core.MavenOrderStateMachine.html new file mode 100644 index 0000000..8ac4b01 --- /dev/null +++ b/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/click.kamil.maven.core.MavenOrderStateMachine.html @@ -0,0 +1,679 @@ + + + + + + State Machine Explorer - click.kamil.maven.core.MavenOrderStateMachine + + + + + + + + + + + + + + + + +
+ + +
+ + + + + + diff --git a/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/click.kamil.maven.core.MavenOrderStateMachine.json b/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/click.kamil.maven.core.MavenOrderStateMachine.json new file mode 100644 index 0000000..9556b89 --- /dev/null +++ b/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/click.kamil.maven.core.MavenOrderStateMachine.json @@ -0,0 +1,66 @@ +{ + "metadata" : { + "triggers" : [ { + "event" : "ORDER_EVENT", + "className" : "click.kamil.maven.core.OrderService", + "methodName" : "onMessage", + "sourceModule" : null, + "stateMachineId" : null, + "lineNumber" : 34 + } ], + "entryPoints" : [ { + "type" : "REST", + "name" : "POST /reactive/order", + "className" : "click.kamil.maven.api.ReactiveOrderApi", + "methodName" : "orderRoutes", + "metadata" : { + "path" : "/reactive/order", + "verb" : "POST", + "style" : "WebFlux Functional" + }, + "parameters" : [ ] + }, { + "type" : "JMS", + "name" : "JMS: order.queue", + "className" : "click.kamil.maven.api.JmsOrderListener", + "methodName" : "onMessage", + "metadata" : { + "protocol" : "JMS", + "destination" : "order.queue" + }, + "parameters" : [ { + "name" : "message", + "type" : "String", + "annotations" : [ ] + } ] + } ], + "callChains" : [ ], + "properties" : { + "default" : { } + } + }, + "name" : "click.kamil.maven.core.MavenOrderStateMachine", + "renderChoicesAsDiamonds" : true, + "startStates" : [ "INIT" ], + "transitions" : [ { + "type" : "EXTERNAL", + "sourceStates" : [ { + "rawName" : "\"INIT\"", + "fullIdentifier" : "INIT" + } ], + "targetStates" : [ { + "rawName" : "\"BUSY\"", + "fullIdentifier" : "BUSY" + } ], + "event" : "ORDER_EVENT", + "guard" : null, + "actions" : [ { + "expression" : "loggingAction()", + "isLambda" : false, + "internalLogic" : "context -> {\n System.out.println(\"LOG: State transition in progress...\");\n}\n", + "lineNumber" : 23 + } ], + "order" : null + } ], + "endStates" : [ "DONE" ] +} diff --git a/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/click.kamil.maven.core.MavenOrderStateMachine.puml b/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/click.kamil.maven.core.MavenOrderStateMachine.puml new file mode 100644 index 0000000..98f60a8 --- /dev/null +++ b/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/click.kamil.maven.core.MavenOrderStateMachine.puml @@ -0,0 +1,31 @@ +@startuml +!pragma layout smetana +set separator none +hide empty description +hide stereotype +skinparam state { + BackgroundColor white + BorderColor #94a3b8 + BorderThickness 1 + FontName Inter + FontSize 9 + FontStyle bold + RoundCorner 20 + Padding 1 +} +skinparam shadowing false +skinparam ArrowFontName JetBrains Mono +skinparam ArrowFontSize 8 +skinparam ArrowColor #cbd5e1 +skinparam ArrowThickness 1 +skinparam dpi 110 +skinparam svgLinkTarget _self + +[*] --> INIT + + +INIT -[#1E90FF,bold]-> BUSY <> <> : ORDER_EVENT / loggingAction() + +DONE --> [*] +@enduml + diff --git a/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/click.kamil.multi.core.OrderStateMachineConfig.json b/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/click.kamil.multi.core.OrderStateMachineConfig.json new file mode 100644 index 0000000..b0c7a90 --- /dev/null +++ b/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/click.kamil.multi.core.OrderStateMachineConfig.json @@ -0,0 +1,108 @@ +{ + "metadata" : { + "triggers" : [ { + "event" : "SUBMIT", + "className" : "click.kamil.multi.core.OrderController", + "methodName" : "submitOrder", + "sourceModule" : null, + "stateMachineId" : null, + "lineNumber" : 18 + } ], + "entryPoints" : [ { + "type" : "REST", + "name" : "POST /orders/submit", + "className" : "click.kamil.multi.core.OrderController", + "methodName" : "submitOrder", + "metadata" : { + "path" : "/orders/submit", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "orderId", + "type" : "String", + "annotations" : [ "RequestBody" ] + } ] + }, { + "type" : "REST", + "name" : "POST /orders/submit", + "className" : "click.kamil.multi.api.OrderApi", + "methodName" : "submitOrder", + "metadata" : { + "path" : "/orders/submit", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "orderId", + "type" : "String", + "annotations" : [ "RequestBody" ] + } ] + } ], + "callChains" : [ { + "entryPoint" : { + "type" : "REST", + "name" : "POST /orders/submit", + "className" : "click.kamil.multi.core.OrderController", + "methodName" : "submitOrder", + "metadata" : { + "path" : "/orders/submit", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "orderId", + "type" : "String", + "annotations" : [ "RequestBody" ] + } ] + }, + "methodChain" : [ "click.kamil.multi.core.OrderController.submitOrder" ], + "triggerPoint" : { + "event" : "SUBMIT", + "className" : "click.kamil.multi.core.OrderController", + "methodName" : "submitOrder", + "sourceModule" : null, + "stateMachineId" : null, + "lineNumber" : 18 + } + } ], + "properties" : { + "default" : { } + } + }, + "name" : "click.kamil.multi.core.OrderStateMachineConfig", + "renderChoicesAsDiamonds" : true, + "startStates" : [ "NEW" ], + "transitions" : [ { + "type" : "EXTERNAL", + "sourceStates" : [ { + "rawName" : "\"NEW\"", + "fullIdentifier" : "NEW" + } ], + "targetStates" : [ { + "rawName" : "\"PROCESSING\"", + "fullIdentifier" : "PROCESSING" + } ], + "event" : "SUBMIT", + "guard" : null, + "actions" : [ { + "expression" : "processAction()", + "isLambda" : false, + "internalLogic" : "@Override default void execute(StateContext context){\n System.out.println(\"Processing order: \" + context.getMessageHeader(\"orderId\"));\n}\n", + "lineNumber" : 31 + } ], + "order" : null + }, { + "type" : "EXTERNAL", + "sourceStates" : [ { + "rawName" : "\"PROCESSING\"", + "fullIdentifier" : "PROCESSING" + } ], + "targetStates" : [ { + "rawName" : "\"COMPLETED\"", + "fullIdentifier" : "COMPLETED" + } ], + "event" : "FINISH", + "guard" : null, + "actions" : [ ], + "order" : null + } ], + "endStates" : [ "COMPLETED" ] +}