diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/CallEdge.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/CallEdge.java index ea74b12..abe6e18 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/CallEdge.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/CallEdge.java @@ -1,11 +1,20 @@ package click.kamil.springstatemachineexporter.analysis.model; import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; import java.util.List; @Data +@NoArgsConstructor +@AllArgsConstructor public class CallEdge { - private final String targetMethod; - private final List arguments; + private String targetMethod; + private List arguments; + private String receiver; + + public CallEdge(String targetMethod, List arguments) { + this(targetMethod, arguments, null); + } } 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 a121b1b..c8557bb 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 @@ -1315,7 +1315,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return -1; } - private void extractConstantsFromArgument(org.eclipse.jdt.core.dom.Expression argObj, List constants) { + protected void extractConstantsFromArgument(org.eclipse.jdt.core.dom.Expression argObj, List constants) { if (argObj instanceof org.eclipse.jdt.core.dom.ClassInstanceCreation innerCic) { String typeName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(innerCic.getType()); if ("String".equals(typeName)) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngine.java index 9396bfa..f859952 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngine.java @@ -35,7 +35,8 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine { List calledMethods = resolveCalledMethodsPolymorphic(node); List args = resolveArguments(node.arguments()); for (String calledMethod : calledMethods) { - graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(calledMethod, args)); + String receiver = getReceiverString(node.getExpression()); + graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(calledMethod, args, receiver)); } for (Object argObj : node.arguments()) { if (argObj instanceof ExpressionMethodReference emr) { @@ -51,7 +52,8 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine { } else { implicitArgs.addAll(args); } - graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(refMethod, implicitArgs)); + String receiver = getReceiverString(node.getExpression()); + graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(refMethod, implicitArgs, receiver)); } } } else { @@ -70,11 +72,12 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine { } else { implicitArgs.addAll(args); } - graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(calledMethod, implicitArgs)); + String receiver = getReceiverString(node.getExpression()); + graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(calledMethod, implicitArgs, receiver)); List impls = context.getImplementations(fallbackTypeFqn); for (String impl : impls) { - graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(impl + "." + emr.getName().getIdentifier(), args)); + graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(impl + "." + emr.getName().getIdentifier(), args, receiver)); } } } @@ -106,7 +109,8 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine { calledMethod = superFqn + "." + methodName; } List args = resolveArguments(node.arguments()); - graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(calledMethod, args)); + String receiver = "super"; + graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(calledMethod, args, receiver)); } } } @@ -165,18 +169,28 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine { String val = null; - // Narrow resolution for "localVar.getX()" patterns where localVar is initialized - // from a ClassInstanceCreation (directly or via a factory method). This avoids - // the broad ENUM_SET fallback when the getter transforms one enum to another. - if (expr instanceof MethodInvocation getterMi - && getterMi.getExpression() instanceof SimpleName instanceSn - && getterMi.arguments().isEmpty()) { - val = tryNarrowGetterViaLocalVarChain(getterMi, instanceSn); - } + // Preserve getter calls on 'this' or 'super' for later resolution in trigger parameter tracing + // where we can substitute the actual receiver from the call edge + boolean isThisOrSuperGetter = expr instanceof MethodInvocation mi + && (mi.getExpression() instanceof ThisExpression || mi.getExpression() instanceof SuperMethodInvocation) + && mi.arguments().isEmpty(); - if (val == null) val = constantResolver.resolve(expr, context); - if (val == null) { - val = expr.toString(); // Fallback to raw string (handles Enums, 'new Class()', etc) + if (!isThisOrSuperGetter) { + // Narrow resolution for "localVar.getX()" patterns where localVar is initialized + // from a ClassInstanceCreation (directly or via a factory method). This avoids + // the broad ENUM_SET fallback when the getter transforms one enum to another. + if (expr instanceof MethodInvocation getterMi + && getterMi.getExpression() instanceof SimpleName instanceSn + && getterMi.arguments().isEmpty()) { + val = tryNarrowGetterViaLocalVarChain(getterMi, instanceSn); + } + + if (val == null) val = constantResolver.resolve(expr, context); + if (val == null) { + val = expr.toString(); // Fallback to raw string (handles Enums, 'new Class()', etc) + } + } else { + val = expr.toString(); // Preserve "this.getTransitionType()" for later substitution } args.add(val); } @@ -195,6 +209,12 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine { String className = baseCalled.substring(0, baseCalled.lastIndexOf('.')); String methodName = baseCalled.substring(baseCalled.lastIndexOf('.') + 1); + String definingClass = findDefiningClass(className, methodName); + if (definingClass != null && !definingClass.equals(className)) { + allResolved.set(0, definingClass + "." + methodName); + className = definingClass; + } + List impls = context.getImplementations(className); for (String impl : impls) { allResolved.add(impl + "." + methodName); @@ -204,6 +224,31 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine { return allResolved; } + private String findDefiningClass(String className, String methodName) { + TypeDeclaration td = context.getTypeDeclaration(className); + if (td == null) return null; + + MethodDeclaration md = context.findMethodDeclaration(td, methodName, false); + if (md != null) { + return className; + } + + String superFqn = context.getSuperclassFqn(td); + while (superFqn != null) { + TypeDeclaration superTd = context.getTypeDeclaration(superFqn); + if (superTd != null) { + MethodDeclaration superMd = context.findMethodDeclaration(superTd, methodName, false); + if (superMd != null) { + return superFqn; + } + superFqn = context.getSuperclassFqn(superTd); + } else { + break; + } + } + return null; + } + protected String resolveCalledMethod(MethodInvocation node) { Expression receiver = node.getExpression(); String methodName = node.getName().getIdentifier(); @@ -487,4 +532,442 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine { } } } + + /** + * Extracts a string representation of the receiver expression from a MethodInvocation. + * Handles ThisExpression, SimpleName, and other expression types. + */ + protected String getReceiverString(Expression receiver) { + if (receiver == null) { + return null; + } + if (receiver instanceof ThisExpression) { + return "this"; + } + if (receiver instanceof SuperMethodInvocation) { + return "super"; + } + if (receiver instanceof SimpleName sn) { + return sn.getIdentifier(); + } + return receiver.toString(); + } + + /** + * Finds the receiver expression used in the caller method when calling the target method. + * Walks the caller's AST to find a MethodInvocation with the target method name + * and returns the receiver string. + */ + protected String findCallerReceiver(String callerFqn, String targetMethodFqn) { + int lastDot = callerFqn.lastIndexOf('.'); + if (lastDot < 0) return null; + String callerClass = callerFqn.substring(0, lastDot); + String callerMethod = callerFqn.substring(lastDot + 1); + + int targetLastDot = targetMethodFqn.lastIndexOf('.'); + if (targetLastDot < 0) return null; + String targetMethodName = targetMethodFqn.substring(targetLastDot + 1); + + TypeDeclaration callerTd = context.getTypeDeclaration(callerClass); + if (callerTd == null) return null; + + MethodDeclaration callerMd = context.findMethodDeclaration(callerTd, callerMethod, false); + if (callerMd == null || callerMd.getBody() == null) return null; + + final String[] foundReceiver = {null}; + callerMd.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(MethodInvocation node) { + if (foundReceiver[0] != null) return false; + String called = resolveCalledMethod(node); + if (called != null && (called.equals(targetMethodFqn) || called.endsWith("." + targetMethodName))) { + foundReceiver[0] = getReceiverString(node.getExpression()); + return false; + } + return super.visit(node); + } + }); + return foundReceiver[0]; + } + + @Override + protected 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 = ""; + boolean didThisSuperSubstitution = false; + + String[] extractedEntry = extractMethodSuffix(currentParamName, methodSuffix); + currentParamName = extractedEntry[0]; + methodSuffix = extractedEntry[1]; + + for (int i = path.size() - 1; i > 0; i--) { + String target = path.get(i); + String caller = path.get(i - 1); + int paramIndex = getParameterIndex(target, currentParamName); + if (paramIndex < 0) { + String tracedVar = traceLocalVariable(target, currentParamName); + if (tracedVar != null && !tracedVar.equals(currentParamName)) { + String[] extractedTraced = extractMethodSuffix(tracedVar, methodSuffix); + tracedVar = extractedTraced[0]; + methodSuffix = extractedTraced[1]; + currentParamName = tracedVar; + resolvedValue = tracedVar + methodSuffix; + paramIndex = getParameterIndex(target, currentParamName); + } + } + if (paramIndex < 0) { + break; + } + + 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) { + System.out.println("DEBUG TRACE: caller=" + caller + " target=" + target + " paramIndex=" + paramIndex + " arg=" + arg + " receiver=" + edge.getReceiver()); + String receiver = edge.getReceiver(); + if (receiver != null && !receiver.isEmpty() && ("this".equals(arg) || arg.startsWith("this.") || "super".equals(arg) || arg.startsWith("super."))) { + String actualReceiver = receiver; + boolean needPrevReceiver = "this".equals(arg) || arg.startsWith("this."); + if (needPrevReceiver && !"this".equals(receiver) && !"super".equals(receiver)) { + if (i - 1 > 0) { + String prevCaller = path.get(i - 2); + List prevEdges = callGraph.get(prevCaller); + if (prevEdges != null) { + for (CallEdge prevEdge : prevEdges) { + if (prevEdge.getTargetMethod().equals(caller) || isHeuristicMatch(prevEdge.getTargetMethod(), caller)) { + actualReceiver = prevEdge.getReceiver(); + System.out.println("DEBUG TRACE: found prev edge, actualReceiver=" + actualReceiver); + break; + } + } + } + } + } else if ("this".equals(receiver) || "super".equals(receiver)) { + if (i - 1 > 0) { + String prevCaller = path.get(i - 2); + List prevEdges = callGraph.get(prevCaller); + if (prevEdges != null) { + for (CallEdge prevEdge : prevEdges) { + if (prevEdge.getTargetMethod().equals(caller) || isHeuristicMatch(prevEdge.getTargetMethod(), caller)) { + actualReceiver = prevEdge.getReceiver(); + System.out.println("DEBUG TRACE: found prev edge, actualReceiver=" + actualReceiver); + break; + } + } + } + } + } + if (actualReceiver != null && !actualReceiver.isEmpty()) { + if ("this".equals(arg) || arg.startsWith("this.")) { + arg = arg.replaceFirst("^this", actualReceiver); + didThisSuperSubstitution = true; + } else if ("super".equals(arg) || arg.startsWith("super.")) { + arg = arg.replaceFirst("^super", actualReceiver); + didThisSuperSubstitution = true; + } + System.out.println("DEBUG TRACE: after substitution arg=" + arg); + } + } + String[] extractedArg = extractMethodSuffix(arg, methodSuffix); + arg = extractedArg[0]; + methodSuffix = extractedArg[1]; + currentParamName = arg; + resolvedValue = arg + methodSuffix; + found = true; + break; + } + } + } + } + } + if (!found) break; + } + + String entryMethod = path.get(0); + int entryParamIndex = getParameterIndex(entryMethod, currentParamName); + if (entryParamIndex < 0) { + if (methodSuffix.startsWith(".get") || methodSuffix.equals(".type") || methodSuffix.equals(".event") || methodSuffix.equals(".type()") || methodSuffix.equals(".event()")) { + String localMethodName = methodSuffix.substring(1).replace("()", ""); + org.eclipse.jdt.core.dom.Expression localSetterExpr = traceLocalSetter(entryMethod, currentParamName, localMethodName); + if (localSetterExpr != null) { + List setterEvents = new ArrayList<>(); + List tracedSetters = traceVariableAll(localSetterExpr); + for (org.eclipse.jdt.core.dom.Expression tracedSetter : tracedSetters) { + extractConstantsFromExpression(tracedSetter, setterEvents); + } + if (!setterEvents.isEmpty()) { + return TriggerPoint.builder() + .event(tp.getEvent()) + .className(tp.getClassName()) + .methodName(tp.getMethodName()) + .sourceFile(tp.getSourceFile()) + .sourceModule(tp.getSourceModule()) + .stateMachineId(tp.getStateMachineId()) + .sourceState(tp.getSourceState()) + .lineNumber(tp.getLineNumber()) + .polymorphicEvents(setterEvents) + .build(); + } + } + } + + boolean hasGetterOnReceiver = methodSuffix.startsWith(".get") && currentParamName != null + && !currentParamName.contains(".") && !currentParamName.contains("(") && !currentParamName.contains("new "); + if (!hasGetterOnReceiver) { + String tracedVar = traceLocalVariable(entryMethod, currentParamName); + if (tracedVar != null && !tracedVar.equals(currentParamName)) { + String[] extractedFinalTraced = extractMethodSuffix(tracedVar, methodSuffix); + tracedVar = extractedFinalTraced[0]; + methodSuffix = extractedFinalTraced[1]; + currentParamName = tracedVar; + resolvedValue = tracedVar + methodSuffix; + } + } + } + + String finalResolvedValue = resolvedValue; + boolean hasGetterOnReceiver = methodSuffix.startsWith(".get") && currentParamName != null + && !currentParamName.contains(".") && !currentParamName.contains("(") && !currentParamName.contains("new "); + if (hasGetterOnReceiver && didThisSuperSubstitution) { + List getterEvents = evaluateGetterOnReceiver(finalResolvedValue, methodSuffix, entryMethod, path, callGraph); + if (getterEvents != null && !getterEvents.isEmpty()) { + return TriggerPoint.builder() + .event(tp.getEvent()) + .className(tp.getClassName()) + .methodName(tp.getMethodName()) + .sourceFile(tp.getSourceFile()) + .sourceModule(tp.getSourceModule()) + .stateMachineId(tp.getStateMachineId()) + .sourceState(tp.getSourceState()) + .lineNumber(tp.getLineNumber()) + .polymorphicEvents(getterEvents) + .build(); + } + // If we couldn't evaluate the getter, fall back to super with modified trigger point + TriggerPoint modifiedTp = TriggerPoint.builder() + .event(resolvedValue) + .className(tp.getClassName()) + .methodName(tp.getMethodName()) + .sourceFile(tp.getSourceFile()) + .sourceModule(tp.getSourceModule()) + .stateMachineId(tp.getStateMachineId()) + .sourceState(tp.getSourceState()) + .lineNumber(tp.getLineNumber()) + .build(); + return super.resolveTriggerPointParameters(modifiedTp, path, callGraph); + } + + return super.resolveTriggerPointParameters(tp, path, callGraph); + } + + private List evaluateGetterOnReceiver(String resolvedValue, String methodSuffix, String entryMethod, List path, Map> callGraph) { + System.out.println("DEBUG evaluateGetterOnReceiver: resolvedValue=" + resolvedValue + " methodSuffix=" + methodSuffix + " entryMethod=" + entryMethod); + org.eclipse.jdt.core.dom.ASTParser exprParser = org.eclipse.jdt.core.dom.ASTParser.newParser(org.eclipse.jdt.core.dom.AST.JLS17); + exprParser.setKind(org.eclipse.jdt.core.dom.ASTParser.K_EXPRESSION); + exprParser.setSource(resolvedValue.toCharArray()); + org.eclipse.jdt.core.dom.ASTNode exprNode = exprParser.createAST(null); + + if (exprNode instanceof org.eclipse.jdt.core.dom.MethodInvocation mi) { + String getterName = mi.getName().getIdentifier(); + org.eclipse.jdt.core.dom.Expression receiver = mi.getExpression(); + String receiverName = null; + String receiverType = null; + + if (receiver instanceof org.eclipse.jdt.core.dom.SimpleName sn) { + receiverName = sn.getIdentifier(); + receiverType = getVariableDeclaredType(entryMethod, receiverName); + System.out.println("DEBUG evaluateGetterOnReceiver: receiverName=" + receiverName + " receiverType=" + receiverType); + } + + if (receiverType == null && path.size() > 1) { + String callerMethod = path.get(1); + receiverType = getVariableDeclaredType(callerMethod, receiverName); + System.out.println("DEBUG evaluateGetterOnReceiver: fallback receiverType=" + receiverType); + } + + if (receiverType != null) { + String simpleReceiverType = receiverType; + if (receiverType.contains("<")) { + simpleReceiverType = receiverType.substring(0, receiverType.indexOf('<')); + } + System.out.println("DEBUG evaluateGetterOnReceiver: simpleReceiverType=" + simpleReceiverType); + TypeDeclaration td = context.getTypeDeclaration(simpleReceiverType); + if (td != null) { + MethodDeclaration getter = context.findMethodDeclaration(td, getterName, true); + if (getter != null && getter.getBody() != null) { + Map fieldValues = new HashMap<>(); + String varName = receiverName; + org.eclipse.jdt.core.dom.Expression initExpr = findVariableInitializer(entryMethod, varName); + System.out.println("DEBUG evaluateGetterOnReceiver: initExpr=" + initExpr + " class=" + (initExpr != null ? initExpr.getClass().getName() : "null")); + if (initExpr instanceof org.eclipse.jdt.core.dom.ClassInstanceCreation cic) { + fieldValues.putAll(buildFieldValuesFromCIC(td, cic, context)); + extractFieldValuesFromConstructorBody(td, cic, fieldValues); + System.out.println("DEBUG evaluateGetterOnReceiver: fieldValues=" + fieldValues); + int lastDot = entryMethod.lastIndexOf('.'); + if (lastDot > 0) { + String className = entryMethod.substring(0, lastDot); + String methodName = entryMethod.substring(lastDot + 1); + TypeDeclaration entryTd = context.getTypeDeclaration(className); + if (entryTd != null) { + MethodDeclaration entryMd = context.findMethodDeclaration(entryTd, methodName, false); + if (entryMd != null && entryMd.getBody() != null) { + // Skip trackSetterCallsBetween since getter call is not in entry method + } + } + } + } + String result = constantResolver.evaluateMethodBodyWithLocals(getter, fieldValues, context, new HashSet<>()); + System.out.println("DEBUG evaluateGetterOnReceiver: result=" + result); + if (result != null) { + String returnType = getter.getReturnType2() != null ? getter.getReturnType2().toString() : null; + if (returnType != null && !result.contains(".")) { + int lastDot = returnType.lastIndexOf('.'); + String simpleReturnType = lastDot > 0 ? returnType.substring(lastDot + 1) : returnType; + result = simpleReturnType + "." + result; + } + return Collections.singletonList(result); + } + } + } + } + } + return null; + } + + private org.eclipse.jdt.core.dom.Expression findVariableInitializer(String methodFqn, String varName) { + int lastDot = methodFqn.lastIndexOf('.'); + if (lastDot < 0) return null; + String className = methodFqn.substring(0, lastDot); + String methodName = methodFqn.substring(lastDot + 1); + + TypeDeclaration td = context.getTypeDeclaration(className); + if (td == null) return null; + + MethodDeclaration md = context.findMethodDeclaration(td, methodName, false); + if (md == null || md.getBody() == null) return null; + + final org.eclipse.jdt.core.dom.Expression[] initExpr = {null}; + md.getBody().accept(new org.eclipse.jdt.core.dom.ASTVisitor() { + @Override + public boolean visit(org.eclipse.jdt.core.dom.VariableDeclarationStatement node) { + for (Object fragObj : node.fragments()) { + org.eclipse.jdt.core.dom.VariableDeclarationFragment frag = (org.eclipse.jdt.core.dom.VariableDeclarationFragment) fragObj; + if (frag.getName().getIdentifier().equals(varName) && frag.getInitializer() != null) { + initExpr[0] = frag.getInitializer(); + return false; + } + } + return super.visit(node); + } + }); + return initExpr[0]; + } + + private void extractFieldValuesFromConstructorBody(TypeDeclaration td, ClassInstanceCreation cic, Map fieldValues) { + System.out.println("DEBUG extractFieldValuesFromConstructorBody: called for " + cic.getType()); + String actualTypeName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType()); + TypeDeclaration actualTd = context.getTypeDeclaration(actualTypeName); + if (actualTd == null) { + actualTd = td; + } + final TypeDeclaration finalTd = actualTd; + + for (MethodDeclaration ctor : finalTd.getMethods()) { + if (!ctor.isConstructor() || ctor.getBody() == null) continue; + if (ctor.parameters().size() != cic.arguments().size()) continue; + + Map paramValues = new HashMap<>(); + for (int i = 0; i < ctor.parameters().size(); i++) { + String pName = ((SingleVariableDeclaration) ctor.parameters().get(i)).getName().getIdentifier(); + Expression argExpr = (Expression) cic.arguments().get(i); + List consts = new ArrayList<>(); + extractConstantsFromArgument(argExpr, consts); + if (!consts.isEmpty()) { + paramValues.put(pName, consts.get(0)); + } else { + String resolved = constantResolver.resolve(argExpr, context); + if (resolved != null) paramValues.put(pName, resolved); + } + } + if (paramValues.isEmpty()) continue; + + ctor.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(Assignment node) { + Expression lhs = node.getLeftHandSide(); + Expression rhs = node.getRightHandSide(); + + String fieldName = null; + if (lhs instanceof FieldAccess fa && fa.getExpression() instanceof ThisExpression) { + fieldName = fa.getName().getIdentifier(); + } else if (lhs instanceof SimpleName sn && !paramValues.containsKey(sn.getIdentifier())) { + fieldName = sn.getIdentifier(); + } + + if (fieldName != null) { + String rhsValue = null; + if (rhs instanceof SimpleName snRhs) { + rhsValue = paramValues.get(snRhs.getIdentifier()); + System.out.println("DEBUG extractFieldValuesFromConstructorBody: found param assignment " + fieldName + "=" + rhsValue); + } else if (rhs instanceof MethodInvocation mi) { + rhsValue = evaluateMethodCallInConstructor(mi, paramValues, finalTd); + System.out.println("DEBUG extractFieldValuesFromConstructorBody: found method call assignment " + fieldName + "=" + rhsValue); + } + + if (rhsValue != null) { + fieldValues.put(fieldName, rhsValue); + fieldValues.put("this." + fieldName, rhsValue); + } + } + return super.visit(node); + } + }); + break; // first matching constructor is enough + } + } + + private String evaluateMethodCallInConstructor(MethodInvocation mi, Map paramValues, TypeDeclaration td) { + System.err.println("DEBUG evaluateMethodCallInConstructor: called for " + mi.getName().getIdentifier() + " in " + context.getFqn(td)); + try { + String methodName = mi.getName().getIdentifier(); + Expression receiver = mi.getExpression(); + System.err.println("DEBUG evaluateMethodCallInConstructor: receiver=" + (receiver != null ? receiver.getClass().getName() : "null")); + // Handle both explicit 'this.' and implicit 'this' (null receiver) + if (receiver != null && !(receiver instanceof ThisExpression)) { + System.err.println("DEBUG evaluateMethodCallInConstructor: receiver not ThisExpression, returning null"); + return null; + } + + System.err.println("DEBUG evaluateMethodCallInConstructor: before findMethodDeclaration"); + MethodDeclaration method = context.findMethodDeclaration(td, methodName, true); + System.err.println("DEBUG evaluateMethodCallInConstructor: method found=" + (method != null)); + if (method != null) { + System.err.println("DEBUG evaluateMethodCallInConstructor: method in " + context.getFqn(findEnclosingType(method)) + " body=" + (method.getBody() != null)); + } + if (method == null || method.getBody() == null) { + System.err.println("DEBUG evaluateMethodCallInConstructor: method null or no body, returning null"); + return null; + } + + Map localVars = new HashMap<>(paramValues); + System.err.println("DEBUG evaluateMethodCallInConstructor: localVars=" + localVars); + String result = constantResolver.evaluateMethodBodyWithLocals(method, localVars, context, new HashSet<>()); + System.err.println("DEBUG evaluateMethodCallInConstructor: result=" + result); + return result; + } catch (Exception e) { + System.err.println("DEBUG evaluateMethodCallInConstructor: EXCEPTION: " + e.getMessage()); + e.printStackTrace(); + return null; + } + } } \ No newline at end of file diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngineExtendedTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngineExtendedTest.java new file mode 100644 index 0000000..92a9393 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngineExtendedTest.java @@ -0,0 +1,133 @@ +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.Tag; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@Tag("heuristic_guess_paths_and_scrape_regex_replace_with_data_flow_analysis") +class HeuristicCallGraphEngineExtendedTest { + /** + * Diagnostic: Generics, inherited processing, multi-enum constructor args, + * and internal constructor mapping via a switch method. + *

+ * Pattern: + * 1. Abstract base class AbstractEventClass holds the payload and transitionType. + * 2. Abstract base class also contains the method that actually fires the state machine. + * 3. MyEventClass constructor takes multiple enums (InputEnum1, InputEnum2). + * 4. MyEventClass assigns: this.transitionType = computeTransitionType(InputEnum2). + * 5. computeTransitionType uses a switch to map InputEnum2 to TransitionEnum. + *

+ * Root cause targeted: The engine must not eagerly grab InputEnum1.IGNORE or + * InputEnum2.SOURCE_B. It must evaluate the internal computeTransitionType() + * method during instantiation to resolve the final TransitionEnum.STATE_Y. + */ + @Test + void shouldResolveTransitionEnumThroughGenericsAndInternalConstructorSwitchComputation() throws IOException { + String source = """ + package com.example; + + public class SystemController { + private StateMachine machine; + + public void processIncomingPayload(String rawData) { + // Creates concrete class with multiple enums and a generic payload. + // Engine MUST NOT stop at IGNORE or SOURCE_B. + MyEventClass event = new MyEventClass<>( + rawData, + InputEnum1.IGNORE, + InputEnum2.SOURCE_B + ); + + // Calls the inherited method that actually processes the event + event.fireEvent(machine); + } + } + + abstract class AbstractEventClass { + protected T payload; + protected TransitionEnum transitionType; + + public AbstractEventClass(T payload) { + this.payload = payload; + } + + // The method to actually process events + public void fireEvent(StateMachine machine) { + machine.fire(this.getTransitionType()); + } + + public TransitionEnum getTransitionType() { + return this.transitionType; + } + } + + class MyEventClass extends AbstractEventClass { + private InputEnum1 routingFlag; + + public MyEventClass(T payload, InputEnum1 flag, InputEnum2 source) { + super(payload); + this.routingFlag = flag; + + // The trap: Assignment via an internal method evaluation + this.transitionType = computeTransitionType(source); + } + + private TransitionEnum computeTransitionType(InputEnum2 source) { + return switch (source) { + case SOURCE_A -> TransitionEnum.STATE_X; + case SOURCE_B -> TransitionEnum.STATE_Y; // Expected resolution + case SOURCE_C -> TransitionEnum.STATE_Z; + }; + } + } + + class StateMachine { + public void fire(TransitionEnum event) {} + } + + enum InputEnum1 { IGNORE, PROCESS_ASYNC } + enum InputEnum2 { SOURCE_A, SOURCE_B, SOURCE_C } + enum TransitionEnum { STATE_X, STATE_Y, STATE_Z } + """; + + java.nio.file.Path tempDir = java.nio.file.Files.createTempDirectory("callgraph_test_generics_computation"); + java.nio.file.Files.writeString(tempDir.resolve("OrderConfig.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.SystemController") + .methodName("processIncomingPayload") + .build(); + + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.StateMachine") + .methodName("fire") + .event("event") + .build(); + + List chains = engine.findChains(List.of(entryPoint), List.of(trigger)); + + assertThat(chains).hasSize(1); + + System.out.println("GENERICS & COMPUTATION polymorphicEvents: " + + chains.get(0).getTriggerPoint().getPolymorphicEvents()); + + // The assertion to prove the bug is fixed. + // If the engine returns InputEnum2.SOURCE_B or InputEnum1.IGNORE, it fails. + // It MUST evaluate computeTransitionType() and return TransitionEnum.STATE_Y. + assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()) + .containsExactlyInAnyOrder("TransitionEnum.STATE_Y"); + } +}