This commit is contained in:
2026-06-24 16:37:57 +02:00
parent cb97392bdd
commit e2f8be9c6b
4 changed files with 644 additions and 19 deletions

View File

@@ -1,11 +1,20 @@
package click.kamil.springstatemachineexporter.analysis.model; package click.kamil.springstatemachineexporter.analysis.model;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import java.util.List; import java.util.List;
@Data @Data
@NoArgsConstructor
@AllArgsConstructor
public class CallEdge { public class CallEdge {
private final String targetMethod; private String targetMethod;
private final List<String> arguments; private List<String> arguments;
private String receiver;
public CallEdge(String targetMethod, List<String> arguments) {
this(targetMethod, arguments, null);
}
} }

View File

@@ -1315,7 +1315,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
return -1; return -1;
} }
private void extractConstantsFromArgument(org.eclipse.jdt.core.dom.Expression argObj, List<String> constants) { protected void extractConstantsFromArgument(org.eclipse.jdt.core.dom.Expression argObj, List<String> constants) {
if (argObj instanceof org.eclipse.jdt.core.dom.ClassInstanceCreation innerCic) { if (argObj instanceof org.eclipse.jdt.core.dom.ClassInstanceCreation innerCic) {
String typeName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(innerCic.getType()); String typeName = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(innerCic.getType());
if ("String".equals(typeName)) { if ("String".equals(typeName)) {

View File

@@ -35,7 +35,8 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine {
List<String> calledMethods = resolveCalledMethodsPolymorphic(node); List<String> calledMethods = resolveCalledMethodsPolymorphic(node);
List<String> args = resolveArguments(node.arguments()); List<String> args = resolveArguments(node.arguments());
for (String calledMethod : calledMethods) { 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()) { for (Object argObj : node.arguments()) {
if (argObj instanceof ExpressionMethodReference emr) { if (argObj instanceof ExpressionMethodReference emr) {
@@ -51,7 +52,8 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine {
} else { } else {
implicitArgs.addAll(args); 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 { } else {
@@ -70,11 +72,12 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine {
} else { } else {
implicitArgs.addAll(args); 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<String> impls = context.getImplementations(fallbackTypeFqn); List<String> impls = context.getImplementations(fallbackTypeFqn);
for (String impl : impls) { 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; calledMethod = superFqn + "." + methodName;
} }
List<String> args = resolveArguments(node.arguments()); List<String> 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,6 +169,13 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine {
String val = null; String val = null;
// 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 (!isThisOrSuperGetter) {
// Narrow resolution for "localVar.getX()" patterns where localVar is initialized // Narrow resolution for "localVar.getX()" patterns where localVar is initialized
// from a ClassInstanceCreation (directly or via a factory method). This avoids // from a ClassInstanceCreation (directly or via a factory method). This avoids
// the broad ENUM_SET fallback when the getter transforms one enum to another. // the broad ENUM_SET fallback when the getter transforms one enum to another.
@@ -178,6 +189,9 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine {
if (val == null) { if (val == null) {
val = expr.toString(); // Fallback to raw string (handles Enums, 'new Class()', etc) 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); args.add(val);
} }
System.out.println("DEBUG resolveArguments: " + args); System.out.println("DEBUG resolveArguments: " + args);
@@ -195,6 +209,12 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine {
String className = baseCalled.substring(0, baseCalled.lastIndexOf('.')); String className = baseCalled.substring(0, baseCalled.lastIndexOf('.'));
String methodName = baseCalled.substring(baseCalled.lastIndexOf('.') + 1); 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<String> impls = context.getImplementations(className); List<String> impls = context.getImplementations(className);
for (String impl : impls) { for (String impl : impls) {
allResolved.add(impl + "." + methodName); allResolved.add(impl + "." + methodName);
@@ -204,6 +224,31 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine {
return allResolved; 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) { protected String resolveCalledMethod(MethodInvocation node) {
Expression receiver = node.getExpression(); Expression receiver = node.getExpression();
String methodName = node.getName().getIdentifier(); 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<String> path, Map<String, List<CallEdge>> 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<CallEdge> 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<CallEdge> 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<CallEdge> 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<String> setterEvents = new ArrayList<>();
List<org.eclipse.jdt.core.dom.Expression> 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<String> 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<String> evaluateGetterOnReceiver(String resolvedValue, String methodSuffix, String entryMethod, List<String> path, Map<String, List<CallEdge>> 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<String, String> 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<String, String> 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<String, String> 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<String> 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<String, String> 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<String, String> 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;
}
}
} }

View File

@@ -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.
* <p>
* Pattern:
* 1. Abstract base class AbstractEventClass<T> holds the payload and transitionType.
* 2. Abstract base class also contains the method that actually fires the state machine.
* 3. MyEventClass<T> constructor takes multiple enums (InputEnum1, InputEnum2).
* 4. MyEventClass assigns: this.transitionType = computeTransitionType(InputEnum2).
* 5. computeTransitionType uses a switch to map InputEnum2 to TransitionEnum.
* <p>
* 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<String> event = new MyEventClass<>(
rawData,
InputEnum1.IGNORE,
InputEnum2.SOURCE_B
);
// Calls the inherited method that actually processes the event
event.fireEvent(machine);
}
}
abstract class AbstractEventClass<T> {
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<T> extends AbstractEventClass<T> {
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<CallChain> 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");
}
}