21 Commits

Author SHA1 Message Date
fcda626629 forward analysis 2026-06-23 06:20:07 +02:00
e31056a44e forward analysis 3 2026-06-22 20:36:01 +02:00
131ccbeda0 forward analysis 2 2026-06-22 18:51:20 +02:00
14b311be2b forward analysis 2026-06-22 18:38:19 +02:00
1b690582d6 fixes 2 2026-06-22 14:11:35 +02:00
3ca834fa71 fixes 2026-06-22 13:53:27 +02:00
c6b4a4be1e better things extraction of constants 2 2026-06-22 13:12:48 +02:00
24a9be3a4e better things extraction of constants 2026-06-22 07:30:05 +02:00
07d241a060 better things 2026-06-21 22:55:39 +02:00
32de0a51c6 edge case fixes 2026-06-21 18:45:24 +02:00
cbb00e8a0f Fix AST constructor tracking gap for variables and fields 2026-06-21 18:36:37 +02:00
6596303b7d Replace fallback string scraper with precise AST traversal 2026-06-21 18:20:30 +02:00
c903b079d3 refactor 2026-06-21 17:53:26 +02:00
23d79d1930 event maching heuristic update 4 2026-06-21 15:54:36 +02:00
2720296d14 event maching heuristic update 3 2026-06-21 15:24:36 +02:00
b8b180ab3d event maching heuristic update 3 2026-06-21 14:58:28 +02:00
fc267c43c6 event maching heuristic update 2 2026-06-21 14:06:08 +02:00
968601eefc event maching heuristic update 2026-06-21 12:44:00 +02:00
e00f4dca81 beans fix more 2026-06-21 11:36:32 +02:00
bf82cc3562 beans fix 2026-06-21 09:00:36 +02:00
24b67be64b beans 2026-06-21 08:51:42 +02:00
91 changed files with 10243 additions and 711 deletions

View File

@@ -12,27 +12,49 @@ public class HeuristicEventMatchingEngine implements EventMatchingEngine {
return false; return false;
} }
String triggerEvent = simplify(triggerPoint.getEvent()); String rawTriggerEvent = triggerPoint.getEvent();
String smEventRaw = stateMachineEvent.fullIdentifier() != null ? stateMachineEvent.fullIdentifier() : stateMachineEvent.rawName(); String smEventRaw = stateMachineEvent.fullIdentifier() != null ? stateMachineEvent.fullIdentifier() : stateMachineEvent.rawName();
String smEvent = simplify(smEventRaw); String smEvent = simplify(smEventRaw);
boolean isWildcard = isWildcardVariable(triggerEvent); boolean isWildcard = isWildcardVariable(rawTriggerEvent);
List<String> polyEvents = triggerPoint.getPolymorphicEvents() != null ? triggerPoint.getPolymorphicEvents() : java.util.Collections.emptyList(); List<String> polyEvents = triggerPoint.getPolymorphicEvents() != null ? triggerPoint.getPolymorphicEvents() : java.util.Collections.emptyList();
boolean hasPolyMatch = false; boolean hasPolyMatch = false;
for (String pe : polyEvents) { for (String pe : polyEvents) {
if (pe.contains(".") && smEventRaw.contains(".")) {
if (smEventRaw.equals(pe) || smEventRaw.endsWith("." + pe)) {
hasPolyMatch = true;
break;
}
continue;
}
String simplePe = pe; String simplePe = pe;
if (pe.contains(".")) { if (pe.contains(".")) {
simplePe = pe.substring(pe.lastIndexOf('.') + 1); simplePe = pe.substring(pe.lastIndexOf('.') + 1);
} }
if (simplePe.equals(smEventRaw) || simplePe.equals(smEvent)) { String simplifiedPe = simplify(simplePe);
if (simplePe.equals(smEventRaw) || simplePe.equals(smEvent) ||
simplePe.equalsIgnoreCase(smEvent) || simplifiedPe.equalsIgnoreCase(smEvent)) {
hasPolyMatch = true; hasPolyMatch = true;
break; break;
} }
} }
return hasPolyMatch || smEvent.equals(triggerEvent) || (polyEvents.isEmpty() && isWildcard); if (hasPolyMatch) return true;
if (polyEvents.isEmpty() && isWildcard) return true;
if (rawTriggerEvent.contains(".") && smEventRaw.contains(".")) {
if (smEventRaw.equals(rawTriggerEvent) || smEventRaw.endsWith("." + rawTriggerEvent)) {
return true;
}
return false;
}
String triggerEvent = simplify(rawTriggerEvent);
return smEvent.equals(triggerEvent);
} }
private boolean isWildcardVariable(String eventStr) { private boolean isWildcardVariable(String eventStr) {

View File

@@ -0,0 +1,69 @@
package click.kamil.springstatemachineexporter.analysis.enricher.path;
import click.kamil.springstatemachineexporter.analysis.enricher.AnalysisEnricher;
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@Slf4j
public class ForwardPathEstimationEnricher implements AnalysisEnricher {
private final boolean isEnabled;
private final PathValueEstimator estimator;
public ForwardPathEstimationEnricher() {
this.isEnabled = Boolean.parseBoolean(System.getProperty("analyzer.forward-path-estimation.enabled", "false"));
this.estimator = new SymbolicPathValueEstimator();
}
@Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
if (!isEnabled || result.getMetadata() == null || result.getMetadata().getCallChains() == null) {
return;
}
log.info("Running Symbolic Forward Path Analysis for {}", result.getName());
List<CallChain> chains = result.getMetadata().getCallChains();
List<CallChain> updatedChains = chains.stream().map(chain -> {
if (chain.getTriggerPoint() == null || chain.getTriggerPoint().getEvent() == null) return chain;
// If the event looks like a dynamic variable (no dots, not an enum format)
if (!chain.getTriggerPoint().getEvent().contains(".")) {
List<String> estimatedValues = estimator.estimatePossibleValues(chain, context);
if (estimatedValues != null && !estimatedValues.isEmpty()) {
log.info("Symbolic Path Analysis estimated values for {} -> {}", chain.getTriggerPoint().getEvent(), estimatedValues);
List<String> polyEvents = new ArrayList<>();
if (chain.getTriggerPoint().getPolymorphicEvents() != null) {
polyEvents.addAll(chain.getTriggerPoint().getPolymorphicEvents());
}
polyEvents.addAll(estimatedValues);
TriggerPoint newTp = chain.getTriggerPoint().toBuilder()
.polymorphicEvents(polyEvents)
.build();
return chain.toBuilder().triggerPoint(newTp).build();
}
}
return chain;
}).collect(Collectors.toList());
click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata updatedMetadata =
click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata.builder()
.triggers(result.getMetadata().getTriggers())
.entryPoints(result.getMetadata().getEntryPoints())
.callChains(updatedChains)
.properties(result.getMetadata().getProperties())
.build();
result.setMetadata(updatedMetadata);
}
}

View File

@@ -0,0 +1,10 @@
package click.kamil.springstatemachineexporter.analysis.enricher.path;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import java.util.List;
public interface PathValueEstimator {
List<String> estimatePossibleValues(CallChain chain, CodebaseContext context);
}

View File

@@ -0,0 +1,130 @@
package click.kamil.springstatemachineexporter.analysis.enricher.path;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public class SymbolicPathValueEstimator implements PathValueEstimator {
@Override
public List<String> estimatePossibleValues(CallChain chain, CodebaseContext context) {
if (chain.getMethodChain() == null || chain.getMethodChain().isEmpty()) {
return null;
}
Set<String> collectedConstants = new HashSet<>();
for (String methodFqn : chain.getMethodChain()) {
int lastDot = methodFqn.lastIndexOf('.');
if (lastDot == -1) continue;
String className = methodFqn.substring(0, lastDot);
String methodName = methodFqn.substring(lastDot + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td == null) continue;
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md == null || md.getBody() == null) continue;
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
if ((node.getName().getIdentifier().equals("equals") || node.getName().getIdentifier().equals("equalsIgnoreCase"))
&& node.arguments().size() == 1) {
Expression caller = node.getExpression();
Expression arg = (Expression) node.arguments().get(0);
click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver resolver = new click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver();
String callerVal = resolver.resolve(caller, context);
if (callerVal != null && !callerVal.isEmpty()) {
addValue(collectedConstants, callerVal);
}
String argVal = resolver.resolve(arg, context);
if (argVal != null && !argVal.isEmpty()) {
addValue(collectedConstants, argVal);
}
}
return super.visit(node);
}
@Override
public boolean visit(InfixExpression node) {
if (node.getOperator() == InfixExpression.Operator.EQUALS) {
click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver resolver = new click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver();
String left = resolver.resolve(node.getLeftOperand(), context);
if (left != null && !left.isEmpty()) {
addValue(collectedConstants, left);
}
String right = resolver.resolve(node.getRightOperand(), context);
if (right != null && !right.isEmpty()) {
addValue(collectedConstants, right);
}
}
return super.visit(node);
}
@Override
public boolean visit(SwitchStatement node) {
for (Object stmt : node.statements()) {
if (stmt instanceof SwitchCase sc && !sc.isDefault()) {
for (Object expr : sc.expressions()) {
click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver resolver = new click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver();
String val = resolver.resolve((Expression) expr, context);
if (val != null && !val.isEmpty()) {
addValue(collectedConstants, val);
}
}
}
}
return super.visit(node);
}
@Override
public boolean visit(SwitchExpression node) {
for (Object stmt : node.statements()) {
if (stmt instanceof SwitchCase sc && !sc.isDefault()) {
for (Object expr : sc.expressions()) {
click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver resolver = new click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver();
String val = resolver.resolve((Expression) expr, context);
if (val != null && !val.isEmpty()) {
addValue(collectedConstants, val);
}
}
}
}
return super.visit(node);
}
});
}
if (collectedConstants.isEmpty()) return null;
return new ArrayList<>(collectedConstants);
}
private void addValue(Set<String> collectedConstants, String val) {
if (val.startsWith("ENUM_SET:")) {
for (String eVal : val.substring(9).split(",")) {
String parsed = parseEnumSetElement(eVal);
if (!collectedConstants.contains(parsed)) collectedConstants.add(parsed);
}
} else {
collectedConstants.add(val);
}
}
private String parseEnumSetElement(String element) {
if (element.contains(".")) {
return element.substring(element.lastIndexOf('.') + 1);
}
return element;
}
}

View File

@@ -8,7 +8,7 @@ import lombok.extern.jackson.Jacksonized;
import java.util.Map; import java.util.Map;
@Data @Data
@Builder @Builder(toBuilder = true)
@Jacksonized @Jacksonized
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
public class TriggerPoint { public class TriggerPoint {

View File

@@ -17,6 +17,19 @@ public class ConstantResolver {
private String resolveInternal(Expression expr, CodebaseContext context, Set<String> visited) { private String resolveInternal(Expression expr, CodebaseContext context, Set<String> visited) {
if (expr == null) return null; if (expr == null) return null;
// Unwrap common wrappers
if (expr instanceof CastExpression ce) {
return resolveInternal(ce.getExpression(), context, visited);
}
if (expr instanceof ParenthesizedExpression pe) {
return resolveInternal(pe.getExpression(), context, visited);
}
if (expr instanceof MethodInvocation mi && (mi.getName().getIdentifier().equals("requireNonNull") || mi.getName().getIdentifier().equals("notNull"))) {
if (!mi.arguments().isEmpty()) {
return resolveInternal((Expression) mi.arguments().get(0), context, visited);
}
}
// 1. Literal? // 1. Literal?
if (expr instanceof StringLiteral sl) { if (expr instanceof StringLiteral sl) {
return sl.getLiteralValue(); return sl.getLiteralValue();
@@ -51,6 +64,9 @@ public class ConstantResolver {
String val = resolveManual(qn, context, visited); String val = resolveManual(qn, context, visited);
return val != null ? val : qn.toString(); return val != null ? val : qn.toString();
} }
if (expr instanceof FieldAccess fa) {
return resolveManual(fa.getName(), context, visited);
}
if (expr instanceof SimpleName sn) { if (expr instanceof SimpleName sn) {
return resolveManual(sn, context, visited); return resolveManual(sn, context, visited);
} }
@@ -75,6 +91,13 @@ public class ConstantResolver {
} }
} }
if ("valueOf".equals(mi.getName().getIdentifier()) && mi.getExpression() != null) {
java.util.List<String> enumValues = context.getEnumValues(mi.getExpression().toString());
if (enumValues != null && !enumValues.isEmpty()) {
return "ENUM_SET:" + String.join(",", enumValues);
}
}
IMethodBinding mb = mi.resolveMethodBinding(); IMethodBinding mb = mi.resolveMethodBinding();
if (mb != null) { if (mb != null) {
ITypeBinding returnType = mb.getReturnType(); ITypeBinding returnType = mb.getReturnType();
@@ -85,10 +108,39 @@ public class ConstantResolver {
} }
} }
} else { } else {
TypeDeclaration td = findEnclosingType(mi); TypeDeclaration td = null;
if (mi.getExpression() != null) {
ITypeBinding typeBinding = mi.getExpression().resolveTypeBinding();
if (typeBinding != null) {
String typeName = typeBinding.getQualifiedName();
if (typeName != null && !typeName.isEmpty()) {
td = context.getTypeDeclaration(typeName);
}
}
}
if (td == null && mi.getExpression() instanceof SimpleName sn) {
String typeName = resolveLocalType(sn);
if (typeName != null) {
td = context.getTypeDeclaration(typeName);
if (td == null) {
CompilationUnit cu = (CompilationUnit) mi.getRoot();
td = context.getTypeDeclaration(typeName, cu);
}
}
}
if (td == null) {
td = findEnclosingType(mi);
}
if (td != null) { if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, mi.getName().getIdentifier(), true); MethodDeclaration md = context.findMethodDeclaration(td, mi.getName().getIdentifier(), true);
if (md != null && md.getReturnType2() != null) { if (md != null) {
String evaluated = evaluateMethodOutput(mi, md, context, visited);
if (evaluated != null) return evaluated;
if (md.getReturnType2() != null) {
java.util.List<String> values = context.getEnumValues(md.getReturnType2().toString()); java.util.List<String> values = context.getEnumValues(md.getReturnType2().toString());
if (values != null && !values.isEmpty()) { if (values != null && !values.isEmpty()) {
return "ENUM_SET:" + String.join(",", values); return "ENUM_SET:" + String.join(",", values);
@@ -97,10 +149,188 @@ public class ConstantResolver {
} }
} }
} }
}
return null; return null;
} }
private String evaluateMethodOutput(MethodInvocation mi, MethodDeclaration md, CodebaseContext context, Set<String> visited) {
if (md.getBody() == null || mi.arguments().size() != md.parameters().size()) return null;
java.util.Map<String, String> localVars = new java.util.HashMap<>();
java.util.Set<String> declaredLocals = new java.util.HashSet<>();
for (int i = 0; i < mi.arguments().size(); i++) {
Expression arg = (Expression) mi.arguments().get(i);
String val = resolveInternal(arg, context, visited);
if (val != null) {
org.eclipse.jdt.core.dom.SingleVariableDeclaration param = (org.eclipse.jdt.core.dom.SingleVariableDeclaration) md.parameters().get(i);
String paramName = param.getName().getIdentifier();
localVars.put(paramName, val);
declaredLocals.add(paramName);
}
}
String[] finalResult = new String[1];
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement vds) {
for (Object fragmentObj : vds.fragments()) {
if (fragmentObj instanceof VariableDeclarationFragment fragment) {
String varName = fragment.getName().getIdentifier();
declaredLocals.add(varName);
if (fragment.getInitializer() != null) {
String rhsVal = resolveExpressionWithParams(fragment.getInitializer(), localVars);
if (rhsVal == null) rhsVal = resolveInternal(fragment.getInitializer(), context, visited);
if (rhsVal != null) {
localVars.put(varName, rhsVal);
}
}
}
}
return super.visit(vds);
}
@Override
public boolean visit(Assignment assignment) {
Expression lhs = assignment.getLeftHandSide();
String varName = null;
if (lhs instanceof SimpleName sn) {
varName = sn.getIdentifier();
} else if (lhs instanceof FieldAccess fa) {
varName = "this." + fa.getName().getIdentifier();
}
String rhsVal = resolveExpressionWithParams(assignment.getRightHandSide(), localVars);
if (rhsVal == null) rhsVal = resolveInternal(assignment.getRightHandSide(), context, visited);
if (varName != null && rhsVal != null) {
if (varName.startsWith("this.")) {
localVars.put(varName, rhsVal);
String bareName = varName.substring(5);
if (!declaredLocals.contains(bareName)) {
localVars.put(bareName, rhsVal); // alias without 'this.' only if not shadowed
}
} else {
localVars.put(varName, rhsVal);
if (!declaredLocals.contains(varName)) {
localVars.put("this." + varName, rhsVal); // alias with 'this.' only if not shadowed
}
}
}
return super.visit(assignment);
}
@Override
public boolean visit(org.eclipse.jdt.core.dom.SwitchStatement ss) {
if (finalResult[0] == null) {
String result = evaluateSwitchStatement(ss, localVars, context, visited);
if (result != null) finalResult[0] = result;
}
return false; // Do not visit children — ReturnStatements inside the switch are handled by evaluateSwitchStatement
}
@Override
public boolean visit(org.eclipse.jdt.core.dom.ReturnStatement rs) {
if (finalResult[0] == null) {
if (rs.getExpression() instanceof org.eclipse.jdt.core.dom.SwitchExpression se) {
String result = evaluateSwitchExpression(se, localVars, context, visited);
if (result != null) finalResult[0] = result;
} else if (rs.getExpression() != null) {
String result = resolveExpressionWithParams(rs.getExpression(), localVars);
if (result == null) result = resolveInternal(rs.getExpression(), context, visited);
if (result != null) finalResult[0] = result;
}
}
return super.visit(rs);
}
});
return finalResult[0];
}
private String evaluateSwitchStatement(org.eclipse.jdt.core.dom.SwitchStatement ss, java.util.Map<String, String> paramValues, CodebaseContext context, Set<String> visited) {
String switchVar = resolveExpressionWithParams(ss.getExpression(), paramValues);
if (switchVar == null) return null;
boolean matchingCase = false;
for (Object stmtObj : ss.statements()) {
if (stmtObj instanceof org.eclipse.jdt.core.dom.SwitchCase sc) {
matchingCase = false;
if (sc.isDefault()) {
matchingCase = true;
} else {
for (Object exprObj : sc.expressions()) {
String caseVal = resolveExpressionWithParams((Expression) exprObj, paramValues);
if (caseVal != null && switchVar.endsWith(caseVal)) {
matchingCase = true;
break;
}
}
}
} else if (matchingCase) {
if (stmtObj instanceof org.eclipse.jdt.core.dom.ReturnStatement rs) {
return resolveInternal(rs.getExpression(), context, visited);
} else if (stmtObj instanceof org.eclipse.jdt.core.dom.YieldStatement ys) {
return resolveInternal(ys.getExpression(), context, visited);
} else if (stmtObj instanceof org.eclipse.jdt.core.dom.ExpressionStatement es) {
return resolveInternal(es.getExpression(), context, visited);
}
}
}
return null;
}
private String evaluateSwitchExpression(org.eclipse.jdt.core.dom.SwitchExpression se, java.util.Map<String, String> paramValues, CodebaseContext context, Set<String> visited) {
String switchVar = resolveExpressionWithParams(se.getExpression(), paramValues);
if (switchVar == null) return null;
boolean matchingCase = false;
for (Object stmtObj : se.statements()) {
if (stmtObj instanceof org.eclipse.jdt.core.dom.SwitchCase sc) {
matchingCase = false;
if (sc.isDefault()) {
matchingCase = true;
} else {
for (Object exprObj : sc.expressions()) {
String caseVal = resolveExpressionWithParams((Expression) exprObj, paramValues);
if (caseVal != null && switchVar.endsWith(caseVal)) {
matchingCase = true;
break;
}
}
}
} else if (matchingCase) {
if (stmtObj instanceof org.eclipse.jdt.core.dom.ExpressionStatement es) {
return resolveInternal(es.getExpression(), context, visited);
} else if (stmtObj instanceof org.eclipse.jdt.core.dom.YieldStatement ys) {
return resolveInternal(ys.getExpression(), context, visited);
} else if (stmtObj instanceof org.eclipse.jdt.core.dom.ReturnStatement rs) {
return resolveInternal(rs.getExpression(), context, visited);
}
}
}
return null;
}
private String resolveExpressionWithParams(Expression expr, java.util.Map<String, String> paramValues) {
if (expr instanceof SimpleName sn) {
String name = sn.getIdentifier();
if (paramValues.containsKey(name)) return paramValues.get(name);
return null;
} else if (expr instanceof FieldAccess fa) {
String name = "this." + fa.getName().getIdentifier();
if (paramValues.containsKey(name)) return paramValues.get(name);
return null;
} else if (expr instanceof QualifiedName qn) {
return qn.getName().getIdentifier();
} else if (expr instanceof org.eclipse.jdt.core.dom.StringLiteral sl) {
return sl.getLiteralValue();
}
return null;
}
private String resolveInfix(InfixExpression expr, CodebaseContext context, Set<String> visited) { private String resolveInfix(InfixExpression expr, CodebaseContext context, Set<String> visited) {
if (expr.getOperator() != InfixExpression.Operator.PLUS) return null; if (expr.getOperator() != InfixExpression.Operator.PLUS) return null;
@@ -180,6 +410,28 @@ public class ConstantResolver {
} }
} }
} }
// Check constructors for assignments
for (MethodDeclaration md : td.getMethods()) {
if (md.isConstructor() && md.getBody() != null) {
for (Object stmtObj : md.getBody().statements()) {
if (stmtObj instanceof ExpressionStatement es) {
if (es.getExpression() instanceof Assignment assignment) {
Expression lhs = assignment.getLeftHandSide();
boolean matches = false;
if (lhs instanceof SimpleName snLhs && snLhs.getIdentifier().equals(fieldName)) {
matches = true;
} else if (lhs instanceof FieldAccess fa && fa.getName().getIdentifier().equals(fieldName) && fa.getExpression() instanceof ThisExpression) {
matches = true;
}
if (matches) {
return resolveInternal(assignment.getRightHandSide(), context, visited);
}
}
}
}
}
}
} finally { } finally {
visited.remove(fieldId); visited.remove(fieldId);
} }
@@ -225,4 +477,49 @@ public class ConstantResolver {
} }
return (TypeDeclaration) parent; return (TypeDeclaration) parent;
} }
private MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null) {
if (parent instanceof MethodDeclaration md) {
return md;
}
parent = parent.getParent();
}
return null;
}
private String resolveLocalType(SimpleName sn) {
String varName = sn.getIdentifier();
MethodDeclaration enclosingMethod = findEnclosingMethod(sn);
if (enclosingMethod != null && enclosingMethod.getBody() != null) {
String[] typeName = new String[1];
enclosingMethod.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement node) {
for (Object fragObj : node.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
typeName[0] = node.getType().toString();
}
}
return super.visit(node);
}
});
if (typeName[0] != null) return typeName[0];
}
TypeDeclaration enclosingType = findEnclosingType(sn);
if (enclosingType != null) {
for (FieldDeclaration field : enclosingType.getFields()) {
for (Object fragObj : field.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
return field.getType().toString();
}
}
}
}
return null;
}
} }

View File

@@ -0,0 +1,22 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import java.util.List;
public interface PolymorphicEventResolver {
/**
* Attempts to resolve a dynamically passed event variable into a set of
* concrete polymorphic event names.
*
* @param triggerPoint The original trigger point to resolve.
* @param resolvedValue The current String expression (e.g., "event.getType()" or "event")
* @param path The call path trace leading up to this variable.
* @param context The codebase context for deep type resolution.
* @return The updated TriggerPoint with polymorphic events set, or the original if unable to resolve.
*/
TriggerPoint resolvePolymorphicEvents(TriggerPoint triggerPoint, String resolvedValue, List<String> path, CodebaseContext context);
}

View File

