7 Commits

Author SHA1 Message Date
073df21395 heauristic bean resolution 2026-06-21 06:55:55 +02:00
f9e6247eb3 fix 6 2026-06-20 13:20:05 +02:00
76ac143370 more fixes 5 2026-06-20 13:11:54 +02:00
9ca8b2fe37 more fixes 4 2026-06-20 12:31:11 +02:00
56cdf96f2d more fixes 3 2026-06-20 12:21:11 +02:00
c37aa92c78 more fixes 2 2026-06-20 07:57:03 +02:00
3ac057618f more fixes 2026-06-20 07:21:19 +02:00
8 changed files with 1556 additions and 38 deletions

View File

@@ -14,8 +14,13 @@ import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicRoutingEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.RoutingEngine;
public class TransitionLinkerEnricher implements AnalysisEnricher {
private final RoutingEngine routingEngine = new HeuristicRoutingEngine();
@Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
if (result.getMetadata() == null || result.getMetadata().getCallChains() == null) {
@@ -70,7 +75,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
}
}
if (hasPolyMatch || (polyEvents.isEmpty() && (isWildcard || smEvent.equals(triggerEvent) || triggerEvent.toLowerCase().contains(smEvent.toLowerCase()) || smEvent.toLowerCase().contains(triggerEvent.toLowerCase())))) {
if (hasPolyMatch || (polyEvents.isEmpty() && (isWildcard || smEvent.equals(triggerEvent)))) {
// Event matches or is a wildcard
for (State smSourceState : t.getSourceStates()) {
String smSourceRaw = smSourceState.fullIdentifier() != null ? smSourceState.fullIdentifier() : smSourceState.rawName();
@@ -123,29 +128,12 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
result.setMetadata(updatedMetadata);
}
private boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName) {
// If the chain's target expression or qualifier indicates a specific machine name, verify it
String targetVar = chain.getContextMachineId();
if (targetVar == null && chain.getTriggerPoint() != null) {
targetVar = chain.getTriggerPoint().getStateMachineId();
}
if (targetVar != null && !targetVar.isEmpty()) {
targetVar = targetVar.toLowerCase();
// E.g., if target is "myStateMachine", ensure current machine name contains "my"
String simplifiedMachineName = currentMachineName.substring(currentMachineName.lastIndexOf('.') + 1).toLowerCase();
// If the variable name ends with StateMachine, we extract the prefix
if (targetVar.endsWith("statemachine")) {
String prefix = targetVar.substring(0, targetVar.length() - "statemachine".length());
if (!prefix.isEmpty() && !simplifiedMachineName.contains(prefix)) {
return false;
}
}
}
return true;
return routingEngine.isRoutedToCorrectMachine(chain, currentMachineName);
}
private String simplify(String name) {
private String simplify(String name) {
if (name == null) return null;
// Strip common suffixes
String simplified = name.replaceAll("(?i)(.*)(Event|Action|Transition|Command)(s)?$", "$1");
@@ -157,4 +145,31 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
// For remaining full caps with underscores (like EVENT_X), keep as is or try to simplify
return simplified;
}
}
private boolean isGuardedContains(String smEvent, String triggerEvent) {
// Only fire if it looks like a simple identifier (no spaces, no parens)
// Prevents unresolved AST nodes (e.g. "event.getPayload()") from accidentally matching "Event"
if (smEvent.contains(" ") || smEvent.contains("(") || smEvent.contains(")") ||
triggerEvent.contains(" ") || triggerEvent.contains("(") || triggerEvent.contains(")")) {
return false;
}
String shorter = smEvent.length() < triggerEvent.length() ? smEvent : triggerEvent;
String longer = smEvent.length() >= triggerEvent.length() ? smEvent : triggerEvent;
shorter = shorter.toLowerCase();
longer = longer.toLowerCase();
if (longer.contains(shorter)) {
// Guard: If the matching part is very short (< 4 chars), it must be bounded by word separators
// to avoid matching completely unrelated words (e.g., "PAY" matching "PAYMENT" or "E" matching "UPDATE")
if (shorter.length() < 4) {
return longer.matches(".*(^|_|-)" + shorter + "(_|-|$).*");
}
// For longer strings, a straight contains is usually safe enough
return true;
}
return false;
}
}

View File

