1 Commits

Author SHA1 Message Date
ff16c1bbe7 fix 6 2026-06-20 13:50:53 +02:00
28 changed files with 924 additions and 958 deletions

View File

@@ -14,16 +14,8 @@ import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.matching.EventMatchingEngine;
import click.kamil.springstatemachineexporter.analysis.enricher.matching.HeuristicEventMatchingEngine;
public class TransitionLinkerEnricher implements AnalysisEnricher {
private final BeanResolutionEngine routingEngine = new HeuristicBeanResolutionEngine();
private final EventMatchingEngine matchingEngine = new HeuristicEventMatchingEngine();
@Override
public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) {
if (result.getMetadata() == null || result.getMetadata().getCallChains() == null) {
@@ -46,10 +38,41 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
List<MatchedTransition> matched = new ArrayList<>();
for (Transition t : stateMachineTransitions) {
if (t.getEvent() != null && matchingEngine.matches(t.getEvent(), tp)) {
if (t.getEvent() != null) {
String smEventRaw = t.getEvent().fullIdentifier() != null ? t.getEvent().fullIdentifier() : t.getEvent().rawName();
// Event matches
for (State smSourceState : t.getSourceStates()) {
String smEvent = simplify(smEventRaw);
boolean isWildcard = triggerEvent.equals("event") || triggerEvent.equals("e") ||
triggerEvent.equals("msg") || triggerEvent.equals("message") ||
triggerEvent.equals("payload");
if (isWildcard) {
String targetVar = chain.getContextMachineId();
if (targetVar == null && chain.getTriggerPoint() != null) {
targetVar = chain.getTriggerPoint().getStateMachineId();
}
// We no longer hard-block wildcards without a specific routing context.
// If a project doesn't use standard SM persisters (e.g. restores state manually),
// contextMachineId will be null. We should still link the wildcard to provide SOME visibility,
// rather than completely hiding the endpoint.
}
List<String> polyEvents = tp.getPolymorphicEvents() != null ? tp.getPolymorphicEvents() : java.util.Collections.emptyList();
boolean hasPolyMatch = false;
for (String pe : polyEvents) {
String simplePe = pe;
if (pe.contains(".")) {
simplePe = pe.substring(pe.lastIndexOf('.') + 1);
}
if (simplePe.equals(smEventRaw) || simplePe.equals(smEvent)) {
hasPolyMatch = true;
break;
}
}
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();
String smSource = simplify(smSourceRaw);
if (triggerSource == null || triggerSource.equals(smSource)) {
@@ -79,6 +102,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
}
}
}
}
if (!matched.isEmpty()) {
CallChain newChain = chain.toBuilder().matchedTransitions(matched).build();
@@ -101,7 +125,177 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
private boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName) {
return routingEngine.isRoutedToCorrectMachine(chain, currentMachineName);
// First, check explicit variable targeting (like contextMachineId)
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();
// E.g., if target is "myStateMachine", ensure current machine name contains "my"
// 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;
}
}
}
// Generic approach combining Prefix, Package, and String-distance heuristics
// without relying on a hardcoded list of magic keywords.
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);
// 1. Explicit Prefix Match (e.g. ElectronicsOrderService & ElectronicsStateMachine)
if (smPrefix != null && chainPrefix != null && smPrefix.equals(chainPrefix)) {
hasPositiveMatch = true;
}
// 2. Explicit Sub-Package Match (e.g. com.company.electronics.service & com.company.electronics.sm)
// We consider it a match if they share a package segment beyond the first 2 (which are usually com.company)
String deepShared = getDeepSharedPackage(smPackage, chainPackage);
if (deepShared != null) {
hasPositiveMatch = true;
}
// Determine if there is a strong mismatch.
// A strong mismatch happens if the chain class has a VERY distinct prefix/package
// that contradicts the SM's distinct prefix/package.
// Without magic keywords, we guess if a prefix is distinct if it's long enough and doesn't match.
if (smPrefix != null && chainPrefix != null && !smPrefix.equals(chainPrefix)) {
// They have different first CamelCase words.
// This could be Electronics vs ComputerStore, or it could be Common vs ComputerStore.
// If they diverge at a deep package level, it's a strong mismatch.
if (isDeepPackageDivergence(smPackage, chainPackage)) {
hasStrongMismatch = true;
}
}
}
// If we found a positive structural match, route it!
if (hasPositiveMatch) {
return true;
}
// If there's no positive match, but we detected a strong structural mismatch, reject it!
if (hasStrongMismatch) {
return false;
}
// If neither, we allow it (fallback for common/shared endpoints without distinct domains)
}
return true;
}
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;
// e.g. "ElectronicsOrderService" -> "Electronics"
String[] words = simpleName.split("(?<!^)(?=[A-Z])");
if (words.length > 0) {
return words[0];
}
return null;
}
private String getDomainRootPackage(String[] p) {
if (p.length > 0 && (p[p.length - 1].equals("config") || p[p.length - 1].equals("configuration"))) {
return String.join(".", java.util.Arrays.copyOf(p, p.length - 1));
}
return String.join(".", p);
}
private String getDeepSharedPackage(String pkg1, String pkg2) {
if (pkg1 == null || pkg2 == null || pkg1.isEmpty() || pkg2.isEmpty()) return null;
String domain1 = getDomainRootPackage(pkg1.split("\\."));
String[] p1 = domain1.split("\\.");
String[] p2 = pkg2.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;
}
}
// Require matching up to the domain root
int requiredMatchIdx = Math.max(1, p1.length - 1);
if (matchIdx >= requiredMatchIdx) {
StringBuilder sb = new StringBuilder();
for(int i = 0; i <= matchIdx; i++) {
sb.append(p1[i]).append(".");
}
return sb.toString();
}
return null;
}
private boolean isDeepPackageDivergence(String pkg1, String pkg2) {
if (pkg1 == null || pkg2 == null || pkg1.isEmpty() || pkg2.isEmpty()) return false;
String domain1 = getDomainRootPackage(pkg1.split("\\."));
String[] p1 = domain1.split("\\.");
String[] p2 = pkg2.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;
}
}
// Dynamic threshold based on domain root
int requiredBaseMatchIdx = p1.length - 2;
if (requiredBaseMatchIdx >= 1 && matchIdx == requiredBaseMatchIdx) {
return p2.length > matchIdx + 1;
}
return false;
}
private String simplify(String name) {

View File

@@ -1,11 +0,0 @@
package click.kamil.springstatemachineexporter.analysis.enricher.matching;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.model.Event;
public interface EventMatchingEngine {
/**
* Determines whether the given trigger point is a match for the state machine event.
*/
boolean matches(Event stateMachineEvent, TriggerPoint triggerPoint);
}

View File

@@ -1,53 +0,0 @@
package click.kamil.springstatemachineexporter.analysis.enricher.matching;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.model.Event;
import java.util.List;
public class HeuristicEventMatchingEngine implements EventMatchingEngine {
@Override
public boolean matches(Event stateMachineEvent, TriggerPoint triggerPoint) {
if (stateMachineEvent == null || triggerPoint == null || triggerPoint.getEvent() == null) {
return false;
}
String triggerEvent = simplify(triggerPoint.getEvent());
String smEventRaw = stateMachineEvent.fullIdentifier() != null ? stateMachineEvent.fullIdentifier() : stateMachineEvent.rawName();
String smEvent = simplify(smEventRaw);
boolean isWildcard = isWildcardVariable(triggerEvent);
List<String> polyEvents = triggerPoint.getPolymorphicEvents() != null ? triggerPoint.getPolymorphicEvents() : java.util.Collections.emptyList();
boolean hasPolyMatch = false;
for (String pe : polyEvents) {
String simplePe = pe;
if (pe.contains(".")) {
simplePe = pe.substring(pe.lastIndexOf('.') + 1);
}
if (simplePe.equals(smEventRaw) || simplePe.equals(smEvent)) {
hasPolyMatch = true;
break;
}
}
return hasPolyMatch || smEvent.equals(triggerEvent) || (polyEvents.isEmpty() && isWildcard);
}
private boolean isWildcardVariable(String eventStr) {
return eventStr.equals("event") || eventStr.equals("e") ||
eventStr.equals("msg") || eventStr.equals("message") ||
eventStr.equals("payload");
}
private String simplify(String name) {
if (name == null) return null;
String simplified = name.replaceAll("(?i)(.*)(Event|Action|Transition|Command)(s)?$", "$1");
if (simplified.isEmpty()) {
simplified = name;
}
simplified = simplified.replaceAll("^.*\\.([A-Z0-9_]+)$", "$1");
return simplified;
}
}

View File

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

View File

@@ -1,188 +0,0 @@
package click.kamil.springstatemachineexporter.analysis.enricher.routing;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import java.util.HashSet;
import java.util.Set;
public class HeuristicBeanResolutionEngine implements BeanResolutionEngine {
@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

@@ -1,11 +0,0 @@
package click.kamil.springstatemachineexporter.analysis.model;
import lombok.Data;
import java.util.List;
@Data
public class CallEdge {
private final String targetMethod;
private final List<String> arguments;
}

View File

@@ -0,0 +1,18 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import lombok.Builder;
import lombok.Data;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import java.util.List;
@Data
@Builder
public class BeanDefinition {
private String name;
private String typeFqn;
private List<String> qualifiers;
private boolean isPrimary;
private TypeDeclaration declaringClass;
private MethodDeclaration declaringMethod;
}

View File

@@ -0,0 +1,84 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import lombok.RequiredArgsConstructor;
import org.eclipse.jdt.core.dom.*;
import java.util.ArrayList;
import java.util.List;
@RequiredArgsConstructor
public class BeanRegistry {
private final CodebaseContext context;
private final List<BeanDefinition> beans = new ArrayList<>();
public void register(BeanDefinition bean) {
beans.add(bean);
}
public List<BeanDefinition> getBeans() {
return beans;
}
public BeanDefinition resolve(String expectedTypeFqn, String expectedName, String expectedQualifier) {
System.out.println("RESOLVING: type=" + expectedTypeFqn + " name=" + expectedName + " qual=" + expectedQualifier);
// 1. Qualifier Match
if (expectedQualifier != null && !expectedQualifier.isEmpty()) {
for (BeanDefinition bean : beans) {
if (matchesType(bean.getTypeFqn(), expectedTypeFqn) && bean.getQualifiers().contains(expectedQualifier)) {
System.out.println(" -> Matched by qualifier: " + bean.getName());
return bean;
}
}
}
List<BeanDefinition> typeMatches = new ArrayList<>();
for (BeanDefinition bean : beans) {
System.out.println(" Checking bean: " + bean.getName() + " of type " + bean.getTypeFqn());
if (matchesType(bean.getTypeFqn(), expectedTypeFqn)) {
typeMatches.add(bean);
}
}
if (typeMatches.isEmpty()) {
System.out.println(" -> No type matches found");
return null;
}
if (typeMatches.size() == 1) {
System.out.println(" -> Single type match: " + typeMatches.get(0).getName());
return typeMatches.get(0);
}
// 2. Name Match
if (expectedName != null && !expectedName.isEmpty()) {
for (BeanDefinition bean : typeMatches) {
if (expectedName.equals(bean.getName())) {
System.out.println(" -> Matched by name: " + bean.getName());
return bean;
}
}
}
// 3. Primary Match
for (BeanDefinition bean : typeMatches) {
if (bean.isPrimary()) {
System.out.println(" -> Matched by primary: " + bean.getName());
return bean;
}
}
// 4. Fallback
System.out.println(" -> Fallback (first match): " + typeMatches.get(0).getName());
return typeMatches.get(0);
}
private boolean matchesType(String actualTypeFqn, String expectedTypeFqn) {
if (actualTypeFqn == null || expectedTypeFqn == null) return false;
if (actualTypeFqn.equals(expectedTypeFqn)) return true;
// Check if actualTypeFqn implements expectedTypeFqn
List<String> actualImpls = context.getImplementations(expectedTypeFqn);
return actualImpls.contains(actualTypeFqn);
}
}

View File

@@ -0,0 +1,171 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.*;
import java.util.ArrayList;
import java.util.List;
public class BeanScanner {
public static void scanAndRegister(CompilationUnit cu, CodebaseContext context, BeanRegistry registry) {
cu.accept(new ASTVisitor() {
@Override
public boolean visit(TypeDeclaration node) {
boolean isComponent = false;
boolean isPrimary = false;
String beanName = null;
List<String> qualifiers = new ArrayList<>();
for (Object modifier : node.modifiers()) {
if (modifier instanceof Annotation annotation) {
String typeName = annotation.getTypeName().getFullyQualifiedName();
if (typeName.endsWith("Component") || typeName.endsWith("Service") ||
typeName.endsWith("Repository") || typeName.endsWith("Controller") ||
typeName.endsWith("Configuration")) {
isComponent = true;
String value = extractAnnotationValue(annotation, "value");
if (!value.isEmpty()) {
beanName = value.replace("\"", "");
}
} else if (typeName.endsWith("Primary")) {
isPrimary = true;
} else if (typeName.endsWith("Qualifier")) {
String value = extractAnnotationValue(annotation, "value");
if (!value.isEmpty()) {
qualifiers.add(value.replace("\"", ""));
}
}
}
}
if (isComponent) {
if (beanName == null) {
beanName = decapitalize(node.getName().getIdentifier());
}
registry.register(BeanDefinition.builder()
.name(beanName)
.typeFqn(context.getFqn(node))
.qualifiers(qualifiers)
.isPrimary(isPrimary)
.declaringClass(node)
.build());
}
return super.visit(node);
}
@Override
public boolean visit(MethodDeclaration node) {
if (!(node.getParent() instanceof TypeDeclaration parentTd)) {
return super.visit(node);
}
boolean isBean = false;
boolean isPrimary = false;
String beanName = null;
List<String> qualifiers = new ArrayList<>();
for (Object modifier : node.modifiers()) {
if (modifier instanceof Annotation annotation) {
String typeName = annotation.getTypeName().getFullyQualifiedName();
if (typeName.endsWith("Bean")) {
isBean = true;
String value = extractAnnotationValue(annotation, "value");
if (value.isEmpty()) {
value = extractAnnotationValue(annotation, "name");
}
if (!value.isEmpty()) {
beanName = value.replace("\"", "");
}
} else if (typeName.endsWith("Primary")) {
isPrimary = true;
} else if (typeName.endsWith("Qualifier")) {
String value = extractAnnotationValue(annotation, "value");
if (!value.isEmpty()) {
qualifiers.add(value.replace("\"", ""));
}
}
}
}
if (isBean && node.getReturnType2() != null) {
if (beanName == null) {
beanName = node.getName().getIdentifier();
}
String returnType = extractTypeName(node.getReturnType2());
// We need the FQN of the return type. Try resolving it using context.
String returnTypeFqn = resolveFqn(returnType, parentTd, context);
if (returnTypeFqn == null) {
returnTypeFqn = returnType; // fallback
}
registry.register(BeanDefinition.builder()
.name(beanName)
.typeFqn(returnTypeFqn)
.qualifiers(qualifiers)
.isPrimary(isPrimary)
.declaringClass(parentTd)
.declaringMethod(node)
.build());
}
return super.visit(node);
}
});
}
private static String extractAnnotationValue(Annotation annotation, String memberName) {
if (annotation instanceof SingleMemberAnnotation sma) {
if ("value".equals(memberName) || "name".equals(memberName)) { // @Bean("name") uses SingleMemberAnnotation with value()
return sma.getValue().toString();
}
} else if (annotation instanceof NormalAnnotation na) {
for (Object pairObj : na.values()) {
MemberValuePair pair = (MemberValuePair) pairObj;
if (pair.getName().getIdentifier().equals(memberName)) {
return pair.getValue().toString();
}
}
}
return "";
}
private static String decapitalize(String name) {
if (name == null || name.isEmpty()) return name;
if (name.length() > 1 && Character.isUpperCase(name.charAt(1)) && Character.isUpperCase(name.charAt(0))) {
return name;
}
char[] chars = name.toCharArray();
chars[0] = Character.toLowerCase(chars[0]);
return new String(chars);
}
private static String extractTypeName(Type type) {
if (type.isSimpleType()) {
return ((SimpleType) type).getName().getFullyQualifiedName();
} else if (type.isParameterizedType()) {
return extractTypeName(((ParameterizedType) type).getType());
} else if (type.isArrayType()) {
return extractTypeName(((ArrayType) type).getElementType());
}
return type.toString();
}
private static String resolveFqn(String typeName, TypeDeclaration contextTd, CodebaseContext context) {
CompilationUnit cu = (CompilationUnit) contextTd.getRoot();
TypeDeclaration td = context.getTypeDeclaration(typeName, cu);
if (td != null) {
return context.getFqn(td);
}
// Fallback to imports
for (Object impObj : cu.imports()) {
ImportDeclaration imp = (ImportDeclaration) impObj;
String impName = imp.getName().getFullyQualifiedName();
if (impName.endsWith("." + typeName)) {
return impName;
}
}
return null;
}
}

View File

@@ -1,7 +1,6 @@
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;
@@ -12,13 +11,19 @@ import org.eclipse.jdt.core.dom.*;
import java.util.*;
@Slf4j
public class HeuristicCallGraphEngine implements CallGraphEngine {
public class CallGraphBuilder {
private final CodebaseContext context;
private final ConstantResolver constantResolver;
private String currentMethodFqn;
private Map<String, List<CallEdge>> graph;
public HeuristicCallGraphEngine(CodebaseContext context) {
@lombok.Data
private static class CallEdge {
private final String targetMethod;
private final List<String> arguments;
}
public CallGraphBuilder(CodebaseContext context) {
this.context = context;
this.constantResolver = new ConstantResolver();
}
@@ -535,9 +540,23 @@ public class HeuristicCallGraphEngine implements CallGraphEngine {
String calledMethod = fallbackTypeFqn + "." + emr.getName().getIdentifier();
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(calledMethod, args));
List<String> impls = context.getImplementations(fallbackTypeFqn);
for (String impl : impls) {
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(impl + "." + emr.getName().getIdentifier(), args));
String exactImpl = null;
if (emr.getExpression() instanceof SimpleName sn) {
String varName = sn.getIdentifier();
String qualifier = getQualifierForVariable(node, varName);
click.kamil.springstatemachineexporter.analysis.resolver.BeanDefinition bean = context.getBeanRegistry().resolve(fallbackTypeFqn, varName, qualifier);
if (bean != null) {
exactImpl = bean.getTypeFqn();
}
}
if (exactImpl != null) {
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(exactImpl + "." + emr.getName().getIdentifier(), args));
} else {
List<String> impls = context.getImplementations(fallbackTypeFqn);
for (String impl : impls) {
graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(impl + "." + emr.getName().getIdentifier(), args));
}
}
}
}
@@ -650,9 +669,24 @@ public class HeuristicCallGraphEngine implements CallGraphEngine {
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);
Expression receiver = node.getExpression();
String exactImpl = null;
if (receiver instanceof SimpleName sn) {
String varName = sn.getIdentifier();
String qualifier = getQualifierForVariable(node, varName);
click.kamil.springstatemachineexporter.analysis.resolver.BeanDefinition bean = context.getBeanRegistry().resolve(className, varName, qualifier);
if (bean != null) {
exactImpl = bean.getTypeFqn();
}
}
if (exactImpl != null) {
allResolved.add(exactImpl + "." + methodName);
} else {
List<String> impls = context.getImplementations(className);
for (String impl : impls) {
allResolved.add(impl + "." + methodName);
}
}
}
@@ -783,6 +817,53 @@ public class HeuristicCallGraphEngine implements CallGraphEngine {
return null;
}
private String getQualifierForVariable(ASTNode contextNode, String varName) {
MethodDeclaration enclosingMethod = findEnclosingMethod(contextNode);
if (enclosingMethod != null) {
for (Object paramObj : enclosingMethod.parameters()) {
SingleVariableDeclaration svd = (SingleVariableDeclaration) paramObj;
if (svd.getName().getIdentifier().equals(varName)) {
return extractQualifier(svd.modifiers());
}
}
}
TypeDeclaration enclosingType = findEnclosingType(contextNode);
if (enclosingType != null) {
for (FieldDeclaration field : enclosingType.getFields()) {
for (Object fragObj : field.fragments()) {
VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj;
if (frag.getName().getIdentifier().equals(varName)) {
return extractQualifier(field.modifiers());
}
}
}
}
return null;
}
private String extractQualifier(List<?> modifiers) {
for (Object mod : modifiers) {
if (mod instanceof Annotation ann) {
String name = ann.getTypeName().getFullyQualifiedName();
if (name.endsWith("Qualifier")) {
if (ann instanceof SingleMemberAnnotation sma) {
return sma.getValue().toString().replace("\"", "");
}
if (ann instanceof NormalAnnotation na) {
for (Object pairObj : na.values()) {
MemberValuePair pair = (MemberValuePair) pairObj;
if (pair.getName().getIdentifier().equals("value")) {
return pair.getValue().toString().replace("\"", "");
}
}
}
}
}
}
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
@@ -813,9 +894,19 @@ public class HeuristicCallGraphEngine implements CallGraphEngine {
}
private boolean isHeuristicMatch(String neighbor, String target) {
if (neighbor.equals(target)) return true;
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;
if (neighbor.endsWith("." + target)) return true;
boolean neighborHasClass = neighbor.contains(".");
boolean targetHasClass = target.contains(".");
if (neighborHasClass && targetHasClass) {
return false;
}
String simpleTarget = targetHasClass ? target.substring(target.lastIndexOf('.') + 1) : target;
String simpleNeighbor = neighborHasClass ? neighbor.substring(neighbor.lastIndexOf('.') + 1) : neighbor;
return simpleNeighbor.equals(simpleTarget);
}

View File

@@ -1,11 +0,0 @@
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 java.util.List;
public interface CallGraphEngine {
List<CallChain> findChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers);
}