@@ -265,8 +265,26 @@ public class GenericEventDetector {
return getSimpleNameString(right); return getSimpleNameString(right);
} }
} else if (expr instanceof MethodInvocation mi) { } else if (expr instanceof MethodInvocation mi) {
if ("equals".equals(mi.getName().getIdentifier()) && !mi.arguments().isEmpty()) { String methodName = mi.getName().getIdentifier();
return getSimpleNameString((Expression) mi.arguments().get(0)); if (("equals".equals(methodName) || "equalsIgnoreCase".equals(methodName)) && !mi.arguments().isEmpty()) {
Expression receiver = mi.getExpression();
Expression arg = (Expression) mi.arguments().get(0);
// If receiver is null (e.g., implicit this), fall back to arg
if (receiver == null) {
return getSimpleNameString(arg);
}
// Prioritize the one that looks like a constant (QualifiedName or StringLiteral)
if (receiver instanceof QualifiedName || receiver instanceof StringLiteral || receiver instanceof FieldAccess) {
return getSimpleNameString(receiver);
}
if (arg instanceof QualifiedName || arg instanceof StringLiteral || arg instanceof FieldAccess) {
return getSimpleNameString(arg);
}
// Fallback to receiver
return getSimpleNameString(receiver);
} }
} }
return null; return null;
@@ -325,6 +343,10 @@ public class GenericEventDetector {
} }
} }
if (expr instanceof ClassInstanceCreation cic) {
return cic.getType().toString();
}
if (!(expr instanceof MethodInvocation mi)) return null; if (!(expr instanceof MethodInvocation mi)) return null;
// Trace back chain: MessageBuilder.withPayload(event).setHeader(...).build() // Trace back chain: MessageBuilder.withPayload(event).setHeader(...).build()

View File

@@ -12,490 +12,15 @@ import org.eclipse.jdt.core.dom.*;
import java.util.*; import java.util.*;
@Slf4j @Slf4j
public class HeuristicCallGraphEngine implements CallGraphEngine { public class HeuristicCallGraphEngine extends AbstractCallGraphEngine {
private final CodebaseContext context; protected String currentMethodFqn;
private final ConstantResolver constantResolver; protected Map<String, List<CallEdge>> graph;
private String currentMethodFqn;
private Map<String, List<CallEdge>> graph;
public HeuristicCallGraphEngine(CodebaseContext context) { public HeuristicCallGraphEngine(CodebaseContext context) {
this.context = context; super(context);
this.constantResolver = new ConstantResolver();
} }
public List<CallChain> findChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) { protected Map<String, List<CallEdge>> buildCallGraph() {
Map<String, List<CallEdge>> callGraph = buildCallGraph();
List<CallChain> chains = new ArrayList<>();
for (EntryPoint ep : entryPoints) {
String startMethod = ep.getClassName() + "." + ep.getMethodName();
boolean foundAny = false;
for (TriggerPoint tp : triggers) {
String targetMethod = tp.getClassName() + "." + tp.getMethodName();
List<String> path = findPath(startMethod, targetMethod, callGraph, new HashSet<>());
if (path != null) {
foundAny = true;
TriggerPoint resolvedTp = resolveTriggerPointParameters(tp, path, callGraph);
String contextMachineId = extractContextMachineId(path, callGraph);
chains.add(CallChain.builder()
.entryPoint(ep)
.triggerPoint(resolvedTp)
.methodChain(path)
.contextMachineId(contextMachineId)
.build());
}
}
if (!foundAny && log.isDebugEnabled()) {
log.debug("Entry point {} ({}) did not reach any trigger points. Graph nodes available: {}", ep.getName(), startMethod, callGraph.keySet());
}
}
return chains;
}
private TriggerPoint resolveTriggerPointParameters(TriggerPoint tp, List<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 = "";
// Extract method calls like .getType() so we can trace the base parameter
int dotIndex = currentParamName.indexOf('.');
if (dotIndex > 0 && dotIndex + 1 < currentParamName.length()) {
char nextChar = currentParamName.charAt(dotIndex + 1);
if (Character.isLowerCase(nextChar)) {
methodSuffix = currentParamName.substring(dotIndex);
currentParamName = currentParamName.substring(0, dotIndex);
}
}
// Walk backwards up the call chain
for (int i = path.size() - 1; i > 0; i--) {
String target = path.get(i);
String caller = path.get(i - 1);
// Find parameter index in target method
int paramIndex = getParameterIndex(target, currentParamName);
if (paramIndex < 0) {
// Not a parameter. Maybe it's a local variable initialized from a parameter or method call?
String tracedVar = traceLocalVariable(target, currentParamName);
if (tracedVar != null && !tracedVar.equals(currentParamName)) {
// Extract method calls like .getType() from the traced variable
int dotIdx = tracedVar.indexOf('.');
if (dotIdx > 0 && dotIdx + 1 < tracedVar.length() && Character.isLowerCase(tracedVar.charAt(dotIdx + 1))) {
methodSuffix = tracedVar.substring(dotIdx) + methodSuffix;
tracedVar = tracedVar.substring(0, dotIdx);
}
currentParamName = tracedVar;
resolvedValue = tracedVar + methodSuffix;
paramIndex = getParameterIndex(target, currentParamName);
}
}
if (paramIndex < 0) {
break; // Parameter name changed or not found, stop tracing
}
// Find the edge from caller to target to get the argument passed
List<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) {
// If the argument passed has a method call, extract it
int dotIdx = arg.indexOf('.');
if (dotIdx > 0 && dotIdx + 1 < arg.length() && Character.isLowerCase(arg.charAt(dotIdx + 1))) {
methodSuffix = arg.substring(dotIdx) + methodSuffix;
arg = arg.substring(0, dotIdx);
}
currentParamName = arg;
resolvedValue = arg + methodSuffix;
found = true;
break;
}
}
}
}
}
if (!found) break; // Could not map argument
}
// Final check on the entry method
String entryMethod = path.get(0);
int entryParamIndex = getParameterIndex(entryMethod, currentParamName);
if (entryParamIndex < 0) {
String tracedVar = traceLocalVariable(entryMethod, currentParamName);
if (tracedVar != null && !tracedVar.equals(currentParamName)) {
int dotIdx = tracedVar.indexOf('.');
if (dotIdx > 0 && dotIdx + 1 < tracedVar.length() && Character.isLowerCase(tracedVar.charAt(dotIdx + 1))) {
methodSuffix = tracedVar.substring(dotIdx) + methodSuffix;
tracedVar = tracedVar.substring(0, dotIdx);
}
currentParamName = tracedVar;
resolvedValue = tracedVar + methodSuffix;
}
}
List<String> polymorphicEvents = new ArrayList<>();
if (resolvedValue.matches(".*\\.[a-zA-Z0-9_]+\\(\\)")) {
int lastDot = resolvedValue.lastIndexOf('.');
int firstDot = resolvedValue.indexOf('.');
int openParen = resolvedValue.indexOf('(', lastDot);
if (lastDot > 0 && openParen > lastDot) {
String varName = resolvedValue.substring(0, firstDot);
if (varName.contains("(")) {
varName = null;
}
String methodName = resolvedValue.substring(lastDot + 1, openParen);
String declaredType = null;
String sourceMethod = null;
if (varName != null) {
for (String methodFqn : path) {
declaredType = getVariableDeclaredType(methodFqn, varName);
if (declaredType != null) {
sourceMethod = methodFqn;
break;
}
}
} else {
String baseExpr = resolvedValue.substring(0, lastDot);
if (baseExpr.contains("new ")) {
int newIdx = baseExpr.indexOf("new ");
int openParenBase = baseExpr.indexOf('(', newIdx);
if (openParenBase > newIdx) {
declaredType = baseExpr.substring(newIdx + 4, openParenBase).trim();
if (declaredType.contains("<")) {
declaredType = declaredType.substring(0, declaredType.indexOf('<'));
}
sourceMethod = "inline-instantiation";
}
}
}
if (declaredType != null) {
System.out.println("DEEP TRACE: " + sourceMethod + " " + (varName != null ? varName : "inline") + " -> " + declaredType);
List<String> typesToInspect = new ArrayList<>();
if ("inline-instantiation".equals(sourceMethod)) {
typesToInspect.add(declaredType);
} else {
typesToInspect.add(declaredType);
typesToInspect.addAll(context.getImplementations(declaredType));
}
System.out.println("DEEP TRACE IMPLS: " + typesToInspect);
for (String type : typesToInspect) {
Set<String> visited = new HashSet<>();
TypeDeclaration baseTd = context.getTypeDeclaration(type);
CompilationUnit cuToUse = null;
if (baseTd != null && baseTd.getRoot() instanceof CompilationUnit) {
cuToUse = (CompilationUnit) baseTd.getRoot();
} else {
String firstPathMethod = path.get(0);
String entryClassName = firstPathMethod.substring(0, firstPathMethod.lastIndexOf('.'));
TypeDeclaration entryTd = context.getTypeDeclaration(entryClassName);
if (entryTd != null && entryTd.getRoot() instanceof CompilationUnit) {
cuToUse = (CompilationUnit) entryTd.getRoot();
}
}
List<String> constants = resolveMethodReturnConstant(type, methodName, 0, visited, cuToUse);
System.out.println("DEEP TRACE RETURN: " + type + " -> " + constants);
for (String constant : constants) {
if (!polymorphicEvents.contains(constant)) {
polymorphicEvents.add(constant);
}
}
}
}
}
}
// LAST RESORT FALLBACK: If AST deep trace failed to find a valid ALL_CAPS constant
// (e.g. it returned a field name like 'type', or nothing), we can scrape the resolvedValue string directly
// for string literals or enum-like constants.
boolean hasValidConstant = false;
for (String ev : polymorphicEvents) {
String val = ev.contains(".") ? ev.substring(ev.lastIndexOf('.') + 1) : ev;
if (val.equals(val.toUpperCase()) && val.length() > 2) {
hasValidConstant = true;
break;
}
}
if (!hasValidConstant && resolvedValue.contains("(")) {
java.util.List<String> scraped = new java.util.ArrayList<>();
// Extract "STRING_LITERALS"
java.util.regex.Matcher m1 = java.util.regex.Pattern.compile("\"([^\"]+)\"").matcher(resolvedValue);
while (m1.find()) {
scraped.add(m1.group(1));
}
// Extract ENUM_LIKE_CONSTANTS
java.util.regex.Matcher m2 = java.util.regex.Pattern.compile("\\b([A-Z_]{3,})\\b").matcher(resolvedValue);
while (m2.find()) {
if (!scraped.contains(m2.group(1))) {
scraped.add(m2.group(1));
}
}
if (!scraped.isEmpty()) {
polymorphicEvents.clear();
polymorphicEvents.addAll(scraped);
}
}
// Clean up any remaining invalid AST fallbacks (like 'type', 'event') if we found valid ones
if (polymorphicEvents.size() > 1) {
polymorphicEvents.removeIf(e -> {
String val = e.contains(".") ? e.substring(e.lastIndexOf('.') + 1) : e;
return !val.equals(val.toUpperCase()) || val.length() <= 2;
});
}
if (!resolvedValue.equals(event) || !polymorphicEvents.isEmpty()) {
return TriggerPoint.builder()
.event(resolvedValue)
.className(tp.getClassName())
.methodName(tp.getMethodName())
.sourceFile(tp.getSourceFile())
.lineNumber(tp.getLineNumber())
.polymorphicEvents(polymorphicEvents)
.build();
}
return tp;
}
private int getParameterIndex(String methodFqn, String paramName) {
if (methodFqn == null || !methodFqn.contains(".")) return -1;
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null) {
for (int i = 0; i < md.parameters().size(); i++) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) md.parameters().get(i);
if (svd.getName().getIdentifier().equals(paramName)) {
return i;
}
}
}
}
return -1;
}
private String getVariableDeclaredType(String methodFqn, String varName) {
if (methodFqn == null || !methodFqn.contains(".")) return null;
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null) {
for (Object pObj : md.parameters()) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) pObj;
if (svd.getName().getIdentifier().equals(varName)) {
return svd.getType().toString();
}
}
final String[] foundType = new String[1];
if (md.getBody() != null) {
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement node) {
for (Object fragObj : node.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
foundType[0] = node.getType().toString();
}
}
return super.visit(node);
}
});
}
return foundType[0];
}
}
return null;
}
private List<String> resolveMethodReturnConstant(String className, String methodName, int depth, Set<String> visited, CompilationUnit contextCu) {
if (depth > 20) return Collections.emptyList();
String fqn = className + "." + methodName;
if (!visited.add(fqn)) return Collections.emptyList();
List<String> constants = new ArrayList<>();
TypeDeclaration tempTd = contextCu != null ? context.getTypeDeclaration(className, contextCu) : null;
if (tempTd == null) {
tempTd = context.getTypeDeclaration(className);
}
final TypeDeclaration td = tempTd;
if (td == null) {
System.out.println("DEEP TRACE FAILED TO FIND TD: " + className);
}
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) {
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement node) {
Expression retExpr = node.getExpression();
if (retExpr != null) {
boolean handled = false;
if (retExpr instanceof MethodInvocation mi) {
// Follow delegation first
String called = resolveCalledMethod(mi);
System.out.println("DEEP TRACE RESOLVED CALLED: " + called);
if (called != null && called.contains(".")) {
if (visited.contains(called)) {
handled = true;
} else {
String cName = called.substring(0, called.lastIndexOf('.'));
String mName = called.substring(called.lastIndexOf('.') + 1);
TypeDeclaration targetTd = contextCu != null ? context.getTypeDeclaration(cName, contextCu) : context.getTypeDeclaration(cName);
if (targetTd == null) targetTd = context.getTypeDeclaration(cName);
if (targetTd != null && context.findMethodDeclaration(targetTd, mName, true) != null) {
List<String> delegationResult = resolveMethodReturnConstant(cName, mName, depth + 1, visited, contextCu);
constants.addAll(delegationResult);
handled = true;
}
}
}
}
if (!handled) {
String val = constantResolver.resolve(retExpr, context);
if (val != null) {
if (val.startsWith("ENUM_SET:")) {
for (String eVal : val.substring(9).split(",")) {
constants.add(eVal.substring(eVal.lastIndexOf('.') + 1));
}
} else {
constants.add(val);
}
} else if (retExpr instanceof QualifiedName qn) {
constants.add(qn.toString());
} else if (retExpr instanceof SimpleName sn) {
List<String> consts = traceFieldInConstructors(td, sn.getIdentifier(), context, visited);
if (!consts.isEmpty()) {
constants.addAll(consts);
} else {
constants.add(sn.toString());
}
}
}
}
return super.visit(node);
}
});
}
}
visited.remove(fqn);
return constants;
}
private String traceLocalVariable(String methodFqn, String varName) {
if (methodFqn == null || !methodFqn.contains(".")) return null;
String className = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1);
TypeDeclaration td = context.getTypeDeclaration(className);
if (td != null) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null && md.getBody() != null) {
final Expression[] initializer = new Expression[1];
md.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationFragment node) {
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
initializer[0] = node.getInitializer();
}
return super.visit(node);
}
@Override
public boolean visit(Assignment node) {
if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) {
initializer[0] = node.getRightHandSide();
}
return super.visit(node);
}
});
if (initializer[0] != null) {
Expression expr = traceVariable(initializer[0]);
if (expr instanceof MethodInvocation mi) {
// Unwrapper logic: If wrapper method is called, extract its arguments recursively
Expression innerMost = unwrapMethodInvocation(mi, 0);
if (innerMost instanceof MethodInvocation innerMi) {
if (innerMi.getExpression() instanceof SimpleName sn) {
return sn.getIdentifier() + "." + innerMi.getName().getIdentifier() + "()";
}
return innerMi.getName().getIdentifier() + "()";
}
if (innerMost instanceof SimpleName sn) {
return sn.getIdentifier();
}
return innerMost.toString();
}
if (expr instanceof SimpleName sn) {
return sn.getIdentifier();
}
return expr.toString();
}
}
}
return null;
}
private Expression unwrapMethodInvocation(MethodInvocation mi, int depth) {
if (depth > 5) return mi;
if (!mi.arguments().isEmpty()) {
Expression arg = (Expression) mi.arguments().get(0);
if (arg instanceof MethodInvocation innerMi) {
return unwrapMethodInvocation(innerMi, depth + 1);
}
return arg;
}
return mi;
}
private String extractContextMachineId(List<String> path, Map<String, List<CallEdge>> callGraph) {
for (String node : path) {
List<CallEdge> edges = callGraph.get(node);
if (edges != null) {
for (CallEdge edge : edges) {
String target = edge.getTargetMethod();
if (target != null && (target.contains(".restore") || target.contains(".read"))) {
// Persister signatures usually like: restore(stateMachine, contextObj)
if (edge.getArguments().size() >= 2) {
return edge.getArguments().get(1); // The contextObj / machineId
}
}
}
}
}
return null;
}
private MethodDeclaration findEnclosingMethod(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof MethodDeclaration)) {
parent = parent.getParent();
}
return (MethodDeclaration) parent;
}
private Map<String, List<CallEdge>> buildCallGraph() {
graph = new HashMap<>(); graph = new HashMap<>();
for (CompilationUnit cu : context.getCompilationUnits()) { for (CompilationUnit cu : context.getCompilationUnits()) {
cu.accept(new ASTVisitor() { cu.accept(new ASTVisitor() {
@@ -520,7 +45,13 @@ public class HeuristicCallGraphEngine implements CallGraphEngine {
if (td2 != null) { if (td2 != null) {
String refMethod = resolveMethodInType(td2, emr.getName().getIdentifier()); String refMethod = resolveMethodInType(td2, emr.getName().getIdentifier());
if (refMethod != null) { if (refMethod != null) {
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(refMethod, args)); List<String> implicitArgs = new ArrayList<>();
if (click.kamil.springstatemachineexporter.ast.common.AstUtils.isFunctionalCollectionMethod(node)) {
implicitArgs.add(node.getExpression().toString());
} else {
implicitArgs.addAll(args);
}
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(refMethod, implicitArgs));
} }
} }
} else { } else {
@@ -533,7 +64,13 @@ public class HeuristicCallGraphEngine implements CallGraphEngine {
} }
if (fallbackTypeFqn != null) { if (fallbackTypeFqn != null) {
String calledMethod = fallbackTypeFqn + "." + emr.getName().getIdentifier(); String calledMethod = fallbackTypeFqn + "." + emr.getName().getIdentifier();
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(calledMethod, args)); List<String> implicitArgs = new ArrayList<>();
if (click.kamil.springstatemachineexporter.ast.common.AstUtils.isFunctionalCollectionMethod(node)) {
implicitArgs.add(node.getExpression().toString());
} else {
implicitArgs.addAll(args);
}
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(calledMethod, implicitArgs));
List<String> impls = context.getImplementations(fallbackTypeFqn); List<String> impls = context.getImplementations(fallbackTypeFqn);
for (String impl : impls) { for (String impl : impls) {
@@ -581,7 +118,7 @@ public class HeuristicCallGraphEngine implements CallGraphEngine {
return graph; return graph;
} }
private List<String> resolveArguments(List<?> astArguments) { protected List<String> resolveArguments(List<?> astArguments) {
List<String> args = new ArrayList<>(); List<String> args = new ArrayList<>();
for (Object argObj : astArguments) { for (Object argObj : astArguments) {
Expression expr = (Expression) argObj; Expression expr = (Expression) argObj;
@@ -616,11 +153,7 @@ public class HeuristicCallGraphEngine implements CallGraphEngine {
} }
} }
// Extract from constructor args (e.g., new CustomMessage(OrderEvent.PROCESS, ...)) // Removed eager tracing here to allow dynamic tracing in resolveTriggerPointParameters
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()) { if (expr instanceof ClassInstanceCreation cic && !cic.arguments().isEmpty()) {
Expression firstArg = (Expression) cic.arguments().get(0); Expression firstArg = (Expression) cic.arguments().get(0);
@@ -636,10 +169,11 @@ public class HeuristicCallGraphEngine implements CallGraphEngine {
} }
args.add(val); args.add(val);
} }
System.out.println("DEBUG resolveArguments: " + args);
return args; return args;
} }
private List<String> resolveCalledMethodsPolymorphic(MethodInvocation node) { protected List<String> resolveCalledMethodsPolymorphic(MethodInvocation node) {
String baseCalled = resolveCalledMethod(node); String baseCalled = resolveCalledMethod(node);
if (baseCalled == null) return Collections.emptyList(); if (baseCalled == null) return Collections.emptyList();
@@ -659,7 +193,7 @@ public class HeuristicCallGraphEngine implements CallGraphEngine {
return allResolved; return allResolved;
} }
private 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();
@@ -695,7 +229,7 @@ public class HeuristicCallGraphEngine implements CallGraphEngine {
return null; return null;
} }
private String resolveReceiverTypeFallback(SimpleName receiverNameNode) { protected String resolveReceiverTypeFallback(SimpleName receiverNameNode) {
String varName = receiverNameNode.getIdentifier(); String varName = receiverNameNode.getIdentifier();
// 1. Check local variables in enclosing method // 1. Check local variables in enclosing method
@@ -745,7 +279,7 @@ public class HeuristicCallGraphEngine implements CallGraphEngine {
return null; return null;
} }
private String resolveTypeToFqn(Type type, ASTNode contextNode) { protected String resolveTypeToFqn(Type type, ASTNode contextNode) {
if (type == null) return null; if (type == null) return null;
String simpleName; String simpleName;
if (type.isSimpleType()) { if (type.isSimpleType()) {
@@ -774,7 +308,7 @@ public class HeuristicCallGraphEngine implements CallGraphEngine {
return simpleName; return simpleName;
} }
private String resolveMethodInType(TypeDeclaration td, String methodName) { protected String resolveMethodInType(TypeDeclaration td, String methodName) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null) { if (md != null) {
TypeDeclaration declaringTd = findEnclosingType(md); TypeDeclaration declaringTd = findEnclosingType(md);
@@ -783,185 +317,4 @@ public class HeuristicCallGraphEngine implements CallGraphEngine {
return null; return null;
} }
private List<String> findPath(String start, String target, Map<String, List<CallEdge>> graph, Set<String> visited) {
if (start.equals(target)) return new ArrayList<>(List.of(start));
if (!visited.add(start)) return null; // Path-scoped cycle detection
List<CallEdge> neighbors = graph.get(start);
if (neighbors != null) {
for (CallEdge edge : neighbors) {
String neighbor = edge.getTargetMethod();
if (neighbor.equals(target) || isHeuristicMatch(neighbor, target)) {
visited.remove(start);
return new ArrayList<>(List.of(start, target));
}
List<String> path = findPath(neighbor, target, graph, visited);
if (path != null) {
path.add(0, start);
visited.remove(start);
return path;
}
}
}
if (log.isDebugEnabled()) {
log.debug("Path search dead-end at {} when looking for {}", start, target);
}
visited.remove(start);
return null;
}
private boolean isHeuristicMatch(String neighbor, String target) {
if (target.endsWith("." + neighbor)) return true;
String simpleTarget = target.contains(".") ? target.substring(target.lastIndexOf('.') + 1) : target;
String simpleNeighbor = neighbor.contains(".") ? neighbor.substring(neighbor.lastIndexOf('.') + 1) : neighbor;
return simpleNeighbor.equals(simpleTarget);
}
private TypeDeclaration findEnclosingType(ASTNode node) {
ASTNode parent = node.getParent();
while (parent != null && !(parent instanceof TypeDeclaration)) {
parent = parent.getParent();
}
return (TypeDeclaration) parent;
}
private Expression traceVariable(Expression expr) {
if (expr instanceof SimpleName sn) {
String varName = sn.getIdentifier();
MethodDeclaration enclosingMethod = findEnclosingMethod(expr);
if (enclosingMethod != null) {
final Expression[] initializer = new Expression[1];
enclosingMethod.accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationFragment node) {
if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) {
initializer[0] = node.getInitializer();
}
return super.visit(node);
}
@Override
public boolean visit(Assignment node) {
if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) {
initializer[0] = node.getRightHandSide();
}
return super.visit(node);
}
});
if (initializer[0] != null) {
return traceVariable(initializer[0]);
}
}
}
return expr;
}
private List<String> traceFieldInConstructors(TypeDeclaration td, String fieldName, CodebaseContext context, Set<String> visited) {
List<String> results = new ArrayList<>();
// 1. Check Field Declarations
for (org.eclipse.jdt.core.dom.FieldDeclaration fd : td.getFields()) {
for (Object fragObj : fd.fragments()) {
org.eclipse.jdt.core.dom.VariableDeclarationFragment frag = (org.eclipse.jdt.core.dom.VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(fieldName) && frag.getInitializer() != null) {
Expression right = frag.getInitializer();
if (right instanceof MethodInvocation mi) {
String calledName = mi.getName().getIdentifier();
String targetClass = context.getFqn(td);
CompilationUnit cu = td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null;
results.addAll(resolveMethodReturnConstant(targetClass, calledName, 0, new HashSet<>(visited), cu));
} else {
String val = constantResolver.resolve(right, context);
if (val != null) results.add(val);
}
}
}
}
org.eclipse.jdt.core.dom.ASTVisitor assignmentVisitor = new org.eclipse.jdt.core.dom.ASTVisitor() {
@Override
public boolean visit(Assignment node) {
Expression left = node.getLeftHandSide();
if ((left instanceof SimpleName && ((SimpleName) left).getIdentifier().equals(fieldName)) ||
(left instanceof FieldAccess && ((FieldAccess) left).getName().getIdentifier().equals(fieldName))) {
Expression right = node.getRightHandSide();
if (right instanceof MethodInvocation mi) {
String calledName = mi.getName().getIdentifier();
String targetClass = context.getFqn(td);
if (mi.getExpression() != null && mi.resolveMethodBinding() != null && mi.resolveMethodBinding().getDeclaringClass() != null) {
targetClass = mi.resolveMethodBinding().getDeclaringClass().getQualifiedName();
} else if (mi.getExpression() instanceof SimpleName) {
String exprName = ((SimpleName) mi.getExpression()).getIdentifier();
CompilationUnit cu = td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null;
TypeDeclaration targetTd = context.resolveStaticImport(exprName, cu);
if (targetTd != null) {
targetClass = context.getFqn(targetTd);
}
}
CompilationUnit cu = td.getRoot() instanceof CompilationUnit ? (CompilationUnit) td.getRoot() : null;
results.addAll(resolveMethodReturnConstant(targetClass, calledName, 0, new HashSet<>(visited), cu));
} else {
String val = constantResolver.resolve(right, context);
if (val != null) results.add(val);
}
}
return super.visit(node);
}
@Override
public boolean visit(ConstructorInvocation node) {
for (Object argObj : node.arguments()) {
Expression arg = (Expression) argObj;
String val = constantResolver.resolve(arg, context);
if (val != null) {
if (val.startsWith("ENUM_SET:")) {
for (String eVal : val.substring(9).split(",")) {
results.add(eVal.substring(eVal.lastIndexOf('.') + 1));
}
} else {
results.add(val);
}
}
}
return super.visit(node);
}
@Override
public boolean visit(SuperConstructorInvocation node) {
for (Object argObj : node.arguments()) {
Expression arg = (Expression) argObj;
String val = constantResolver.resolve(arg, context);
if (val != null) {
if (val.startsWith("ENUM_SET:")) {
for (String eVal : val.substring(9).split(",")) {
results.add(eVal.substring(eVal.lastIndexOf('.') + 1));
}
} else {
results.add(val);
}
}
}
return super.visit(node);
}
};
// 2. Check Constructors
for (MethodDeclaration md : td.getMethods()) {
if (md.isConstructor() && md.getBody() != null) {
md.getBody().accept(assignmentVisitor);
}
}
// 3. Check Initializers
for (Object bodyDecl : td.bodyDeclarations()) {
if (bodyDecl instanceof org.eclipse.jdt.core.dom.Initializer init && init.getBody() != null) {
init.getBody().accept(assignmentVisitor);
}
}
return results;
}
} }

View File

@@ -0,0 +1,395 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.*;
import java.util.*;
import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer;
@Slf4j
public class JdtCallGraphEngine extends AbstractCallGraphEngine {
private final InjectionPointAnalyzer injectionAnalyzer;
private String currentMethodFqn;
private Map<String, List<CallEdge>> graph;
public JdtCallGraphEngine(CodebaseContext context, InjectionPointAnalyzer injectionAnalyzer) {
super(context);
this.injectionAnalyzer = injectionAnalyzer;
}
protected Map<String, List<CallEdge>> buildCallGraph() {
graph = new HashMap<>();
for (CompilationUnit cu : context.getCompilationUnits()) {
cu.accept(new ASTVisitor() {
@Override
public boolean visit(MethodInvocation node) {
MethodDeclaration md = findEnclosingMethod(node);
if (md != null) {
TypeDeclaration td = findEnclosingType(md);
if (td != null) {
String currentMethodFqn = context.getFqn(td) + "." + md.getName().getIdentifier();
List<String> calledMethods = resolveCalledMethodsPolymorphic(node);
List<String> args = resolveArguments(node.arguments());
for (String calledMethod : calledMethods) {
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(calledMethod, args));
}
for (Object argObj : node.arguments()) {
if (argObj instanceof ExpressionMethodReference emr) {
String typeName = emr.getExpression().toString();
if ("this".equals(typeName) || "super".equals(typeName)) {
TypeDeclaration td2 = findEnclosingType(node);
if (td2 != null) {
String refMethod = resolveMethodInType(td2, emr.getName().getIdentifier());
if (refMethod != null) {
List<String> implicitArgs = new ArrayList<>();
if (click.kamil.springstatemachineexporter.ast.common.AstUtils.isFunctionalCollectionMethod(node)) {
implicitArgs.add(node.getExpression().toString());
} else {
implicitArgs.addAll(args);
}
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(refMethod, implicitArgs));
}
}
} else {
String fallbackTypeFqn = null;
ITypeBinding binding = emr.getExpression().resolveTypeBinding();
if (binding != null) {
fallbackTypeFqn = binding.getQualifiedName();
} else if (emr.getExpression() instanceof SimpleName sn) {
fallbackTypeFqn = resolveReceiverTypeFallback(sn);
}
if (fallbackTypeFqn != null) {
String calledMethod = fallbackTypeFqn + "." + emr.getName().getIdentifier();
List<String> implicitArgs = new ArrayList<>();
if (click.kamil.springstatemachineexporter.ast.common.AstUtils.isFunctionalCollectionMethod(node)) {
implicitArgs.add(node.getExpression().toString());
} else {
implicitArgs.addAll(args);
}
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(calledMethod, implicitArgs));
List<String> impls = context.getImplementations(fallbackTypeFqn);
for (String impl : impls) {
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(impl + "." + emr.getName().getIdentifier(), args));
}
}
}
}
}
}
}
return super.visit(node);
}
@Override
public boolean visit(SuperMethodInvocation node) {
MethodDeclaration md = findEnclosingMethod(node);
if (md != null) {
TypeDeclaration tdOuter = findEnclosingType(md);
if (tdOuter != null) {
String currentMethodFqn = context.getFqn(tdOuter) + "." + md.getName().getIdentifier();
String methodName = node.getName().getIdentifier();
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
String calledMethod = null;
if (superTd != null) {
calledMethod = resolveMethodInType(superTd, methodName);
}
if (calledMethod == null) {
calledMethod = superFqn + "." + methodName;
}
List<String> args = resolveArguments(node.arguments());
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(calledMethod, args));
}
}
}
}
return super.visit(node);
}
});
}
return graph;
}
private List<String> resolveArguments(List<?> astArguments) {
List<String> 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);
}
return args;
}
private List<String> resolveCalledMethodsPolymorphic(MethodInvocation node) {
String baseCalled = resolveCalledMethod(node);
if (baseCalled == null) return Collections.emptyList();
List<String> allResolved = new ArrayList<>();
allResolved.add(baseCalled);
if (baseCalled.contains(".")) {
String className = baseCalled.substring(0, baseCalled.lastIndexOf('.'));
String methodName = baseCalled.substring(baseCalled.lastIndexOf('.') + 1);
List<String> impls = context.getImplementations(className);
for (String impl : impls) {
allResolved.add(impl + "." + methodName);
}
}
return allResolved;
}
protected String resolveCalledMethod(MethodInvocation node) {
Expression receiver = node.getExpression();
String methodName = node.getName().getIdentifier();
if (receiver == null) {
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
return resolveMethodInType(td, methodName);
}
return null;
}
if (injectionAnalyzer != null) {
IBinding nameBinding = null;
if (receiver instanceof SimpleName sn) {
nameBinding = sn.resolveBinding();
} else if (receiver instanceof FieldAccess fa) {
nameBinding = fa.resolveFieldBinding();
}
if (nameBinding instanceof IVariableBinding varBinding) {
System.out.println("CALLGRAPH RESOLVING BEAN FOR BINDING: " + varBinding.getName() + " calling " + methodName);
String concreteFqn = injectionAnalyzer.resolveInjectedBeanFqn(varBinding);
if (concreteFqn != null) {
System.out.println(" -> RESOLVED CONCRETE FQN: " + concreteFqn);
return concreteFqn + "." + methodName;
} else {
System.out.println(" -> RESOLVE FAILED, RETURNED NULL");
}
} else {
System.out.println("CALLGRAPH NAME BINDING IS NOT VARIABLE: " + nameBinding + " calling " + methodName);
}
}
ITypeBinding binding = receiver.resolveTypeBinding();
if (binding != null) {
String typeName = null;
try {
if (binding.getErasure() != null) {
typeName = binding.getErasure().getQualifiedName();
if (typeName == null || typeName.isEmpty()) {
typeName = binding.getErasure().getName();
}
}
} catch (Exception e) {
// Ignore JDT internal exceptions
}
if (typeName == null || typeName.isEmpty()) {
typeName = binding.getQualifiedName();
if (typeName == null || typeName.isEmpty()) {
typeName = binding.getName();
}
if (typeName != null && typeName.contains("<")) {
typeName = typeName.replaceAll("<.*>", "");
}
}
if (typeName != null && !typeName.isEmpty()) {
return typeName + "." + methodName;
}
}
if (receiver instanceof ThisExpression) {
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
return context.getFqn(td) + "." + methodName;
}
}
if (receiver instanceof SimpleName sn) {
String fallbackTypeFqn = resolveReceiverTypeFallback(sn);
if (fallbackTypeFqn != null) {
return fallbackTypeFqn + "." + methodName;
}
String receiverName = sn.getIdentifier();
return receiverName + "." + methodName;
}
return null;
}
private String resolveReceiverTypeFallback(SimpleName receiverNameNode) {
String varName = receiverNameNode.getIdentifier();
// 1. Check local variables in enclosing method
MethodDeclaration enclosingMethod = findEnclosingMethod(receiverNameNode);
if (enclosingMethod != null) {
// Check parameters
for (Object paramObj : enclosingMethod.parameters()) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) paramObj;
if (svd.getName().getIdentifier().equals(varName)) {
return resolveTypeToFqn(svd.getType(), receiverNameNode);
}
}
// Check method body (local variables)
if (enclosingMethod.getBody() != null) {
Type[] foundType = new Type[1];
enclosingMethod.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(VariableDeclarationStatement node) {
for (Object fragObj : node.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
foundType[0] = node.getType();
}
}
return super.visit(node);
}
});
if (foundType[0] != null) {
return resolveTypeToFqn(foundType[0], receiverNameNode);
}
}
}
// 2. Check fields in enclosing class
TypeDeclaration enclosingType = findEnclosingType(receiverNameNode);
if (enclosingType != null) {
for (FieldDeclaration field : enclosingType.getFields()) {
for (Object fragObj : field.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
return resolveTypeToFqn(field.getType(), receiverNameNode);
}
}
}
}
return null;
}
private String resolveTypeToFqn(Type type, ASTNode contextNode) {
if (type == null) return null;
String simpleName;
if (type.isSimpleType()) {
simpleName = ((SimpleType) type).getName().getFullyQualifiedName();
} else if (type.isParameterizedType()) {
return resolveTypeToFqn(((ParameterizedType) type).getType(), contextNode);
} else {
simpleName = type.toString();
}
CompilationUnit cu = (CompilationUnit) contextNode.getRoot();
TypeDeclaration td = context.getTypeDeclaration(simpleName, cu);
if (td != null) {
return context.getFqn(td);
}
// Fallback to import matching if CodebaseContext doesn't know it (e.g., external library)
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + simpleName)) {
return impName;
}
}
return simpleName;
}
private String resolveMethodInType(TypeDeclaration td, String methodName) {
MethodDeclaration md = context.findMethodDeclaration(td, methodName, true);
if (md != null) {
TypeDeclaration declaringTd = findEnclosingType(md);
return context.getFqn(declaringTd) + "." + methodName;
}
return null;
}
private Expression unwrapMessageBuilder(Expression expr) {
if (expr instanceof MethodInvocation mi) {
MethodInvocation current = mi;
while (current != null) {
String name = current.getName().getIdentifier();
if (("withPayload".equals(name) || "just".equals(name)) && !current.arguments().isEmpty()) {
return (Expression) current.arguments().get(0);
}
Expression receiver = current.getExpression();
if (receiver instanceof MethodInvocation nextMi) {
current = nextMi;
} else {
current = null;
}
}
}
return expr;
}
}

