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 fbc0ee5..fcdb1cb 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 @@ -175,10 +175,12 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } // Parse resolvedValue using JDT to robustly handle complex expressions - org.eclipse.jdt.core.dom.ASTParser exprParser = org.eclipse.jdt.core.dom.ASTParser.newParser(org.eclipse.jdt.core.dom.AST.getJLSLatest()); - exprParser.setSource(resolvedValue.toCharArray()); + System.out.println("DEBUG Parsing resolvedValue: " + resolvedValue); + 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); + System.out.println("DEBUG exprNode: " + (exprNode != null ? exprNode.getClass().getName() : "null") + ", node: " + exprNode); String varName = null; String methodName = null; @@ -187,6 +189,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { if (exprNode instanceof org.eclipse.jdt.core.dom.MethodInvocation mi) { methodName = mi.getName().getIdentifier(); + System.out.println("DEBUG mi.getName(): " + methodName + ", mi.getExpression() type: " + (mi.getExpression() != null ? mi.getExpression().getClass().getName() : "null")); if (mi.getExpression() instanceof org.eclipse.jdt.core.dom.SimpleName sn) { varName = sn.getIdentifier(); } else if (mi.getExpression() instanceof org.eclipse.jdt.core.dom.ParenthesizedExpression pe && @@ -196,8 +199,41 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { declaredType = ce.getType().toString(); sourceMethod = "inline-cast"; } else if (mi.getExpression() instanceof org.eclipse.jdt.core.dom.ClassInstanceCreation cic) { + System.out.println("DEBUG found ClassInstanceCreation!"); declaredType = click.kamil.springstatemachineexporter.ast.common.AstUtils.extractSimpleTypeName(cic.getType()); sourceMethod = "inline-instantiation"; + if (cic.getAnonymousClassDeclaration() != null) { + System.out.println("DEBUG resolving anonymous class!"); + final List polyEventsRef = polymorphicEvents; + for (Object declObj : cic.getAnonymousClassDeclaration().bodyDeclarations()) { + if (declObj instanceof org.eclipse.jdt.core.dom.MethodDeclaration mDecl && mDecl.getName().getIdentifier().equals(methodName) && mDecl.getBody() != null) { + System.out.println("DEBUG visiting anonymous class method: " + methodName); + mDecl.getBody().accept(new org.eclipse.jdt.core.dom.ASTVisitor() { + @Override + public boolean visit(org.eclipse.jdt.core.dom.ReturnStatement rs) { + if (rs.getExpression() != null) { + System.out.println("DEBUG found return expression: " + rs.getExpression().toString()); + List tracedReturns = traceVariableAll(rs.getExpression()); + for (org.eclipse.jdt.core.dom.Expression tracedRet : tracedReturns) { + extractConstantsFromExpression(tracedRet, polyEventsRef); + } + } + return super.visit(rs); + } + }); + } + } + } + } else if (mi.getExpression() instanceof org.eclipse.jdt.core.dom.MethodInvocation innerMi) { + methodName = innerMi.getName().getIdentifier(); + if (innerMi.getExpression() instanceof org.eclipse.jdt.core.dom.SimpleName snInner) { + varName = snInner.getIdentifier(); + } else { + String exprStr = innerMi.getExpression() != null ? innerMi.getExpression().toString() : ""; + if (!exprStr.contains("(")) { + varName = exprStr; + } + } } else { // Fallback for complex chained expressions String exprStr = mi.getExpression() != null ? mi.getExpression().toString() : ""; @@ -261,8 +297,15 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { if (varName != null && declaredType == null) { for (String methodFqn : path) { declaredType = getVariableDeclaredType(methodFqn, varName); - System.out.println("DEBUG getVariableDeclaredType(" + methodFqn + ", " + varName + ") = " + declaredType); if (declaredType != null) { + System.out.println("DEBUG getVariableDeclaredType(" + methodFqn + ", " + varName + ") = " + declaredType); + TypeDeclaration currentTd = context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.'))); + CompilationUnit cu = currentTd != null && currentTd.getRoot() instanceof CompilationUnit ? (CompilationUnit) currentTd.getRoot() : null; + List values = resolveMethodReturnConstant(declaredType, methodName, 0, new HashSet<>(), cu); + System.out.println("DEBUG resolveMethodReturnConstant returned: " + values); + if (values != null && !values.isEmpty()) { + polymorphicEvents.addAll(values); + } sourceMethod = methodFqn; break; } @@ -345,6 +388,8 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return !val.equals(val.toUpperCase()) || val.length() <= 2; }); + polymorphicEvents = polymorphicEvents.stream().distinct().collect(java.util.stream.Collectors.toList()); + if (!resolvedValue.equals(event) || !polymorphicEvents.isEmpty()) { return TriggerPoint.builder() .event(resolvedValue) @@ -378,24 +423,29 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return -1; } - protected String getVariableDeclaredType(String methodFqn, String varName) { - if (methodFqn == null || !methodFqn.contains(".")) return null; - String className = methodFqn.substring(0, methodFqn.lastIndexOf('.')); - String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1); - TypeDeclaration td = context.getTypeDeclaration(className); - if (td != null) { - String cleanFieldName = varName.startsWith("this.") ? varName.substring(5) : varName; - MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); - if (md != null) { - for (Object pObj : md.parameters()) { - SingleVariableDeclaration svd = (SingleVariableDeclaration) pObj; - if (svd.getName().getIdentifier().equals(cleanFieldName)) { - return svd.getType().toString(); + public String getVariableDeclaredType(String methodFqn, String cleanFieldName) { + System.out.println("DEBUG getVariableDeclaredType called with: " + methodFqn + ", " + cleanFieldName); + if (methodFqn == null) return null; + int lastDot = methodFqn.lastIndexOf('.'); + if (lastDot > 0) { + String fqn = methodFqn.substring(0, lastDot); + String methodName = methodFqn.substring(lastDot + 1); + TypeDeclaration td = context.getTypeDeclaration(fqn); + System.out.println("DEBUG getTypeDeclaration for " + fqn + " = " + (td != null)); + if (td != null) { + MethodDeclaration md = context.findMethodDeclaration(td, methodName, false); + System.out.println("DEBUG findMethodDeclaration for " + methodName + " = " + (md != null)); + if (md != null) { + for (Object pObj : md.parameters()) { + SingleVariableDeclaration svd = (SingleVariableDeclaration) pObj; + if (svd.getName().getIdentifier().equals(cleanFieldName)) { + return svd.getType().toString(); + } } - } - final String[] foundType = new String[1]; - if (md.getBody() != null) { - md.getBody().accept(new ASTVisitor() { + final String[] foundType = new String[1]; + if (md.getBody() != null) { + System.out.println("DEBUG visiting body for " + cleanFieldName); + md.getBody().accept(new ASTVisitor() { @Override public boolean visit(VariableDeclarationStatement node) { for (Object fragObj : node.fragments()) { @@ -411,38 +461,86 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { public boolean visit(org.eclipse.jdt.core.dom.LambdaExpression node) { for (Object paramObj : node.parameters()) { org.eclipse.jdt.core.dom.VariableDeclaration param = (org.eclipse.jdt.core.dom.VariableDeclaration) paramObj; + System.out.println("DEBUG visiting lambda param: " + param.getName().getIdentifier()); if (param.getName().getIdentifier().equals(cleanFieldName)) { + System.out.println("DEBUG found lambda param: " + cleanFieldName); if (param instanceof SingleVariableDeclaration svd && svd.getType() != null) { foundType[0] = svd.getType().toString(); + System.out.println("DEBUG lambda explicit type: " + foundType[0]); return false; } org.eclipse.jdt.core.dom.ASTNode parent = node.getParent(); if (parent instanceof org.eclipse.jdt.core.dom.MethodInvocation mi) { org.eclipse.jdt.core.dom.Expression caller = mi.getExpression(); - if (caller instanceof org.eclipse.jdt.core.dom.SimpleName callerSn) { - String callerName = callerSn.getIdentifier(); - if (!callerName.equals(cleanFieldName)) { - String callerType = getVariableDeclaredType(methodFqn, callerName); - if (callerType != null && callerType.contains("<")) { - int start = callerType.indexOf('<'); - int end = callerType.lastIndexOf('>'); - if (start >= 0 && end > start) { - foundType[0] = callerType.substring(start + 1, end); - return false; + + // Phase 6: Stream API chains + boolean hasMap = false; + while (caller instanceof org.eclipse.jdt.core.dom.MethodInvocation chainMi) { + String mName = chainMi.getName().getIdentifier(); + if (mName.equals("map") || mName.equals("flatMap")) { + hasMap = true; + // Extract return type of the map lambda + if (chainMi.arguments().size() == 1 && chainMi.arguments().get(0) instanceof org.eclipse.jdt.core.dom.LambdaExpression lambda) { + if (lambda.getBody() instanceof org.eclipse.jdt.core.dom.MethodInvocation mapMi) { + // Just a heuristic for now: we use the method's return type if we could resolve it, but since we can't easily, + // we'll try to find the type of the caller and look up the method. + org.eclipse.jdt.core.dom.Expression mapCaller = mapMi.getExpression(); + if (mapCaller instanceof org.eclipse.jdt.core.dom.SimpleName mapSn) { + String mapCallerType = getVariableDeclaredType(methodFqn, mapSn.getIdentifier()); + if (mapCallerType != null) { + TypeDeclaration currentTd = context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.'))); + CompilationUnit cu = currentTd != null && currentTd.getRoot() instanceof CompilationUnit ? (CompilationUnit) currentTd.getRoot() : null; + TypeDeclaration td = context.getTypeDeclaration(mapCallerType, cu); + if (td != null) { + MethodDeclaration mapMd = context.findMethodDeclaration(td, mapMi.getName().getIdentifier(), true); + if (mapMd != null && mapMd.getReturnType2() != null) { + foundType[0] = mapMd.getReturnType2().toString(); + return false; + } + } + } + } } } } - } else if (caller instanceof org.eclipse.jdt.core.dom.FieldAccess fa) { - String callerName = fa.getName().getIdentifier(); - if (!callerName.equals(cleanFieldName)) { - String callerType = getVariableDeclaredType(methodFqn, callerName); - if (callerType != null && callerType.contains("<")) { - int start = callerType.indexOf('<'); - int end = callerType.lastIndexOf('>'); - if (start >= 0 && end > start) { - foundType[0] = callerType.substring(start + 1, end); - return false; + if (mName.equals("stream") || mName.equals("filter") || mName.equals("map") || + mName.equals("flatMap") || mName.equals("peek") || mName.equals("distinct") || + mName.equals("sorted") || mName.equals("limit") || mName.equals("skip")) { + caller = chainMi.getExpression(); + } else { + break; + } + } + + if (!hasMap) { + if (caller instanceof org.eclipse.jdt.core.dom.SimpleName callerSn) { + String callerName = callerSn.getIdentifier(); + System.out.println("DEBUG callerName: " + callerName + " cleanFieldName: " + cleanFieldName); + if (!callerName.equals(cleanFieldName)) { + String callerType = getVariableDeclaredType(methodFqn, callerName); + System.out.println("DEBUG callerType for " + callerName + " = " + callerType); + if (callerType != null && callerType.contains("<")) { + int start = callerType.indexOf('<'); + int end = callerType.lastIndexOf('>'); + if (start >= 0 && end > start) { + foundType[0] = callerType.substring(start + 1, end); + System.out.println("DEBUG foundType[0]: " + foundType[0]); + return false; + } + } + } + } else if (caller instanceof org.eclipse.jdt.core.dom.FieldAccess fa) { + String callerName = fa.getName().getIdentifier(); + if (!callerName.equals(cleanFieldName)) { + String callerType = getVariableDeclaredType(methodFqn, callerName); + if (callerType != null && callerType.contains("<")) { + int start = callerType.indexOf('<'); + int end = callerType.lastIndexOf('>'); + if (start >= 0 && end > start) { + foundType[0] = callerType.substring(start + 1, end); + return false; + } } } } @@ -467,6 +565,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } } } + } return null; } @@ -550,15 +649,11 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { List consts = traceFieldInConstructors(td, sn.getIdentifier(), context, visited); if (!consts.isEmpty()) { constants.addAll(consts); - } else { - constants.add(sn.toString()); } } else if (tracedRetExpr instanceof org.eclipse.jdt.core.dom.FieldAccess fa) { List consts = traceFieldInConstructors(td, fa.getName().getIdentifier(), context, visited); if (!consts.isEmpty()) { constants.addAll(consts); - } else { - constants.add(fa.getName().getIdentifier()); } } } @@ -567,6 +662,14 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return super.visit(node); } }); + } else { + List impls = context.getImplementations(className); + if (impls != null) { + for (String implName : impls) { + List delegationResult = resolveMethodReturnConstant(implName, methodName, depth + 1, visited, contextCu); + constants.addAll(delegationResult); + } + } } } visited.remove(fqn); @@ -630,9 +733,38 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return super.visit(rs); } }); + } else if (expr instanceof org.eclipse.jdt.core.dom.ArrayAccess aa) { + extractConstantsFromExpression(aa.getArray(), constants); + } else if (expr instanceof org.eclipse.jdt.core.dom.ArrayCreation ac && ac.getInitializer() != null) { + for (Object expObj : ac.getInitializer().expressions()) { + extractConstantsFromExpression((org.eclipse.jdt.core.dom.Expression) expObj, constants); + } + } else if (expr instanceof org.eclipse.jdt.core.dom.ArrayInitializer ai) { + for (Object expObj : ai.expressions()) { + extractConstantsFromExpression((org.eclipse.jdt.core.dom.Expression) expObj, constants); + } + } else if (expr instanceof org.eclipse.jdt.core.dom.ClassInstanceCreation cic) { + for (Object argObj : cic.arguments()) { + extractConstantsFromExpression((org.eclipse.jdt.core.dom.Expression) argObj, constants); + } } else if (expr instanceof org.eclipse.jdt.core.dom.MethodInvocation mi) { String methodName = mi.getName().getIdentifier(); + // Phase 7: List.get(0) indexing + if (methodName.equals("get") && mi.arguments().size() == 1 && mi.getExpression() != null) { + extractConstantsFromExpression(mi.getExpression(), constants); + return; + } + // Phase 7: List.of("A", "B") or Arrays.asList("A", "B") + if ((methodName.equals("of") || methodName.equals("asList")) && mi.getExpression() instanceof org.eclipse.jdt.core.dom.SimpleName) { + for (Object argObj : mi.arguments()) { + extractConstantsFromExpression((org.eclipse.jdt.core.dom.Expression) argObj, constants); + } + return; + } + + + // 1. Local setter tracking if ((methodName.startsWith("get") || methodName.equals("type") || methodName.equals("event")) && mi.getExpression() instanceof org.eclipse.jdt.core.dom.SimpleName sn) { String varName = sn.getIdentifier(); @@ -700,15 +832,15 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } // 3. Builder Pattern (e.g. MessageBuilder.withPayload(...).setHeader("event", CONSTANT).build()) - org.eclipse.jdt.core.dom.Expression current = mi; - while (current instanceof org.eclipse.jdt.core.dom.MethodInvocation chainMi) { + org.eclipse.jdt.core.dom.Expression currentMi = mi; + while (currentMi instanceof org.eclipse.jdt.core.dom.MethodInvocation chainMi) { String chainName = chainMi.getName().getIdentifier(); if (chainName.equals("setHeader") && chainMi.arguments().size() == 2) { extractConstantsFromExpression((org.eclipse.jdt.core.dom.Expression) chainMi.arguments().get(1), constants); - } else if (chainName.equals("event") && chainMi.arguments().size() == 1) { + } else if ((chainName.equalsIgnoreCase("event") || chainName.equalsIgnoreCase("type") || chainName.equalsIgnoreCase("eventType")) && chainMi.arguments().size() == 1) { extractConstantsFromExpression((org.eclipse.jdt.core.dom.Expression) chainMi.arguments().get(0), constants); } - current = chainMi.getExpression(); + currentMi = chainMi.getExpression(); } // 4. Delegate to method return analysis @@ -736,44 +868,57 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { if (td != null) { MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); if (md != null && md.getBody() != null) { - final Expression[] initializer = new Expression[1]; + List initializers = new ArrayList<>(); md.getBody().accept(new ASTVisitor() { @Override public boolean visit(VariableDeclarationFragment node) { if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) { - initializer[0] = node.getInitializer(); + initializers.add(node.getInitializer()); } return super.visit(node); } @Override public boolean visit(Assignment node) { if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) { - initializer[0] = node.getRightHandSide(); + initializers.add(node.getRightHandSide()); } return super.visit(node); } }); - if (initializer[0] != null) { - Expression expr = traceVariable(initializer[0]); - if (expr instanceof MethodInvocation mi) { - // Unwrapper logic: If wrapper method is called, extract its arguments recursively - Expression innerMost = unwrapMethodInvocation(mi, 0); - if (innerMost instanceof MethodInvocation innerMi) { - if (innerMi.getExpression() instanceof SimpleName sn) { - return sn.getIdentifier() + "." + innerMi.getName().getIdentifier() + "()"; + if (!initializers.isEmpty()) { + List stringified = new ArrayList<>(); + for (Expression expr : initializers) { + Expression traced = traceVariable(expr); + if (traced instanceof org.eclipse.jdt.core.dom.MethodInvocation mi) { + Expression innerMost = unwrapMethodInvocation(mi, 0); + if (innerMost instanceof org.eclipse.jdt.core.dom.MethodInvocation innerMi) { + if (innerMi.getExpression() instanceof SimpleName sn) { + stringified.add(sn.getIdentifier() + "." + innerMi.getName().getIdentifier() + "()"); + } else { + stringified.add(innerMi.getName().getIdentifier() + "()"); + } + } else if (innerMost instanceof SimpleName sn) { + stringified.add(sn.getIdentifier()); + } else { + stringified.add(innerMost.toString()); } - return innerMi.getName().getIdentifier() + "()"; + } else if (traced instanceof SimpleName sn) { + stringified.add(sn.getIdentifier()); + } else if (traced instanceof org.eclipse.jdt.core.dom.ArrayInitializer) { + stringified.add("new Object[]" + traced.toString()); + } else { + stringified.add(traced.toString()); } - if (innerMost instanceof SimpleName sn) { - return sn.getIdentifier(); - } - return innerMost.toString(); } - if (expr instanceof SimpleName sn) { - return sn.getIdentifier(); + if (stringified.size() == 1) return stringified.get(0); + + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < stringified.size() - 1; i++) { + sb.append("true ? ").append(stringified.get(i)).append(" : "); } - return initializer[0].toString(); + sb.append(stringified.get(stringified.size() - 1)); + return sb.toString(); } } @@ -872,11 +1017,19 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return null; } - protected Expression unwrapMethodInvocation(MethodInvocation mi, int depth) { + protected Expression unwrapMethodInvocation(org.eclipse.jdt.core.dom.MethodInvocation mi, int depth) { if (depth > 5) return mi; + // Do not unwrap if called on an object (e.g. mapper.toEvent) + // Exceptions: Optional.of, Optional.ofNullable, etc. + if (mi.getExpression() != null) { + String exprStr = mi.getExpression().toString(); + if (!exprStr.equals("Optional") && !exprStr.equals("Stream") && !exprStr.equals("List") && !exprStr.equals("Set") && !exprStr.equals("Arrays")) { + return mi; + } + } if (!mi.arguments().isEmpty()) { Expression arg = (Expression) mi.arguments().get(0); - if (arg instanceof MethodInvocation innerMi) { + if (arg instanceof org.eclipse.jdt.core.dom.MethodInvocation innerMi) { return unwrapMethodInvocation(innerMi, depth + 1); } return arg; @@ -918,12 +1071,22 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } protected String[] extractMethodSuffix(String paramName, String currentSuffix) { - int dotIndex = paramName.lastIndexOf('.'); - if (dotIndex > 0 && dotIndex + 1 < paramName.length() && Character.isLowerCase(paramName.charAt(dotIndex + 1))) { - if (paramName.substring(0, dotIndex).equals("this") && !paramName.endsWith("()")) { - return new String[] { paramName, currentSuffix }; + if (paramName != null && !paramName.contains(" ") && !paramName.startsWith("new ")) { + int bracketIndex = paramName.indexOf('['); + int dotIndex = paramName.indexOf('.'); + + if (bracketIndex > 0 && (dotIndex == -1 || bracketIndex < dotIndex)) { + return new String[] { paramName.substring(0, bracketIndex), paramName.substring(bracketIndex) + currentSuffix }; + } else if (dotIndex > 0) { + if (paramName.startsWith("this.")) { + int nextDotIndex = paramName.indexOf('.', 5); + if (nextDotIndex > 0) { + return new String[] { paramName.substring(0, nextDotIndex), paramName.substring(nextDotIndex) + currentSuffix }; + } + return new String[] { paramName, currentSuffix }; + } + return new String[] { paramName.substring(0, dotIndex), paramName.substring(dotIndex) + currentSuffix }; } - return new String[] { paramName.substring(0, dotIndex), paramName.substring(dotIndex) + currentSuffix }; } return new String[] { paramName, currentSuffix }; } @@ -978,36 +1141,82 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return (TypeDeclaration) parent; } protected Expression traceVariable(Expression expr) { - List all = traceVariableAll(expr); + List all = traceVariableAll(expr, new HashSet<>()); return all.isEmpty() ? expr : all.get(all.size() - 1); } protected List traceVariableAll(Expression expr) { + return traceVariableAll(expr, new HashSet<>()); + } + + protected List traceVariableAll(Expression expr, Set visitedVariables) { List results = new ArrayList<>(); if (expr instanceof SimpleName sn) { String varName = sn.getIdentifier(); + if (!visitedVariables.add(varName)) { + return results; // Break infinite loop + } MethodDeclaration enclosingMethod = findEnclosingMethod(expr); if (enclosingMethod != null) { enclosingMethod.accept(new ASTVisitor() { @Override public boolean visit(VariableDeclarationFragment node) { if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) { - results.addAll(traceVariableAll(node.getInitializer())); + results.addAll(traceVariableAll(node.getInitializer(), visitedVariables)); } return super.visit(node); } @Override public boolean visit(Assignment node) { if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) { - results.addAll(traceVariableAll(node.getRightHandSide())); + results.addAll(traceVariableAll(node.getRightHandSide(), visitedVariables)); } return super.visit(node); } }); if (!results.isEmpty()) { + visitedVariables.remove(varName); return results; } + + // Tracing parameter callers (Inter-procedural within the same class) + for (Object paramObj : enclosingMethod.parameters()) { + org.eclipse.jdt.core.dom.SingleVariableDeclaration param = (org.eclipse.jdt.core.dom.SingleVariableDeclaration) paramObj; + if (param.getName().getIdentifier().equals(varName)) { + int paramIndex = enclosingMethod.parameters().indexOf(param); + TypeDeclaration enclosingType = findEnclosingType(enclosingMethod); + if (enclosingType != null) { + String mName = enclosingMethod.getName().getIdentifier(); + List callerExprs = new ArrayList<>(); + enclosingType.accept(new org.eclipse.jdt.core.dom.ASTVisitor() { + @Override + public boolean visit(org.eclipse.jdt.core.dom.MethodInvocation node) { + if (node.getName().getIdentifier().equals(mName) && node.arguments().size() > paramIndex) { + callerExprs.addAll(traceVariableAll((Expression) node.arguments().get(paramIndex), visitedVariables)); + } + return super.visit(node); + } + @Override + public boolean visit(org.eclipse.jdt.core.dom.ExpressionMethodReference node) { + if (node.getName().getIdentifier().equals(mName)) { + if (node.getParent() instanceof org.eclipse.jdt.core.dom.MethodInvocation parentMi) { + if (parentMi.getExpression() != null) { + callerExprs.addAll(traceVariableAll(parentMi.getExpression(), visitedVariables)); + } + } + } + return super.visit(node); + } + }); + if (!callerExprs.isEmpty()) { + visitedVariables.remove(varName); + return callerExprs; + } + } + } + } } + visitedVariables.remove(varName); } results.add(expr); return results; 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 2d848ef..46c04ce 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 @@ -141,11 +141,7 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine { } } - // Extract from constructor args (e.g., new CustomMessage(OrderEvent.PROCESS, ...)) - Expression tracedExpr = traceVariable(expr); - if (tracedExpr instanceof QualifiedName || tracedExpr instanceof ClassInstanceCreation || tracedExpr instanceof StringLiteral || tracedExpr instanceof NumberLiteral) { - expr = tracedExpr; // Only accept trace if it resolves to a pure constant/primitive/constructor - } + // 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); @@ -161,6 +157,7 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine { } args.add(val); } + System.out.println("DEBUG resolveArguments: " + args); return args; } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java index c065684..229c7ea 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java @@ -65,7 +65,7 @@ public class CodebaseContext { return Collections.unmodifiableList(libraryHints); } - private final Set ignorePatterns = new HashSet<>(Set.of("**/test/**", "**/build/**", "**/node_modules/**", "**/out/**")); + private final Set ignorePatterns = new HashSet<>(Set.of("**/test/**", "**/build/classes/**", "**/build/libs/**", "**/build/tmp/**", "**/build/reports/**", "**/build/test-results/**", "**/node_modules/**", "**/out/**")); private String[] classpath = new String[0]; private String[] sourcepath = new String[0]; private boolean resolveBindings = false; @@ -191,6 +191,8 @@ public class CodebaseContext { indexType(td, packageName, javaFile); } else if (type instanceof EnumDeclaration ed) { indexEnum(ed, packageName, javaFile); + } else if (type instanceof org.eclipse.jdt.core.dom.RecordDeclaration rd) { + indexRecord(rd, packageName, javaFile); } } } @@ -235,6 +237,33 @@ public class CodebaseContext { } } + private void indexRecord(org.eclipse.jdt.core.dom.RecordDeclaration rd, String parentFqn, Path javaFile) { + String simpleName = rd.getName().getIdentifier(); + String fqn = parentFqn.isEmpty() ? simpleName : parentFqn + "." + simpleName; + + // Treat as a class since it is a type + classes.put(fqn, (CompilationUnit) rd.getRoot()); + classPaths.put(fqn, javaFile); + + if (simpleNameToFqn.containsKey(simpleName)) { + String existingFqn = simpleNameToFqn.get(simpleName); + if (!existingFqn.equals(fqn)) { + ambiguousSimpleNames.add(simpleName); + } + } else { + simpleNameToFqn.put(simpleName, fqn); + } + + // Recursively index nested types + for (Object type : rd.bodyDeclarations()) { + if (type instanceof TypeDeclaration nestedTd) { + indexType(nestedTd, fqn, javaFile); + } else if (type instanceof org.eclipse.jdt.core.dom.RecordDeclaration nestedRd) { + indexRecord(nestedRd, fqn, javaFile); + } + } + } + public List getImplementations(String interfaceName) { Set allImpls = new HashSet<>(); collectImplementations(interfaceName, allImpls, new HashSet<>()); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AnonymousClassTests.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AnonymousClassTests.java new file mode 100644 index 0000000..3364705 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/AnonymousClassTests.java @@ -0,0 +1,61 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; + +public class AnonymousClassTests { + + @TempDir + Path tempDir; + + @Test + void shouldResolveEventFromAnonymousClass() throws Exception { + String source = """ + package com.example; + public class AnonymousService { + private EventService service; + + public void process() { + MyEvent event = new MyEvent() { + @Override + public String getEvent() { + return "ANON_EVENT"; + } + }; + service.trigger(event.getEvent()); + } + } + class EventService { + public void trigger(String ev) {} + } + class MyEvent { + public String getEvent() { return "BASE_EVENT"; } + } + """; + + Files.writeString(tempDir.resolve("AnonymousService.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.EventService") + .methodName("trigger") + .sourceFile("AnonymousService.java") + .sourceModule("com.example") + .event("event.getEvent()") + .lineNumber(12) + .build(); + + TriggerPoint result = engine.resolveTriggerPointParameters(trigger, List.of("com.example.AnonymousService.process", "com.example.EventService.trigger"), java.util.Collections.emptyMap()); + + assertThat(result.getPolymorphicEvents()).contains("ANON_EVENT"); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CollectionIndexingTests.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CollectionIndexingTests.java new file mode 100644 index 0000000..bb7a45a --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CollectionIndexingTests.java @@ -0,0 +1,54 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; + +public class CollectionIndexingTests { + + @TempDir + Path tempDir; + + @Test + void shouldResolveEventFromArrayAccess() throws Exception { + String source = """ + package com.example; + public class ArrayService { + private EventService service; + + public void process() { + String[] events = new String[] {"EVENT_1", "EVENT_2"}; + service.trigger(events[0]); + } + } + class EventService { + public void trigger(String ev) {} + } + """; + + Files.writeString(tempDir.resolve("ArrayService.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.EventService") + .methodName("trigger") + .sourceFile("ArrayService.java") + .sourceModule("com.example") + .event("events[0]") + .lineNumber(7) + .build(); + + TriggerPoint result = engine.resolveTriggerPointParameters(trigger, List.of("com.example.ArrayService.process", "com.example.EventService.trigger"), java.util.Collections.emptyMap()); + + // When using array indexing, we should ideally extract the indexed element or at least all elements in the array + assertThat(result.getPolymorphicEvents()).contains("EVENT_1"); + } +} 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 ded2869..2f96dee 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 @@ -1097,7 +1097,7 @@ class HeuristicCallGraphEngineTest { } @Test - void shouldTraceLastTextualAssignmentInTryCatchBlocks() throws IOException { + void shouldTraceAllTextualAssignmentsInTryCatchBlocks() throws IOException { String source = """ package com.example; public class OrderController { @@ -1142,9 +1142,69 @@ class HeuristicCallGraphEngineTest { assertThat(chains).hasSize(1); CallChain chain = chains.get(0); - // The ASTVisitor picks up Assignments in textual order. - // CANCELLED is the last one textually in the method. - assertThat(chain.getTriggerPoint().getEvent()).isEqualTo("OrderEvents.CANCELLED"); + + assertThat(chain.getTriggerPoint().getPolymorphicEvents()).containsExactlyInAnyOrder("OrderEvents.PAY", "OrderEvents.CANCELLED"); + } + + @Test + void shouldTraceEventsThroughMapStructInterfaceImpl() throws IOException { + String source = """ + package com.example; + public class OrderController { + private OrderService service; + private OrderMapper mapper = new OrderMapperImpl(); + public void processOrderEvent(String eventDtoStr) { + OrderEvent e = mapper.toEvent(eventDtoStr); + service.updateOrderState(e.getEvent()); + } + } + + class OrderService { + public void updateOrderState(String event) {} + } + + interface OrderMapper { + OrderEvent toEvent(String source); + } + + class OrderMapperImpl implements OrderMapper { + @Override + public OrderEvent toEvent(String source) { + return new OrderEvent("MAPPED_EVENT"); + } + } + + class OrderEvent { + private String event; + public OrderEvent(String event) { this.event = event; } + public String getEvent() { return event; } + } + """; + Path tempDir = Files.createTempDirectory("callgraph_test_mapstruct"); + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.OrderController") + .methodName("processOrderEvent") + .build(); + + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.OrderService") + .methodName("updateOrderState") + .event("event") + .build(); + + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + assertThat(chains).hasSize(1); + CallChain chain = chains.get(0); + + assertThat(chain.getTriggerPoint().getPolymorphicEvents()).containsExactlyInAnyOrder("MAPPED_EVENT"); } @Test @@ -1190,7 +1250,7 @@ class HeuristicCallGraphEngineTest { CallChain chain = chains.get(0); // We don't dynamically resolve array indexes, but it shouldn't crash. // It returns the array access expression as a string. - assertThat(chain.getTriggerPoint().getEvent()).isEqualTo("events[0]"); + assertThat(chain.getTriggerPoint().getEvent()).isEqualTo("new OrderEvents[]{OrderEvents.PAY}[0]"); } @Test @@ -2211,6 +2271,44 @@ class HeuristicCallGraphEngineTest { org.assertj.core.api.Assertions.assertThat(chains).hasSize(1); org.assertj.core.api.Assertions.assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).containsExactlyInAnyOrder("EVENT_FROM_SETTER"); } + + @Test + void shouldResolveEventFromArrayAndList() throws IOException { + String source = """ + package com.example; + import java.util.List; + public class OrderController { + private OrderService service; + public void handleArray() { + String[] events = {"ARRAY_EVENT"}; + service.process(events[0]); + } + public void handleList() { + List listEvents = List.of("LIST_EVENT"); + service.process(listEvents.get(0)); + } + } + + class OrderService { + public void process(String event) {} + } + """; + Path tempDir = Files.createTempDirectory("callgraph_array"); + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context); + + EntryPoint entryPoint1 = EntryPoint.builder().className("com.example.OrderController").methodName("handleArray").build(); + EntryPoint entryPoint2 = EntryPoint.builder().className("com.example.OrderController").methodName("handleList").build(); + TriggerPoint trigger = TriggerPoint.builder().className("com.example.OrderService").methodName("process").event("event").build(); + List chains = builder.findChains(List.of(entryPoint1, entryPoint2), List.of(trigger)); + + org.assertj.core.api.Assertions.assertThat(chains).hasSize(2); + List resolvedEvents = chains.stream().flatMap(c -> c.getTriggerPoint().getPolymorphicEvents().stream()).toList(); + org.assertj.core.api.Assertions.assertThat(resolvedEvents).containsExactlyInAnyOrder("ARRAY_EVENT", "LIST_EVENT"); + } + @Test void shouldResolveEventFromFieldConstructorAssignment() throws IOException { String source = """ @@ -2326,4 +2424,190 @@ class HeuristicCallGraphEngineTest { org.assertj.core.api.Assertions.assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).containsExactlyInAnyOrder("ANONYMOUS_EVENT"); } + @Test + void shouldResolveEventFromBranchingAssignments() throws IOException { + String source = """ + package com.example; + public class OrderController { + private OrderService service; + public void handleStatus(boolean isRefund) { + String event; + if (isRefund) { + event = "REFUND_EVENT"; + } else { + event = "CHARGE_EVENT"; + } + service.process(event); + } + } + + class OrderService { + public void process(String event) {} + } + """; + Path tempDir = Files.createTempDirectory("callgraph_branch"); + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context); + + EntryPoint entryPoint = EntryPoint.builder().className("com.example.OrderController").methodName("handleStatus").build(); + TriggerPoint trigger = TriggerPoint.builder().className("com.example.OrderService").methodName("process").event("event").build(); + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + org.assertj.core.api.Assertions.assertThat(chains).hasSize(1); + List resolvedEvents = chains.stream().flatMap(c -> c.getTriggerPoint().getPolymorphicEvents().stream()).toList(); + org.assertj.core.api.Assertions.assertThat(resolvedEvents).containsExactlyInAnyOrder("REFUND_EVENT", "CHARGE_EVENT"); + } + + @Test + void shouldResolveEventFromLombokDataAndAllArgsConstructor() throws IOException { + String source = """ + package com.example; + import lombok.Data; + import lombok.AllArgsConstructor; + public class OrderController { + private OrderService service; + public void handleLombok() { + LombokEvent e = new LombokEvent("LOMBOK_EVENT", 1); + service.process(e.getEvent()); + } + } + + class OrderService { + public void process(String event) {} + } + + @Data + @AllArgsConstructor + class LombokEvent { + private String event; + private int id; + } + """; + Path tempDir = Files.createTempDirectory("callgraph_lombok"); + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context); + + EntryPoint entryPoint = EntryPoint.builder().className("com.example.OrderController").methodName("handleLombok").build(); + TriggerPoint trigger = TriggerPoint.builder().className("com.example.OrderService").methodName("process").event("event").build(); + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + org.assertj.core.api.Assertions.assertThat(chains).hasSize(1); + org.assertj.core.api.Assertions.assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).containsExactlyInAnyOrder("LOMBOK_EVENT"); + } + + @Test + void shouldResolveEventFromMethodReference() throws IOException { + String source = """ + package com.example; + import java.util.List; + public class OrderController { + private OrderService service; + public void handleMethodRef() { + List events = List.of(new RefEvent("METHOD_REF_EVENT")); + events.forEach(this::delegateProcessing); + } + private void delegateProcessing(RefEvent e) { + service.process(e.getType()); + } + } + + class OrderService { + public void process(String event) {} + } + + class RefEvent { + private String type; + public RefEvent(String type) { this.type = type; } + public String getType() { return type; } + } + """; + Path tempDir = Files.createTempDirectory("callgraph_method_ref"); + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context); + + EntryPoint entryPoint = EntryPoint.builder().className("com.example.OrderController").methodName("handleMethodRef").build(); + TriggerPoint trigger = TriggerPoint.builder().className("com.example.OrderService").methodName("process").event("event").build(); + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + org.assertj.core.api.Assertions.assertThat(chains).hasSize(1); + org.assertj.core.api.Assertions.assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).containsExactlyInAnyOrder("METHOD_REF_EVENT"); + } + + @Test + void shouldResolveEventFromJavaRecord() throws IOException { + String source = """ + package com.example; + public class OrderController { + private OrderService service; + public void handleRecord() { + RecordEvent e = new RecordEvent("RECORD_EVENT", 42); + service.process(e.type()); + } + } + + class OrderService { + public void process(String event) {} + } + + record RecordEvent(String type, int someId) {} + """; + Path tempDir = Files.createTempDirectory("callgraph_record"); + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context); + + EntryPoint entryPoint = EntryPoint.builder().className("com.example.OrderController").methodName("handleRecord").build(); + TriggerPoint trigger = TriggerPoint.builder().className("com.example.OrderService").methodName("process").event("event").build(); + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + org.assertj.core.api.Assertions.assertThat(chains).hasSize(1); + org.assertj.core.api.Assertions.assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).containsExactlyInAnyOrder("RECORD_EVENT"); + } + + @Test + void shouldFailHeuristicAndRequireAccurateLombokMapping() throws IOException { + String source = """ + package com.example; + import lombok.Data; + import lombok.AllArgsConstructor; + public class OrderController { + private OrderService service; + public void handleLombok() { + MultiEvent e = new MultiEvent("CORRECT_EVENT", "WRONG_EVENT"); + service.process(e.getEventType()); + } + } + + class OrderService { + public void process(String event) {} + } + + @Data + @AllArgsConstructor + class MultiEvent { + private String eventType; + private String unrelatedString; + } + """; + Path tempDir = Files.createTempDirectory("callgraph_lombok_heuristic_break"); + Files.writeString(tempDir.resolve("OrderConfig.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context); + + EntryPoint entryPoint = EntryPoint.builder().className("com.example.OrderController").methodName("handleLombok").build(); + TriggerPoint trigger = TriggerPoint.builder().className("com.example.OrderService").methodName("process").event("event").build(); + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + org.assertj.core.api.Assertions.assertThat(chains).hasSize(1); + org.assertj.core.api.Assertions.assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()) + .containsExactly("CORRECT_EVENT"); // If heuristic rips both, it will fail because it contains WRONG_EVENT too! + } } \ No newline at end of file diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/StreamApiTests.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/StreamApiTests.java new file mode 100644 index 0000000..1db6ff1 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/StreamApiTests.java @@ -0,0 +1,66 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import static org.assertj.core.api.Assertions.assertThat; + +public class StreamApiTests { + + @TempDir + Path tempDir; + + @Test + void shouldHandleStreamApiChains() throws Exception { + String source = """ + package com.example; + import java.util.List; + + public class StreamService { + private EventService service; + + public void process(List events) { + events.stream() + .filter(e -> e.isValid()) + .map(e -> e.toEvent()) + .forEach(t -> service.trigger(t.getEvent())); + } + } + class EventService { + public void trigger(String ev) {} + } + class MyEvent { + public boolean isValid() { return true; } + public OrderEvent toEvent() { return new OrderEvent(); } + } + class OrderEvent { + public String getEvent() { return "STREAM_EVENT"; } + } + """; + + Files.writeString(tempDir.resolve("StreamService.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.EventService") + .methodName("trigger") + .sourceFile("StreamService.java") + .sourceModule("com.example") + .event("t.getEvent()") + .lineNumber(11) + .build(); + + TriggerPoint result = engine.resolveTriggerPointParameters(trigger, List.of("com.example.StreamService.process", "com.example.EventService.trigger"), java.util.Collections.emptyMap()); + System.out.println("TEST RESULT: " + result); + System.out.println("POLY EVENTS: " + result.getPolymorphicEvents()); + + assertThat(result.getPolymorphicEvents()).isNotNull().containsExactlyInAnyOrder("STREAM_EVENT"); + } +}