@@ -0,0 +1,188 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import java.util.HashSet;
import java.util.Set;
public class HeuristicRoutingEngine implements RoutingEngine {
@Override
public boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName) {
String targetVar = chain.getContextMachineId();
if (targetVar == null && chain.getTriggerPoint() != null) {
targetVar = chain.getTriggerPoint().getStateMachineId();
}
String simplifiedMachineName = currentMachineName != null ? currentMachineName.substring(currentMachineName.lastIndexOf('.') + 1).toLowerCase() : "";
if (targetVar != null && !targetVar.isEmpty()) {
targetVar = targetVar.toLowerCase();
if (targetVar.endsWith("statemachine")) {
String prefix = targetVar.substring(0, targetVar.length() - "statemachine".length());
if (!prefix.isEmpty()) {
if (simplifiedMachineName.contains(prefix)) {
return true; // Explicit positive match
} else {
return false; // Explicit negative match
}
}
} else if (simplifiedMachineName.contains(targetVar)) {
return true;
}
}
if (chain.getMethodChain() != null && !chain.getMethodChain().isEmpty() && currentMachineName != null) {
String smPackage = getPackageName(currentMachineName);
String smSimple = getSimpleClassName(currentMachineName);
String smPrefix = getFirstCamelCaseWord(smSimple);
boolean hasPositiveMatch = false;
boolean hasStrongMismatch = false;
for (String method : chain.getMethodChain()) {
String chainClass = getClassNameOnly(method);
String chainPackage = getPackageName(chainClass);
String chainSimple = getSimpleClassName(chainClass);
String chainPrefix = getFirstCamelCaseWord(chainSimple);
if (smPrefix != null && chainPrefix != null && smPrefix.equals(chainPrefix)) {
hasPositiveMatch = true;
}
if (isDomainMatch(smPackage, chainPackage, smPrefix, chainPrefix)) {
hasPositiveMatch = true;
}
if (isDomainMismatch(smPackage, chainPackage, smPrefix, chainPrefix)) {
hasStrongMismatch = true;
}
}
if (hasPositiveMatch) {
return true;
}
if (hasStrongMismatch) {
return false;
}
}
return true;
}
private boolean isDomainMatch(String smPackage, String chainPackage, String smPrefix, String chainPrefix) {
if (smPackage == null || chainPackage == null || smPackage.isEmpty() || chainPackage.isEmpty()) return false;
if (smPackage.startsWith(chainPackage) || chainPackage.startsWith(smPackage)) {
return true;
}
// Find deepest common package root
String[] p1 = smPackage.split("\\.");
String[] p2 = chainPackage.split("\\.");
int matchIdx = -1;
for (int i = 0; i < Math.min(p1.length, p2.length); i++) {
if (p1[i].equals(p2[i])) {
matchIdx = i;
} else {
break;
}
}
// If the state machine's prefix is part of the shared common package,
// then the state machine represents the parent domain of the caller.
if (smPrefix != null && matchIdx >= 0) {
String lowerPrefix = smPrefix.toLowerCase();
for (int i = 0; i <= matchIdx; i++) {
if (p1[i].toLowerCase().contains(lowerPrefix) || lowerPrefix.contains(p1[i].toLowerCase())) {
return true;
}
}
}
return false;
}
private boolean isDomainMismatch(String smPackage, String chainPackage, String smPrefix, String chainPrefix) {
if (smPackage == null || chainPackage == null || smPackage.isEmpty() || chainPackage.isEmpty()) return false;
String[] p1 = smPackage.split("\\.");
String[] p2 = chainPackage.split("\\.");
int matchIdx = -1;
for (int i = 0; i < Math.min(p1.length, p2.length); i++) {
if (p1[i].equals(p2[i])) {
matchIdx = i;
} else {
break;
}
}
Set<String> smDivergentTerms = new HashSet<>();
if (smPrefix != null) smDivergentTerms.add(smPrefix.toLowerCase());
for (int i = matchIdx + 1; i < p1.length; i++) {
smDivergentTerms.add(p1[i].toLowerCase());
}
Set<String> chainDivergentTerms = new HashSet<>();
if (chainPrefix != null) chainDivergentTerms.add(chainPrefix.toLowerCase());
for (int i = matchIdx + 1; i < p2.length; i++) {
chainDivergentTerms.add(p2[i].toLowerCase());
}
if (!smDivergentTerms.isEmpty() && !chainDivergentTerms.isEmpty()) {
Set<String> intersection = new HashSet<>(smDivergentTerms);
intersection.retainAll(chainDivergentTerms);
return intersection.isEmpty();
}
return false;
}
private Set<String> extractDomainTerms(String pkg, String prefix) {
Set<String> terms = new HashSet<>();
if (pkg != null && !pkg.isEmpty()) {
String[] segments = pkg.split("\\.");
// Take segments from index 2 onwards if length > 2
int start = segments.length > 2 ? 2 : 0;
for (int i = start; i < segments.length; i++) {
terms.add(segments[i].toLowerCase());
}
}
if (prefix != null && !prefix.isEmpty()) {
terms.add(prefix.toLowerCase());
}
return terms;
}
private String getPackageName(String fqn) {
if (fqn == null || !fqn.contains(".")) return "";
return fqn.substring(0, fqn.lastIndexOf('.'));
}
private String getSimpleClassName(String fqn) {
if (fqn == null) return "";
if (!fqn.contains(".")) return fqn;
return fqn.substring(fqn.lastIndexOf('.') + 1);
}
private String getClassNameOnly(String methodFqn) {
if (methodFqn == null) return "";
String clean = methodFqn;
if (clean.contains("(")) {
clean = clean.substring(0, clean.indexOf('('));
}
if (clean.contains(".")) {
clean = clean.substring(0, clean.lastIndexOf('.'));
}
return clean;
}
private String getFirstCamelCaseWord(String simpleName) {
if (simpleName == null || simpleName.isEmpty()) return null;
String[] words = simpleName.split("(?<!^)(?=[A-Z])");
if (words.length > 0) {
return words[0];
}
return null;
}
}