View File

@@ -4,6 +4,10 @@ import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer;
import click.kamil.springstatemachineexporter.analysis.spring.SpringBeanRegistry;
import click.kamil.springstatemachineexporter.analysis.spring.SpringContextScanner;
import click.kamil.springstatemachineexporter.analysis.spring.SpringDependencyResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.eclipse.jdt.core.dom.CompilationUnit; import org.eclipse.jdt.core.dom.CompilationUnit;
@@ -32,7 +36,17 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
this.eventDetector = new GenericEventDetector(context, constantResolver, context.getLibraryHints()); this.eventDetector = new GenericEventDetector(context, constantResolver, context.getLibraryHints());
this.mvcDetector = new SpringMvcDetector(context, constantResolver); this.mvcDetector = new SpringMvcDetector(context, constantResolver);
this.messagingDetector = new MessagingDetector(context); this.messagingDetector = new MessagingDetector(context);
this.callGraphEngine = new HeuristicCallGraphEngine(context);
SpringBeanRegistry registry = new SpringBeanRegistry();
SpringContextScanner scanner = new SpringContextScanner(registry);
for (CompilationUnit cu : context.getCompilationUnits()) {
cu.accept(scanner);
}
SpringDependencyResolver dependencyResolver = new SpringDependencyResolver(registry);
InjectionPointAnalyzer injectionAnalyzer = new InjectionPointAnalyzer(dependencyResolver);
this.callGraphEngine = new JdtCallGraphEngine(context, injectionAnalyzer);
this.lifecycleDetector = new LifecycleDetector(context); this.lifecycleDetector = new LifecycleDetector(context);
this.interceptorDetector = new InterceptorDetector(context); this.interceptorDetector = new InterceptorDetector(context);
this.componentDetector = new SpringComponentDetector(context); this.componentDetector = new SpringComponentDetector(context);

View File

@@ -0,0 +1,93 @@
package click.kamil.springstatemachineexporter.analysis.spring;
import org.eclipse.jdt.core.dom.IAnnotationBinding;
import org.eclipse.jdt.core.dom.IMemberValuePairBinding;
import org.eclipse.jdt.core.dom.IVariableBinding;
import java.util.List;
public class InjectionPointAnalyzer {
private final SpringDependencyResolver resolver;
public InjectionPointAnalyzer(SpringDependencyResolver resolver) {
this.resolver = resolver;
}
/**
* Resolves the Spring bean injected into the given variable.
*
* @param variableBinding The binding of the field or parameter being injected.
* @return The FQN of the resolved concrete bean class, or null if unresolved.
*/
public String resolveInjectedBeanFqn(IVariableBinding variableBinding) {
if (variableBinding == null || variableBinding.getType() == null) {
return null;
}
String requiredTypeFqn = variableBinding.getType().getQualifiedName();
String injectionName = variableBinding.getName();
String qualifier = extractQualifier(variableBinding);
List<SpringBean> resolvedBeans = resolver.resolve(requiredTypeFqn, qualifier, injectionName);
if (resolvedBeans != null && resolvedBeans.size() == 1) {
return resolvedBeans.get(0).getTypeFqn();
}
return null;
}
private String extractQualifier(IVariableBinding binding) {
String qualifier = getQualifierValue(binding.getAnnotations());
if (qualifier != null) {
return qualifier;
}
if (binding.isField()) {
org.eclipse.jdt.core.dom.ITypeBinding declaringClass = binding.getDeclaringClass();
if (declaringClass != null) {
for (org.eclipse.jdt.core.dom.IMethodBinding method : declaringClass.getDeclaredMethods()) {
boolean isAutowiredMethod = false;
for (IAnnotationBinding ann : method.getAnnotations()) {
if (ann.getAnnotationType() != null && "org.springframework.beans.factory.annotation.Autowired".equals(ann.getAnnotationType().getQualifiedName())) {
isAutowiredMethod = true;
break;
}
}
if (method.isConstructor() || isAutowiredMethod) {
for (int i = 0; i < method.getParameterTypes().length; i++) {
org.eclipse.jdt.core.dom.ITypeBinding paramType = method.getParameterTypes()[i];
try {
IAnnotationBinding[] paramAnns = method.getParameterAnnotations(i);
String paramQual = getQualifierValue(paramAnns);
if (paramQual != null && paramType.getErasure().isEqualTo(binding.getType().getErasure())) {
// For setters, verify it roughly matches the field name or just rely on type.
// We will rely on type equality for now as a heuristic.
return paramQual;
}
} catch (Exception e) {
// Ignore
}
}
}
}
}
}
return null;
}
private String getQualifierValue(IAnnotationBinding[] annotations) {
for (IAnnotationBinding ann : annotations) {
if (ann.getAnnotationType() != null && "org.springframework.beans.factory.annotation.Qualifier".equals(ann.getAnnotationType().getQualifiedName())) {
for (IMemberValuePairBinding pair : ann.getDeclaredMemberValuePairs()) {
if ("value".equals(pair.getName()) && pair.getValue() instanceof String) {
return (String) pair.getValue();
}
}
}
}
return null;
}
}

View File

@@ -0,0 +1,27 @@
package click.kamil.springstatemachineexporter.analysis.spring;
import java.util.HashSet;
import java.util.Set;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class SpringBean {
private String typeFqn;
@Builder.Default
private Set<String> assignableTypes = new HashSet<>();
@Builder.Default
private Set<String> beanNames = new HashSet<>();
@Builder.Default
private Set<String> qualifiers = new HashSet<>();
private boolean isPrimary;
private Integer order;
private String declaringClassFqn;
private String factoryMethodName;
}

View File

@@ -0,0 +1,17 @@
package click.kamil.springstatemachineexporter.analysis.spring;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class SpringBeanRegistry {
private final List<SpringBean> beans = new ArrayList<>();
public void addBean(SpringBean bean) {
beans.add(bean);
}
public List<SpringBean> getBeans() {
return Collections.unmodifiableList(beans);
}
}

View File

@@ -0,0 +1,235 @@
package click.kamil.springstatemachineexporter.analysis.spring;
import org.eclipse.jdt.core.dom.*;
import java.util.HashSet;
import java.util.Set;
public class SpringContextScanner extends ASTVisitor {
private final SpringBeanRegistry registry;
public SpringContextScanner(SpringBeanRegistry registry) {
this.registry = registry;
}
@Override
public boolean visit(TypeDeclaration node) {
ITypeBinding typeBinding = node.resolveBinding();
if (typeBinding == null) return true;
if (isSpringComponent(typeBinding)) {
SpringBean bean = SpringBean.builder()
.typeFqn(typeBinding.getQualifiedName())
.assignableTypes(getAssignableTypes(typeBinding))
.isPrimary(hasAnnotation(typeBinding, "org.springframework.context.annotation.Primary"))
.qualifiers(extractQualifiers(typeBinding))
.order(extractOrder(typeBinding))
.declaringClassFqn(typeBinding.getQualifiedName())
.build();
// Default bean name is uncapitalized simple name
String defaultName = Character.toLowerCase(node.getName().getIdentifier().charAt(0)) + node.getName().getIdentifier().substring(1);
// Check if @Component or @Service specifies a name (e.g., @Service("myService"))
String explicitName = extractStereotypeValue(typeBinding);
if (explicitName != null && !explicitName.isEmpty()) {
bean.getBeanNames().add(explicitName);
} else {
bean.getBeanNames().add(defaultName);
}
registry.addBean(bean);
}
return true;
}
@Override
public boolean visit(MethodDeclaration node) {
IMethodBinding methodBinding = node.resolveBinding();
if (methodBinding == null) return true;
if (hasAnnotation(methodBinding, "org.springframework.context.annotation.Bean")) {
ITypeBinding returnType = methodBinding.getReturnType();
if (returnType != null) {
Set<String> assignables = getAssignableTypes(returnType);
String[] concreteType = new String[] { returnType.getQualifiedName() };
if (node.getBody() != null) {
node.getBody().accept(new ASTVisitor() {
@Override
public boolean visit(ReturnStatement ret) {
if (ret.getExpression() != null) {
ITypeBinding exprType = ret.getExpression().resolveTypeBinding();
if (exprType != null && exprType.isClass()) {
concreteType[0] = exprType.getQualifiedName();
assignables.addAll(getAssignableTypes(exprType));
}
}
return super.visit(ret);
}
});
}
SpringBean bean = SpringBean.builder()
.typeFqn(concreteType[0])
.assignableTypes(assignables)
.isPrimary(hasAnnotation(methodBinding, "org.springframework.context.annotation.Primary"))
.qualifiers(extractQualifiers(methodBinding))
.order(extractOrder(methodBinding))
.declaringClassFqn(methodBinding.getDeclaringClass().getQualifiedName())
.factoryMethodName(node.getName().getIdentifier())
.build();
// Default bean name is method name
String defaultName = node.getName().getIdentifier();
// Check if @Bean specifies a name (e.g., @Bean("myBean"))
String explicitName = extractBeanName(methodBinding);
if (explicitName != null && !explicitName.isEmpty()) {
bean.getBeanNames().add(explicitName);
} else {
bean.getBeanNames().add(defaultName);
}
registry.addBean(bean);
}
}
return true;
}
private boolean isSpringComponent(ITypeBinding binding) {
return hasMetaAnnotation(binding, "org.springframework.stereotype.Component") ||
hasMetaAnnotation(binding, "org.springframework.web.bind.annotation.RestController");
}
private boolean hasMetaAnnotation(ITypeBinding binding, String targetFqn) {
return checkMetaAnnotation(binding.getAnnotations(), targetFqn, new HashSet<>());
}
private boolean checkMetaAnnotation(IAnnotationBinding[] annotations, String targetFqn, Set<String> visited) {
for (IAnnotationBinding ann : annotations) {
ITypeBinding annType = ann.getAnnotationType();
if (annType != null) {
String fqn = annType.getQualifiedName();
if (fqn.equals(targetFqn)) return true;
// Avoid circular meta-annotations (e.g. Documented -> Documented)
if (visited.add(fqn)) {
if (checkMetaAnnotation(annType.getAnnotations(), targetFqn, visited)) {
return true;
}
}
}
}
return false;
}
private boolean hasAnnotation(IBinding binding, String annotationFqn) {
IAnnotationBinding[] annotations = binding.getAnnotations();
for (IAnnotationBinding ann : annotations) {
if (ann.getAnnotationType() != null && annotationFqn.equals(ann.getAnnotationType().getQualifiedName())) {
return true;
}
}
return false;
}
private Set<String> getAssignableTypes(ITypeBinding binding) {
Set<String> types = new HashSet<>();
collectAssignableTypes(binding, types);
return types;
}
private void collectAssignableTypes(ITypeBinding binding, Set<String> types) {
if (binding == null) return;
String qName = binding.getQualifiedName();
if (qName != null && !qName.isEmpty()) {
types.add(qName);
}
ITypeBinding erasure = binding.getErasure();
if (erasure != null) {
String erasureName = erasure.getQualifiedName();
if (erasureName != null && !erasureName.isEmpty()) {
types.add(erasureName);
}
}
collectAssignableTypes(binding.getSuperclass(), types);
if (binding.getInterfaces() != null) {
for (ITypeBinding iface : binding.getInterfaces()) {
collectAssignableTypes(iface, types);
}
}
}
private Set<String> extractQualifiers(IBinding binding) {
Set<String> qualifiers = new HashSet<>();
for (IAnnotationBinding ann : binding.getAnnotations()) {
if (ann.getAnnotationType() != null && "org.springframework.beans.factory.annotation.Qualifier".equals(ann.getAnnotationType().getQualifiedName())) {
for (IMemberValuePairBinding pair : ann.getDeclaredMemberValuePairs()) {
if ("value".equals(pair.getName()) && pair.getValue() instanceof String) {
qualifiers.add((String) pair.getValue());
}
}
}
}
return qualifiers;
}
private Integer extractOrder(IBinding binding) {
for (IAnnotationBinding ann : binding.getAnnotations()) {
if (ann.getAnnotationType() != null && "org.springframework.core.annotation.Order".equals(ann.getAnnotationType().getQualifiedName())) {
for (IMemberValuePairBinding pair : ann.getDeclaredMemberValuePairs()) {
if ("value".equals(pair.getName())) {
Object val = pair.getValue();
if (val instanceof Integer) {
return (Integer) val;
} else if (val instanceof IVariableBinding) {
Object constantValue = ((IVariableBinding) val).getConstantValue();
if (constantValue instanceof Integer) {
return (Integer) constantValue;
}
}
}
}
return Integer.MAX_VALUE; // Spring's default for @Order()
}
}
return null;
}
private String extractStereotypeValue(ITypeBinding binding) {
for (IAnnotationBinding ann : binding.getAnnotations()) {
// Check if annotation itself is meta-annotated with @Component
if (checkMetaAnnotation(new IAnnotationBinding[]{ann}, "org.springframework.stereotype.Component", new HashSet<>()) ||
"org.springframework.stereotype.Component".equals(ann.getAnnotationType().getQualifiedName())) {
for (IMemberValuePairBinding pair : ann.getDeclaredMemberValuePairs()) {
if ("value".equals(pair.getName()) && pair.getValue() instanceof String) {
return (String) pair.getValue();
}
}
}
}
return null;
}
private String extractBeanName(IMethodBinding binding) {
for (IAnnotationBinding ann : binding.getAnnotations()) {
if ("org.springframework.context.annotation.Bean".equals(ann.getAnnotationType().getQualifiedName())) {
for (IMemberValuePairBinding pair : ann.getDeclaredMemberValuePairs()) {
if (("value".equals(pair.getName()) || "name".equals(pair.getName())) && pair.getValue() instanceof String) {
return (String) pair.getValue();
} else if (("value".equals(pair.getName()) || "name".equals(pair.getName())) && pair.getValue() instanceof Object[]) {
Object[] vals = (Object[]) pair.getValue();
if (vals.length > 0 && vals[0] instanceof String) {
return (String) vals[0];
}
}
}
}
}
return null;
}
}