View File

@@ -1,162 +0,0 @@
package click.kamil.springstatemachineexporter.analysis.service;
import lombok.extern.slf4j.Slf4j;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
@Slf4j
public class DynamicClasspathResolver {
public List<String> resolveClasspath(Path projectRoot) {
log.info("Attempting dynamic classpath resolution for project at {}", projectRoot);
if (projectRoot == null || !Files.exists(projectRoot)) {
return Collections.emptyList();
}
if (Files.exists(projectRoot.resolve("pom.xml"))) {
return resolveMaven(projectRoot);
} else if (Files.exists(projectRoot.resolve("build.gradle")) || Files.exists(projectRoot.resolve("build.gradle.kts"))) {
return resolveGradle(projectRoot);
}
log.info("No Maven or Gradle configuration found at {}. Proceeding without dynamic classpath.", projectRoot);
return Collections.emptyList();
}
protected List<String> resolveMaven(Path projectRoot) {
log.info("Maven project detected. Resolving dependencies...");
Path cpFile = null;
try {
cpFile = Files.createTempFile("state_machine_exporter_cp", ".txt");
String mvnCmd = getCommand(projectRoot, "mvnw", "mvn");
if (mvnCmd == null) {
log.warn("Maven executable not found.");
return Collections.emptyList();
}
ProcessBuilder pb = new ProcessBuilder(
mvnCmd,
"-q",
"dependency:build-classpath",
"-DincludeScope=compile",
"-Dmdep.outputFile=" + cpFile.toAbsolutePath().toString()
);
pb.directory(projectRoot.toFile());
runProcess(pb);
if (Files.exists(cpFile)) {
String cpString = Files.readString(cpFile).trim();
if (!cpString.isEmpty()) {
return Arrays.asList(cpString.split(File.pathSeparator));
}
}
} catch (Exception e) {
log.error("Failed to dynamically resolve Maven classpath", e);
} finally {
if (cpFile != null) {
try {
Files.deleteIfExists(cpFile);
} catch (IOException ignored) {}
}
}
return Collections.emptyList();
}
protected List<String> resolveGradle(Path projectRoot) {
log.info("Gradle project detected. Resolving dependencies...");
Path initScript = null;
try {
initScript = Files.createTempFile("state_machine_exporter_init", ".gradle");
String gradleCmd = getCommand(projectRoot, "gradlew", "gradle");
if (gradleCmd == null) {
log.warn("Gradle executable not found.");
return Collections.emptyList();
}
String scriptContent = """
allprojects {
task smePrintClasspath {
doLast {
def cp = []
if (configurations.findByName("compileClasspath")) {
cp = configurations.compileClasspath.files
}
println "SME_CLASSPATH_MARKER:" + cp.join(File.pathSeparator)
}
}
}
""";
Files.writeString(initScript, scriptContent);
ProcessBuilder pb = new ProcessBuilder(
gradleCmd,
"-q",
"-I", initScript.toAbsolutePath().toString(),
"smePrintClasspath"
);
pb.directory(projectRoot.toFile());
String output = runProcessAndGetOutput(pb);
for (String line : output.split("\\r?\\n")) {
if (line.startsWith("SME_CLASSPATH_MARKER:")) {
String cpString = line.substring("SME_CLASSPATH_MARKER:".length()).trim();
if (!cpString.isEmpty()) {
return Arrays.asList(cpString.split(File.pathSeparator));
}
}
}
} catch (Exception e) {
log.error("Failed to dynamically resolve Gradle classpath", e);
} finally {
if (initScript != null) {
try {
Files.deleteIfExists(initScript);
} catch (IOException ignored) {}
}
}
return Collections.emptyList();
}
private String getCommand(Path projectRoot, String wrapper, String systemBinary) {
Path wrapperUnix = projectRoot.resolve(wrapper);
Path wrapperWin = projectRoot.resolve(wrapper + ".bat");
boolean isWin = System.getProperty("os.name").toLowerCase().contains("win");
if (isWin && Files.exists(wrapperWin)) return wrapperWin.toAbsolutePath().toString();
if (!isWin && Files.exists(wrapperUnix)) {
// Need absolute path like ./gradlew
return wrapperUnix.toAbsolutePath().toString();
}
return systemBinary;
}
protected void runProcess(ProcessBuilder pb) throws IOException, InterruptedException {
Process p = pb.start();
p.waitFor();
}
protected String runProcessAndGetOutput(ProcessBuilder pb) throws IOException, InterruptedException {
Process p = pb.start();
StringBuilder out = new StringBuilder();
try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) {
String line;
while ((line = br.readLine()) != null) {
out.append(line).append("\n");
}
}
p.waitFor();
return out.toString();
}
}