View File

@@ -0,0 +1,7 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
public interface RoutingEngine {
boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName);
}

View File

@@ -55,7 +55,26 @@ public class ConstantResolver {
return resolveManual(sn, context, visited);
}
if (expr instanceof MethodInvocation mi) {
if ((mi.getName().getIdentifier().equals("name") || mi.getName().getIdentifier().equals("toString")) && mi.arguments().isEmpty()) {
String val = resolveInternal(mi.getExpression(), context, visited);
if (val != null) {
// If it was resolved as a fully qualified enum string like "MyEvents.ORDER_PAID", strip it
if (val.contains(".")) {
return val.substring(val.lastIndexOf('.') + 1);
}
return val;
}
// Fallback for unresolved AST nodes
if (mi.getExpression() instanceof QualifiedName qn) {
return qn.getName().getIdentifier();
} else if (mi.getExpression() instanceof SimpleName sn) {
return sn.getIdentifier();
}
}
IMethodBinding mb = mi.resolveMethodBinding();
if (mb != null) {
ITypeBinding returnType = mb.getReturnType();

View File

@@ -159,27 +159,52 @@ public class CallGraphBuilder {
}
String methodName = resolvedValue.substring(lastDot + 1, openParen);
String declaredType = null;
String sourceMethod = null;
if (varName != null) {
// Resolve in the first method in the path where the variable might be declared
for (String methodFqn : path) {
String declaredType = getVariableDeclaredType(methodFqn, varName);
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: " + methodFqn + " " + varName + " -> " + declaredType);
System.out.println("DEEP TRACE: " + sourceMethod + " " + (varName != null ? varName : "inline") + " -> " + declaredType);
List<String> typesToInspect = new ArrayList<>();
typesToInspect.add(declaredType);
typesToInspect.addAll(context.getImplementations(declaredType));
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<>();
// We must find the compilation unit to pass for simple names!
// Let's pass the first available CU for this class
TypeDeclaration baseTd = context.getTypeDeclaration(type);
CompilationUnit cuToUse = null;
if (baseTd != null && baseTd.getRoot() instanceof CompilationUnit) {
cuToUse = (CompilationUnit) baseTd.getRoot();
} else {
String entryClassName = methodFqn.substring(0, methodFqn.lastIndexOf('.'));
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();
@@ -193,13 +218,54 @@ public class CallGraphBuilder {
}
}
}
break;
}
}
}
}
}
// 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)
@@ -274,7 +340,11 @@ public class CallGraphBuilder {
if (!visited.add(fqn)) return Collections.emptyList();
List<String> constants = new ArrayList<>();
TypeDeclaration td = contextCu != null ? context.getTypeDeclaration(className, contextCu) : context.getTypeDeclaration(className);
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);
}
@@ -322,7 +392,12 @@ public class CallGraphBuilder {
} else if (retExpr instanceof QualifiedName qn) {
constants.add(qn.toString());
} else if (retExpr instanceof SimpleName sn) {
constants.add(sn.toString());
List<String> consts = traceFieldInConstructors(td, sn.getIdentifier(), context, visited);
if (!consts.isEmpty()) {
constants.addAll(consts);
} else {
constants.add(sn.toString());
}
}
}
}
@@ -785,4 +860,113 @@ public class CallGraphBuilder {
}
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

@@ -192,4 +192,190 @@ class TransitionLinkerEnricherTest {
CallChain updatedChain = result.getMetadata().getCallChains().get(0);
assertThat(updatedChain.getMatchedTransitions()).hasSize(1);
}
}
@Test
void shouldNotFalsePositiveOnSubstringContains() {
// This test proves that the heuristic is entirely removed and false positives are eliminated
Transition t1 = new Transition();
t1.setSourceStates(List.of(State.of("NEW", "NEW")));
t1.setTargetStates(List.of(State.of("PAID", "PAID")));
t1.setEvent(Event.of("PAY", "PAY"));
// Trigger point sends "PAYMENT" which contains "PAY". Under the old heuristics, this would falsely match!
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event("PAYMENT").build())
.build();
AnalysisResult result = AnalysisResult.builder()
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
// It should NOT match, because "PAYMENT" != "PAY" strictly.
CallChain updatedChain = result.getMetadata().getCallChains().get(0);
assertThat(updatedChain.getMatchedTransitions()).isNullOrEmpty();
}
@Test
void shouldFilterCrossStateMachineRoutingByVertical() {
Transition t1 = new Transition();
t1.setSourceStates(List.of(State.of("NEW", "NEW")));
t1.setTargetStates(List.of(State.of("ASSEMBLED", "ASSEMBLED")));
t1.setEvent(Event.of("ASSEMBLE", "ASSEMBLE"));
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event("ASSEMBLE").build())
.methodChain(List.of(
"com.example.electronics.ElectronicsOrderController.post()",
"com.example.electronics.ElectronicsOrderService.processOrderEvent()"
))
.build();
// Testing against Electronics SM - should match
AnalysisResult resultElectronics = AnalysisResult.builder()
.name("com.example.electronics.ElectronicsOwnDeliveryStateMachineConfiguration")
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(resultElectronics, null, null);
CallChain updatedChainElec = resultElectronics.getMetadata().getCallChains().get(0);
assertThat(updatedChainElec.getMatchedTransitions()).hasSize(1);
// Testing against ComputerStore SM - should NOT match because chain has Electronics service
AnalysisResult resultComputer = AnalysisResult.builder()
.name("com.example.computerstore.OwnDeliveryStateMachineConfiguration") // the non-electronics one
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(resultComputer, null, null);
CallChain updatedChainComp = resultComputer.getMetadata().getCallChains().get(0);
assertThat(updatedChainComp.getMatchedTransitions()).isNullOrEmpty();
}
@Test
void shouldAllowSharedServicesToRouteToMultipleStateMachines() {
Transition t1 = new Transition();
t1.setSourceStates(List.of(State.of("NEW", "NEW")));
t1.setTargetStates(List.of(State.of("PROCESSED", "PROCESSED")));
t1.setEvent(Event.of("PROCESS", "PROCESS"));
// Chain contains NO vertical prefix (e.g. CommonOrderService)
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event("PROCESS").build())
.methodChain(List.of(
"com.example.CommonOrderController.post()",
"com.example.CommonOrderService.processOrderEvent()"
))
.build();
AnalysisResult resultElectronics = AnalysisResult.builder()
.name("com.example.electronics.ElectronicsOwnDeliveryStateMachineConfiguration")
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(resultElectronics, null, null);
CallChain updatedChainElec = resultElectronics.getMetadata().getCallChains().get(0);
// Should match Electronics because it's a shared service (no computerstore service in path)
assertThat(updatedChainElec.getMatchedTransitions()).hasSize(1);
AnalysisResult resultComputer = AnalysisResult.builder()
.name("com.example.computerstore.OwnDeliveryStateMachineConfiguration")
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(resultComputer, null, null);
CallChain updatedChainComp = resultComputer.getMetadata().getCallChains().get(0);
// Should ALSO match ComputerStore because it's a shared service
assertThat(updatedChainComp.getMatchedTransitions()).hasSize(1);
}
@Test
void shouldFilterCrossStateMachineRoutingWithMultipleVerticals() {
Transition t1 = new Transition();
t1.setSourceStates(List.of(State.of("NEW", "NEW")));
t1.setTargetStates(List.of(State.of("ASSEMBLED", "ASSEMBLED")));
t1.setEvent(Event.of("ASSEMBLE", "ASSEMBLE"));
// Chain 1: Electronics
CallChain chainElec = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event("ASSEMBLE").build())
.methodChain(List.of("com.example.electronics.ElectronicsOrderController.post()"))
.build();
// Chain 2: Furniture
CallChain chainFurn = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event("ASSEMBLE").build())
.methodChain(List.of("com.example.furniture.FurnitureOrderController.post()"))
.build();
// Chain 3: Groceries
CallChain chainGroc = CallChain.builder()
.triggerPoint(TriggerPoint.builder().event("ASSEMBLE").build())
.methodChain(List.of("com.example.groceries.GroceriesOrderController.post()"))
.build();
// SM 1: Electronics
AnalysisResult resElec = AnalysisResult.builder()
.name("com.example.electronics.ElectronicsOwnDeliveryStateMachineConfiguration")
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chainElec, chainFurn, chainGroc)).build())
.build();
// SM 2: Furniture
AnalysisResult resFurn = AnalysisResult.builder()
.name("com.example.furniture.FurnitureOwnDeliveryStateMachineConfiguration")
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chainElec, chainFurn, chainGroc)).build())
.build();
// SM 3: Groceries
AnalysisResult resGroc = AnalysisResult.builder()
.name("com.example.groceries.GroceriesStateMachineConfiguration")
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chainElec, chainFurn, chainGroc)).build())
.build();
// SM 4: Computer Store (The catch-all base one in computerstore package)
AnalysisResult resComp = AnalysisResult.builder()
.name("com.example.computerstore.OwnDeliveryStateMachineConfiguration")
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chainElec, chainFurn, chainGroc)).build())
.build();
// Test Electronics SM - Should only keep Electronics Chain
enricher.enrich(resElec, null, null);
assertThat(resElec.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1); // Elec
assertThat(resElec.getMetadata().getCallChains().get(1).getMatchedTransitions()).isNullOrEmpty(); // Furn
assertThat(resElec.getMetadata().getCallChains().get(2).getMatchedTransitions()).isNullOrEmpty(); // Groc
// Test Furniture SM - Should only keep Furniture Chain
enricher.enrich(resFurn, null, null);
assertThat(resFurn.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty(); // Elec
assertThat(resFurn.getMetadata().getCallChains().get(1).getMatchedTransitions()).hasSize(1); // Furn
assertThat(resFurn.getMetadata().getCallChains().get(2).getMatchedTransitions()).isNullOrEmpty(); // Groc
// Test Groceries SM - Should only keep Groceries Chain
enricher.enrich(resGroc, null, null);
assertThat(resGroc.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty(); // Elec
assertThat(resGroc.getMetadata().getCallChains().get(1).getMatchedTransitions()).isNullOrEmpty(); // Furn
assertThat(resGroc.getMetadata().getCallChains().get(2).getMatchedTransitions()).hasSize(1); // Groc
// Test ComputerStore SM - Should reject ALL because none belong to computerstore
enricher.enrich(resComp, null, null);
assertThat(resComp.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty(); // Elec
assertThat(resComp.getMetadata().getCallChains().get(1).getMatchedTransitions()).isNullOrEmpty(); // Furn
assertThat(resComp.getMetadata().getCallChains().get(2).getMatchedTransitions()).isNullOrEmpty(); // Groc
}
}

View File

@@ -0,0 +1,103 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.*;
@Tag("heuristic_bean_resolution")
public class HeuristicRoutingEngineTest {
private final HeuristicRoutingEngine engine = new HeuristicRoutingEngine();
@Test
public void testDeepPackageDivergenceMismatch() {
// This is the bug case: sharing a deep root, but diverging into different domains
CallChain chain = CallChain.builder()
.methodChain(Arrays.asList("com.acme.corp.division.project.orders.OrderService.pay()"))
.build();
String machineName = "com.acme.corp.division.project.payments.PaymentStateMachine";
boolean result = engine.isRoutedToCorrectMachine(chain, machineName);
// They share com.acme.corp.division.project, but diverge into orders vs payments
// This should be a strong mismatch, so it should return false
assertFalse(result, "Deep package divergence into different domains should be rejected");
}
@Test
public void testSameDomainMatch() {
CallChain chain = CallChain.builder()
.methodChain(Arrays.asList("com.acme.ecommerce.orders.OrderService.process()"))
.build();
String machineName = "com.acme.ecommerce.orders.OrderStateMachine";
boolean result = engine.isRoutedToCorrectMachine(chain, machineName);
// Same exact domain, should match
assertTrue(result, "Same domain should be accepted");
}
@Test
public void testSubPackageMatch() {
CallChain chain = CallChain.builder()
.methodChain(Arrays.asList("com.acme.ecommerce.orders.impl.OrderServiceImpl.process()"))
.build();
String machineName = "com.acme.ecommerce.orders.OrderStateMachine";
boolean result = engine.isRoutedToCorrectMachine(chain, machineName);
// Subpackage should match
assertTrue(result, "Sub-packages of the same domain should be accepted");
}
@Test
public void testSharedDomainTermMatch() {
CallChain chain = CallChain.builder()
.methodChain(Arrays.asList("com.acme.orders.web.OrderController.submit()"))
.build();
String machineName = "com.acme.orders.service.OrderStateMachine";
boolean result = engine.isRoutedToCorrectMachine(chain, machineName);
// Different subpackages, but they share the "order" domain term and common prefix
assertTrue(result, "Divergent packages that share a domain term should be accepted");
}
@Test
public void testExplicitTargetVariableMatch() {
CallChain chain = CallChain.builder()
.methodChain(Arrays.asList("com.acme.common.GlobalTrigger.fire()"))
.contextMachineId("paymentStateMachine") // Explicitly targets payment
.build();
String machineName = "com.acme.corp.payments.PaymentStateMachine";
boolean result = engine.isRoutedToCorrectMachine(chain, machineName);
// Mismatched domains, but explicit variable targeting works
assertTrue(result, "Explicit variable target matching the machine name should be accepted");
}
@Test
public void testExplicitTargetVariableMismatch() {
CallChain chain = CallChain.builder()
.methodChain(Arrays.asList("com.acme.common.GlobalTrigger.fire()"))
.contextMachineId("paymentStateMachine") // Explicitly targets payment
.build();
String machineName = "com.acme.corp.orders.OrderStateMachine"; // But this is Order
boolean result = engine.isRoutedToCorrectMachine(chain, machineName);
// Explicit variable targeting completely conflicts
assertFalse(result, "Explicit variable target mismatching the machine name should be rejected");
}
}

View File

@@ -1081,4 +1081,820 @@ class CallGraphBuilderTest {
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).containsExactlyInAnyOrder("EVENT_A", "EVENT_B");
}
}
@Test
void shouldResolveConstantsFromSwitchStatementInMethod() throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void handleStatus(DomainEvent domainEvent) {
service.process(domainEvent.getEventForStatus());
}
}
class DomainEvent {
private OrderStatus status;
public String getEventForStatus() {
switch (status) {
case NEW: return "CREATE_EVENT";
case PAID: return "PAY_EVENT";
default: return "CANCEL_EVENT";
}
}
}
class OrderService {
public void process(String event) {}
}
enum OrderStatus { NEW, PAID }
""";
Path tempDir = Files.createTempDirectory("callgraph_switch");
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallGraphBuilder builder = new CallGraphBuilder(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("CREATE_EVENT", "PAY_EVENT", "CANCEL_EVENT");
}
@Test
void shouldGracefullyHandleComplexMethodChainsWithoutCrashing() throws IOException {
String source = """
package com.example;
import java.util.Optional;
public class OrderController {
private OrderService service;
public void handleStatus(DomainEvent domainEvent) {
service.process(Optional.of(domainEvent).get().getEvent());
}
}
class DomainEvent {
public String getEvent() { return "COMPLEX_EVENT"; }
}
class OrderService {
public void process(String event) {}
}
""";
Path tempDir = Files.createTempDirectory("callgraph_complex_chain");
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallGraphBuilder builder = new CallGraphBuilder(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);
}
@Test
void shouldNotCrashOnStaticMethodWrapping() throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void handleStatus(DomainEvent domainEvent) {
service.process(String.valueOf(domainEvent.getEvent()));
}
}
class DomainEvent {
public String getEvent() { return "STATIC_WRAP_EVENT"; }
}
class OrderService {
public void process(String event) {}
}
""";
Path tempDir = Files.createTempDirectory("callgraph_static_wrap");
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallGraphBuilder builder = new CallGraphBuilder(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);
}
@Test
void shouldResolveConstantsFromStringConcatenation() throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void handleStatus(DomainEvent domainEvent) {
service.process(domainEvent.getCustomEvent());
}
}
class DomainEvent {
public static final String PREFIX = "ORDER_";
public String getCustomEvent() {
return PREFIX + "CREATED";
}
}
class OrderService {
public void process(String event) {}
}
""";
Path tempDir = Files.createTempDirectory("callgraph_concat");
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallGraphBuilder builder = new CallGraphBuilder(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("ORDER_CREATED");
}
@Test
void shouldResolveConstantsFromEnumReturn() throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void handleStatus(DomainEvent domainEvent) {
service.process(domainEvent.getEventEnum());
}
}
class DomainEvent {
public String getEventEnum() {
return MyEvents.ORDER_PAID.name();
}
}
class OrderService {
public void process(String event) {}
}
enum MyEvents { ORDER_PAID }
""";
Path tempDir = Files.createTempDirectory("callgraph_enum_return");
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallGraphBuilder builder = new CallGraphBuilder(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("ORDER_PAID");
}
@Test
void shouldResolveConstantsFromAlternativeGetterName() throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void handleStatus(DomainEvent domainEvent) {
service.process(domainEvent.get2Type());
}
}
class DomainEvent {
public String get2Type() {
return "ALTERNATIVE_EVENT";
}
}
class OrderService {
public void process(String event) {}
}
""";
Path tempDir = Files.createTempDirectory("callgraph_alt_getter");
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallGraphBuilder builder = new CallGraphBuilder(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("ALTERNATIVE_EVENT");
}
@Test
void shouldResolveConstantsFromStringToString() throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void handleStatus(DomainEvent domainEvent) {
service.process(domainEvent.getEventString());
}
}
class DomainEvent {
public static final String ACTUAL_STRING = "STRING_LITERAL_EVENT";
public String getEventString() {
return ACTUAL_STRING.toString();
}
}
class OrderService {
public void process(String event) {}
}
""";
Path tempDir = Files.createTempDirectory("callgraph_string_tostring");
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallGraphBuilder builder = new CallGraphBuilder(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("STRING_LITERAL_EVENT");
}
@Test
void shouldResolveConstantsFromInlineInstantiation() throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void handleStatus() {
service.process(new DomainEvent().getEvent());
}
}
class DomainEvent {
public String getEvent() { return "INLINE_EVENT"; }
}
class OrderService {
public void process(String event) {}
}
""";
Path tempDir = Files.createTempDirectory("callgraph_inline_inst");
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallGraphBuilder builder = new CallGraphBuilder(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("INLINE_EVENT");
}
@Test
void shouldResolveConstantsFromInlineInstantiationWithArguments() throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void handleStatus() {
service.process(new DomainEvent("PAYLOAD_ID").getEvent());
}
}
class DomainEvent {
private String id;
public DomainEvent(String id) { this.id = id; }
public String getEvent() { return "INLINE_EVENT_WITH_ARGS"; }
}
class OrderService {
public void process(String event) {}
}
""";
Path tempDir = Files.createTempDirectory("callgraph_inline_inst_args");
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallGraphBuilder builder = new CallGraphBuilder(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("INLINE_EVENT_WITH_ARGS");
}
@Test
void shouldResolveConstantsFromInlineInstantiationWithGenerics() throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void handleStatus() {
service.process(new DomainEvent<String>().getEvent());
}
}
class DomainEvent<T> {
public String getEvent() { return "INLINE_EVENT_WITH_GENERICS"; }
}
class OrderService {
public void process(String event) {}
}
""";
Path tempDir = Files.createTempDirectory("callgraph_inline_inst_gen");
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallGraphBuilder builder = new CallGraphBuilder(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("INLINE_EVENT_WITH_GENERICS");
}
@Test
void shouldResolveConstantsFromInlineInstantiationInheritedMethod() throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void handleStatus() {
service.process(new SpecificDomainEvent().getEvent());
}
}
abstract class BaseDomainEvent {
public String getEvent() { return "BASE_EVENT_RETURN"; }
}
class SpecificDomainEvent extends BaseDomainEvent {
// no getEvent here, it inherits it!
}
class OrderService {
public void process(String event) {}
}
""";
Path tempDir = Files.createTempDirectory("callgraph_inline_inst_hier");
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallGraphBuilder builder = new CallGraphBuilder(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("BASE_EVENT_RETURN");
}
@Test
void shouldResolveConstantsFromInlineInstantiationWithComplexArguments() throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void handleStatus(Order o, String abc) {
service.process(new DomainEvent(o.getId(), abc).getEvent());
}
}
class Order {
public String getId() { return "123"; }
}
class DomainEvent {
private String id;
private String extra;
public DomainEvent(String id, String extra) { this.id = id; this.extra = extra; }
public String getEvent() { return "INLINE_EVENT_COMPLEX_ARGS"; }
}
class OrderService {
public void process(String event) {}
}
""";
Path tempDir = Files.createTempDirectory("callgraph_inline_inst_complex_args");
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallGraphBuilder builder = new CallGraphBuilder(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("INLINE_EVENT_COMPLEX_ARGS");
}
@Test
void shouldNotIncludeSubclassesForInlineInstantiation() throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void handleStatus() {
service.process(new BaseEvent().getEvent());
}
}
class BaseEvent {
public String getEvent() { return "BASE_EVENT_RETURN"; }
}
class SubEvent extends BaseEvent {
public String getEvent() { return "SUB_EVENT_RETURN"; }
}
class OrderService {
public void process(String event) {}
}
""";
Path tempDir = Files.createTempDirectory("callgraph_inline_inst_subclass");
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallGraphBuilder builder = new CallGraphBuilder(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);
// Only BASE_EVENT_RETURN should be found. SUB_EVENT_RETURN is a subclass and should not be included for direct instantiations
org.assertj.core.api.Assertions.assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).containsExactlyInAnyOrder("BASE_EVENT_RETURN");
}
@Test
void shouldResolveConstantsFromFieldAssignedInConstructor() throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void handleStatus() {
service.process(new OrderPickedUpEvent().getType());
}
}
class OrderPickedUpEvent {
private String type;
public OrderPickedUpEvent() {
this.type = "PICKED_UP_IN_CONSTRUCTOR";
}
public String getType() { return type; }
}
class OrderService {
public void process(String event) {}
}
""";
Path tempDir = Files.createTempDirectory("callgraph_field_const");
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallGraphBuilder builder = new CallGraphBuilder(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("PICKED_UP_IN_CONSTRUCTOR");
}
@Test
void shouldResolveConstantsFromFieldAssignedInSuperConstructor() throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void handleStatus() {
service.process(new OrderCancelledEvent().getType());
}
}
abstract class BaseEvent {
private String type;
public BaseEvent(String type, String otherVar) {
this.type = type;
}
public String getType() { return type; }
}
class OrderCancelledEvent extends BaseEvent {
public OrderCancelledEvent() {
super("CANCELLED_IN_SUPER", "someVar");
}
}
class OrderService {
public void process(String event) {}
}
""";
Path tempDir = Files.createTempDirectory("callgraph_super_const");
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallGraphBuilder builder = new CallGraphBuilder(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()).contains("CANCELLED_IN_SUPER");
}
@Test
void shouldResolveConstantsFromFieldAssignedToAnotherConstantInConstructor() throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void handleStatus() {
service.process(new EventWithIndirectConstant().getType());
}
}
class EventConstants {
public static final String INDIRECT_EVENT = "INDIRECT_EVENT_VALUE";
}
class EventWithIndirectConstant {
private String type;
public EventWithIndirectConstant() {
this.type = EventConstants.INDIRECT_EVENT;
}
public String getType() { return type; }
}
class OrderService {
public void process(String event) {}
}
""";
Path tempDir = Files.createTempDirectory("callgraph_indirect_const");
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallGraphBuilder builder = new CallGraphBuilder(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()).contains("INDIRECT_EVENT_VALUE");
}
@Test
void shouldResolveConstantsFromConstructorInvocation() throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void handleStatus() {
service.process(new EventWithThisCall().getType());
}
}
class EventWithThisCall {
private String type;
public EventWithThisCall() {
this("THIS_CALL_CONSTANT");
}
public EventWithThisCall(String type) {
this.type = type;
}
public String getType() { return type; }
}
class OrderService {
public void process(String event) {}
}
""";
Path tempDir = Files.createTempDirectory("callgraph_this_const");
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallGraphBuilder builder = new CallGraphBuilder(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()).contains("THIS_CALL_CONSTANT");
}
@Test
void shouldResolveConstantsFromDynamicMethodAssignmentInConstructor() throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void handleStatus() {
service.process(new OrderCancelledEvent().getType());
}
}
class OrderCancelledEvent {
private String cancelEventType;
public OrderCancelledEvent() {
this.cancelEventType = assertMatchingCancelEventType("some_sender");
}
private String assertMatchingCancelEventType(String sender) {
return "CANCELLED_BY_SENDER";
}
public String getType() { return cancelEventType; }
}
class OrderService {
public void process(String event) {}
}
""";
Path tempDir = Files.createTempDirectory("callgraph_dynamic_rhs");
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallGraphBuilder builder = new CallGraphBuilder(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()).contains("CANCELLED_BY_SENDER");
}
@Test
void shouldResolveConstantsFromAbstractSuperclass() throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void handleStatus() {
service.process(new PostAcceptanceOrderModificationsRejectedEvent().getType());
}
}
abstract class AbstractPostAcceptanceOrderModificationsEvent {
private String event;
public AbstractPostAcceptanceOrderModificationsEvent(String event) {
this.event = event;
}
public String getType() { return event; }
}
class PostAcceptanceOrderModificationsRejectedEvent extends AbstractPostAcceptanceOrderModificationsEvent {
public PostAcceptanceOrderModificationsRejectedEvent() {
super("POST_ACCEPTANCE_REJECTED");
}
}
class OrderService {
public void process(String event) {}
}
""";
Path tempDir = Files.createTempDirectory("callgraph_post_acc");
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallGraphBuilder builder = new CallGraphBuilder(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()).contains("POST_ACCEPTANCE_REJECTED");
}
@Test
void shouldResolveConstantsFromFieldDeclarationsAndInitializers() throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void handleStatus1() {
service.process(new EventWithFieldDecl().getType());
}
public void handleStatus2() {
service.process(new EventWithInitBlock().getType());
}
}
class EventWithFieldDecl {
private String event = "FIELD_DECL";
public String getType() { return event; }
}
class EventWithInitBlock {
private String event;
{
event = "INIT_BLOCK";
}
public String getType() { return event; }
}
class OrderService {
public void process(String event) {}
}
""";
Path tempDir = Files.createTempDirectory("callgraph_field_init");
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallGraphBuilder builder = new CallGraphBuilder(context);
EntryPoint ep1 = EntryPoint.builder().className("com.example.OrderController").methodName("handleStatus1").build();
EntryPoint ep2 = EntryPoint.builder().className("com.example.OrderController").methodName("handleStatus2").build();
TriggerPoint trigger = TriggerPoint.builder().className("com.example.OrderService").methodName("process").event("event").build();
List<CallChain> chains = builder.findChains(List.of(ep1, ep2), List.of(trigger));
org.assertj.core.api.Assertions.assertThat(chains).hasSize(2);
java.util.List<String> allEvents = new java.util.ArrayList<>();
chains.forEach(c -> allEvents.addAll(c.getTriggerPoint().getPolymorphicEvents()));
org.assertj.core.api.Assertions.assertThat(allEvents).containsExactlyInAnyOrder("FIELD_DECL", "INIT_BLOCK");
}
@Test
void shouldFallbackToScraperForComplexInlineInstantiations() throws IOException {
String source = """
package com.example;
public class OrderController {
private OrderService service;
public void handleStatus() {
// The AST tracer will trace EventWithConstructorArg.getType()
// It will find `this.type = typeArg`, which it cannot resolve because it's a parameter.
// The fallback scraper should catch "COMPLEX_INLINE_CONST".
service.process(new EventWithConstructorArg("COMPLEX_INLINE_CONST", 123).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_scraper");
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallGraphBuilder builder = new CallGraphBuilder(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()).contains("COMPLEX_INLINE_CONST");
}
}