View File

@@ -0,0 +1,87 @@
package click.kamil.springstatemachineexporter.analysis.spring;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
public class SpringDependencyResolver {
private final SpringBeanRegistry registry;
public SpringDependencyResolver(SpringBeanRegistry registry) {
this.registry = registry;
}
/**
* Resolves the exact SpringBean that would be injected for a given injection point.
*
* @param requiredTypeFqn The exact FQN of the type requested at the injection point.
* @param qualifier The value of the @Qualifier annotation on the injection point (if any).
* @param injectionName The name of the field or parameter being injected (used as fallback).
* @return A list of resolved beans. Usually 1. If 0, unresolved. If > 1, ambiguous.
*/
public List<SpringBean> resolve(String requiredTypeFqn, String qualifier, String injectionName) {
if (requiredTypeFqn == null) return new ArrayList<>();
// 1. Filter by Type (Exact FQN or Assignable Type)
System.out.println("RESOLVING " + requiredTypeFqn + " qual=" + qualifier + " injectionName=" + injectionName);
List<SpringBean> candidates = registry.getBeans().stream()
.filter(bean -> requiredTypeFqn.equals(bean.getTypeFqn()) || bean.getAssignableTypes().contains(requiredTypeFqn))
.collect(Collectors.toList());
System.out.println("CANDIDATES AFTER TYPE: " + candidates.stream().map(c -> c.getTypeFqn() + " (primary=" + c.isPrimary() + " names=" + c.getBeanNames() + " qual=" + c.getQualifiers() + ")").collect(Collectors.toList()));
if (candidates.isEmpty() || candidates.size() == 1) {
System.out.println("RETURNING: " + candidates.size());
return candidates;
}
// 2. Filter by Qualifier (if provided)
if (qualifier != null && !qualifier.isEmpty()) {
List<SpringBean> qualifiedCandidates = candidates.stream()
.filter(bean -> bean.getQualifiers().contains(qualifier) || bean.getBeanNames().contains(qualifier))
.collect(Collectors.toList());
if (qualifiedCandidates.size() == 1) {
return qualifiedCandidates;
} else if (!qualifiedCandidates.isEmpty()) {
candidates = qualifiedCandidates;
}
}
// 3. Filter by @Primary
List<SpringBean> primaryCandidates = candidates.stream()
.filter(SpringBean::isPrimary)
.collect(Collectors.toList());
if (primaryCandidates.size() == 1) {
return primaryCandidates;
}
// 4. Fallback to Injection Name (field name or parameter name)
if (injectionName != null && !injectionName.isEmpty()) {
List<SpringBean> namedCandidates = candidates.stream()
.filter(bean -> bean.getBeanNames().contains(injectionName))
.collect(Collectors.toList());
if (namedCandidates.size() == 1) {
return namedCandidates;
}
}
// 5. Fallback to @Order (lowest order wins)
Integer minOrder = candidates.stream()
.map(SpringBean::getOrder)
.filter(o -> o != null)
.min(Integer::compareTo)
.orElse(null);
if (minOrder != null) {
List<SpringBean> orderedCandidates = candidates.stream()
.filter(bean -> bean.getOrder() != null && bean.getOrder().equals(minOrder))
.collect(Collectors.toList());
if (!orderedCandidates.isEmpty()) {
candidates = orderedCandidates;
}
}
// Return whatever candidates remain (could be ambiguous)
System.out.println("RETURNING: " + candidates.size());
return candidates;
}
}

View File

@@ -292,6 +292,15 @@ public class AstTransitionParser {
// Try same file // Try same file
ASTNode declNode = cu.findDeclaringNode(binding.getKey()); ASTNode declNode = cu.findDeclaringNode(binding.getKey());
if (declNode instanceof MethodDeclaration md) { if (declNode instanceof MethodDeclaration md) {
if (md.getBody() != null && md.getBody().statements().size() == 1) {
Object stmt = md.getBody().statements().get(0);
if (stmt instanceof org.eclipse.jdt.core.dom.ReturnStatement rs) {
Expression retExpr = rs.getExpression();
if (isLambdaOrAnonymous(retExpr)) {
return retExpr.toString();
}
}
}
return md.toString(); return md.toString();
} }

View File

@@ -36,4 +36,62 @@ public final class AstUtils {
return type.toString(); // fallback return type.toString(); // fallback
} }
} }
public static boolean isFunctionalCollectionMethod(org.eclipse.jdt.core.dom.MethodInvocation node) {
if (node.getExpression() == null) {
return false;
}
org.eclipse.jdt.core.dom.ITypeBinding typeBinding = node.getExpression().resolveTypeBinding();
if (typeBinding != null) {
if (isCollectionOrStreamType(typeBinding)) {
return true;
}
return false;
}
String methodName = node.getName().getIdentifier();
if (methodName.equals("forEach") || methodName.equals("map") ||
methodName.equals("filter") || methodName.equals("flatMap") ||
methodName.equals("anyMatch") || methodName.equals("allMatch") ||
methodName.equals("noneMatch") || methodName.equals("reduce") ||
methodName.equals("collect") || methodName.equals("removeIf") ||
methodName.equals("ifPresent") || methodName.equals("ifPresentOrElse") ||
methodName.equals("compute") || methodName.equals("computeIfAbsent") ||
methodName.equals("computeIfPresent") || methodName.equals("replaceAll") ||
methodName.equals("merge") || methodName.equals("peek") ||
methodName.equals("doOnNext") || methodName.equals("doOnSuccess") ||
methodName.equals("doOnError") || methodName.equals("concatMap") ||
methodName.equals("switchMap")) {
return true;
}
return false;
}
private static boolean isCollectionOrStreamType(org.eclipse.jdt.core.dom.ITypeBinding typeBinding) {
String fqn = typeBinding.getErasure().getQualifiedName();
if (fqn.startsWith("java.util.stream.") ||
fqn.equals("java.lang.Iterable") ||
fqn.equals("java.util.Collection") ||
fqn.equals("java.util.List") ||
fqn.equals("java.util.Set") ||
fqn.equals("java.util.Map") ||
fqn.equals("java.util.Iterator") ||
fqn.equals("java.util.Optional") ||
fqn.equals("reactor.core.publisher.Flux") ||
fqn.equals("reactor.core.publisher.Mono")) {
return true;
}
for (org.eclipse.jdt.core.dom.ITypeBinding intf : typeBinding.getInterfaces()) {
if (isCollectionOrStreamType(intf)) return true;
}
if (typeBinding.getSuperclass() != null) {
return isCollectionOrStreamType(typeBinding.getSuperclass());
}
return false;
}
} }

View File

@@ -65,7 +65,7 @@ public class CodebaseContext {
return Collections.unmodifiableList(libraryHints); return Collections.unmodifiableList(libraryHints);
} }
private final Set<String> ignorePatterns = new HashSet<>(Set.of("**/test/**", "**/build/**", "**/node_modules/**", "**/out/**")); private final Set<String> 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[] classpath = new String[0];
private String[] sourcepath = new String[0]; private String[] sourcepath = new String[0];
private boolean resolveBindings = false; private boolean resolveBindings = false;
@@ -191,6 +191,8 @@ public class CodebaseContext {
indexType(td, packageName, javaFile); indexType(td, packageName, javaFile);
} else if (type instanceof EnumDeclaration ed) { } else if (type instanceof EnumDeclaration ed) {
indexEnum(ed, packageName, javaFile); indexEnum(ed, packageName, javaFile);
} else if (type instanceof org.eclipse.jdt.core.dom.RecordDeclaration rd) {
indexRecord(rd, packageName, javaFile);
} }
} }
} }
@@ -235,9 +237,37 @@ 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<String> getImplementations(String interfaceName) { public List<String> getImplementations(String interfaceName) {
Set<String> allImpls = new HashSet<>(); Set<String> allImpls = new HashSet<>();
collectImplementations(interfaceName, allImpls, new HashSet<>()); collectImplementations(interfaceName, allImpls, new HashSet<>());
System.out.println("GET IMPLEMENTATIONS FOR: " + interfaceName + " -> " + allImpls);
return new ArrayList<>(allImpls); return new ArrayList<>(allImpls);
} }

View File

@@ -45,6 +45,7 @@ public class ExportService {
new EntryPointEnricher(), new EntryPointEnricher(),
new PropertyEnricher(), new PropertyEnricher(),
new CallChainEnricher(), new CallChainEnricher(),
new click.kamil.springstatemachineexporter.analysis.enricher.path.ForwardPathEstimationEnricher(),
new click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher() new click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher()
))); )));
} }

View File

@@ -143,6 +143,12 @@ public class RegressionTest {
root.resolve("state_machines/complex_multi_module_sm"), root.resolve("state_machines/complex_multi_module_sm"),
Path.of("src/test/resources/golden/StateMachineConfig"), Path.of("src/test/resources/golden/StateMachineConfig"),
"StateMachineConfig" "StateMachineConfig"
),
new TestScenario(
"Polymorphic Events Sample",
root.resolve("state_machines/polymorphic_events_sample"),
Path.of("src/test/resources/golden/PolymorphicStateMachineConfiguration"),
"PolymorphicStateMachineConfiguration"
) )
); );
} }
@@ -155,7 +161,7 @@ public class RegressionTest {
if (scenario.inputPath() == null) System.out.println("inputPath is NULL"); if (scenario.inputPath() == null) System.out.println("inputPath is NULL");
if (tempDir == null) System.out.println("tempDir is NULL"); if (tempDir == null) System.out.println("tempDir is NULL");
exportService.runExporter(scenario.inputPath(), tempDir, List.of("puml", "dot", "scxml", "json"), true, scenario.activeProfiles()); exportService.runExporter(scenario.inputPath(), tempDir, List.of("puml", "dot", "scxml", "json"), true, scenario.activeProfiles(), null, null, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, true);
// Find the generated directory (it might be named with FQN) // Find the generated directory (it might be named with FQN)
List<Path> generatedDirs; List<Path> generatedDirs;

View File

@@ -0,0 +1,78 @@
package click.kamil.springstatemachineexporter.analysis.enricher.matching;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.model.Event;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class HeuristicEventMatchingEngineTest {
private final HeuristicEventMatchingEngine engine = new HeuristicEventMatchingEngine();
@Test
void shouldMatchExactFqn() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder().event("com.example.OrderEvents.PAY").build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
@Test
void shouldMatchEnumQualifierToFqn() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder().event("OrderEvents.PAY").build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
@Test
void shouldPreventCrossEnumContamination() {
Event smEvent = Event.of("PAY", "com.example.InvoiceEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder().event("OrderEvents.PAY").build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
@Test
void shouldPreventCrossEnumContaminationWithPolymorphicEvents() {
Event smEvent = Event.of("PAY", "com.example.InvoiceEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder().event("getType()").polymorphicEvents(List.of("OrderEvents.PAY")).build();
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
}
@Test
void shouldMatchPolymorphicEnumQualifierToFqn() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder().event("getType()").polymorphicEvents(List.of("OrderEvents.PAY")).build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
@Test
void shouldMatchRawStringToFqn() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder().event("PAY").build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
@Test
void shouldMatchRawStringToRawString() {
Event smEvent = Event.of("PAY", "PAY");
TriggerPoint triggerPoint = TriggerPoint.builder().event("PAY").build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
@Test
void shouldMatchPolymorphicRawStringToFqn() {
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
TriggerPoint triggerPoint = TriggerPoint.builder().event("getType()").polymorphicEvents(List.of("PAY")).build();
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
}
}

View File

@@ -152,4 +152,334 @@ public class ConstantResolverTest {
String result = resolver.resolve(returnExpr, context); String result = resolver.resolve(returnExpr, context);
assertThat(result).isEqualTo("${app.path}"); assertThat(result).isEqualTo("${app.path}");
} }
@Test
void testConstructorAssignmentAndFieldAccess(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("EventDto.java"),
"package com.example;\n" +
"public class EventDto {\n" +
" private String eventType;\n" +
" public EventDto() {\n" +
" this.eventType = \"PAYMENT_SUCCESS\";\n" +
" }\n" +
" public String getEventType() {\n" +
" return this.eventType;\n" +
" }\n" +
"}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration td = context.getTypeDeclaration("com.example.EventDto");
MethodDeclaration method = td.getMethods()[1]; // getEventType
ReturnStatement rs = (ReturnStatement) method.getBody().statements().get(0);
Expression returnExpr = rs.getExpression(); // this.eventType
ConstantResolver resolver = new ConstantResolver();
String result = resolver.resolve(returnExpr, context);
assertThat(result).isEqualTo("PAYMENT_SUCCESS");
}
@Test
void testMethodEvaluationWithNestedBlocksAndVars(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("Logic.java"),
"package com.example;\n" +
"public class Logic {\n" +
" public String process(String event) {\n" +
" String myVar = event;\n" +
" if (myVar != null) {\n" +
" return myVar;\n" +
" }\n" +
" return \"DEFAULT\";\n" +
" }\n" +
"}");
Files.writeString(dir.resolve("Caller.java"),
"package com.example;\n" +
"public class Caller {\n" +
" public void call() {\n" +
" Logic logic = new Logic();\n" +
" String result = logic.process(\"MY_EVENT\");\n" +
" }\n" +
"}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration callerTd = context.getTypeDeclaration("com.example.Caller");
MethodDeclaration callMethod = callerTd.getMethods()[0]; // call()
VariableDeclarationStatement vds = (VariableDeclarationStatement) callMethod.getBody().statements().get(1);
VariableDeclarationFragment fragment = (VariableDeclarationFragment) vds.fragments().get(0);
MethodInvocation mi = (MethodInvocation) fragment.getInitializer(); // logic.process("MY_EVENT")
ConstantResolver resolver = new ConstantResolver();
String result = resolver.resolve(mi, context);
assertThat(result).isEqualTo("MY_EVENT");
}
@Test
void testMethodShadowingReturnsParameter(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("Logic.java"),
"package com.example;\n" +
"public class Logic {\n" +
" public String a = \"OLD_FIELD\";\n" +
" public String process(String a) {\n" + // shadowing
" this.a = \"NEW_FIELD\";\n" +
" return a;\n" + // should return the parameter
" }\n" +
"}");
Files.writeString(dir.resolve("Caller.java"),
"package com.example;\n" +
"public class Caller {\n" +
" public void call() {\n" +
" Logic logic = new Logic();\n" +
" String result = logic.process(\"PARAM_VALUE\");\n" +
" }\n" +
"}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration callerTd = context.getTypeDeclaration("com.example.Caller");
MethodDeclaration callMethod = callerTd.getMethods()[0]; // call()
VariableDeclarationStatement vds = (VariableDeclarationStatement) callMethod.getBody().statements().get(1);
VariableDeclarationFragment fragment = (VariableDeclarationFragment) vds.fragments().get(0);
MethodInvocation mi = (MethodInvocation) fragment.getInitializer(); // logic.process(...)
ConstantResolver resolver = new ConstantResolver();
String result = resolver.resolve(mi, context);
assertThat(result).isEqualTo("PARAM_VALUE");
}
@Test
void testMethodShadowingReturnsField(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("Logic.java"),
"package com.example;\n" +
"public class Logic {\n" +
" public String a = \"OLD_FIELD\";\n" +
" public String process(String a) {\n" + // shadowing
" this.a = \"NEW_FIELD_VALUE\";\n" +
" return this.a;\n" + // should return the field
" }\n" +
"}");
Files.writeString(dir.resolve("Caller.java"),
"package com.example;\n" +
"public class Caller {\n" +
" public void call() {\n" +
" Logic logic = new Logic();\n" +
" String result = logic.process(\"PARAM_VALUE\");\n" +
" }\n" +
"}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration callerTd = context.getTypeDeclaration("com.example.Caller");
MethodDeclaration callMethod = callerTd.getMethods()[0]; // call()
VariableDeclarationStatement vds = (VariableDeclarationStatement) callMethod.getBody().statements().get(1);
VariableDeclarationFragment fragment = (VariableDeclarationFragment) vds.fragments().get(0);
MethodInvocation mi = (MethodInvocation) fragment.getInitializer(); // logic.process(...)
ConstantResolver resolver = new ConstantResolver();
String result = resolver.resolve(mi, context);
assertThat(result).isEqualTo("NEW_FIELD_VALUE");
}
@Test
void testMethodImplicitFieldAssignment(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("Logic.java"),
"package com.example;\n" +
"public class Logic {\n" +
" public String a = \"OLD_FIELD\";\n" +
" public String process(String p) {\n" +
" a = p;\n" + // implicit field assignment
" return this.a;\n" +
" }\n" +
"}");
Files.writeString(dir.resolve("Caller.java"),
"package com.example;\n" +
"public class Caller {\n" +
" public void call() {\n" +
" Logic logic = new Logic();\n" +
" String result = logic.process(\"MY_EVENT\");\n" +
" }\n" +
"}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration callerTd = context.getTypeDeclaration("com.example.Caller");
MethodDeclaration callMethod = callerTd.getMethods()[0]; // call()
VariableDeclarationStatement vds = (VariableDeclarationStatement) callMethod.getBody().statements().get(1);
VariableDeclarationFragment fragment = (VariableDeclarationFragment) vds.fragments().get(0);
MethodInvocation mi = (MethodInvocation) fragment.getInitializer(); // logic.process(...)
ConstantResolver resolver = new ConstantResolver();
String result = resolver.resolve(mi, context);
assertThat(result).isEqualTo("MY_EVENT");
}
@Test
void testZeroArgMethodEvaluation(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("Logic.java"),
"package com.example;\n" +
"public class Logic {\n" +
" public String a = \"MY_CONSTANT\";\n" +
" public String getEvent() {\n" +
" return this.a;\n" +
" }\n" +
"}");
Files.writeString(dir.resolve("Caller.java"),
"package com.example;\n" +
"public class Caller {\n" +
" public void call() {\n" +
" Logic logic = new Logic();\n" +
" String result = logic.getEvent();\n" +
" }\n" +
"}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration callerTd = context.getTypeDeclaration("com.example.Caller");
MethodDeclaration callMethod = callerTd.getMethods()[0]; // call()
VariableDeclarationStatement vds = (VariableDeclarationStatement) callMethod.getBody().statements().get(1);
VariableDeclarationFragment fragment = (VariableDeclarationFragment) vds.fragments().get(0);
MethodInvocation mi = (MethodInvocation) fragment.getInitializer(); // logic.getEvent()
ConstantResolver resolver = new ConstantResolver();
String result = resolver.resolve(mi, context);
assertThat(result).isEqualTo("MY_CONSTANT");
}
/**
* When a switch method is called with a <em>known</em> constant argument, {@code evaluateMethodOutput}
* should evaluate the switch and return only the matching branch.
*
* <p>Regression guard: before the {@code SwitchStatement.visit} fix, the ASTVisitor would also descend
* into child {@code ReturnStatement} nodes after the switch was already handled, potentially overwriting
* a correct result with a wrong one.
*/
@Test
void testOldStyleSwitchResolvesCorrectBranchForKnownConstant(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("Classifier.java"),
"package com.example;\n" +
"public class Classifier {\n" +
" public OrderEvents classify(String q) {\n" +
" switch (q) {\n" +
" case \"a\": return OrderEvents.A8;\n" +
" case \"b\": return OrderEvents.B2;\n" +
" default: return OrderEvents.DEF;\n" +
" }\n" +
" }\n" +
"}\n" +
"enum OrderEvents { A8, B2, DEF }");
Files.writeString(dir.resolve("Caller.java"),
"package com.example;\n" +
"public class Caller {\n" +
" public void call() {\n" +
" Classifier c = new Classifier();\n" +
" OrderEvents r = c.classify(\"a\");\n" + // known constant → should match case "a"
" }\n" +
"}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration callerTd = context.getTypeDeclaration("com.example.Caller");
MethodDeclaration callMethod = callerTd.getMethods()[0];
VariableDeclarationStatement vds = (VariableDeclarationStatement) callMethod.getBody().statements().get(1);
VariableDeclarationFragment fragment = (VariableDeclarationFragment) vds.fragments().get(0);
MethodInvocation mi = (MethodInvocation) fragment.getInitializer(); // c.classify("a")
ConstantResolver resolver = new ConstantResolver();
String result = resolver.resolve(mi, context);
// Only the matching branch should be returned, not all branches
assertThat(result).isEqualTo("OrderEvents.A8");
}
/**
* When a switch method is called with a <em>runtime</em> variable that cannot be statically resolved,
* {@code evaluateMethodOutput} must return {@code null} so the resolver falls back to the return-type
* enum set (covering all branches).
*
* <p>Regression: before the fix, {@code SwitchStatement.visit} returned {@code super.visit(ss)} (true),
* causing child {@code ReturnStatement} nodes inside the switch to fire. The first return — e.g.
* {@code return OrderEvents.A8} — was captured as if it were the only possible result, silently
* dropping the other branches. After the fix the visitor returns {@code false}, preventing child
* traversal, so the resolver correctly falls back to the full ENUM_SET.
*/
@Test
void testOldStyleSwitchWithUnknownVariableReturnsFallbackEnumSet(@TempDir Path tempDir) throws IOException {
Path dir = tempDir.resolve("com/example");
Files.createDirectories(dir);
Files.writeString(dir.resolve("Classifier.java"),
"package com.example;\n" +
"public class Classifier {\n" +
" public OrderEvents classify(String q) {\n" +
" switch (q) {\n" +
" case \"a\": return OrderEvents.A8;\n" +
" case \"b\": return OrderEvents.B2;\n" +
" default: return OrderEvents.DEF;\n" +
" }\n" +
" }\n" +
"}\n" +
"enum OrderEvents { A8, B2, DEF }");
Files.writeString(dir.resolve("Caller.java"),
"package com.example;\n" +
"public class Caller {\n" +
" public void call(String x) {\n" + // x is a runtime parameter — not a constant
" Classifier c = new Classifier();\n" +
" OrderEvents r = c.classify(x);\n" +
" }\n" +
"}");
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration callerTd = context.getTypeDeclaration("com.example.Caller");
MethodDeclaration callMethod = callerTd.getMethods()[0];
VariableDeclarationStatement vds = (VariableDeclarationStatement) callMethod.getBody().statements().get(1);
VariableDeclarationFragment fragment = (VariableDeclarationFragment) vds.fragments().get(0);
MethodInvocation mi = (MethodInvocation) fragment.getInitializer(); // c.classify(x)
ConstantResolver resolver = new ConstantResolver();
String result = resolver.resolve(mi, context);
// Must cover ALL branches via ENUM_SET — not just the first one ("A8")
assertThat(result).startsWith("ENUM_SET:");
assertThat(result).contains("OrderEvents.A8");
assertThat(result).contains("OrderEvents.B2");
assertThat(result).contains("OrderEvents.DEF");
}
} }

View File

@@ -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");
}
}