View File

@@ -172,97 +172,40 @@ public class GenericEventDetector {
}
private String extractSourceState(ASTNode node) {
ASTNode current = node;
ASTNode current = node.getParent();
while (current != null && !(current instanceof MethodDeclaration)) {
ASTNode parent = current.getParent();
// 1. Direct parent is IfStatement wrapper (e.g., if (state == X) { sm.sendEvent() })
if (parent instanceof IfStatement ifStmt) {
// Only consider if our current node is in the 'then' or 'else' part, not the condition
if (ifStmt.getExpression() != current) {
Expression expr = ifStmt.getExpression();
String state = extractStateFromExpression(expr);
if (state != null) return state;
}
}
// 2. Parent is a Block or SwitchStatement: perform sibling traversal
if (parent instanceof Block block) {
String state = extractStateFromSiblings(current, block.statements());
if (current instanceof IfStatement ifStmt) {
Expression expr = ifStmt.getExpression();
String state = extractStateFromExpression(expr);
if (state != null) return state;
} else if (parent instanceof SwitchStatement switchStmt) {
String state = extractStateFromSiblings(current, switchStmt.statements());
if (state != null) return state;
}
current = parent;
}
return null;
}
private String extractStateFromSiblings(ASTNode currentNode, List<?> statements) {
int index = statements.indexOf(currentNode);
if (index <= 0) return null; // No previous siblings
// Walk backwards through siblings
for (int i = index - 1; i >= 0; i--) {
Object stmtObj = statements.get(i);
if (stmtObj instanceof SwitchCase switchCase) {
} else if (current instanceof SwitchCase switchCase) {
if (!switchCase.expressions().isEmpty()) {
return getSimpleNameString((Expression) switchCase.expressions().get(0));
}
} else if (stmtObj instanceof IfStatement ifStmt) {
// Guard clause detection: check if the 'then' block has a return/throw
if (isGuardClause(ifStmt.getThenStatement())) {
Expression expr = ifStmt.getExpression();
// If it's a guard clause, it usually checks `state != EXPECTED`, so the true state *is* EXPECTED
String state = extractStateFromExpression(expr);
if (state != null) return state;
}
} else if (current instanceof SwitchStatement switchStmt) {
// If it's a switch block but we haven't hit a SwitchCase directly (AST hierarchy usually has SwitchCase as siblings of block statements)
// Actually, walking up from the node, we will hit the SwitchStatement, but we need the SwitchCase right before us in the block.
ASTNode parentBlock = current;
// It's complex to walk siblings. A simpler heuristic is to just use IfStatement and SwitchCase (if wrapped correctly).
}
current = current.getParent();
}
return null;
}
private boolean isGuardClause(Statement thenStatement) {
if (thenStatement instanceof ReturnStatement || thenStatement instanceof ThrowStatement) {
return true;
}
if (thenStatement instanceof Block block) {
for (Object obj : block.statements()) {
if (obj instanceof ReturnStatement || obj instanceof ThrowStatement) {
return true;
}
}
}
return false;
}
private String extractStateFromExpression(Expression expr) {
if (expr instanceof InfixExpression infix) {
if (infix.getOperator() == InfixExpression.Operator.EQUALS || infix.getOperator() == InfixExpression.Operator.NOT_EQUALS) {
if (infix.getOperator() == InfixExpression.Operator.EQUALS) {
Expression left = infix.getLeftOperand();
Expression right = infix.getRightOperand();
// Ignore null checks entirely
if (left instanceof NullLiteral || right instanceof NullLiteral) {
return null;
}
// Usually one is a method call like getState() or a variable like `state`
// and the other is the constant enum like `OrderState.PENDING` or `"PENDING"`
// If one is a QualifiedName (enum constant) or StringLiteral, it's likely the state
if (left instanceof QualifiedName || left instanceof StringLiteral || left instanceof FieldAccess) {
// Usually one is a method call like getState(), the other is an enum
if (left instanceof QualifiedName || left instanceof SimpleName && !(right instanceof SimpleName)) {
return getSimpleNameString(left);
}
if (right instanceof QualifiedName || right instanceof StringLiteral || right instanceof FieldAccess) {
if (right instanceof QualifiedName || right instanceof SimpleName && !(left instanceof SimpleName)) {
return getSimpleNameString(right);
}
// Fallback
return getSimpleNameString(right);
return getSimpleNameString(right); // Fallback
}
} else if (expr instanceof MethodInvocation mi) {
if ("equals".equals(mi.getName().getIdentifier()) && !mi.arguments().isEmpty()) {

View File

@@ -21,7 +21,7 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
private final GenericEventDetector eventDetector;
private final SpringMvcDetector mvcDetector;
private final MessagingDetector messagingDetector;
private final CallGraphEngine callGraphEngine;
private final CallGraphBuilder callGraphBuilder;
private final LifecycleDetector lifecycleDetector;
private final InterceptorDetector interceptorDetector;
private final SpringComponentDetector componentDetector;
@@ -32,7 +32,7 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
this.eventDetector = new GenericEventDetector(context, constantResolver, context.getLibraryHints());
this.mvcDetector = new SpringMvcDetector(context, constantResolver);
this.messagingDetector = new MessagingDetector(context);
this.callGraphEngine = new HeuristicCallGraphEngine(context);
this.callGraphBuilder = new CallGraphBuilder(context);
this.lifecycleDetector = new LifecycleDetector(context);
this.interceptorDetector = new InterceptorDetector(context);
this.componentDetector = new SpringComponentDetector(context);
@@ -69,7 +69,7 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider {
@Override
public List<CallChain> findCallChains(List<EntryPoint> entryPoints, List<TriggerPoint> triggers) {
log.info("JdtIntelligenceProvider searching for call chains between {} entry points and {} triggers", entryPoints.size(), triggers.size());
return callGraphEngine.findChains(entryPoints, triggers);
return callGraphBuilder.findChains(entryPoints, triggers);
}
@Override

View File

@@ -9,6 +9,8 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.eclipse.jdt.core.JavaCore;
import org.eclipse.jdt.core.dom.*;
import click.kamil.springstatemachineexporter.analysis.resolver.BeanRegistry;
import click.kamil.springstatemachineexporter.analysis.resolver.BeanScanner;
import java.io.IOException;
import java.nio.file.Files;
@@ -31,6 +33,7 @@ public class CodebaseContext {
private final Set<String> ambiguousSimpleNames = new HashSet<>();
private final ConstantResolver constantResolver = new ConstantResolver();
private final PropertyResolver propertyResolver = new PropertyResolver();
private final BeanRegistry beanRegistry = new BeanRegistry(this);
private final List<String> activeProfiles = new ArrayList<>();
private Map<String, Map<String, String>> allProperties = new HashMap<>();
private List<LibraryHint> libraryHints = new ArrayList<>();
@@ -167,6 +170,10 @@ public class CodebaseContext {
scan(Set.of(rootDir), Collections.emptySet());
}
public BeanRegistry getBeanRegistry() {
return beanRegistry;
}
private final List<CompilationUnit> allCus = new ArrayList<>();
private final Map<String, List<String>> interfaceToImpls = new HashMap<>();
@@ -194,6 +201,11 @@ public class CodebaseContext {
}
}
}
// Post-processing: scan all parsed CompilationUnits for Spring beans
for (CompilationUnit cu : allCus) {
BeanScanner.scanAndRegister(cu, this, beanRegistry);
}
}
private void indexType(TypeDeclaration td, String parentFqn, Path javaFile) {
@@ -615,14 +627,31 @@ public class CodebaseContext {
}
}
// If it's a leaf class (not abstract) and extends StateMachineConfigurerAdapter indirectly
if (!isAbstract && extendsStateMachineConfigurerAdapter(td)) {
// If it's a leaf class (not abstract) and extends StateMachineConfigurerAdapter indirectly or has configurer methods
if (!isAbstract && (extendsStateMachineConfigurerAdapter(td) || hasConfigurerMethod(td))) {
return true;
}
return false;
}
private boolean hasConfigurerMethod(TypeDeclaration td) {
for (MethodDeclaration method : td.getMethods()) {
if (method.parameters().isEmpty()) continue;
for (Object paramObj : method.parameters()) {
if (paramObj instanceof SingleVariableDeclaration param) {
String paramType = param.getType().toString();
if (paramType.contains("StateMachineTransitionConfigurer") ||
paramType.contains("StateMachineStateConfigurer") ||
paramType.contains("StateMachineConfigurationConfigurer")) {
return true;
}
}
}
}
return false;
}
public boolean extendsStateMachineConfigurerAdapter(TypeDeclaration td) {
return extendsStateMachineConfigurerAdapter(td, new HashSet<>());
}

View File

@@ -57,9 +57,6 @@ public class ExporterCommand implements Callable<Integer> {
@Option(names = {"--debug"}, description = "Enable diagnostic mode to show unresolved call chains and detailed analysis logging.", defaultValue = "false")
private boolean debug;
@Option(names = {"--resolve-classpath"}, description = "Automatically run Maven/Gradle to resolve full classpath dependencies for perfect AST bindings.", defaultValue = "false")
private boolean resolveClasspath;
@Override
public Integer call() throws Exception {
var out = spec.commandLine().getOut();
@@ -90,7 +87,7 @@ public class ExporterCommand implements Callable<Integer> {
if (jsonFile != null) {
exportService.runJsonExporter(jsonFile, outputDir, selectedFormats, profiles, eventFormat, stateFormat);
} else {
exportService.runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, profiles, null, null, eventFormat, stateFormat, resolveClasspath);
exportService.runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, profiles, eventFormat, stateFormat);
}
out.println(CommandLine.Help.Ansi.AUTO.string("%n@|bold,green SUCCESS:|@ All state machines have been exported to " + outputDir.toAbsolutePath()));
return 0;

View File

@@ -53,22 +53,22 @@ public class ExportService {
public static final Set<String> STATE_MACHINE_RETURN_TYPES = Set.of("StateMachine", "StateMachineFactory", "StateMachineModelFactory");
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds) throws IOException {
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, Collections.emptyList(), null, null, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, false);
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, Collections.emptyList(), click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn);
}
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<String> activeProfiles) throws IOException {
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, null, null, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, false);
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, null, null, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn);
}
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<String> activeProfiles, Path flowsOverride, String machineFilter) throws IOException {
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, flowsOverride, machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, false);
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, flowsOverride, machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, click.kamil.springstatemachineexporter.exporter.EnumFormat.fn);
}
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<String> activeProfiles, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat) throws IOException {
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, null, null, eventFormat, stateFormat, false);
runExporter(inputDir, outputDir, selectedFormats, renderChoicesAsDiamonds, activeProfiles, null, null, eventFormat, stateFormat);
}
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<String> activeProfiles, Path flowsOverride, String machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat, boolean resolveClasspath) throws IOException {
public void runExporter(Path inputDir, Path outputDir, List<String> selectedFormats, boolean renderChoicesAsDiamonds, List<String> activeProfiles, Path flowsOverride, String machineFilter, click.kamil.springstatemachineexporter.exporter.EnumFormat eventFormat, click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat) throws IOException {
CodebaseContext context = new CodebaseContext();
context.setActiveProfiles(activeProfiles);
@@ -94,18 +94,6 @@ public class ExportService {
.collect(Collectors.toList());
log.info("Setting JDT sourcepath: {}", sourcepaths);
context.setSourcepath(sourcepaths);
if (resolveClasspath) {
click.kamil.springstatemachineexporter.analysis.service.DynamicClasspathResolver classpathResolver = new click.kamil.springstatemachineexporter.analysis.service.DynamicClasspathResolver();
List<String> dynamicClasspath = classpathResolver.resolveClasspath(projectRoot);
if (!dynamicClasspath.isEmpty()) {
context.setClasspath(dynamicClasspath);
log.info("Injected {} external JARs into JDT ASTParser", dynamicClasspath.size());
}
} else {
log.info("Dynamic classpath resolution disabled. Use --resolve-classpath to enable.");
}
context.setResolveBindings(true);
Path hintsFile = inputDir.resolve("hints.json");

View File

@@ -27,13 +27,18 @@ public class GoldenUpdater {
public static void main(String[] args) throws Exception {
List<TestScenario> scenarios = provideTestScenarios();
Path rootDir = Path.of("state_machine_exporter");
Path projectRoot = Path.of(System.getProperty("user.dir"));
if (projectRoot.endsWith("state_machine_exporter")) {
projectRoot = projectRoot.getParent();
}
Path rootDir = projectRoot.resolve("state_machine_exporter");
for (TestScenario scenario : scenarios) {
System.out.println("Updating golden files for: " + scenario.name());
Path tempDir = Files.createTempDirectory("golden-update-");
try {
exportService.runExporter(scenario.inputPath(), tempDir, List.of("puml", "dot", "scxml", "json"), true);
Path inputPath = projectRoot.resolve(scenario.inputPath());
exportService.runExporter(inputPath, tempDir, List.of("puml", "dot", "scxml", "json"), true, scenario.activeProfiles());
List<Path> generatedDirs;
try (var stream = Files.list(tempDir)) {
@@ -158,6 +163,49 @@ public class GoldenUpdater {
Path.of("state_machines/inheritance_extra_functions3_state_machine"),
Path.of("src/test/resources/golden/G2StateMachineConfiguration"),
"G2StateMachineConfiguration"
),
new TestScenario(
"Extended Analysis Sample",
Path.of("state_machines/extended_analysis_sample"),
Path.of("src/test/resources/golden/ExtendedStateMachineConfig"),
"ExtendedStateMachineConfig"
),
new TestScenario(
"Extended Analysis Sample (PROD)",
Path.of("state_machines/extended_analysis_sample"),
Path.of("src/test/resources/golden/ExtendedStateMachineConfig_PROD"),
"ExtendedStateMachineConfig",
List.of("prod")
),
new TestScenario(
"Inheritance Sample",
Path.of("state_machines/inheritance_sample"),
Path.of("src/test/resources/golden/InheritanceStateMachineConfig"),
"InheritanceStateMachineConfig"
),
new TestScenario(
"Enterprise Order System",
Path.of("state_machines/enterprise_order_system"),
Path.of("src/test/resources/golden/EnterpriseStateMachineConfig"),
"EnterpriseStateMachineConfig"
),
new TestScenario(
"Multi-Module Sample",
Path.of("state_machines/multi_module_sample/core-module"),
Path.of("src/test/resources/golden/OrderStateMachineConfig"),
"OrderStateMachineConfig"
),
new TestScenario(
"Maven Multi-Module Sample",
Path.of("state_machines/maven_multi_module/core-module"),
Path.of("src/test/resources/golden/MavenOrderStateMachine"),
"MavenOrderStateMachine"
),
new TestScenario(
"Complex Multi-Module Sample",
Path.of("state_machines/complex_multi_module_sm"),
Path.of("src/test/resources/golden/StateMachineConfig"),
"StateMachineConfig"
)
);
}

View File

@@ -4,7 +4,7 @@ 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.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.service.HeuristicCallGraphEngine;
import click.kamil.springstatemachineexporter.analysis.service.CallGraphBuilder;
import click.kamil.springstatemachineexporter.analysis.service.GenericEventDetector;
import click.kamil.springstatemachineexporter.analysis.service.SpringMvcDetector;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
@@ -76,7 +76,7 @@ public class InheritedEventDetectionTest {
assertThat(tp.getEvent()).isEqualTo("event");
// Build Call Chains
HeuristicCallGraphEngine callGraphBuilder = new HeuristicCallGraphEngine(context);
CallGraphBuilder callGraphBuilder = new CallGraphBuilder(context);
List<CallChain> chains = callGraphBuilder.findChains(entryPoints, triggers);
assertThat(chains).describedAs("Should link ChildController.trigger to BaseController.send").hasSize(1);

View File

@@ -145,36 +145,6 @@ class TransitionLinkerEnricherTest {
@Test
void shouldMatchBaseEventDespiteScrapedPolyEvents() {
Transition t1 = new Transition();
t1.setSourceStates(List.of(State.of("NEW", "NEW")));
t1.setTargetStates(List.of(State.of("PAID", "PAID")));
// State machine uses the base event generically
t1.setEvent(Event.of("BaseEvent", "BaseEvent"));
// Trigger point uses "BaseEvent", but polyEvents scraped some implementation/constants
CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder()
.event("BaseEvent")
.polymorphicEvents(List.of("EVENT_A", "EVENT_B"))
.build())
.contextMachineId("testMachine")
.build();
AnalysisResult result = AnalysisResult.builder()
.name("testMachine")
.transitions(List.of(t1))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
// It should match because smEvent matches triggerEvent directly, overriding the failed poly search
CallChain updatedChain = result.getMetadata().getCallChains().get(0);
assertThat(updatedChain.getMatchedTransitions()).hasSize(1);
}
@Test
void shouldNotMatchArbitraryGetXMethodWildcard() {
Transition t1 = new Transition();

View File

@@ -1,103 +0,0 @@
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 HeuristicBeanResolutionEngineTest {
private final HeuristicBeanResolutionEngine engine = new HeuristicBeanResolutionEngine();
@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

@@ -0,0 +1,123 @@
package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class BeanRegistryTest {
private TestCodebaseContext context;
private BeanRegistry registry;
static class TestCodebaseContext extends CodebaseContext {
private final Map<String, List<String>> impls = new HashMap<>();
public void setImpls(String fqn, List<String> implList) {
impls.put(fqn, implList);
}
@Override
public List<String> getImplementations(String fqn) {
return impls.getOrDefault(fqn, List.of());
}
}
@BeforeEach
void setUp() {
context = new TestCodebaseContext();
registry = new BeanRegistry(context);
}
@Test
void resolve_shouldMatchByExactType() {
registry.register(BeanDefinition.builder()
.name("paymentService")
.typeFqn("com.example.PaymentService")
.qualifiers(List.of())
.isPrimary(false)
.build());
BeanDefinition resolved = registry.resolve("com.example.PaymentService", null, null);
assertThat(resolved).isNotNull();
assertThat(resolved.getName()).isEqualTo("paymentService");
}
@Test
void resolve_shouldMatchByQualifierBypassingNameAndType() {
registry.register(BeanDefinition.builder()
.name("emailNotifier")
.typeFqn("com.example.EmailNotifier")
.qualifiers(List.of("email"))
.isPrimary(false)
.build());
registry.register(BeanDefinition.builder()
.name("smsNotifier")
.typeFqn("com.example.SmsNotifier")
.qualifiers(List.of("sms"))
.isPrimary(false)
.build());
context.setImpls("com.example.Notifier", List.of("com.example.EmailNotifier", "com.example.SmsNotifier"));
BeanDefinition resolved = registry.resolve("com.example.Notifier", "emailNotifier", "sms");
assertThat(resolved).isNotNull();
assertThat(resolved.getName()).isEqualTo("smsNotifier");
}
@Test
void resolve_shouldMatchByNameWhenNoQualifier() {
registry.register(BeanDefinition.builder()
.name("fastProcessor")
.typeFqn("com.example.FastProcessor")
.qualifiers(List.of())
.isPrimary(false)
.build());
registry.register(BeanDefinition.builder()
.name("slowProcessor")
.typeFqn("com.example.SlowProcessor")
.qualifiers(List.of())
.isPrimary(false)
.build());
context.setImpls("com.example.Processor", List.of("com.example.FastProcessor", "com.example.SlowProcessor"));
BeanDefinition resolved = registry.resolve("com.example.Processor", "fastProcessor", null);
assertThat(resolved).isNotNull();
assertThat(resolved.getName()).isEqualTo("fastProcessor");
}
@Test
void resolve_shouldMatchByPrimary() {
registry.register(BeanDefinition.builder()
.name("localStorage")
.typeFqn("com.example.LocalStorage")
.qualifiers(List.of())
.isPrimary(false)
.build());
registry.register(BeanDefinition.builder()
.name("cloudStorage")
.typeFqn("com.example.CloudStorage")
.qualifiers(List.of())
.isPrimary(true)
.build());
context.setImpls("com.example.Storage", List.of("com.example.LocalStorage", "com.example.CloudStorage"));
// Name doesn't match either, no qualifier
BeanDefinition resolved = registry.resolve("com.example.Storage", "storage", null);
assertThat(resolved).isNotNull();
assertThat(resolved.getName()).isEqualTo("cloudStorage");
}
}

View File

@@ -12,11 +12,9 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.Tag;
import static org.assertj.core.api.Assertions.assertThat;
@Tag("heuristic_guess_paths_and_scrape_regex_replace_with_data_flow_analysis")
class HeuristicCallGraphEngineTest {
class CallGraphBuilderTest {
@Test
void shouldFindCallChainWithLambdaMethodReference(@TempDir Path tempDir) throws IOException {
@@ -45,7 +43,7 @@ class HeuristicCallGraphEngineTest {
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
CallGraphBuilder builder = new CallGraphBuilder(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.OrderService")
@@ -96,7 +94,7 @@ class HeuristicCallGraphEngineTest {
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
CallGraphBuilder builder = new CallGraphBuilder(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.PersisterService")
@@ -148,7 +146,7 @@ class HeuristicCallGraphEngineTest {
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
CallGraphBuilder builder = new CallGraphBuilder(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.OrderController")
@@ -202,7 +200,7 @@ class HeuristicCallGraphEngineTest {
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
CallGraphBuilder builder = new CallGraphBuilder(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.OrderController")
@@ -270,7 +268,7 @@ class HeuristicCallGraphEngineTest {
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
CallGraphBuilder builder = new CallGraphBuilder(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.OrderController")
@@ -324,7 +322,7 @@ class HeuristicCallGraphEngineTest {
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
CallGraphBuilder builder = new CallGraphBuilder(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.OrderController")
@@ -394,7 +392,7 @@ class HeuristicCallGraphEngineTest {
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
CallGraphBuilder builder = new CallGraphBuilder(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.OrderController")
@@ -440,7 +438,7 @@ class HeuristicCallGraphEngineTest {
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
CallGraphBuilder builder = new CallGraphBuilder(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.OrderController")
@@ -491,7 +489,7 @@ class HeuristicCallGraphEngineTest {
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
CallGraphBuilder builder = new CallGraphBuilder(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.OrderController")
@@ -549,7 +547,7 @@ class HeuristicCallGraphEngineTest {
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
CallGraphBuilder builder = new CallGraphBuilder(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.OrderController")
@@ -599,7 +597,7 @@ class HeuristicCallGraphEngineTest {
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
CallGraphBuilder builder = new CallGraphBuilder(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.OrderController")
@@ -652,7 +650,7 @@ class HeuristicCallGraphEngineTest {
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
CallGraphBuilder builder = new CallGraphBuilder(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.OrderController")
@@ -697,7 +695,7 @@ class HeuristicCallGraphEngineTest {
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
CallGraphBuilder builder = new CallGraphBuilder(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.OrderController")
@@ -749,7 +747,7 @@ class HeuristicCallGraphEngineTest {
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
CallGraphBuilder builder = new CallGraphBuilder(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.OrderController")
@@ -795,7 +793,7 @@ class HeuristicCallGraphEngineTest {
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
CallGraphBuilder builder = new CallGraphBuilder(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.OrderController")
@@ -839,7 +837,7 @@ class HeuristicCallGraphEngineTest {
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
CallGraphBuilder builder = new CallGraphBuilder(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.OrderController")
@@ -889,7 +887,7 @@ class HeuristicCallGraphEngineTest {
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
CallGraphBuilder builder = new CallGraphBuilder(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.OrderController")
@@ -935,7 +933,7 @@ class HeuristicCallGraphEngineTest {
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
CallGraphBuilder builder = new CallGraphBuilder(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.OrderController")
@@ -986,7 +984,7 @@ class HeuristicCallGraphEngineTest {
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
CallGraphBuilder builder = new CallGraphBuilder(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();
@@ -1019,7 +1017,7 @@ class HeuristicCallGraphEngineTest {
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
CallGraphBuilder builder = new CallGraphBuilder(context);
EntryPoint entryPoint = EntryPoint.builder().className("com.example.OrderController").methodName("createOrder").build();
TriggerPoint trigger = TriggerPoint.builder().className("com.example.OrderService").methodName("processOrderEvent").event("event").build();
@@ -1075,7 +1073,7 @@ class HeuristicCallGraphEngineTest {
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
CallGraphBuilder builder = new CallGraphBuilder(context);
EntryPoint entryPoint = EntryPoint.builder().className("com.example.controller.EventController").methodName("handleEvent").build();
TriggerPoint trigger = TriggerPoint.builder().className("com.example.service.EventService").methodName("process").event("type").build();
@@ -1084,7 +1082,6 @@ class HeuristicCallGraphEngineTest {
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).containsExactlyInAnyOrder("EVENT_A", "EVENT_B");
}
@Test
void shouldResolveConstantsFromSwitchStatementInMethod() throws IOException {
String source = """
@@ -1117,7 +1114,7 @@ class HeuristicCallGraphEngineTest {
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
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();
@@ -1151,7 +1148,7 @@ class HeuristicCallGraphEngineTest {
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
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();
@@ -1183,7 +1180,7 @@ class HeuristicCallGraphEngineTest {
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
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();
@@ -1220,7 +1217,7 @@ class HeuristicCallGraphEngineTest {
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
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();
@@ -1257,7 +1254,7 @@ class HeuristicCallGraphEngineTest {
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
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();
@@ -1292,7 +1289,7 @@ class HeuristicCallGraphEngineTest {
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
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();
@@ -1329,7 +1326,7 @@ class HeuristicCallGraphEngineTest {
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
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();
@@ -1363,7 +1360,7 @@ class HeuristicCallGraphEngineTest {
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
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();
@@ -1399,7 +1396,7 @@ class HeuristicCallGraphEngineTest {
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
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();
@@ -1433,7 +1430,7 @@ class HeuristicCallGraphEngineTest {
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
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();
@@ -1471,7 +1468,7 @@ class HeuristicCallGraphEngineTest {
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
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();
@@ -1512,7 +1509,7 @@ class HeuristicCallGraphEngineTest {
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
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();
@@ -1550,7 +1547,7 @@ class HeuristicCallGraphEngineTest {
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
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();
@@ -1589,7 +1586,7 @@ class HeuristicCallGraphEngineTest {
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
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();
@@ -1632,7 +1629,7 @@ class HeuristicCallGraphEngineTest {
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
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();
@@ -1674,7 +1671,7 @@ class HeuristicCallGraphEngineTest {
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
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();
@@ -1715,7 +1712,7 @@ class HeuristicCallGraphEngineTest {
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
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();
@@ -1756,7 +1753,7 @@ class HeuristicCallGraphEngineTest {
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
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();
@@ -1799,7 +1796,7 @@ class HeuristicCallGraphEngineTest {
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
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();
@@ -1844,7 +1841,7 @@ class HeuristicCallGraphEngineTest {
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
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();
@@ -1889,7 +1886,7 @@ class HeuristicCallGraphEngineTest {
Files.writeString(tempDir.resolve("OrderConfig.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
HeuristicCallGraphEngine builder = new HeuristicCallGraphEngine(context);
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();
@@ -1899,4 +1896,73 @@ class HeuristicCallGraphEngineTest {
org.assertj.core.api.Assertions.assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).contains("COMPLEX_INLINE_CONST");
}
@Test
void shouldNarrowPolymorphicCallUsingBeanResolution(@TempDir Path tempDir) throws IOException {
String source = """
package com.example;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Qualifier;
public interface Notifier {
void send(String event);
}
@Component("emailNotifier")
class EmailNotifier implements Notifier {
public void send(String event) {
// Email implementation
}
}
@Component("smsNotifier")
class SmsNotifier implements Notifier {
public void send(String event) {
// SMS implementation
}
}
@Component
public class OrderService {
private final Notifier smsNotifier;
public OrderService(Notifier smsNotifier) {
this.smsNotifier = smsNotifier;
}
public void processOrder() {
smsNotifier.send("ORDER_COMPLETED");
}
}
""";
Files.writeString(tempDir.resolve("OrderService.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
CallGraphBuilder builder = new CallGraphBuilder(context);
EntryPoint entryPoint = EntryPoint.builder()
.className("com.example.OrderService")
.methodName("processOrder")
.build();
TriggerPoint triggerEmail = TriggerPoint.builder()
.className("com.example.EmailNotifier")
.methodName("send")
.event("event")
.build();
TriggerPoint triggerSms = TriggerPoint.builder()
.className("com.example.SmsNotifier")
.methodName("send")
.event("event")
.build();
List<CallChain> chains = builder.findChains(List.of(entryPoint), List.of(triggerEmail, triggerSms));
// Because 'smsNotifier' perfectly matches the component name "smsNotifier",
// the bean resolution should narrow the call graph to ONLY SmsNotifier.
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getTriggerPoint().getClassName()).isEqualTo("com.example.SmsNotifier");
}
}

View File

@@ -1,78 +0,0 @@
package click.kamil.springstatemachineexporter.analysis.service;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.File;
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;
@Tag("dynamic_classpath_resolver")
class DynamicClasspathResolverTest {
@Test
void shouldResolveMavenClasspathDynamically(@TempDir Path tempDir) throws IOException {
// Create dummy pom.xml
Files.createFile(tempDir.resolve("pom.xml"));
// Mock the resolver so it doesn't actually run maven, but writes the expected output file
DynamicClasspathResolver resolver = new DynamicClasspathResolver() {
@Override
protected void runProcess(ProcessBuilder pb) throws IOException {
// Pretend maven executed and generated the cp.txt
String cp = "/fake/m2/repo/spring-core.jar" + File.pathSeparator + "/fake/m2/repo/spring-context.jar";
String outputFileArg = pb.command().stream()
.filter(arg -> arg.startsWith("-Dmdep.outputFile="))
.findFirst()
.orElseThrow();
Path outputFile = Path.of(outputFileArg.substring("-Dmdep.outputFile=".length()));
Files.writeString(outputFile, cp);
}
};
List<String> classpath = resolver.resolveClasspath(tempDir);
assertThat(classpath).containsExactly(
"/fake/m2/repo/spring-core.jar",
"/fake/m2/repo/spring-context.jar"
);
}
@Test
void shouldResolveGradleClasspathDynamically(@TempDir Path tempDir) throws IOException {
// Create dummy build.gradle
Files.createFile(tempDir.resolve("build.gradle"));
DynamicClasspathResolver resolver = new DynamicClasspathResolver() {
@Override
protected String runProcessAndGetOutput(ProcessBuilder pb) {
// Pretend gradle executed and printed the marker
String cp = "/fake/gradle/caches/spring-core.jar" + File.pathSeparator + "/fake/gradle/caches/spring-context.jar";
return "Some noisy gradle output\n" +
"SME_CLASSPATH_MARKER:" + cp + "\n" +
"BUILD SUCCESSFUL\n";
}
};
List<String> classpath = resolver.resolveClasspath(tempDir);
assertThat(classpath).containsExactly(
"/fake/gradle/caches/spring-core.jar",
"/fake/gradle/caches/spring-context.jar"
);
}
@Test
void shouldReturnEmptyListIfNoBuildFileFound(@TempDir Path tempDir) {
DynamicClasspathResolver resolver = new DynamicClasspathResolver();
List<String> classpath = resolver.resolveClasspath(tempDir);
assertThat(classpath).isEmpty();
}
}

View File

@@ -1,132 +0,0 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.Tag;
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.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@Tag("control_flow_sibling_avoidance")
class GenericEventDetectorControlFlowTest {
@Test
void shouldDetectStateFromDirectIfWrapper(@TempDir Path tempDir) throws IOException {
String source = """
package com.example;
public class OrderService {
private StateMachine sm;
public void processOrder(OrderState state) {
if (state == OrderState.PENDING) {
sm.sendEvent(OrderEvent.PAY);
}
}
}
class StateMachine {
public void sendEvent(OrderEvent e) {}
}
enum OrderState { PENDING }
enum OrderEvent { PAY }
""";
Files.writeString(tempDir.resolve("OrderService.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
GenericEventDetector detector = new GenericEventDetector(context, new ConstantResolver(), Collections.emptyList());
List<TriggerPoint> triggers = detector.detect((org.eclipse.jdt.core.dom.CompilationUnit) context.getTypeDeclaration("com.example.OrderService").getRoot());
assertThat(triggers).hasSize(1);
TriggerPoint tp = triggers.get(0);
assertThat(tp.getEvent()).isEqualTo("OrderEvent.PAY");
assertThat(tp.getSourceState()).isEqualTo("PENDING");
}
@Test
void shouldDetectStateFromGuardClauseSibling(@TempDir Path tempDir) throws IOException {
String source = """
package com.example;
public class OrderService {
private StateMachine sm;
public void processOrder(OrderState state) {
if (state != OrderState.PENDING) {
return;
}
sm.sendEvent(OrderEvent.PAY);
}
}
class StateMachine {
public void sendEvent(OrderEvent e) {}
}
enum OrderState { PENDING }
enum OrderEvent { PAY }
""";
Files.writeString(tempDir.resolve("OrderService.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
GenericEventDetector detector = new GenericEventDetector(context, new ConstantResolver(), Collections.emptyList());
List<TriggerPoint> triggers = detector.detect((org.eclipse.jdt.core.dom.CompilationUnit) context.getTypeDeclaration("com.example.OrderService").getRoot());
assertThat(triggers).hasSize(1);
TriggerPoint tp = triggers.get(0);
assertThat(tp.getEvent()).isEqualTo("OrderEvent.PAY");
assertThat(tp.getSourceState()).isEqualTo("PENDING");
}
@Test
void shouldDetectStateFromSwitchCaseSibling(@TempDir Path tempDir) throws IOException {
String source = """
package com.example;
public class OrderService {
private StateMachine sm;
public void processOrder(OrderState state) {
switch (state) {
case PENDING:
sm.sendEvent(OrderEvent.PAY);
break;
case PAID:
sm.sendEvent(OrderEvent.SHIP);
break;
}
}
}
class StateMachine {
public void sendEvent(OrderEvent e) {}
}
enum OrderState { PENDING, PAID }
enum OrderEvent { PAY, SHIP }
""";
Files.writeString(tempDir.resolve("OrderService.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
GenericEventDetector detector = new GenericEventDetector(context, new ConstantResolver(), Collections.emptyList());
List<TriggerPoint> triggers = detector.detect((org.eclipse.jdt.core.dom.CompilationUnit) context.getTypeDeclaration("com.example.OrderService").getRoot());
assertThat(triggers).hasSize(2);
TriggerPoint payTrigger = triggers.stream().filter(t -> t.getEvent().equals("OrderEvent.PAY")).findFirst().get();
assertThat(payTrigger.getSourceState()).isEqualTo("PENDING");
TriggerPoint shipTrigger = triggers.stream().filter(t -> t.getEvent().equals("OrderEvent.SHIP")).findFirst().get();
assertThat(shipTrigger.getSourceState()).isEqualTo("PAID");
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB