From b35effd05cb3d5f650cfe4ce642fe15adef0139b Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Wed, 24 Jun 2026 06:57:46 +0200 Subject: [PATCH] heuristics update --- .../analysis/model/CallEdge.java | 13 +- .../service/AbstractCallGraphEngine.java | 289 ++++++++++- .../service/HeuristicCallGraphEngine.java | 471 +++++++++++++++--- .../analysis/service/JdtCallGraphEngine.java | 78 +-- .../HeuristicCallGraphEngineExtendedTest.java | 133 +++++ .../service/HeuristicCallGraphEngineTest.java | 108 ++++ 6 files changed, 949 insertions(+), 143 deletions(-) create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngineExtendedTest.java 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 532625b..b38c3db 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 @@ -12,14 +12,19 @@ import org.eclipse.jdt.core.dom.*; import java.util.*; @Slf4j -public abstract class AbstractCallGraphEngine implements CallGraphEngine { - protected final CodebaseContext context; - protected final ConstantResolver constantResolver; + public abstract class AbstractCallGraphEngine implements CallGraphEngine { + protected final CodebaseContext context; + protected final ConstantResolver constantResolver; - public AbstractCallGraphEngine(CodebaseContext context) { - this.context = context; - this.constantResolver = new ConstantResolver(); - } + public AbstractCallGraphEngine(CodebaseContext context) { + this.context = context; + this.constantResolver = new ConstantResolver(); + } + + @FunctionalInterface + protected interface RhsResolver { + String resolve(Expression rhs, Map paramValues, TypeDeclaration td); + } public List findChains(List entryPoints, List triggers) { Map> callGraph = buildCallGraph(); @@ -986,13 +991,48 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { * field assignments found in the matching constructor body. * *

Handles both {@code this.field = param} and bare {@code field = param} assignment forms. + * + * @param rhsResolver optional strategy to resolve non-SimpleName RHS expressions (e.g., method calls) + */ + protected java.util.Map buildFieldValuesFromCIC( + org.eclipse.jdt.core.dom.TypeDeclaration td, + org.eclipse.jdt.core.dom.ClassInstanceCreation cic, + CodebaseContext context, + RhsResolver rhsResolver) { + + // Use the CIC's actual type as the starting point, not the declared type + String actualTypeName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType()); + TypeDeclaration actualTd = context.getTypeDeclaration(actualTypeName); + if (actualTd == null) { + actualTd = td; // Fallback to passed-in type + } + + java.util.Map fieldValues = new java.util.HashMap<>(); + java.util.Set visitedCtors = new java.util.HashSet<>(); + buildFieldValuesFromCICRecursive(actualTd, cic, context, fieldValues, visitedCtors, rhsResolver); + return fieldValues; + } + + /** + * Backward-compatible overload without RhsResolver. */ protected java.util.Map buildFieldValuesFromCIC( org.eclipse.jdt.core.dom.TypeDeclaration td, org.eclipse.jdt.core.dom.ClassInstanceCreation cic, CodebaseContext context) { + return buildFieldValuesFromCIC(td, cic, context, null); + } - java.util.Map fieldValues = new java.util.HashMap<>(); + private void buildFieldValuesFromCICRecursive( + org.eclipse.jdt.core.dom.TypeDeclaration td, + org.eclipse.jdt.core.dom.ClassInstanceCreation cic, + CodebaseContext context, + java.util.Map fieldValues, + java.util.Set visitedCtors, + RhsResolver rhsResolver) { + + String tdKey = context.getFqn(td); + if (!visitedCtors.add(tdKey)) return; for (org.eclipse.jdt.core.dom.MethodDeclaration ctor : td.getMethods()) { if (!ctor.isConstructor() || ctor.getBody() == null) continue; @@ -1029,12 +1069,17 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { fieldName = fa.getName().getIdentifier(); } else if (lhs instanceof org.eclipse.jdt.core.dom.SimpleName sn && !paramValues.containsKey(sn.getIdentifier())) { - // Bare field assignment (not a parameter-to-itself assignment) fieldName = sn.getIdentifier(); } - if (fieldName != null && rhs instanceof org.eclipse.jdt.core.dom.SimpleName snRhs) { - String paramVal = paramValues.get(snRhs.getIdentifier()); + if (fieldName != null) { + String paramVal = null; + if (rhs instanceof org.eclipse.jdt.core.dom.SimpleName snRhs) { + paramVal = paramValues.get(snRhs.getIdentifier()); + } else if (rhsResolver != null) { + paramVal = rhsResolver.resolve(rhs, paramValues, td); + } + if (paramVal != null) { fieldValues.put(fieldName, paramVal); fieldValues.put("this." + fieldName, paramVal); @@ -1043,9 +1088,152 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return super.visit(node); } }); + + // 3. Follow super constructor chain + ctor.getBody().accept(new org.eclipse.jdt.core.dom.ASTVisitor() { + @Override + public boolean visit(org.eclipse.jdt.core.dom.SuperConstructorInvocation sci) { + String superFqn = context.getSuperclassFqn(td); + if (superFqn != null) { + TypeDeclaration superTd = context.getTypeDeclaration(superFqn); + if (superTd != null) { + processSuperConstructorArgs(superTd, sci.arguments(), paramValues, context, fieldValues, visitedCtors, rhsResolver); + } + } + return super.visit(sci); + } + }); break; // first matching constructor is enough } - return fieldValues; + } + + private void processSuperConstructorArgs( + TypeDeclaration superTd, + List superArgs, + java.util.Map subClassParamValues, + CodebaseContext context, + java.util.Map fieldValues, + java.util.Set visitedCtors, + RhsResolver rhsResolver) { + + if (superTd == null) return; + + for (MethodDeclaration superCtor : superTd.getMethods()) { + if (!superCtor.isConstructor() || superCtor.getBody() == null) continue; + if (superCtor.parameters().size() != superArgs.size()) continue; + + // Map super() argument values to superclass constructor parameters + // superArgs may be parameter references from the subclass constructor + java.util.Map superParamValues = new java.util.HashMap<>(); + for (int i = 0; i < superCtor.parameters().size(); i++) { + String pName = ((org.eclipse.jdt.core.dom.SingleVariableDeclaration) + superCtor.parameters().get(i)).getName().getIdentifier(); + org.eclipse.jdt.core.dom.Expression argExpr = + (org.eclipse.jdt.core.dom.Expression) superArgs.get(i); + + // If the argument is a SimpleName (parameter reference), resolve it using subclass paramValues + String resolved = null; + if (argExpr instanceof org.eclipse.jdt.core.dom.SimpleName sn) { + resolved = subClassParamValues.get(sn.getIdentifier()); + } + if (resolved == null) { + java.util.List consts = new java.util.ArrayList<>(); + extractConstantsFromArgument(argExpr, consts); + if (!consts.isEmpty()) { + resolved = consts.get(0); + } else { + resolved = constantResolver.resolve(argExpr, context); + } + } + if (resolved != null) { + superParamValues.put(pName, resolved); + } + } + if (superParamValues.isEmpty()) continue; + + // Scan superclass constructor body for field assignments + superCtor.getBody().accept(new org.eclipse.jdt.core.dom.ASTVisitor() { + @Override + public boolean visit(org.eclipse.jdt.core.dom.Assignment node) { + org.eclipse.jdt.core.dom.Expression lhs = node.getLeftHandSide(); + org.eclipse.jdt.core.dom.Expression rhs = node.getRightHandSide(); + + String fieldName = null; + if (lhs instanceof org.eclipse.jdt.core.dom.FieldAccess fa + && fa.getExpression() instanceof org.eclipse.jdt.core.dom.ThisExpression) { + fieldName = fa.getName().getIdentifier(); + } else if (lhs instanceof org.eclipse.jdt.core.dom.SimpleName sn + && !superParamValues.containsKey(sn.getIdentifier())) { + fieldName = sn.getIdentifier(); + } + + if (fieldName != null) { + String paramVal = null; + if (rhs instanceof org.eclipse.jdt.core.dom.SimpleName snRhs) { + paramVal = superParamValues.get(snRhs.getIdentifier()); + } else if (rhsResolver != null) { + paramVal = rhsResolver.resolve(rhs, superParamValues, superTd); + } + + if (paramVal != null) { + fieldValues.put(fieldName, paramVal); + fieldValues.put("this." + fieldName, paramVal); + } + } + return super.visit(node); + } + }); + + // Recursively follow further super() calls, passing down the current superclass paramValues + superCtor.getBody().accept(new org.eclipse.jdt.core.dom.ASTVisitor() { + @Override + public boolean visit(org.eclipse.jdt.core.dom.SuperConstructorInvocation sci) { + String superFqn = context.getSuperclassFqn(superTd); + if (superFqn != null) { + TypeDeclaration nextSuperTd = context.getTypeDeclaration(superFqn); + if (nextSuperTd != null) { + processSuperConstructorArgs(nextSuperTd, sci.arguments(), superParamValues, context, fieldValues, visitedCtors, rhsResolver); + } + } + return super.visit(sci); + } + }); + break; // first matching super constructor is enough + } + } + + /** + * Evaluates a method call expression found in a constructor body (e.g., {@code this.field = computeValue(param)}). + * Handles both explicit {@code this.} and implicit {@code this} (null receiver) calls. + * + * @param rhs the expression to evaluate (expected to be a MethodInvocation) + * @param paramValues map of constructor parameter names to their resolved values + * @param td the type declaration containing the method + * @return the resolved value, or {@code null} if evaluation is not possible + */ + protected String evaluateMethodCallInConstructor( + org.eclipse.jdt.core.dom.Expression rhs, + java.util.Map paramValues, + org.eclipse.jdt.core.dom.TypeDeclaration td) { + + if (!(rhs instanceof org.eclipse.jdt.core.dom.MethodInvocation mi)) { + return null; + } + + String methodName = mi.getName().getIdentifier(); + org.eclipse.jdt.core.dom.Expression receiver = mi.getExpression(); + // Handle both explicit 'this.' and implicit 'this' (null receiver) + if (receiver != null && !(receiver instanceof org.eclipse.jdt.core.dom.ThisExpression)) { + return null; + } + + org.eclipse.jdt.core.dom.MethodDeclaration method = context.findMethodDeclaration(td, methodName, true); + if (method == null || method.getBody() == null) { + return null; + } + + java.util.Map localVars = new java.util.HashMap<>(paramValues); + return constantResolver.evaluateMethodBodyWithLocals(method, localVars, context, new java.util.HashSet<>()); } /** @@ -1193,7 +1381,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)) { @@ -1464,6 +1652,81 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return eVal.contains(".") ? eVal.substring(eVal.lastIndexOf('.', eVal.lastIndexOf('.') - 1) + 1) : eVal; } + /** + * Resolves a single argument expression to a string value. + * Handles lambdas, method references, and constructor argument unwrapping. + * Subclasses can override {@link #postProcessResolvedArgument(Expression, String)} for custom behavior. + */ + protected String resolveArgument(org.eclipse.jdt.core.dom.Expression expr) { + // Extract from lambda + if (expr instanceof org.eclipse.jdt.core.dom.LambdaExpression le) { + org.eclipse.jdt.core.dom.ASTNode body = le.getBody(); + if (body instanceof org.eclipse.jdt.core.dom.Expression bodyExpr) { + expr = bodyExpr; + } else if (body instanceof org.eclipse.jdt.core.dom.Block block) { + for (Object stmtObj : block.statements()) { + if (stmtObj instanceof org.eclipse.jdt.core.dom.ReturnStatement rs && rs.getExpression() != null) { + expr = rs.getExpression(); + break; + } + } + } + } + + // Extract from method reference + if (expr instanceof org.eclipse.jdt.core.dom.ExpressionMethodReference emr) { + org.eclipse.jdt.core.dom.TypeDeclaration td = findEnclosingType(emr); + if (td != null) { + org.eclipse.jdt.core.dom.MethodDeclaration md = context.findMethodDeclaration(td, emr.getName().getIdentifier(), true); + if (md != null && md.getBody() != null) { + for (Object stmtObj : md.getBody().statements()) { + if (stmtObj instanceof org.eclipse.jdt.core.dom.ReturnStatement rs && rs.getExpression() != null) { + expr = rs.getExpression(); + break; + } + } + } + } + } + + // Unwrap constructor first argument if it's a constant (e.g., new CustomMessage(OrderEvent.PROCESS)) + if (expr instanceof org.eclipse.jdt.core.dom.ClassInstanceCreation cic && !cic.arguments().isEmpty()) { + org.eclipse.jdt.core.dom.Expression firstArg = (org.eclipse.jdt.core.dom.Expression) cic.arguments().get(0); + String resolved = constantResolver.resolve(firstArg, context); + if (resolved != null) { + expr = firstArg; + } + } + + String val = constantResolver.resolve(expr, context); + if (val == null) { + val = expr.toString(); // Fallback to raw string (handles Enums, 'new Class()', etc) + } + + // Allow subclasses to post-process (e.g., preserve getters for later substitution) + return postProcessResolvedArgument(expr, val); + } + + /** + * Hook for subclasses to customize argument resolution. + * Default implementation returns the value as-is. + */ + protected String postProcessResolvedArgument(org.eclipse.jdt.core.dom.Expression originalExpr, String resolvedValue) { + return resolvedValue; + } + + /** + * Resolves a list of argument expressions. + * Default implementation calls {@link #resolveArgument(Expression)} for each. + */ + protected List resolveArguments(List astArguments) { + List args = new ArrayList<>(); + for (Object argObj : astArguments) { + args.add(resolveArgument((org.eclipse.jdt.core.dom.Expression) argObj)); + } + return args; + } + protected abstract Map> buildCallGraph(); protected abstract String resolveCalledMethod(MethodInvocation node); protected List findPath(String start, String target, Map> graph, Set visited) { 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..c0d2db1 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)); } } } @@ -118,70 +122,31 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine { return graph; } - protected 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; - } - } - } - } + @Override + protected String postProcessResolvedArgument(org.eclipse.jdt.core.dom.Expression originalExpr, String resolvedValue) { + // 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 = originalExpr instanceof org.eclipse.jdt.core.dom.MethodInvocation mi + && (mi.getExpression() instanceof org.eclipse.jdt.core.dom.ThisExpression || mi.getExpression() instanceof org.eclipse.jdt.core.dom.SuperMethodInvocation) + && mi.arguments().isEmpty(); - 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; - } - } - } - } - } - - // Removed eager tracing here to allow dynamic tracing in resolveTriggerPointParameters - - 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 = 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); - } - - if (val == null) val = constantResolver.resolve(expr, context); - if (val == null) { - val = expr.toString(); // Fallback to raw string (handles Enums, 'new Class()', etc) - } - args.add(val); + if (isThisOrSuperGetter) { + return originalExpr.toString(); // Preserve "this.getTransitionType()" for later substitution } - System.out.println("DEBUG resolveArguments: " + args); - return args; + + // 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 (originalExpr instanceof org.eclipse.jdt.core.dom.MethodInvocation getterMi + && getterMi.getExpression() instanceof org.eclipse.jdt.core.dom.SimpleName instanceSn + && getterMi.arguments().isEmpty()) { + String narrowed = tryNarrowGetterViaLocalVarChain(getterMi, instanceSn); + if (narrowed != null) { + return narrowed; + } + } + + return resolvedValue; } protected List resolveCalledMethodsPolymorphic(MethodInvocation node) { @@ -195,6 +160,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 +175,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(); @@ -389,7 +385,7 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine { if (varTypeTd == null) return null; // Evaluate the getter body with field values substituted from the CIC's constructor args - java.util.Map fieldValues = buildFieldValuesFromCIC(varTypeTd, cic, context); + java.util.Map fieldValues = buildFieldValuesFromCIC(varTypeTd, cic, context, this::evaluateMethodCallInConstructor); if (fieldValues.isEmpty()) return null; // Track setter calls on the variable between its initialization and the getter call @@ -487,4 +483,343 @@ 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, this::evaluateMethodCallInConstructor)); + 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]; + } } \ No newline at end of file diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngine.java index 2e8c68b..d2804a2 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngine.java @@ -122,67 +122,25 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine { 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 unwrappedBuilder = unwrapMessageBuilder(expr); - if (unwrappedBuilder != expr) { - expr = unwrappedBuilder; - } - - 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); + @Override + protected String resolveArgument(org.eclipse.jdt.core.dom.Expression expr) { + // Extract from constructor args (e.g., new CustomMessage(OrderEvent.PROCESS, ...)) + org.eclipse.jdt.core.dom.Expression unwrappedBuilder = unwrapMessageBuilder(expr); + if (unwrappedBuilder != expr) { + expr = unwrappedBuilder; } - return args; + + // Trace variable to resolve local variable references + org.eclipse.jdt.core.dom.Expression tracedExpr = traceVariable(expr); + if (tracedExpr instanceof org.eclipse.jdt.core.dom.QualifiedName + || tracedExpr instanceof org.eclipse.jdt.core.dom.ClassInstanceCreation + || tracedExpr instanceof org.eclipse.jdt.core.dom.StringLiteral + || tracedExpr instanceof org.eclipse.jdt.core.dom.NumberLiteral) { + expr = tracedExpr; // Only accept trace if it resolves to a pure constant/primitive/constructor + } + + // Delegate to base class for lambda, method reference, and constructor unwrapping + return super.resolveArgument(expr); } private List resolveCalledMethodsPolymorphic(MethodInvocation node) { 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"); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngineTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngineTest.java index 5299437..781e47f 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngineTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngineTest.java @@ -2991,4 +2991,112 @@ class HeuristicCallGraphEngineTest { assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()) .containsExactlyInAnyOrder("SMEvent.PAY_RECEIVED"); } + /** + * Diagnostic: Complex multi-argument initialization with abstract base class mapping. + * + * Pattern: + * 1. A Factory creates a specific subclass (WebCommand), passing multiple arguments + * including an intermediate enum (SourceType.WEB_API) alongside strings and ints. + * 2. The subclass delegates to an abstract BaseCommand via super(...). + * 3. The base class stores the intermediate enum in a protected field. + * 4. The controller calls an inherited method: cmd.getTransitionEvent() + * 5. The inherited method uses a switch on the protected field to return a TransitionEnum. + * + * Root cause targeted: The engine traces the field back to the constructor and finds + * SourceType.WEB_API. If the engine is buggy, it will eagerly return "SourceType.WEB_API". + * If correct, it will evaluate the switch mapping and return "TransitionEnum.ON_WEB_START". + */ + @Test + void shouldResolveMappedTransitionEnumThroughAbstractBaseClassAndMultipleArgs() throws IOException { + String source = """ + package com.example; + + public class FlowController { + private CommandFactory factory; + private StateMachine machine; + + public void handleRequest(String payload) { + // Creates subclass with multiple args, including intermediate enum + BaseCommand cmd = factory.buildCommand(payload); + + // Fires using the mapped enum from the inherited base method + machine.fire(cmd.getTransitionEvent()); + } + } + + class CommandFactory { + public BaseCommand buildCommand(String payload) { + // Passing multiple args: String, Enum1, String, int + return new WebCommand(payload, SourceType.WEB_API, "meta_v1", 42); + } + } + + abstract class BaseCommand { + protected String id; + protected SourceType sourceType; // The intermediate enum + protected String metadata; + + public BaseCommand(String id, SourceType sourceType, String metadata) { + this.id = id; + this.sourceType = sourceType; + this.metadata = metadata; + } + + // The tricky part: Inherited method doing the mapping + public TransitionEnum getTransitionEvent() { + return switch (this.sourceType) { + case WEB_API -> TransitionEnum.ON_WEB_START; + case MOBILE_APP -> TransitionEnum.ON_MOBILE_START; + case CRON_JOB -> TransitionEnum.ON_SYSTEM_START; + }; + } + } + + class WebCommand extends BaseCommand { + private int priorityLevel; + + public WebCommand(String id, SourceType sourceType, String metadata, int priorityLevel) { + super(id, sourceType, metadata); // Engine must track through super() + this.priorityLevel = priorityLevel; + } + } + + class StateMachine { + public void fire(TransitionEnum event) {} + } + + enum SourceType { WEB_API, MOBILE_APP, CRON_JOB } + enum TransitionEnum { ON_WEB_START, ON_MOBILE_START, ON_SYSTEM_START } + """; + java.nio.file.Path tempDir = java.nio.file.Files.createTempDirectory("callgraph_test_complex_inheritance"); + 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.FlowController") + .methodName("handleRequest") + .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("COMPLEX INHERITANCE polymorphicEvents: " + + chains.get(0).getTriggerPoint().getPolymorphicEvents()); + + // The assertion to prove the bug is fixed. + // It MUST NOT contain "SourceType.WEB_API" + assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()) + .containsExactlyInAnyOrder("TransitionEnum.ON_WEB_START"); + } }