View File

@@ -0,0 +1,69 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class BuilderLocalVariableTest {
@Test
void shouldTraceLocalVariableInBuilderPattern(@TempDir Path tempDir) throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void processOrderEvent() {
OrderEvents e = OrderEvents.PAY;
RichOrderEvent event = new RichOrderEvent();
event.setType(e);
service.updateOrderState(event.getType());
}
}
class OrderService {
public void updateOrderState(OrderEvents event) {}
}
class RichOrderEvent {
private OrderEvents type;
public void setType(OrderEvents type) { this.type = type; }
public OrderEvents getType() { return type; }
}
enum OrderEvents { PAY }
""";
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<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).hasSize(1);
CallChain chain = chains.get(0);
System.out.println("POLY EVENTS FOUND: " + chain.getTriggerPoint().getPolymorphicEvents());
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
.containsExactlyInAnyOrder("OrderEvents.PAY");
}
}

View File

@@ -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");
}
}

View File

@@ -0,0 +1,412 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class ConstructorInvocationTest {
@Test
void shouldNotBlindlyAddAllConstructorArguments(@TempDir Path tempDir) throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void processOrderEvent(RichOrderEvent domainEvent) {
service.updateOrderState(domainEvent.getType());
}
}
class OrderService {
public void updateOrderState(OrderEvents event) {}
}
class RichOrderEvent {
private OrderEvents type;
private String irrelevantInfo;
public RichOrderEvent() {
this(OrderEvents.PAY, "DEFAULT_INFO");
}
public RichOrderEvent(OrderEvents type, String info) {
this.type = type;
this.irrelevantInfo = info;
}
public OrderEvents getType() { return type; }
}
enum OrderEvents { PAY }
""";
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<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).hasSize(1);
CallChain chain = chains.get(0);
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
.contains("OrderEvents.PAY")
.doesNotContain("DEFAULT_INFO");
}
@Test
void shouldHandleOverloadedConstructorsGracefully(@TempDir Path tempDir) throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void processOrderEvent(RichOrderEvent domainEvent) {
service.updateOrderState(domainEvent.getType());
}
}
class OrderService {
public void updateOrderState(OrderEvents event) {}
}
class RichOrderEvent {
private OrderEvents type;
private String irrelevantInfo;
private int count;
public RichOrderEvent() {
this(OrderEvents.PAY, "DEFAULT_INFO");
}
// Overloaded constructor 1
public RichOrderEvent(OrderEvents type, String info) {
this.type = type;
this.irrelevantInfo = info;
}
// Overloaded constructor 2 (same number of arguments!)
public RichOrderEvent(String info, int count) {
this.irrelevantInfo = info;
this.count = count;
// type is not set here
}
public OrderEvents getType() { return type; }
}
enum OrderEvents { PAY }
""";
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<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).hasSize(1);
CallChain chain = chains.get(0);
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
.contains("OrderEvents.PAY")
.doesNotContain("DEFAULT_INFO")
.doesNotContain("count");
}
@Test
void shouldHandleChainedConstructorDelegationGracefully(@TempDir Path tempDir) throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void processOrderEvent(RichOrderEvent domainEvent) {
service.updateOrderState(domainEvent.getType());
}
}
class OrderService {
public void updateOrderState(OrderEvents event) {}
}
class BaseEvent {
protected OrderEvents type;
public BaseEvent(OrderEvents type) {
this.type = type;
}
}
class RichOrderEvent extends BaseEvent {
public RichOrderEvent() {
this(OrderEvents.PAY);
}
public RichOrderEvent(OrderEvents type) {
this(type, "DEFAULT_INFO");
}
public RichOrderEvent(OrderEvents type, String info) {
super(type);
}
public OrderEvents getType() { return type; }
}
enum OrderEvents { PAY }
""";
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<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).hasSize(1);
CallChain chain = chains.get(0);
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
.contains("OrderEvents.PAY")
.doesNotContain("DEFAULT_INFO");
}
@Test
void shouldHandleFieldAccessGracefully(@TempDir Path tempDir) throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void processOrderEvent(RichOrderEvent domainEvent) {
service.updateOrderState(domainEvent.getType());
}
}
class OrderService {
public void updateOrderState(OrderEvents event) {}
}
class RichOrderEvent {
private OrderEvents type;
public RichOrderEvent() {
this(OrderEvents.PAY);
}
public RichOrderEvent(OrderEvents type) {
this.type = type;
}
public OrderEvents getType() {
return this.type; // Specifically testing FieldAccess
}
}
enum OrderEvents { PAY }
""";
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<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).hasSize(1);
CallChain chain = chains.get(0);
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
.contains("OrderEvents.PAY");
}
@Test
void shouldTraceThroughWrappedConstructorArguments(@TempDir Path tempDir) throws IOException {
String source = """
package com.example;
import java.util.Objects;
public class OrderController {
private OrderService service;
public void processOrderEvent(RichOrderEvent domainEvent) {
service.updateOrderState(domainEvent.getType());
}
}
class OrderService {
public void updateOrderState(OrderEvents event) {}
}
class RichOrderEvent {
private OrderEvents type;
public RichOrderEvent() {
this(Objects.requireNonNull(OrderEvents.PAY));
}
public RichOrderEvent(OrderEvents type) {
this((OrderEvents) type, "DEFAULT_INFO");
}
public RichOrderEvent(OrderEvents type, String info) {
this.type = Objects.requireNonNull(type);
}
public OrderEvents getType() { return type; }
}
enum OrderEvents { PAY }
""";
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<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).hasSize(1);
CallChain chain = chains.get(0);
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
.contains("OrderEvents.PAY");
}
@Test
void shouldTraceThroughComplexOverloadedConstructors(@TempDir Path tempDir) throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void processOrderEvent(EnterpriseEvent domainEvent) {
service.updateOrderState(domainEvent.getType());
}
}
class OrderService {
public void updateOrderState(OrderEvents event) {}
}
class EnterpriseEvent {
private OrderEvents type;
private String info;
public EnterpriseEvent() {
this(OrderEvents.PAY);
}
// Matches by parameter count but wrong type!
public EnterpriseEvent(String info) {
this.info = info;
}
public EnterpriseEvent(OrderEvents type) {
this("info", type);
}
// Target of previous constructor
public EnterpriseEvent(String info, OrderEvents type) {
this(type, info, null);
}
// Final destination
public EnterpriseEvent(OrderEvents type, String info, Object extra) {
this.type = type;
this.info = info;
}
public OrderEvents getType() { return type; }
}
enum OrderEvents { PAY }
""";
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<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).hasSize(1);
CallChain chain = chains.get(0);
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
.contains("OrderEvents.PAY");
}
}

View File

@@ -0,0 +1,72 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class ConstructorLocalVariableTest {
@Test
void shouldTraceLocalVariableInConstructor(@TempDir Path tempDir) throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void processOrderEvent(RichOrderEvent domainEvent) {
service.updateOrderState(domainEvent.getType());
}
}
class OrderService {
public void updateOrderState(OrderEvents event) {}
}
class RichOrderEvent {
private OrderEvents type;
public RichOrderEvent() {
OrderEvents e = OrderEvents.PAY;
this.type = e;
}
public OrderEvents getType() {
return type;
}
}
enum OrderEvents { PAY }
""";
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<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).hasSize(1);
CallChain chain = chains.get(0);
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
.containsExactlyInAnyOrder("OrderEvents.PAY");
}
}

View File

@@ -89,7 +89,8 @@ public class GenericEventDetectorTest {
"import org.springframework.statemachine.StateMachine;\n" + "import org.springframework.statemachine.StateMachine;\n" +
"public class MyService {\n" + "public class MyService {\n" +
" private StateMachine<String, String> stateMachine;\n" + " private StateMachine<String, String> stateMachine;\n" +
" public MyEnum getEvent() { return MyEnum.STATE_A; }\n" + // Pretend it returns the enum " public MyEnum getEvent() { return someExternalCall(); }\n" +
" public MyEnum someExternalCall() { return null; }\n" +
" public void trigger() {\n" + " public void trigger() {\n" +
" stateMachine.sendEvent(this.getEvent());\n" + " stateMachine.sendEvent(this.getEvent());\n" +
" }\n" + " }\n" +

View File

@@ -18,6 +18,185 @@ import static org.assertj.core.api.Assertions.assertThat;
@Tag("heuristic_guess_paths_and_scrape_regex_replace_with_data_flow_analysis") @Tag("heuristic_guess_paths_and_scrape_regex_replace_with_data_flow_analysis")
class HeuristicCallGraphEngineTest { class HeuristicCallGraphEngineTest {
@Test
void shouldResolveLocalSetterAndSwitchExpressionPolymorphism(@TempDir Path tempDir) throws IOException {
String source = """
package com.example;
public class OrderService {
public void updateOrderState(String vv) {
StateMachine sm = new StateMachine();
MysteryPayload a = new MysteryPayload();
a.setType(my_func(vv));
sm.sendEvent(a.getType());
}
private OrderEvents my_func(String q) {
return switch (q) {
case "a" -> OrderEvents.A8;
case "b" -> OrderEvents.A133;
default -> throw new IllegalArgumentException("Unknown");
};
}
}
enum OrderEvents { A8, A133 }
class MysteryPayload {
private OrderEvents type;
public void setType(OrderEvents type) { this.type = type; }
public OrderEvents getType() { return type; }
}
class StateMachine {
public void sendEvent(OrderEvents event) {}
}
""";
Files.writeString(tempDir.resolve("OrderService.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.OrderService")
.methodName("updateOrderState")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.StateMachine")
.methodName("sendEvent")
.event("event")
.build();
List<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).hasSize(1);
CallChain chain = chains.get(0);
System.out.println("Resolved poly events: " + chain.getTriggerPoint().getPolymorphicEvents());
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
.containsExactlyInAnyOrder("OrderEvents.A8", "OrderEvents.A133");
}
@Test
void shouldResolveOldStyleSwitchStatementPolymorphism(@TempDir Path tempDir) throws IOException {
String source = """
package com.example;
public class OrderService {
public void updateOrderState(String vv) {
StateMachine sm = new StateMachine();
sm.sendEvent(getEventFromOldSwitch(vv));
}
private OrderEvents getEventFromOldSwitch(String q) {
switch (q) {
case "a": return OrderEvents.A8;
case "b":
case "c": return OrderEvents.A133;
default: return OrderEvents.PAY;
}
}
}
enum OrderEvents { A8, A133, PAY }
class StateMachine {
public void sendEvent(OrderEvents event) {}
}
""";
Files.writeString(tempDir.resolve("OrderService.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
EntryPoint entryPoint = EntryPoint.builder().className("com.example.OrderService").methodName("updateOrderState").build();
TriggerPoint trigger = TriggerPoint.builder().className("com.example.StateMachine").methodName("sendEvent").event("event").build();
List<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
.containsExactlyInAnyOrder("OrderEvents.A8", "OrderEvents.A133", "OrderEvents.PAY");
}
@Test
void shouldResolveMultiValueSwitchExpressionPolymorphism(@TempDir Path tempDir) throws IOException {
String source = """
package com.example;
public class OrderService {
public void updateOrderState(String vv) {
StateMachine sm = new StateMachine();
sm.sendEvent(getEventMultiValue(vv));
}
private OrderEvents getEventMultiValue(String q) {
return switch (q) {
case "a", "b" -> OrderEvents.A8;
case "c" -> OrderEvents.A133;
default -> OrderEvents.PAY;
};
}
}
enum OrderEvents { A8, A133, PAY }
class StateMachine {
public void sendEvent(OrderEvents event) {}
}
""";
Files.writeString(tempDir.resolve("OrderService.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
EntryPoint entryPoint = EntryPoint.builder().className("com.example.OrderService").methodName("updateOrderState").build();
TriggerPoint trigger = TriggerPoint.builder().className("com.example.StateMachine").methodName("sendEvent").event("event").build();
List<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
.containsExactlyInAnyOrder("OrderEvents.A8", "OrderEvents.A133", "OrderEvents.PAY");
}
@Test
void shouldResolveSwitchExpressionWithBlockAndYieldPolymorphism(@TempDir Path tempDir) throws IOException {
String source = """
package com.example;
public class OrderService {
public void updateOrderState(String vv) {
StateMachine sm = new StateMachine();
sm.sendEvent(getEventWithYield(vv));
}
private OrderEvents getEventWithYield(String q) {
return switch (q) {
case "a" -> {
System.out.println("Processing a");
yield OrderEvents.A8;
}
case "b" -> { yield OrderEvents.A133; }
default -> OrderEvents.PAY;
};
}
}
enum OrderEvents { A8, A133, PAY }
class StateMachine {
public void sendEvent(OrderEvents event) {}
}
""";
Files.writeString(tempDir.resolve("OrderService.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
EntryPoint entryPoint = EntryPoint.builder().className("com.example.OrderService").methodName("updateOrderState").build();
TriggerPoint trigger = TriggerPoint.builder().className("com.example.StateMachine").methodName("sendEvent").event("event").build();
List<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
.containsExactlyInAnyOrder("OrderEvents.A8", "OrderEvents.A133", "OrderEvents.PAY");
}
@Test @Test
void shouldFindCallChainWithLambdaMethodReference(@TempDir Path tempDir) throws IOException { void shouldFindCallChainWithLambdaMethodReference(@TempDir Path tempDir) throws IOException {
String source = """ String source = """
@@ -292,6 +471,63 @@ class HeuristicCallGraphEngineTest {
.containsExactlyInAnyOrder("OrderEvents.CANCELLED", "OrderEvents.RECEIVED"); .containsExactlyInAnyOrder("OrderEvents.CANCELLED", "OrderEvents.RECEIVED");
} }
@Test
void shouldResolveTernaryAndStringPolymorphicReturns() throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void processOrderEvent() {
service.updateOrderState(new MysteryPayload().getType());
}
}
class OrderService {
public void updateOrderState(Object event) {
// sendEvent
}
}
class MysteryPayload {
public Object getType() {
int a = 5;
if (a > 10) {
return "RAW_STRING_EVENT";
}
return a > 5 ? OrderEvents.ABCD : OrderEvents.PAY;
}
}
enum OrderEvents { ABCD, PAY }
""";
Path tempDir = Files.createTempDirectory("callgraph_test_ternary_string");
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<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).hasSize(1);
CallChain chain = chains.get(0);
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
.as("Resolved event was: " + chain.getTriggerPoint().getEvent())
.containsExactlyInAnyOrder("OrderEvents.ABCD", "OrderEvents.PAY", "RAW_STRING_EVENT");
}
@Test @Test
void shouldUnwrapDeepMethodWrappers() throws IOException { void shouldUnwrapDeepMethodWrappers() throws IOException {
String source = """ String source = """
@@ -766,9 +1002,10 @@ class HeuristicCallGraphEngineTest {
assertThat(chains).hasSize(1); assertThat(chains).hasSize(1);
CallChain chain = chains.get(0); CallChain chain = chains.get(0);
// It successfully traces the local variable 'event' to its anonymous class initializer // The engine traces the anonymous class's getType() through the RichEvent interface,
assertThat(chain.getTriggerPoint().getEvent()).startsWith("new RichEvent(){"); // resolving to OrderEvents.CREATE via the enum return-type fallback.
assertThat(chain.getTriggerPoint().getEvent()).endsWith(".getType()"); assertThat(chain.getTriggerPoint().getPolymorphicEvents())
.containsExactlyInAnyOrder("OrderEvents.CREATE");
} }
@Test @Test
@@ -861,7 +1098,7 @@ class HeuristicCallGraphEngineTest {
} }
@Test @Test
void shouldTraceLastTextualAssignmentInTryCatchBlocks() throws IOException { void shouldTraceAllTextualAssignmentsInTryCatchBlocks() throws IOException {
String source = """ String source = """
package com.example; package com.example;
public class OrderController { public class OrderController {
@@ -906,9 +1143,69 @@ class HeuristicCallGraphEngineTest {
assertThat(chains).hasSize(1); assertThat(chains).hasSize(1);
CallChain chain = chains.get(0); 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().getPolymorphicEvents()).containsExactlyInAnyOrder("OrderEvents.PAY", "OrderEvents.CANCELLED");
assertThat(chain.getTriggerPoint().getEvent()).isEqualTo("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<CallChain> 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 @Test
@@ -954,7 +1251,7 @@ class HeuristicCallGraphEngineTest {
CallChain chain = chains.get(0); CallChain chain = chains.get(0);
// We don't dynamically resolve array indexes, but it shouldn't crash. // We don't dynamically resolve array indexes, but it shouldn't crash.
// It returns the array access expression as a string. // 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 @Test
@@ -1898,5 +2195,639 @@ class HeuristicCallGraphEngineTest {
org.assertj.core.api.Assertions.assertThat(chains).hasSize(1); org.assertj.core.api.Assertions.assertThat(chains).hasSize(1);
org.assertj.core.api.Assertions.assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).contains("COMPLEX_INLINE_CONST"); org.assertj.core.api.Assertions.assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).contains("COMPLEX_INLINE_CONST");
} }
@Test
void shouldResolveEventFromConstructorAssignment() throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void handleStatus() {
EventWithConstructorArg e = new EventWithConstructorArg("EVENT_FROM_CONSTRUCTOR", 123);
service.process(e.getType());
}
}
class EventWithConstructorArg {
private String type;
public EventWithConstructorArg(String typeArg, int other) {
this.type = typeArg;
}
public String getType() { return type; }
}
class OrderService {
public void process(String event) {}
}
""";
Path tempDir = Files.createTempDirectory("callgraph_constructor_assignment");
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<CallChain> 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("EVENT_FROM_CONSTRUCTOR");
}
@Test
void shouldPrioritizeSetterOverConstructor() throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void handleStatus() {
EventWithConstructorArg e = new EventWithConstructorArg("EVENT_FROM_CONSTRUCTOR", 123);
e.setType("EVENT_FROM_SETTER");
service.process(e.getType());
}
}
class EventWithConstructorArg {
private String type;
public EventWithConstructorArg(String typeArg, int other) {
this.type = typeArg;
}
public void setType(String t) { this.type = t; }
public String getType() { return type; }
}
class OrderService {
public void process(String event) {}
}
""";
Path tempDir = Files.createTempDirectory("callgraph_setter_priority");
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<CallChain> 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("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<String> 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<CallChain> chains = builder.findChains(List.of(entryPoint1, entryPoint2), List.of(trigger));
org.assertj.core.api.Assertions.assertThat(chains).hasSize(2);
List<String> 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 = """
package com.example;
public class OrderController {
private OrderService service;
private EventWithConstructorArg e;
public OrderController() {
this.e = new EventWithConstructorArg("EVENT_FROM_FIELD", 123);
}
public void handleStatus() {
service.process(this.e.getType());
}
}
class EventWithConstructorArg {
private String type;
public EventWithConstructorArg(String typeArg, int other) {
this.type = typeArg;
}
public String getType() { return type; }
}
class OrderService {
public void process(String event) {}
}
""";
Path tempDir = Files.createTempDirectory("callgraph_field_assignment");
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<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
System.out.println("DEBUG CHAINS: " + (chains.isEmpty() ? "empty" : chains.get(0).getTriggerPoint().getPolymorphicEvents()));
org.assertj.core.api.Assertions.assertThat(chains).hasSize(1);
org.assertj.core.api.Assertions.assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).containsExactlyInAnyOrder("EVENT_FROM_FIELD");
}
@Test
void shouldResolveEventFromLambda() throws IOException {
String source = """
package com.example;
import java.util.List;
public class OrderController {
private OrderService service;
public void handleList(List<LambdaEvent> events) {
events.forEach(e -> service.process(e.getType()));
}
}
class LambdaEvent {
private String type;
public LambdaEvent(String typeArg) { this.type = typeArg; }
public String getType() { return type; }
}
class OrderService {
public void process(String event) {}
}
""";
Path tempDir = Files.createTempDirectory("callgraph_lambda");
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("handleList").build();
TriggerPoint trigger = TriggerPoint.builder().className("com.example.OrderService").methodName("process").event("event").build();
List<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
System.out.println("DEBUG LAMBDA CHAINS: " + (chains.isEmpty() ? "empty" : chains.get(0).getTriggerPoint().getPolymorphicEvents()));
org.assertj.core.api.Assertions.assertThat(chains).hasSize(1);
org.assertj.core.api.Assertions.assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).isNullOrEmpty();
}
@Test
void shouldResolveEventFromAnonymousClass() throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void handleStatus() {
service.process(new EventProvider() {
@Override
public String getEvent() { return "ANONYMOUS_EVENT"; }
}.getEvent());
}
}
interface EventProvider {
String getEvent();
}
class OrderService {
public void process(String event) {}
}
""";
Path tempDir = Files.createTempDirectory("callgraph_anonymous");
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<CallChain> 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("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<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
org.assertj.core.api.Assertions.assertThat(chains).hasSize(1);
List<String> 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<CallChain> 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<RefEvent> 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<CallChain> 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<CallChain> 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<CallChain> 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!
}
@Test
void shouldNotExtractConstantsFromNestedClassInstanceCreations() throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void handleNestedConstructors() {
// The event contains a nested constructor. The heuristic should NOT extract WRONG_EVENT.
OuterEvent e = new OuterEvent("CORRECT_EVENT", new InnerPayload("WRONG_EVENT"));
service.process(e.getEventType());
}
}
class OrderService {
public void process(String event) {}
}
class OuterEvent {
private String eventType;
private InnerPayload payload;
public OuterEvent(String eventType, InnerPayload payload) {
this.eventType = eventType;
this.payload = payload;
}
public String getEventType() { return eventType; }
}
class InnerPayload {
private String someData;
public InnerPayload(String someData) { this.someData = someData; }
}
""";
Path tempDir = Files.createTempDirectory("callgraph_nested_constructor");
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("handleNestedConstructors").build();
TriggerPoint trigger = TriggerPoint.builder().className("com.example.OrderService").methodName("process").event("event").build();
List<CallChain> 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");
}
@Test
void shouldNotExtractConstantsFromNestedArraysOfObjects() throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void handleNestedArrays() {
// The event contains a nested array of objects. The heuristic should NOT extract WRONG_EVENT.
OuterEvent e = new OuterEvent("CORRECT_EVENT", new InnerPayload[] { new InnerPayload("WRONG_EVENT") });
service.process(e.getEventType());
}
}
class OrderService {
public void process(String event) {}
}
class OuterEvent {
private String eventType;
private InnerPayload[] payloads;
public OuterEvent(String eventType, InnerPayload[] payloads) {
this.eventType = eventType;
this.payloads = payloads;
}
public String getEventType() { return eventType; }
}
class InnerPayload {
private String someData;
public InnerPayload(String someData) { this.someData = someData; }
}
""";
Path tempDir = Files.createTempDirectory("callgraph_nested_array");
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("handleNestedArrays").build();
TriggerPoint trigger = TriggerPoint.builder().className("com.example.OrderService").methodName("process").event("event").build();
List<CallChain> 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");
}
@Test
void shouldResolveEnumValueOfFromString(@TempDir Path tempDir) throws IOException {
String source = """
package com.example;
public class Endpoint {
private OrderService service;
public void trigger(String eventString) {
service.doSomething(eventString);
}
}
class OrderService {
public void doSomething(String str) {
MyEvents event = mapToEnum(str);
sendEvent(event);
}
private MyEvents mapToEnum(String str) {
return MyEvents.valueOf(str);
}
public void sendEvent(MyEvents e) {}
}
enum MyEvents { A, B }
""";
Files.writeString(tempDir.resolve("Endpoint.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.Endpoint")
.methodName("trigger")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.OrderService")
.methodName("sendEvent")
.event("e")
.build();
List<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).hasSize(1);
CallChain chain = chains.get(0);
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
.containsExactlyInAnyOrder("MyEvents.A", "MyEvents.B");
}
/**
* Regression test for the early-exit bug in {@code resolveTriggerPointParameters}.
*
* <p>When {@code traceLocalSetter} matches a field method call whose name happens to equal the
* accessor suffix (e.g. {@code mapper.transform(dto)} matching {@code methodName="transform"}),
* and the argument passed to that method is a non-constant runtime parameter, no events are
* extracted. The old condition
* <pre>if (!polymorphicEvents.isEmpty() || !resolvedValue.equals(tp.getEvent()))</pre>
* caused an early return with empty {@code polymorphicEvents} because {@code resolvedValue} had
* already diverged from the original event name.
*
* <p>After the fix the condition is just {@code !polymorphicEvents.isEmpty()}, so resolution
* continues through {@code getVariableDeclaredType} → {@code resolveMethodReturnConstant},
* which correctly traces the field's declared type to its implementation and extracts the
* constant returned by {@code TransformerImpl.transform()}.
*/
@Test
void shouldResolveEventsWhenFieldTransformerMethodMatchesSetterPatternButArgIsNonConstant() throws IOException {
String source = """
package com.example;
public class OrderController {
private Transformer transformer;
public void processEvent(String dto) {
Event e = transformer.transform(dto);
machine.fire(e.getType());
}
}
interface Transformer {
Event transform(String input);
}
class TransformerImpl implements Transformer {
public Event transform(String input) {
return new Event("ORDER_DISPATCHED");
}
}
class Event {
private String type;
public Event(String type) { this.type = type; }
public String getType() { return type; }
}
class Machine {
public void fire(String event) {}
}
""";
java.nio.file.Path tempDir = java.nio.file.Files.createTempDirectory("callgraph_test_transformer");
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.OrderController")
.methodName("processEvent")
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className("com.example.Machine")
.methodName("fire")
.event("event")
.build();
List<CallChain> chains = engine.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).hasSize(1);
// Must resolve through TransformerImpl.transform() → "ORDER_DISPATCHED",
// NOT return empty polymorphicEvents due to early exit on the setter match.
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
.containsExactlyInAnyOrder("ORDER_DISPATCHED");
}
} }

View File

@@ -0,0 +1,285 @@
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.analysis.spring.InjectionPointAnalyzer;
import click.kamil.springstatemachineexporter.analysis.spring.SpringBeanRegistry;
import click.kamil.springstatemachineexporter.analysis.spring.SpringContextScanner;
import click.kamil.springstatemachineexporter.analysis.spring.SpringDependencyResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.analysis.service.DynamicClasspathResolver;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class JdtCallGraphEngineIntegrationTest {
private CodebaseContext context;
private SpringBeanRegistry registry;
private JdtCallGraphEngine engine;
@BeforeEach
void setUp() throws IOException {
Path projectRoot = Path.of("../state_machines/extended_analysis_sample").toAbsolutePath().normalize();
context = new CodebaseContext();
context.setProjectRoot(projectRoot);
context.setSourcepath(List.of(projectRoot.resolve("src/main/java").toString()));
DynamicClasspathResolver resolver = new DynamicClasspathResolver();
List<String> cp = resolver.resolveClasspath(projectRoot);
if (!cp.isEmpty()) {
context.setClasspath(cp);
}
context.setResolveBindings(true);
context.scan(Set.of(projectRoot), Collections.emptySet());
registry = new SpringBeanRegistry();
SpringContextScanner scanner = new SpringContextScanner(registry);
for (var cu : context.getCompilationUnits()) {
cu.accept(scanner);
}
SpringDependencyResolver dependencyResolver = new SpringDependencyResolver(registry);
InjectionPointAnalyzer injectionAnalyzer = new InjectionPointAnalyzer(dependencyResolver);
engine = new JdtCallGraphEngine(context, injectionAnalyzer);
}
@Test
void shouldResolvePrimaryBeanInjection() {
EntryPoint ep = EntryPoint.builder()
.className("click.kamil.examples.statemachine.extended.web.QuirkController")
.methodName("testPrimary")
.build();
TriggerPoint tp = TriggerPoint.builder()
.className("click.kamil.examples.statemachine.extended.service.PrimaryQuirkService")
.methodName("doQuirk")
.build();
List<CallChain> chains = engine.findChains(List.of(ep), List.of(tp));
System.out.println("CHAINS FOUND: " + chains.size()); assertThat(chains).hasSize(1);
assertThat(chains.get(0).getMethodChain())
.containsExactly(
"click.kamil.examples.statemachine.extended.web.QuirkController.testPrimary",
"click.kamil.examples.statemachine.extended.service.PrimaryQuirkService.doQuirk"
);
}
@Test
void shouldResolveQualifierBeanInjection() {
EntryPoint ep = EntryPoint.builder()
.className("click.kamil.examples.statemachine.extended.web.QuirkController")
.methodName("testQualifier")
.build();
TriggerPoint tp = TriggerPoint.builder()
.className("click.kamil.examples.statemachine.extended.service.QualifierQuirkService")
.methodName("doQuirk")
.build();
List<CallChain> chains = engine.findChains(List.of(ep), List.of(tp));
System.out.println("CHAINS FOUND: " + chains.size()); assertThat(chains).hasSize(1);
assertThat(chains.get(0).getMethodChain())
.containsExactly(
"click.kamil.examples.statemachine.extended.web.QuirkController.testQualifier",
"click.kamil.examples.statemachine.extended.service.QualifierQuirkService.doQuirk"
);
}
@Test
void shouldResolveNamedBeanInjection() {
EntryPoint ep = EntryPoint.builder()
.className("click.kamil.examples.statemachine.extended.web.QuirkController")
.methodName("testNamed")
.build();
TriggerPoint tp = TriggerPoint.builder()
.className("click.kamil.examples.statemachine.extended.service.NamedQuirkService")
.methodName("doQuirk")
.build();
List<CallChain> chains = engine.findChains(List.of(ep), List.of(tp));
System.out.println("CHAINS FOUND: " + chains.size()); assertThat(chains).hasSize(1);
assertThat(chains.get(0).getMethodChain())
.containsExactly(
"click.kamil.examples.statemachine.extended.web.QuirkController.testNamed",
"click.kamil.examples.statemachine.extended.service.NamedQuirkService.doQuirk"
);
}
@Test
void shouldResolveFallbackBeanInjection() {
EntryPoint ep = EntryPoint.builder()
.className("click.kamil.examples.statemachine.extended.web.QuirkController")
.methodName("testFallback")
.build();
TriggerPoint tp = TriggerPoint.builder()
.className("click.kamil.examples.statemachine.extended.service.PrimaryQuirkService")
.methodName("doQuirk")
.build();
List<CallChain> chains = engine.findChains(List.of(ep), List.of(tp));
System.out.println("CHAINS FOUND: " + chains.size()); assertThat(chains).hasSize(1);
assertThat(chains.get(0).getMethodChain())
.containsExactly(
"click.kamil.examples.statemachine.extended.web.QuirkController.testFallback",
"click.kamil.examples.statemachine.extended.service.PrimaryQuirkService.doQuirk"
);
}
@Test
void shouldResolveBeanParameterInjection() {
EntryPoint ep = EntryPoint.builder()
.className("click.kamil.examples.statemachine.extended.config.BeanParameterTestConfig")
.methodName("myBeanParamTester")
.build();
TriggerPoint tp = TriggerPoint.builder()
.className("click.kamil.examples.statemachine.extended.service.NamedQuirkService")
.methodName("doQuirk")
.build();
List<CallChain> chains = engine.findChains(List.of(ep), List.of(tp));
System.out.println("CHAINS FOUND: " + chains.size()); assertThat(chains).hasSize(1);
assertThat(chains.get(0).getMethodChain())
.containsExactly(
"click.kamil.examples.statemachine.extended.config.BeanParameterTestConfig.myBeanParamTester",
"click.kamil.examples.statemachine.extended.service.NamedQuirkService.doQuirk"
);
}
@Test
void shouldResolveOrderedBeanInjection() {
EntryPoint ep = EntryPoint.builder()
.className("click.kamil.examples.statemachine.extended.web.OrderedTestController")
.methodName("testOrder")
.build();
TriggerPoint tp = TriggerPoint.builder()
.className("click.kamil.examples.statemachine.extended.service.HighPriorityOrderedService")
.methodName("doAction")
.build();
List<CallChain> chains = engine.findChains(List.of(ep), List.of(tp));
System.out.println("CHAINS FOUND: " + chains.size()); assertThat(chains).hasSize(1);
assertThat(chains.get(0).getMethodChain())
.containsExactly(
"click.kamil.examples.statemachine.extended.web.OrderedTestController.testOrder",
"click.kamil.examples.statemachine.extended.service.HighPriorityOrderedService.doAction"
);
}
@Test
void shouldResolveFieldInjection() {
EntryPoint ep = EntryPoint.builder()
.className("click.kamil.examples.statemachine.extended.web.FieldInjectionController")
.methodName("testField")
.build();
TriggerPoint tp = TriggerPoint.builder()
.className("click.kamil.examples.statemachine.extended.service.NamedQuirkService")
.methodName("doQuirk")
.build();
List<CallChain> chains = engine.findChains(List.of(ep), List.of(tp));
System.out.println("CHAINS FOUND: " + chains.size()); assertThat(chains).hasSize(1);
assertThat(chains.get(0).getMethodChain()).contains(
"click.kamil.examples.statemachine.extended.web.FieldInjectionController.testField",
"click.kamil.examples.statemachine.extended.service.NamedQuirkService.doQuirk"
);
}
@Test
void shouldResolveSetterInjection() {
EntryPoint ep = EntryPoint.builder()
.className("click.kamil.examples.statemachine.extended.web.SetterInjectionController")
.methodName("testSetter")
.build();
TriggerPoint tp = TriggerPoint.builder()
.className("click.kamil.examples.statemachine.extended.service.NamedQuirkService")
.methodName("doQuirk")
.build();
List<CallChain> chains = engine.findChains(List.of(ep), List.of(tp));
System.out.println("CHAINS FOUND: " + chains.size()); assertThat(chains).hasSize(1);
assertThat(chains.get(0).getMethodChain()).contains(
"click.kamil.examples.statemachine.extended.web.SetterInjectionController.testSetter",
"click.kamil.examples.statemachine.extended.service.NamedQuirkService.doQuirk"
);
}
@Test
void shouldResolveAbstractInheritedBean() {
EntryPoint ep = EntryPoint.builder()
.className("click.kamil.examples.statemachine.extended.config.AbstractBaseConfig")
.methodName("inheritedBeanTester")
.build();
TriggerPoint tp = TriggerPoint.builder()
.className("click.kamil.examples.statemachine.extended.service.NamedQuirkService")
.methodName("doQuirk")
.build();
List<CallChain> chains = engine.findChains(List.of(ep), List.of(tp));
System.out.println("CHAINS FOUND: " + chains.size()); assertThat(chains).hasSize(1);
assertThat(chains.get(0).getMethodChain()).contains(
"click.kamil.examples.statemachine.extended.config.AbstractBaseConfig.inheritedBeanTester",
"click.kamil.examples.statemachine.extended.service.NamedQuirkService.doQuirk"
);
}
@Test
void shouldResolveAmbiguousListFallback() {
EntryPoint ep = EntryPoint.builder()
.className("click.kamil.examples.statemachine.extended.web.ListInjectionController")
.methodName("testList")
.build();
TriggerPoint tp1 = TriggerPoint.builder()
.className("click.kamil.examples.statemachine.extended.service.AmbiguousA")
.methodName("doAmbig")
.build();
TriggerPoint tp2 = TriggerPoint.builder()
.className("click.kamil.examples.statemachine.extended.service.AmbiguousB")
.methodName("doAmbig")
.build();
List<CallChain> chains1 = engine.findChains(List.of(ep), List.of(tp1));
List<CallChain> chains2 = engine.findChains(List.of(ep), List.of(tp2));
assertThat(chains1).isNotEmpty();
assertThat(chains1.get(0).getMethodChain()).contains(
"click.kamil.examples.statemachine.extended.web.ListInjectionController.testList",
"click.kamil.examples.statemachine.extended.service.AmbiguousA.doAmbig"
);
assertThat(chains2).isNotEmpty();
assertThat(chains2.get(0).getMethodChain()).contains(
"click.kamil.examples.statemachine.extended.web.ListInjectionController.testList",
"click.kamil.examples.statemachine.extended.service.AmbiguousB.doAmbig"
);
}
@Test
void shouldResolveConcreteReturnType() {
EntryPoint ep = EntryPoint.builder()
.className("click.kamil.examples.statemachine.extended.web.ConcreteReturnTypeController")
.methodName("testConcrete")
.build();
TriggerPoint tp = TriggerPoint.builder()
.className("click.kamil.examples.statemachine.extended.service.QualifierQuirkService")
.methodName("doQuirk")
.build();
List<CallChain> chains = engine.findChains(List.of(ep), List.of(tp));
System.out.println("CHAINS FOUND: " + chains.size()); assertThat(chains).hasSize(1);
assertThat(chains.get(0).getMethodChain()).contains(
"click.kamil.examples.statemachine.extended.web.ConcreteReturnTypeController.testConcrete",
"click.kamil.examples.statemachine.extended.service.QualifierQuirkService.doQuirk"
);
}
}

View File

@@ -0,0 +1,122 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class LocalVariableTest {
@Test
void shouldTraceLocalVariableInGetter(@TempDir Path tempDir) throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void processOrderEvent(RichOrderEvent domainEvent) {
service.updateOrderState(domainEvent.getType());
}
}
class OrderService {
public void updateOrderState(OrderEvents event) {}
}
class RichOrderEvent {
public OrderEvents getType() {
OrderEvents e = OrderEvents.PAY;
return e;
}
}
enum OrderEvents { PAY }
""";
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<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).hasSize(1);
CallChain chain = chains.get(0);
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
.contains("OrderEvents.PAY")
.doesNotContain("e"); // Should not blindly add the variable name
}
@Test
void shouldTraceMultipleAssignments(@TempDir Path tempDir) throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void processOrderEvent(RichOrderEvent domainEvent) {
service.updateOrderState(domainEvent.getType());
}
}
class OrderService {
public void updateOrderState(OrderEvents event) {}
}
class RichOrderEvent {
public OrderEvents getType() {
OrderEvents e = null;
if (System.currentTimeMillis() % 2 == 0) {
e = OrderEvents.PAY;
} else {
e = OrderEvents.CANCEL;
}
return e;
}
}
enum OrderEvents { PAY, CANCEL }
""";
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<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).hasSize(1);
CallChain chain = chains.get(0);
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
.containsExactlyInAnyOrder("OrderEvents.PAY", "OrderEvents.CANCEL");
}
}

View File

@@ -0,0 +1,66 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class ReturnStatementTest {
@Test
void shouldHandleAssignmentInReturn(@TempDir Path tempDir) throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void processOrderEvent(RichOrderEvent domainEvent) {
service.updateOrderState(domainEvent.getType());
}
}
class OrderService {
public void updateOrderState(OrderEvents event) {}
}
class RichOrderEvent {
private OrderEvents type;
public OrderEvents getType() {
return this.type = OrderEvents.PAY;
}
}
enum OrderEvents { PAY }
""";
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<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).hasSize(1);
CallChain chain = chains.get(0);
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
.containsExactlyInAnyOrder("OrderEvents.PAY");
}
}

View File

@@ -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<MyEvent> 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");
}
}

View File

@@ -0,0 +1,71 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class SuperMethodInvocationTest {
@Test
void shouldHandleSuperMethodInvocation(@TempDir Path tempDir) throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void processOrderEvent(DerivedEvent domainEvent) {
service.updateOrderState(domainEvent.getType());
}
}
class OrderService {
public void updateOrderState(OrderEvents event) {}
}
class BaseEvent {
public OrderEvents getType() {
return OrderEvents.PAY;
}
}
class DerivedEvent extends BaseEvent {
public OrderEvents getType() {
return super.getType();
}
}
enum OrderEvents { PAY }
""";
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<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains).hasSize(1);
CallChain chain = chains.get(0);
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
.contains("OrderEvents.PAY");
}
}

View File

@@ -0,0 +1,89 @@
package click.kamil.springstatemachineexporter.analysis.spring;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.analysis.service.DynamicClasspathResolver;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class SpringContextScannerIntegrationTest {
private CodebaseContext context;
private SpringBeanRegistry registry;
@BeforeEach
void setUp() throws IOException {
Path projectRoot = Path.of("../state_machines/extended_analysis_sample").toAbsolutePath().normalize();
context = new CodebaseContext();
context.setProjectRoot(projectRoot);
context.setSourcepath(List.of(projectRoot.resolve("src/main/java").toString()));
DynamicClasspathResolver resolver = new DynamicClasspathResolver();
List<String> cp = resolver.resolveClasspath(projectRoot);
if (!cp.isEmpty()) {
context.setClasspath(cp);
}
context.setResolveBindings(true);
context.scan(Set.of(projectRoot), Collections.emptySet());
registry = new SpringBeanRegistry();
SpringContextScanner scanner = new SpringContextScanner(registry);
for (var cu : context.getCompilationUnits()) {
cu.accept(scanner);
}
}
@Test
void shouldFindStereotypeBeans() {
List<SpringBean> beans = registry.getBeans();
// PrimaryQuirkService
assertThat(beans).anySatisfy(bean -> {
assertThat(bean.getTypeFqn()).isEqualTo("click.kamil.examples.statemachine.extended.service.PrimaryQuirkService");
assertThat(bean.isPrimary()).isTrue();
assertThat(bean.getAssignableTypes()).contains("click.kamil.examples.statemachine.extended.service.QuirkService");
assertThat(bean.getBeanNames()).contains("primaryQuirkService");
});
// QualifierQuirkService
assertThat(beans).anySatisfy(bean -> {
assertThat(bean.getTypeFqn()).isEqualTo("click.kamil.examples.statemachine.extended.service.QualifierQuirkService");
assertThat(bean.isPrimary()).isFalse();
assertThat(bean.getBeanNames()).contains("qualifierQuirkService");
});
// NamedQuirkService (should have explicit name via @Service("customName"))
assertThat(beans).anySatisfy(bean -> {
assertThat(bean.getTypeFqn()).isEqualTo("click.kamil.examples.statemachine.extended.service.NamedQuirkService");
assertThat(bean.getBeanNames()).contains("customName");
});
// PaymentController (should be detected via @RestController)
assertThat(beans).anySatisfy(bean -> {
assertThat(bean.getTypeFqn()).isEqualTo("click.kamil.examples.statemachine.extended.web.PaymentController");
});
}
@Test
void shouldFindMethodBeans() {
List<SpringBean> beans = registry.getBeans();
// Beans created via @Bean
assertThat(beans).anySatisfy(bean -> {
assertThat(bean.getTypeFqn()).isEqualTo("java.lang.String");
assertThat(bean.getFactoryMethodName()).isEqualTo("myStringBean");
assertThat(bean.getBeanNames()).contains("customMockBean");
assertThat(bean.getDeclaringClassFqn()).isEqualTo("click.kamil.examples.statemachine.extended.config.MockBeanConfig");
});
}
}

View File

@@ -0,0 +1,220 @@
package click.kamil.springstatemachineexporter.analysis.spring;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class SpringDependencyResolverTest {
private SpringBeanRegistry registry;
private SpringDependencyResolver resolver;
@BeforeEach
void setUp() {
registry = new SpringBeanRegistry();
resolver = new SpringDependencyResolver(registry);
}
@Test
void shouldResolveSingleMatchByType() {
registry.addBean(SpringBean.builder()
.typeFqn("com.example.PaymentServiceImpl")
.assignableTypes(Set.of("com.example.PaymentService"))
.build());
List<SpringBean> resolved = resolver.resolve("com.example.PaymentService", null, "paymentService");
assertThat(resolved).hasSize(1);
assertThat(resolved.get(0).getTypeFqn()).isEqualTo("com.example.PaymentServiceImpl");
}
@Test
void shouldResolveByPrimaryWhenMultipleImplementationsExist() {
registry.addBean(SpringBean.builder()
.typeFqn("com.example.NormalService")
.assignableTypes(Set.of("com.example.PaymentService"))
.isPrimary(false)
.build());
registry.addBean(SpringBean.builder()
.typeFqn("com.example.PrimaryService")
.assignableTypes(Set.of("com.example.PaymentService"))
.isPrimary(true)
.build());
List<SpringBean> resolved = resolver.resolve("com.example.PaymentService", null, "paymentService");
assertThat(resolved).hasSize(1);
assertThat(resolved.get(0).getTypeFqn()).isEqualTo("com.example.PrimaryService");
}
@Test
void shouldResolveByQualifierWhenMultipleImplementationsExist() {
registry.addBean(SpringBean.builder()
.typeFqn("com.example.ServiceA")
.assignableTypes(Set.of("com.example.PaymentService"))
.qualifiers(Set.of("serviceA"))
.build());
registry.addBean(SpringBean.builder()
.typeFqn("com.example.ServiceB")
.assignableTypes(Set.of("com.example.PaymentService"))
.qualifiers(Set.of("serviceB"))
.build());
List<SpringBean> resolved = resolver.resolve("com.example.PaymentService", "serviceB", "paymentService");
assertThat(resolved).hasSize(1);
assertThat(resolved.get(0).getTypeFqn()).isEqualTo("com.example.ServiceB");
}
@Test
void shouldResolveByInjectionNameFallback() {
registry.addBean(SpringBean.builder()
.typeFqn("com.example.ServiceA")
.assignableTypes(Set.of("com.example.PaymentService"))
.beanNames(Set.of("serviceA"))
.build());
registry.addBean(SpringBean.builder()
.typeFqn("com.example.ServiceB")
.assignableTypes(Set.of("com.example.PaymentService"))
.beanNames(Set.of("specialService"))
.build());
// Qualifier is null, Primary is false. Should match by variable name "specialService"
List<SpringBean> resolved = resolver.resolve("com.example.PaymentService", null, "specialService");
assertThat(resolved).hasSize(1);
assertThat(resolved.get(0).getTypeFqn()).isEqualTo("com.example.ServiceB");
}
@Test
void shouldResolveByOrderWhenOtherFiltersTie() {
registry.addBean(SpringBean.builder()
.typeFqn("com.example.LowestOrderService")
.assignableTypes(Set.of("com.example.PaymentService"))
.order(Integer.MAX_VALUE) // Lowest precedence
.build());
registry.addBean(SpringBean.builder()
.typeFqn("com.example.HighestOrderService")
.assignableTypes(Set.of("com.example.PaymentService"))
.order(Integer.MIN_VALUE) // Highest precedence
.build());
registry.addBean(SpringBean.builder()
.typeFqn("com.example.MiddleOrderService")
.assignableTypes(Set.of("com.example.PaymentService"))
.order(0)
.build());
List<SpringBean> resolved = resolver.resolve("com.example.PaymentService", null, "someUnknownName");
assertThat(resolved).hasSize(1);
assertThat(resolved.get(0).getTypeFqn()).isEqualTo("com.example.HighestOrderService");
}
@Test
void shouldResolveByOrderWithCustomValues() {
registry.addBean(SpringBean.builder()
.typeFqn("com.example.ServiceA")
.assignableTypes(Set.of("com.example.PaymentService"))
.order(400)
.build());
registry.addBean(SpringBean.builder()
.typeFqn("com.example.ServiceB")
.assignableTypes(Set.of("com.example.PaymentService"))
.order(100)
.build());
registry.addBean(SpringBean.builder()
.typeFqn("com.example.ServiceC")
.assignableTypes(Set.of("com.example.PaymentService"))
.order(2147483647 + 400) // This wraps around in Java due to overflow, but let's test a very large valid value
.build());
List<SpringBean> resolved = resolver.resolve("com.example.PaymentService", null, "someUnknownName");
assertThat(resolved).hasSize(1);
assertThat(resolved.get(0).getTypeFqn()).isEqualTo("com.example.ServiceC");
}
@Test
void primaryShouldOverrideOrder() {
registry.addBean(SpringBean.builder()
.typeFqn("com.example.PrimaryButLowestOrder")
.assignableTypes(Set.of("com.example.PaymentService"))
.isPrimary(true)
.order(Integer.MAX_VALUE)
.build());
registry.addBean(SpringBean.builder()
.typeFqn("com.example.NotPrimaryButHighestOrder")
.assignableTypes(Set.of("com.example.PaymentService"))
.isPrimary(false)
.order(Integer.MIN_VALUE)
.build());
List<SpringBean> resolved = resolver.resolve("com.example.PaymentService", null, "someUnknownName");
assertThat(resolved).hasSize(1);
assertThat(resolved.get(0).getTypeFqn()).isEqualTo("com.example.PrimaryButLowestOrder");
}
@Test
void qualifierShouldOverridePrimaryAndOrder() {
registry.addBean(SpringBean.builder()
.typeFqn("com.example.PrimaryService")
.assignableTypes(Set.of("com.example.PaymentService"))
.isPrimary(true)
.order(Integer.MIN_VALUE)
.qualifiers(Set.of("primary"))
.build());
registry.addBean(SpringBean.builder()
.typeFqn("com.example.QualifiedService")
.assignableTypes(Set.of("com.example.PaymentService"))
.isPrimary(false)
.order(Integer.MAX_VALUE)
.qualifiers(Set.of("targetQualifier"))
.build());
List<SpringBean> resolved = resolver.resolve("com.example.PaymentService", "targetQualifier", "someUnknownName");
assertThat(resolved).hasSize(1);
assertThat(resolved.get(0).getTypeFqn()).isEqualTo("com.example.QualifiedService");
}
@Test
void shouldReturnMultipleWhenOrderIsTied() {
registry.addBean(SpringBean.builder()
.typeFqn("com.example.ServiceA")
.assignableTypes(Set.of("com.example.PaymentService"))
.order(10)
.build());
registry.addBean(SpringBean.builder()
.typeFqn("com.example.ServiceB")
.assignableTypes(Set.of("com.example.PaymentService"))
.order(10)
.build());
registry.addBean(SpringBean.builder()
.typeFqn("com.example.ServiceC")
.assignableTypes(Set.of("com.example.PaymentService"))
.order(20)
.build());
List<SpringBean> resolved = resolver.resolve("com.example.PaymentService", null, "someUnknownName");
assertThat(resolved).hasSize(2);
assertThat(resolved.stream().map(SpringBean::getTypeFqn))
.containsExactlyInAnyOrder("com.example.ServiceA", "com.example.ServiceB");
}
}

View File

@@ -261,6 +261,56 @@ class AstTransitionParserTest {
.contains("System.out.println(\"Hello\")"); .contains("System.out.println(\"Hello\")");
} }
@Test
void shouldExtractInternalLogicForSameFileMethodReturningLambda() {
String source = """
import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer;
import org.springframework.statemachine.guard.Guard;
public class TestClass {
public void configure(StateMachineTransitionConfigurer<String, String> transitions) throws Exception {
transitions
.withExternal()
.source("S1")
.target("S2")
.guard(guardVarEquals("test"));
}
protected Guard<String, String> guardVarEquals(String expected) {
return context -> {
return expected.equals(context.getMessage());
};
}
}
""";
MethodDeclaration method = createMethodDeclarationWithBindings(source);
List<Transition> transitions = AstTransitionParser.parseTransitions(method, context);
assertThat(transitions).hasSize(1);
assertThat(transitions.getFirst().getGuard().internalLogic())
.contains("return expected.equals(context.getMessage());")
.doesNotContain("protected Guard<String, String> guardVarEquals");
}
private MethodDeclaration createMethodDeclarationWithBindings(String source) {
ASTParser parser = ASTParser.newParser(AST.getJLSLatest());
parser.setKind(ASTParser.K_COMPILATION_UNIT);
parser.setResolveBindings(true);
parser.setBindingsRecovery(true);
parser.setEnvironment(new String[0], new String[0], null, true);
parser.setUnitName("TestClass.java");
parser.setSource(source.toCharArray());
CompilationUnit cu = (CompilationUnit) parser.createAST(null);
if (cu.types().isEmpty()) {
return null;
}
TypeDeclaration typeDecl = (TypeDeclaration) cu.types().getFirst();
MethodDeclaration[] methods = typeDecl.getMethods();
if (methods.length > 0) {
return methods[0];
}
return null;
}
private MethodDeclaration createMethodDeclaration(String source) { private MethodDeclaration createMethodDeclaration(String source) {
ASTParser parser = ASTParser.newParser(AST.getJLSLatest()); ASTParser parser = ASTParser.newParser(AST.getJLSLatest());
parser.setKind(ASTParser.K_COMPILATION_UNIT); parser.setKind(ASTParser.K_COMPILATION_UNIT);

View File

@@ -0,0 +1,15 @@
digraph statemachine {
rankdir=LR;
node [shape=rounded, style=filled, fillcolor=white, fontname="Arial"];
edge [fontname="Arial", fontsize=10];
_start [shape=circle, label="", fillcolor=black, width=0.1];
_start -> SUBMITTED;
CANCELED [fillcolor=lightgray];
FULFILLED [fillcolor=lightgray];
SUBMITTED -> PAID [label="OrderEvents.PAY", style="solid", color="black"];
PAID -> FULFILLED [label="OrderEvents.FULFILL", style="solid", color="black"];
SUBMITTED -> CANCELED [label="OrderEvents.CANCEL", style="solid", color="black"];
PAID -> CANCELED [label="OrderEvents.ABCD", style="solid", color="black"];
}

View File

@@ -0,0 +1,627 @@
{
"metadata" : {
"triggers" : [ {
"event" : "event",
"className" : "click.kamil.examples.statemachine.polymorphic.OrderService",
"methodName" : "processEvent",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/OrderService.java",
"sourceModule" : null,
"stateMachineId" : null,
"sourceState" : null,
"lineNumber" : 13,
"polymorphicEvents" : null
}, {
"event" : "payload",
"className" : "click.kamil.examples.statemachine.polymorphic.OrderService",
"methodName" : "processPayloadEvent",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/OrderService.java",
"sourceModule" : null,
"stateMachineId" : null,
"sourceState" : null,
"lineNumber" : 17,
"polymorphicEvents" : null
}, {
"event" : "event",
"className" : "click.kamil.examples.statemachine.polymorphic.OrderService",
"methodName" : "processCustomEvent",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/OrderService.java",
"sourceModule" : null,
"stateMachineId" : null,
"sourceState" : null,
"lineNumber" : 21,
"polymorphicEvents" : null
} ],
"entryPoints" : [ {
"type" : "REST",
"name" : "POST /pay",
"className" : "click.kamil.examples.statemachine.polymorphic.PolymorphicController",
"methodName" : "pay",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/PolymorphicController.java",
"metadata" : {
"path" : "/pay",
"verb" : "POST"
},
"parameters" : [ ]
}, {
"type" : "REST",
"name" : "POST /fulfill",
"className" : "click.kamil.examples.statemachine.polymorphic.PolymorphicController",
"methodName" : "fulfill",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/PolymorphicController.java",
"metadata" : {
"path" : "/fulfill",
"verb" : "POST"
},
"parameters" : [ ]
}, {
"type" : "REST",
"name" : "POST /cancel",
"className" : "click.kamil.examples.statemachine.polymorphic.PolymorphicController",
"methodName" : "cancel",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/PolymorphicController.java",
"metadata" : {
"path" : "/cancel",
"verb" : "POST"
},
"parameters" : [ ]
}, {
"type" : "REST",
"name" : "POST /payload-pay",
"className" : "click.kamil.examples.statemachine.polymorphic.PolymorphicController",
"methodName" : "payloadPay",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/PolymorphicController.java",
"metadata" : {
"path" : "/payload-pay",
"verb" : "POST"
},
"parameters" : [ ]
}, {
"type" : "REST",
"name" : "POST /pay-variable",
"className" : "click.kamil.examples.statemachine.polymorphic.PolymorphicController",
"methodName" : "payVariable",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/PolymorphicController.java",
"metadata" : {
"path" : "/pay-variable",
"verb" : "POST"
},
"parameters" : [ ]
}, {
"type" : "REST",
"name" : "POST /pay-cast",
"className" : "click.kamil.examples.statemachine.polymorphic.PolymorphicController",
"methodName" : "payCast",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/PolymorphicController.java",
"metadata" : {
"path" : "/pay-cast",
"verb" : "POST"
},
"parameters" : [ ]
}, {
"type" : "REST",
"name" : "POST /pay-ternary",
"className" : "click.kamil.examples.statemachine.polymorphic.PolymorphicController",
"methodName" : "payTernary",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/PolymorphicController.java",
"metadata" : {
"path" : "/pay-ternary",
"verb" : "POST"
},
"parameters" : [ {
"name" : "isPay",
"type" : "boolean",
"annotations" : [ ]
} ]
}, {
"type" : "REST",
"name" : "POST /pay-list",
"className" : "click.kamil.examples.statemachine.polymorphic.PolymorphicController",
"methodName" : "payList",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/PolymorphicController.java",
"metadata" : {
"path" : "/pay-list",
"verb" : "POST"
},
"parameters" : [ ]
}, {
"type" : "REST",
"name" : "POST /pay-builder-static",
"className" : "click.kamil.examples.statemachine.polymorphic.PolymorphicController",
"methodName" : "payBuilderStatic",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/PolymorphicController.java",
"metadata" : {
"path" : "/pay-builder-static",
"verb" : "POST"
},
"parameters" : [ ]
}, {
"type" : "REST",
"name" : "POST /pay-builder-instance",
"className" : "click.kamil.examples.statemachine.polymorphic.PolymorphicController",
"methodName" : "payBuilderInstance",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/PolymorphicController.java",
"metadata" : {
"path" : "/pay-builder-instance",
"verb" : "POST"
},
"parameters" : [ ]
}, {
"type" : "REST",
"name" : "POST /abcd",
"className" : "click.kamil.examples.statemachine.polymorphic.PolymorphicController",
"methodName" : "abcd",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/PolymorphicController.java",
"metadata" : {
"path" : "/abcd",
"verb" : "POST"
},
"parameters" : [ ]
}, {
"type" : "REST",
"name" : "POST /mystery",
"className" : "click.kamil.examples.statemachine.polymorphic.PolymorphicController",
"methodName" : "mystery",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/PolymorphicController.java",
"metadata" : {
"path" : "/mystery",
"verb" : "POST"
},
"parameters" : [ ]
} ],
"callChains" : [ {
"entryPoint" : {
"type" : "REST",
"name" : "POST /pay",
"className" : "click.kamil.examples.statemachine.polymorphic.PolymorphicController",
"methodName" : "pay",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/PolymorphicController.java",
"metadata" : {
"path" : "/pay",
"verb" : "POST"
},
"parameters" : [ ]
},
"methodChain" : [ "click.kamil.examples.statemachine.polymorphic.PolymorphicController.pay", "click.kamil.examples.statemachine.polymorphic.OrderService.processEvent" ],
"triggerPoint" : {
"event" : "new PayEvent()",
"className" : "click.kamil.examples.statemachine.polymorphic.OrderService",
"methodName" : "processEvent",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/OrderService.java",
"sourceModule" : null,
"stateMachineId" : null,
"sourceState" : null,
"lineNumber" : 13,
"polymorphicEvents" : [ "OrderEvents.PAY" ]
},
"contextMachineId" : null,
"matchedTransitions" : [ {
"sourceState" : "OrderStates.SUBMITTED",
"targetState" : "OrderStates.PAID",
"event" : "OrderEvents.PAY"
} ]
}, {
"entryPoint" : {
"type" : "REST",
"name" : "POST /fulfill",
"className" : "click.kamil.examples.statemachine.polymorphic.PolymorphicController",
"methodName" : "fulfill",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/PolymorphicController.java",
"metadata" : {
"path" : "/fulfill",
"verb" : "POST"
},
"parameters" : [ ]
},
"methodChain" : [ "click.kamil.examples.statemachine.polymorphic.PolymorphicController.fulfill", "click.kamil.examples.statemachine.polymorphic.OrderService.processEvent" ],
"triggerPoint" : {
"event" : "new FulfillEvent()",
"className" : "click.kamil.examples.statemachine.polymorphic.OrderService",
"methodName" : "processEvent",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/OrderService.java",
"sourceModule" : null,
"stateMachineId" : null,
"sourceState" : null,
"lineNumber" : 13,
"polymorphicEvents" : [ "OrderEvents.FULFILL" ]
},
"contextMachineId" : null,
"matchedTransitions" : [ {
"sourceState" : "OrderStates.PAID",
"targetState" : "OrderStates.FULFILLED",
"event" : "OrderEvents.FULFILL"
} ]
}, {
"entryPoint" : {
"type" : "REST",
"name" : "POST /cancel",
"className" : "click.kamil.examples.statemachine.polymorphic.PolymorphicController",
"methodName" : "cancel",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/PolymorphicController.java",
"metadata" : {
"path" : "/cancel",
"verb" : "POST"
},
"parameters" : [ ]
},
"methodChain" : [ "click.kamil.examples.statemachine.polymorphic.PolymorphicController.cancel", "click.kamil.examples.statemachine.polymorphic.OrderService.processEvent" ],
"triggerPoint" : {
"event" : "new CancelEvent()",
"className" : "click.kamil.examples.statemachine.polymorphic.OrderService",
"methodName" : "processEvent",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/OrderService.java",
"sourceModule" : null,
"stateMachineId" : null,
"sourceState" : null,
"lineNumber" : 13,
"polymorphicEvents" : [ "OrderEvents.CANCEL" ]
},
"contextMachineId" : null,
"matchedTransitions" : [ {
"sourceState" : "OrderStates.SUBMITTED",
"targetState" : "OrderStates.CANCELED",
"event" : "OrderEvents.CANCEL"
} ]
}, {
"entryPoint" : {
"type" : "REST",
"name" : "POST /payload-pay",
"className" : "click.kamil.examples.statemachine.polymorphic.PolymorphicController",
"methodName" : "payloadPay",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/PolymorphicController.java",
"metadata" : {
"path" : "/payload-pay",
"verb" : "POST"
},
"parameters" : [ ]
},
"methodChain" : [ "click.kamil.examples.statemachine.polymorphic.PolymorphicController.payloadPay", "click.kamil.examples.statemachine.polymorphic.OrderService.processPayloadEvent" ],
"triggerPoint" : {
"event" : "new PayEvent()",
"className" : "click.kamil.examples.statemachine.polymorphic.OrderService",
"methodName" : "processPayloadEvent",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/OrderService.java",
"sourceModule" : null,
"stateMachineId" : null,
"sourceState" : null,
"lineNumber" : 17,
"polymorphicEvents" : [ "OrderEvents.PAY" ]
},
"contextMachineId" : null,
"matchedTransitions" : [ {
"sourceState" : "OrderStates.SUBMITTED",
"targetState" : "OrderStates.PAID",
"event" : "OrderEvents.PAY"
} ]
}, {
"entryPoint" : {
"type" : "REST",
"name" : "POST /pay-variable",
"className" : "click.kamil.examples.statemachine.polymorphic.PolymorphicController",
"methodName" : "payVariable",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/PolymorphicController.java",
"metadata" : {
"path" : "/pay-variable",
"verb" : "POST"
},
"parameters" : [ ]
},
"methodChain" : [ "click.kamil.examples.statemachine.polymorphic.PolymorphicController.payVariable", "click.kamil.examples.statemachine.polymorphic.OrderService.processEvent" ],
"triggerPoint" : {
"event" : "new PayEvent()",
"className" : "click.kamil.examples.statemachine.polymorphic.OrderService",
"methodName" : "processEvent",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/OrderService.java",
"sourceModule" : null,
"stateMachineId" : null,
"sourceState" : null,
"lineNumber" : 13,
"polymorphicEvents" : [ "OrderEvents.PAY" ]
},
"contextMachineId" : null,
"matchedTransitions" : [ {
"sourceState" : "OrderStates.SUBMITTED",
"targetState" : "OrderStates.PAID",
"event" : "OrderEvents.PAY"
} ]
}, {
"entryPoint" : {
"type" : "REST",
"name" : "POST /pay-cast",
"className" : "click.kamil.examples.statemachine.polymorphic.PolymorphicController",
"methodName" : "payCast",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/PolymorphicController.java",
"metadata" : {
"path" : "/pay-cast",
"verb" : "POST"
},
"parameters" : [ ]
},
"methodChain" : [ "click.kamil.examples.statemachine.polymorphic.PolymorphicController.payCast", "click.kamil.examples.statemachine.polymorphic.OrderService.processEvent" ],
"triggerPoint" : {
"event" : "(BaseEvent)new PayEvent()",
"className" : "click.kamil.examples.statemachine.polymorphic.OrderService",
"methodName" : "processEvent",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/OrderService.java",
"sourceModule" : null,
"stateMachineId" : null,
"sourceState" : null,
"lineNumber" : 13,
"polymorphicEvents" : [ "OrderEvents.PAY" ]
},
"contextMachineId" : null,
"matchedTransitions" : [ {
"sourceState" : "OrderStates.SUBMITTED",
"targetState" : "OrderStates.PAID",
"event" : "OrderEvents.PAY"
} ]
}, {
"entryPoint" : {
"type" : "REST",
"name" : "POST /pay-ternary",
"className" : "click.kamil.examples.statemachine.polymorphic.PolymorphicController",
"methodName" : "payTernary",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/PolymorphicController.java",
"metadata" : {
"path" : "/pay-ternary",
"verb" : "POST"
},
"parameters" : [ {
"name" : "isPay",
"type" : "boolean",
"annotations" : [ ]
} ]
},
"methodChain" : [ "click.kamil.examples.statemachine.polymorphic.PolymorphicController.payTernary", "click.kamil.examples.statemachine.polymorphic.OrderService.processEvent" ],
"triggerPoint" : {
"event" : "isPay ? new PayEvent() : new CancelEvent()",
"className" : "click.kamil.examples.statemachine.polymorphic.OrderService",
"methodName" : "processEvent",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/OrderService.java",
"sourceModule" : null,
"stateMachineId" : null,
"sourceState" : null,
"lineNumber" : 13,
"polymorphicEvents" : [ "OrderEvents.PAY", "OrderEvents.CANCEL" ]
},
"contextMachineId" : null,
"matchedTransitions" : [ {
"sourceState" : "OrderStates.SUBMITTED",
"targetState" : "OrderStates.PAID",
"event" : "OrderEvents.PAY"
}, {
"sourceState" : "OrderStates.SUBMITTED",
"targetState" : "OrderStates.CANCELED",
"event" : "OrderEvents.CANCEL"
} ]
}, {
"entryPoint" : {
"type" : "REST",
"name" : "POST /pay-list",
"className" : "click.kamil.examples.statemachine.polymorphic.PolymorphicController",
"methodName" : "payList",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/PolymorphicController.java",
"metadata" : {
"path" : "/pay-list",
"verb" : "POST"
},
"parameters" : [ ]
},
"methodChain" : [ "click.kamil.examples.statemachine.polymorphic.PolymorphicController.payList", "click.kamil.examples.statemachine.polymorphic.OrderService.processEvent" ],
"triggerPoint" : {
"event" : "new PayEvent()",
"className" : "click.kamil.examples.statemachine.polymorphic.OrderService",
"methodName" : "processEvent",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/OrderService.java",
"sourceModule" : null,
"stateMachineId" : null,
"sourceState" : null,
"lineNumber" : 13,
"polymorphicEvents" : [ "OrderEvents.PAY" ]
},
"contextMachineId" : null,
"matchedTransitions" : [ {
"sourceState" : "OrderStates.SUBMITTED",
"targetState" : "OrderStates.PAID",
"event" : "OrderEvents.PAY"
} ]
}, {
"entryPoint" : {
"type" : "REST",
"name" : "POST /pay-builder-static",
"className" : "click.kamil.examples.statemachine.polymorphic.PolymorphicController",
"methodName" : "payBuilderStatic",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/PolymorphicController.java",
"metadata" : {
"path" : "/pay-builder-static",
"verb" : "POST"
},
"parameters" : [ ]
},
"methodChain" : [ "click.kamil.examples.statemachine.polymorphic.PolymorphicController.payBuilderStatic", "click.kamil.examples.statemachine.polymorphic.OrderService.processEvent" ],
"triggerPoint" : {
"event" : "EventBuilder.buildEvent()",
"className" : "click.kamil.examples.statemachine.polymorphic.OrderService",
"methodName" : "processEvent",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/OrderService.java",
"sourceModule" : null,
"stateMachineId" : null,
"sourceState" : null,
"lineNumber" : 13,
"polymorphicEvents" : [ ]
},
"contextMachineId" : null,
"matchedTransitions" : null
}, {
"entryPoint" : {
"type" : "REST",
"name" : "POST /pay-builder-instance",
"className" : "click.kamil.examples.statemachine.polymorphic.PolymorphicController",
"methodName" : "payBuilderInstance",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/PolymorphicController.java",
"metadata" : {
"path" : "/pay-builder-instance",
"verb" : "POST"
},
"parameters" : [ ]
},
"methodChain" : [ "click.kamil.examples.statemachine.polymorphic.PolymorphicController.payBuilderInstance", "click.kamil.examples.statemachine.polymorphic.OrderService.processEvent" ],
"triggerPoint" : {
"event" : "new EventBuilder().buildInstanceEvent()",
"className" : "click.kamil.examples.statemachine.polymorphic.OrderService",
"methodName" : "processEvent",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/OrderService.java",
"sourceModule" : null,
"stateMachineId" : null,
"sourceState" : null,
"lineNumber" : 13,
"polymorphicEvents" : [ ]
},
"contextMachineId" : null,
"matchedTransitions" : null
}, {
"entryPoint" : {
"type" : "REST",
"name" : "POST /abcd",
"className" : "click.kamil.examples.statemachine.polymorphic.PolymorphicController",
"methodName" : "abcd",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/PolymorphicController.java",
"metadata" : {
"path" : "/abcd",
"verb" : "POST"
},
"parameters" : [ ]
},
"methodChain" : [ "click.kamil.examples.statemachine.polymorphic.PolymorphicController.abcd", "click.kamil.examples.statemachine.polymorphic.OrderService.processCustomEvent" ],
"triggerPoint" : {
"event" : "new AbcdEvent()",
"className" : "click.kamil.examples.statemachine.polymorphic.OrderService",
"methodName" : "processCustomEvent",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/OrderService.java",
"sourceModule" : null,
"stateMachineId" : null,
"sourceState" : null,
"lineNumber" : 21,
"polymorphicEvents" : [ "OrderEvents.ABCD" ]
},
"contextMachineId" : null,
"matchedTransitions" : [ {
"sourceState" : "OrderStates.PAID",
"targetState" : "OrderStates.CANCELED",
"event" : "OrderEvents.ABCD"
} ]
}, {
"entryPoint" : {
"type" : "REST",
"name" : "POST /mystery",
"className" : "click.kamil.examples.statemachine.polymorphic.PolymorphicController",
"methodName" : "mystery",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/PolymorphicController.java",
"metadata" : {
"path" : "/mystery",
"verb" : "POST"
},
"parameters" : [ ]
},
"methodChain" : [ "click.kamil.examples.statemachine.polymorphic.PolymorphicController.mystery", "click.kamil.examples.statemachine.polymorphic.OrderService.processCustomEvent" ],
"triggerPoint" : {
"event" : "new MysteryPayload()",
"className" : "click.kamil.examples.statemachine.polymorphic.OrderService",
"methodName" : "processCustomEvent",
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/OrderService.java",
"sourceModule" : null,
"stateMachineId" : null,
"sourceState" : null,
"lineNumber" : 21,
"polymorphicEvents" : [ "OrderEvents.ABCD", "OrderEvents.PAY" ]
},
"contextMachineId" : null,
"matchedTransitions" : [ {
"sourceState" : "OrderStates.SUBMITTED",
"targetState" : "OrderStates.PAID",
"event" : "OrderEvents.PAY"
}, {
"sourceState" : "OrderStates.PAID",
"targetState" : "OrderStates.CANCELED",
"event" : "OrderEvents.ABCD"
} ]
} ],
"properties" : {
"default" : {
"spring.application.name" : "statemachinedemo"
}
}
},
"name" : "click.kamil.examples.statemachine.polymorphic.PolymorphicStateMachineConfiguration",
"renderChoicesAsDiamonds" : true,
"startStates" : [ "OrderStates.SUBMITTED" ],
"transitions" : [ {
"type" : "EXTERNAL",
"sourceStates" : [ {
"rawName" : "OrderStates.SUBMITTED",
"fullIdentifier" : "OrderStates.SUBMITTED"
} ],
"targetStates" : [ {
"rawName" : "OrderStates.PAID",
"fullIdentifier" : "OrderStates.PAID"
} ],
"event" : {
"rawName" : "OrderEvents.PAY",
"fullIdentifier" : "OrderEvents.PAY"
},
"guard" : null,
"actions" : [ ],
"order" : null
}, {
"type" : "EXTERNAL",
"sourceStates" : [ {
"rawName" : "OrderStates.PAID",
"fullIdentifier" : "OrderStates.PAID"
} ],
"targetStates" : [ {
"rawName" : "OrderStates.FULFILLED",
"fullIdentifier" : "OrderStates.FULFILLED"
} ],
"event" : {
"rawName" : "OrderEvents.FULFILL",
"fullIdentifier" : "OrderEvents.FULFILL"
},
"guard" : null,
"actions" : [ ],
"order" : null
}, {
"type" : "EXTERNAL",
"sourceStates" : [ {
"rawName" : "OrderStates.SUBMITTED",
"fullIdentifier" : "OrderStates.SUBMITTED"
} ],
"targetStates" : [ {
"rawName" : "OrderStates.CANCELED",
"fullIdentifier" : "OrderStates.CANCELED"
} ],
"event" : {
"rawName" : "OrderEvents.CANCEL",
"fullIdentifier" : "OrderEvents.CANCEL"
},
"guard" : null,
"actions" : [ ],
"order" : null
}, {
"type" : "EXTERNAL",
"sourceStates" : [ {
"rawName" : "OrderStates.PAID",
"fullIdentifier" : "OrderStates.PAID"
} ],
"targetStates" : [ {
"rawName" : "OrderStates.CANCELED",
"fullIdentifier" : "OrderStates.CANCELED"
} ],
"event" : {
"rawName" : "OrderEvents.ABCD",
"fullIdentifier" : "OrderEvents.ABCD"
},
"guard" : null,
"actions" : [ ],
"order" : null
} ],
"endStates" : [ "OrderStates.CANCELED", "OrderStates.FULFILLED" ]
}

View File

@@ -0,0 +1,35 @@
@startuml
!pragma layout smetana
set separator none
hide empty description
hide stereotype
skinparam state {
BackgroundColor white
BorderColor #94a3b8
BorderThickness 1
FontName Inter
FontSize 9
FontStyle bold
RoundCorner 20
Padding 1
}
skinparam shadowing false
skinparam ArrowFontName JetBrains Mono
skinparam ArrowFontSize 8
skinparam ArrowColor #cbd5e1
skinparam ArrowThickness 1
skinparam dpi 110
skinparam svgLinkTarget _self
[*] --> OrderStates.SUBMITTED
OrderStates.SUBMITTED -[#1E90FF,bold]-> OrderStates.PAID <<external>> : OrderEvents.PAY
OrderStates.PAID -[#1E90FF,bold]-> OrderStates.FULFILLED <<external>> : OrderEvents.FULFILL
OrderStates.SUBMITTED -[#1E90FF,bold]-> OrderStates.CANCELED <<external>> : OrderEvents.CANCEL
OrderStates.PAID -[#1E90FF,bold]-> OrderStates.CANCELED <<external>> : OrderEvents.ABCD
OrderStates.CANCELED --> [*]
OrderStates.FULFILLED --> [*]
@enduml

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" initial="SUBMITTED">
<state id="SUBMITTED">
<transition target="PAID" event="OrderEvents.PAY"/>
<transition target="CANCELED" event="OrderEvents.CANCEL"/>
</state>
<state id="PAID">
<transition target="FULFILLED" event="OrderEvents.FULFILL"/>
<transition target="CANCELED" event="OrderEvents.ABCD"/>
</state>
<state id="FULFILLED">
</state>
<state id="CANCELED">
</state>
</scxml>

View File

@@ -144,7 +144,7 @@
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 25, "lineNumber" : 25,
"polymorphicEvents" : [ ] "polymorphicEvents" : [ "OrderEvent.PROCESS" ]
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {
@@ -175,7 +175,7 @@
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 25, "lineNumber" : 25,
"polymorphicEvents" : [ ] "polymorphicEvents" : [ "OrderEvent.COMPLETE" ]
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {
@@ -206,7 +206,7 @@
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 25, "lineNumber" : 25,
"polymorphicEvents" : [ ] "polymorphicEvents" : [ "OrderEvent.CANCEL" ]
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {
@@ -241,7 +241,7 @@
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 25, "lineNumber" : 25,
"polymorphicEvents" : [ ] "polymorphicEvents" : [ "OrderEvent.COMPLETE" ]
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {
@@ -276,7 +276,7 @@
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 78, "lineNumber" : 78,
"polymorphicEvents" : [ ] "polymorphicEvents" : [ "OrderEvent.PROCESS" ]
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {
@@ -311,7 +311,7 @@
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 25, "lineNumber" : 25,
"polymorphicEvents" : [ ] "polymorphicEvents" : [ "OrderEvent.PROCESS" ]
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {
@@ -346,7 +346,7 @@
"stateMachineId" : null, "stateMachineId" : null,
"sourceState" : null, "sourceState" : null,
"lineNumber" : 50, "lineNumber" : 50,
"polymorphicEvents" : [ ] "polymorphicEvents" : [ "OrderEvent.CANCEL" ]
}, },
"contextMachineId" : null, "contextMachineId" : null,
"matchedTransitions" : [ { "matchedTransitions" : [ {

View File

@@ -1,6 +1,37 @@
HELP.md
.gradle
build/ build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/ out/
.gradle/ !**/src/main/**/out/
.idea/ !**/src/test/**/out/
*.class
*.log ### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/

View File

@@ -0,0 +1,14 @@
package click.kamil.examples.statemachine.extended.config;
import click.kamil.examples.statemachine.extended.service.QuirkService;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
public abstract class AbstractBaseConfig {
@Bean
public String inheritedBeanTester(@Qualifier("customName") QuirkService someService) {
someService.doQuirk();
return "inherited";
}
}

View File

@@ -0,0 +1,17 @@
package click.kamil.examples.statemachine.extended.config;
import click.kamil.examples.statemachine.extended.service.QuirkService;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class BeanParameterTestConfig extends AbstractBaseConfig {
@Bean
public String myBeanParamTester(@Qualifier("customName") QuirkService someService) {
// We will use this method as an entry point in the test
someService.doQuirk();
return "tested";
}
}

View File

@@ -0,0 +1,15 @@
package click.kamil.examples.statemachine.extended.config;
import click.kamil.examples.statemachine.extended.service.QuirkService;
import click.kamil.examples.statemachine.extended.service.QualifierQuirkService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ConcreteReturnTypeConfig {
@Bean
public QuirkService hiddenConcreteService() {
return new QualifierQuirkService(); // It returns an interface, but the concrete type is QualifierQuirkService
}
}

View File

@@ -0,0 +1,13 @@
package click.kamil.examples.statemachine.extended.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MockBeanConfig {
@Bean(name = {"customMockBean", "aliasMockBean"})
public String myStringBean() {
return "Hello World";
}
}

View File

@@ -0,0 +1,58 @@
package click.kamil.examples.statemachine.extended.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.statemachine.config.EnableStateMachine;
import org.springframework.statemachine.config.StateMachineConfigurerAdapter;
import org.springframework.statemachine.config.builders.StateMachineStateConfigurer;
import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer;
@Configuration
@EnableStateMachine(name = "paymentStateMachine")
public class PaymentStateMachineConfig extends StateMachineConfigurerAdapter<String, String> {
@Override
public void configure(StateMachineStateConfigurer<String, String> states) throws Exception {
states
.withStates()
.initial("NEW")
.state("AUTHORIZED")
.state("CAPTURED")
.state("DECLINED")
.state("QUIRK1")
.state("QUIRK2")
.state("QUIRK3")
.state("QUIRK4");
}
@Override
public void configure(StateMachineTransitionConfigurer<String, String> transitions) throws Exception {
transitions
.withExternal()
.source("NEW").target("AUTHORIZED")
.event("AUTHORIZE")
.and()
.withExternal()
.source("AUTHORIZED").target("CAPTURED")
.event("CAPTURE")
.and()
.withExternal()
.source("NEW").target("DECLINED")
.event("DECLINE")
.and()
.withExternal()
.source("NEW").target("QUIRK1")
.event("PRIMARY_EVENT")
.and()
.withExternal()
.source("NEW").target("QUIRK2")
.event("NAMED_EVENT")
.and()
.withExternal()
.source("NEW").target("QUIRK3")
.event("QUALIFIER_EVENT")
.and()
.withExternal()
.source("NEW").target("QUIRK4")
.event("FALLBACK_EVENT");
}
}

View File

@@ -0,0 +1,9 @@
package click.kamil.examples.statemachine.extended.service;
import org.springframework.stereotype.Service;
@Service
public class AmbiguousA implements AmbiguousService {
@Override
public void doAmbig() {}
}

View File

@@ -0,0 +1,9 @@
package click.kamil.examples.statemachine.extended.service;
import org.springframework.stereotype.Service;
@Service
public class AmbiguousB implements AmbiguousService {
@Override
public void doAmbig() {}
}

View File

@@ -0,0 +1,5 @@
package click.kamil.examples.statemachine.extended.service;
public interface AmbiguousService {
void doAmbig();
}

View File

@@ -0,0 +1,19 @@
package click.kamil.examples.statemachine.extended.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.statemachine.StateMachine;
import org.springframework.stereotype.Service;
@Service
public class FallbackQuirkService implements QuirkService {
@Autowired
@Qualifier("paymentStateMachine")
private StateMachine<String, String> stateMachine;
@Override
public void doQuirk() {
stateMachine.sendEvent("FALLBACK_EVENT");
}
}

View File

@@ -0,0 +1,12 @@
package click.kamil.examples.statemachine.extended.service;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Service;
@Service
@Order(Ordered.HIGHEST_PRECEDENCE)
public class HighPriorityOrderedService implements OrderedService {
@Override
public void doAction() {}
}

View File

@@ -0,0 +1,11 @@
package click.kamil.examples.statemachine.extended.service;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Service;
@Service
@Order(10)
public class LowPriorityOrderedService implements OrderedService {
@Override
public void doAction() {}
}

View File

@@ -0,0 +1,19 @@
package click.kamil.examples.statemachine.extended.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.statemachine.StateMachine;
import org.springframework.stereotype.Service;
@Service("customName")
public class NamedQuirkService implements QuirkService {
@Autowired
@Qualifier("paymentStateMachine")
private StateMachine<String, String> stateMachine;
@Override
public void doQuirk() {
stateMachine.sendEvent("NAMED_EVENT");
}
}

View File

@@ -0,0 +1,5 @@
package click.kamil.examples.statemachine.extended.service;
public interface OrderedService {
void doAction();
}

View File

@@ -0,0 +1,6 @@
package click.kamil.examples.statemachine.extended.service;
public interface PaymentService {
void processPayment(String paymentId);
void capturePayment(String paymentId);
}

View File

@@ -0,0 +1,37 @@
package click.kamil.examples.statemachine.extended.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.statemachine.StateMachine;
import org.springframework.statemachine.persist.StateMachinePersister;
import org.springframework.stereotype.Service;
@Service
public class PaymentServiceImpl implements PaymentService {
@Autowired
@Qualifier("paymentStateMachine")
private StateMachine<String, String> stateMachine;
@Autowired
private StateMachinePersister<String, String, String> persister;
@Override
public void processPayment(String paymentId) {
// Send a trigger mapped strictly to PaymentStateMachineConfig
stateMachine.sendEvent("AUTHORIZE");
}
@Override
public void capturePayment(String paymentId) {
try {
persister.restore(stateMachine, paymentId);
} catch (Exception e) {}
org.springframework.messaging.Message<String> msg = org.springframework.messaging.support.MessageBuilder
.withPayload("CAPTURE")
.setHeader("paymentId", paymentId)
.build();
stateMachine.sendEvent(msg);
}
}

View File

@@ -0,0 +1,21 @@
package click.kamil.examples.statemachine.extended.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Primary;
import org.springframework.statemachine.StateMachine;
import org.springframework.stereotype.Service;
@Service
@Primary
public class PrimaryQuirkService implements QuirkService {
@Autowired
@Qualifier("paymentStateMachine")
private StateMachine<String, String> stateMachine;
@Override
public void doQuirk() {
stateMachine.sendEvent("PRIMARY_EVENT");
}
}

View File

@@ -0,0 +1,21 @@
package click.kamil.examples.statemachine.extended.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Profile;
import org.springframework.statemachine.StateMachine;
import org.springframework.stereotype.Service;
@Service
@Profile("nonexistent")
public class ProfiledQuirkService implements QuirkService {
@Autowired
@Qualifier("paymentStateMachine")
private StateMachine<String, String> stateMachine;
@Override
public void doQuirk() {
stateMachine.sendEvent("PROFILED_EVENT");
}
}

View File

@@ -0,0 +1,19 @@
package click.kamil.examples.statemachine.extended.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.statemachine.StateMachine;
import org.springframework.stereotype.Service;
@Service
public class QualifierQuirkService implements QuirkService {
@Autowired
@Qualifier("paymentStateMachine")
private StateMachine<String, String> stateMachine;
@Override
public void doQuirk() {
stateMachine.sendEvent("QUALIFIER_EVENT");
}
}

View File

@@ -0,0 +1,5 @@
package click.kamil.examples.statemachine.extended.service;
public interface QuirkService {
void doQuirk();
}

View File

@@ -0,0 +1,10 @@
package click.kamil.examples.statemachine.extended.web;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
public interface BaseController {
@GetMapping("/api/base/{id}")
String processBaseEndpoint(@PathVariable String id);
}

View File

@@ -0,0 +1,21 @@
package click.kamil.examples.statemachine.extended.web;
import click.kamil.examples.statemachine.extended.service.QuirkService;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class ConcreteReturnTypeController {
private final QuirkService hiddenConcreteService;
public ConcreteReturnTypeController(@Qualifier("hiddenConcreteService") QuirkService hiddenConcreteService) {
this.hiddenConcreteService = hiddenConcreteService;
}
@GetMapping("/test-concrete")
public void testConcrete() {
hiddenConcreteService.doQuirk();
}
}

View File

@@ -0,0 +1,20 @@
package click.kamil.examples.statemachine.extended.web;
import click.kamil.examples.statemachine.extended.service.QuirkService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class FieldInjectionController {
@Autowired
@Qualifier("customName")
private QuirkService myFieldService;
@GetMapping("/test-field")
public void testField() {
myFieldService.doQuirk();
}
}

View File

@@ -0,0 +1,24 @@
package click.kamil.examples.statemachine.extended.web;
import click.kamil.examples.statemachine.extended.service.AmbiguousService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
public class ListInjectionController {
private final List<AmbiguousService> services;
public ListInjectionController(List<AmbiguousService> services) {
this.services = services;
}
@GetMapping("/test-list")
public void testList() {
for (AmbiguousService s : services) {
s.doAmbig();
}
}
}

View File

@@ -0,0 +1,22 @@
package click.kamil.examples.statemachine.extended.web;
import click.kamil.examples.statemachine.extended.service.OrderedService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class OrderedTestController {
private final OrderedService service;
// The name "service" does not match HighPriorityOrderedService or LowPriorityOrderedService
// So the Fallback by name will fail, and it will fall back to @Order
public OrderedTestController(OrderedService service) {
this.service = service;
}
@GetMapping("/test-order")
public void testOrder() {
service.doAction();
}
}

View File

@@ -0,0 +1,28 @@
package click.kamil.examples.statemachine.extended.web;
import click.kamil.examples.statemachine.extended.service.PaymentService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.PathVariable;
@RestController
public class PaymentController implements BaseController {
private final PaymentService paymentService;
public PaymentController(PaymentService paymentService) {
this.paymentService = paymentService;
}
@Override
public String processBaseEndpoint(@PathVariable String id) {
paymentService.processPayment(id);
return "Started Base Payment: " + id;
}
@PostMapping("/api/payment/{id}/capture")
public String capturePaymentEndpoint(@PathVariable String id) {
paymentService.capturePayment(id);
return "Captured: " + id;
}
}

View File

@@ -0,0 +1,45 @@
package click.kamil.examples.statemachine.extended.web;
import click.kamil.examples.statemachine.extended.service.QuirkService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class QuirkController {
@Autowired
private QuirkService quirkService; // Should resolve to PrimaryQuirkService
@Autowired
@Qualifier("customName")
private QuirkService someService; // Should resolve to NamedQuirkService
@Autowired
@Qualifier("qualifierQuirkService")
private QuirkService anotherService; // Should resolve to QualifierQuirkService
@Autowired
private QuirkService fallbackQuirkService; // Should resolve to FallbackQuirkService
@PostMapping("/api/quirk/primary")
public void testPrimary() {
quirkService.doQuirk();
}
@PostMapping("/api/quirk/named")
public void testNamed() {
someService.doQuirk();
}
@PostMapping("/api/quirk/qualifier")
public void testQualifier() {
anotherService.doQuirk();
}
@PostMapping("/api/quirk/fallback")
public void testFallback() {
fallbackQuirkService.doQuirk();
}
}

View File

@@ -0,0 +1,23 @@
package click.kamil.examples.statemachine.extended.web;
import click.kamil.examples.statemachine.extended.service.QuirkService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class SetterInjectionController {
private QuirkService setterService;
@Autowired
public void setSetterService(@Qualifier("customName") QuirkService setterService) {
this.setterService = setterService;
}
@GetMapping("/test-setter")
public void testSetter() {
setterService.doQuirk();
}
}

View File

@@ -0,0 +1,37 @@
HELP.md
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/

View File

@@ -0,0 +1,45 @@
plugins {
id 'java'
id 'org.springframework.boot' version '3.5.3'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'click.kamil'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.statemachine:spring-statemachine-starter:3.2.0'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}
bootJar { enabled = false }
jar { enabled = true }

View File

@@ -0,0 +1,8 @@
package click.kamil.examples.statemachine.polymorphic;
public class AbcdEvent implements CustomCodeEventInterface {
@Override
public OrderEvents resolveEventCode() {
return OrderEvents.ABCD;
}
}

View File

@@ -0,0 +1,2 @@
package click.kamil.examples.statemachine.polymorphic;
public interface BaseEvent { OrderEvents getType(); }

View File

@@ -0,0 +1,4 @@
package click.kamil.examples.statemachine.polymorphic;
public class CancelEvent implements BaseEvent {
@Override public OrderEvents getType() { return OrderEvents.CANCEL; }
}

View File

@@ -0,0 +1,5 @@
package click.kamil.examples.statemachine.polymorphic;
public interface CustomCodeEventInterface {
OrderEvents resolveEventCode();
}

View File

@@ -0,0 +1,11 @@
package click.kamil.examples.statemachine.polymorphic;
public class EventBuilder {
public static BaseEvent buildEvent() {
return new FulfillEvent();
}
public BaseEvent buildInstanceEvent() {
return new CancelEvent();
}
}

View File

@@ -0,0 +1,4 @@
package click.kamil.examples.statemachine.polymorphic;
public class FulfillEvent implements BaseEvent {
@Override public OrderEvents getType() { return OrderEvents.FULFILL; }
}

View File

@@ -0,0 +1,9 @@
package click.kamil.examples.statemachine.polymorphic;
public class MysteryPayload implements CustomCodeEventInterface {
@Override
public OrderEvents resolveEventCode() {
int a = (int) (Math.random() * 10);
return a > 5 ? OrderEvents.ABCD : OrderEvents.PAY;
}
}

View File

@@ -0,0 +1,2 @@
package click.kamil.examples.statemachine.polymorphic;
public enum OrderEvents { FULFILL, PAY, CANCEL, ABCD, NOPE, IGNORE }

View File

@@ -0,0 +1,23 @@
package click.kamil.examples.statemachine.polymorphic;
import org.springframework.statemachine.StateMachine;
import org.springframework.stereotype.Service;
import org.springframework.messaging.support.MessageBuilder;
@Service
public class OrderService {
private final StateMachine<OrderStates, OrderEvents> sm;
public OrderService(StateMachine<OrderStates, OrderEvents> sm) { this.sm = sm; }
public void processEvent(BaseEvent event) {
sm.sendEvent(event.getType());
}
public void processPayloadEvent(Object payload) {
sm.sendEvent(MessageBuilder.withPayload(payload).build());
}
public void processCustomEvent(CustomCodeEventInterface event) {
sm.sendEvent(event.resolveEventCode());
}
}

View File

@@ -0,0 +1,2 @@
package click.kamil.examples.statemachine.polymorphic;
public enum OrderStates { SUBMITTED, PAID, FULFILLED, CANCELED, PAID1, PAID2, PAID3, INVALID, HAPPEN, SKIPPED, NEVER }

View File

@@ -0,0 +1,4 @@
package click.kamil.examples.statemachine.polymorphic;
public class PayEvent implements BaseEvent {
@Override public OrderEvents getType() { return OrderEvents.PAY; }
}

View File

@@ -0,0 +1,79 @@
package click.kamil.examples.statemachine.polymorphic;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class PolymorphicController {
private final OrderService orderService;
public PolymorphicController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping("/pay")
public void pay() {
orderService.processEvent(new PayEvent());
}
@PostMapping("/fulfill")
public void fulfill() {
orderService.processEvent(new FulfillEvent());
}
@PostMapping("/cancel")
public void cancel() {
orderService.processEvent(new CancelEvent());
}
@PostMapping("/payload-pay")
public void payloadPay() {
orderService.processPayloadEvent(new PayEvent());
}
@PostMapping("/pay-variable")
public void payVariable() {
PayEvent event = new PayEvent();
orderService.processEvent(event);
}
@PostMapping("/pay-cast")
public void payCast() {
orderService.processEvent((BaseEvent) new PayEvent());
}
@PostMapping("/pay-ternary")
public void payTernary(boolean isPay) {
orderService.processEvent(isPay ? new PayEvent() : new CancelEvent());
}
@PostMapping("/pay-list")
public void payList() {
java.util.List<BaseEvent> events = java.util.Arrays.asList(new PayEvent(), new FulfillEvent());
for (BaseEvent event : events) {
orderService.processEvent(event);
}
}
@PostMapping("/pay-builder-static")
public void payBuilderStatic() {
orderService.processEvent(EventBuilder.buildEvent());
}
@PostMapping("/pay-builder-instance")
public void payBuilderInstance() {
EventBuilder builder = new EventBuilder();
orderService.processEvent(builder.buildInstanceEvent());
}
@PostMapping("/abcd")
public void abcd() {
orderService.processCustomEvent(new AbcdEvent());
}
@PostMapping("/mystery")
public void mystery() {
orderService.processCustomEvent(new MysteryPayload());
}
}

View File

@@ -0,0 +1,23 @@
package click.kamil.examples.statemachine.polymorphic;
import org.springframework.context.annotation.Configuration;
import org.springframework.statemachine.config.EnableStateMachine;
import org.springframework.statemachine.config.EnumStateMachineConfigurerAdapter;
import org.springframework.statemachine.config.builders.StateMachineStateConfigurer;
import org.springframework.statemachine.config.builders.StateMachineTransitionConfigurer;
@Configuration
@EnableStateMachine
public class PolymorphicStateMachineConfiguration extends EnumStateMachineConfigurerAdapter<OrderStates, OrderEvents> {
@Override
public void configure(StateMachineStateConfigurer<OrderStates, OrderEvents> states) throws Exception {
states.withStates().initial(OrderStates.SUBMITTED).state(OrderStates.PAID).state(OrderStates.FULFILLED).state(OrderStates.CANCELED);
}
@Override
public void configure(StateMachineTransitionConfigurer<OrderStates, OrderEvents> transitions) throws Exception {
transitions
.withExternal().source(OrderStates.SUBMITTED).target(OrderStates.PAID).event(OrderEvents.PAY).and()
.withExternal().source(OrderStates.PAID).target(OrderStates.FULFILLED).event(OrderEvents.FULFILL).and()
.withExternal().source(OrderStates.SUBMITTED).target(OrderStates.CANCELED).event(OrderEvents.CANCEL).and()
.withExternal().source(OrderStates.PAID).target(OrderStates.CANCELED).event(OrderEvents.ABCD);
}
}

View File

@@ -0,0 +1,7 @@
package click.kamil.examples.statemachine.polymorphic.app;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class StateMachineApplication {
public static void main(String[] args) { SpringApplication.run(StateMachineApplication.class, args); }
}

View File

@@ -0,0 +1 @@
spring.application.name=statemachinedemo