diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java index 1163428..0440498 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java @@ -239,12 +239,20 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { 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[] p1 = pkg1.split("\\."); + + String domain1 = getDomainRootPackage(pkg1.split("\\.")); + String[] p1 = domain1.split("\\."); String[] p2 = pkg2.split("\\."); - // Find the deepest common segment index int matchIdx = -1; for (int i = 0; i < Math.min(p1.length, p2.length); i++) { if (p1[i].equals(p2[i])) { @@ -253,8 +261,10 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { break; } } - // If they share at least 3 segments (e.g. com.company.electronics), it's a deep match - if (matchIdx >= 2) { + + // 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("."); @@ -266,13 +276,24 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { private boolean isDeepPackageDivergence(String pkg1, String pkg2) { if (pkg1 == null || pkg2 == null || pkg1.isEmpty() || pkg2.isEmpty()) return false; - String[] p1 = pkg1.split("\\."); + + String domain1 = getDomainRootPackage(pkg1.split("\\.")); + String[] p1 = domain1.split("\\."); String[] p2 = pkg2.split("\\."); - // If they share exactly the root (com.company) but diverge at the 3rd segment (e.g. electronics vs computerstore) - // Then it's a divergence. - if (p1.length >= 3 && p2.length >= 3) { - return p1[0].equals(p2[0]) && p1[1].equals(p2[1]) && !p1[2].equals(p2[2]); + 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; } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BeanDefinition.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BeanDefinition.java new file mode 100644 index 0000000..e75a80a --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BeanDefinition.java @@ -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 qualifiers; + private boolean isPrimary; + private TypeDeclaration declaringClass; + private MethodDeclaration declaringMethod; +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BeanRegistry.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BeanRegistry.java new file mode 100644 index 0000000..b5f5bdd --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BeanRegistry.java @@ -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 beans = new ArrayList<>(); + + public void register(BeanDefinition bean) { + beans.add(bean); + } + + public List 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 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 actualImpls = context.getImplementations(expectedTypeFqn); + return actualImpls.contains(actualTypeFqn); + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BeanScanner.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BeanScanner.java new file mode 100644 index 0000000..5abb11b --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BeanScanner.java @@ -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 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 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; + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilder.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilder.java index 9f99830..8904b41 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilder.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilder.java @@ -540,9 +540,23 @@ public class CallGraphBuilder { String calledMethod = fallbackTypeFqn + "." + emr.getName().getIdentifier(); graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(calledMethod, args)); - List 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 impls = context.getImplementations(fallbackTypeFqn); + for (String impl : impls) { + graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(impl + "." + emr.getName().getIdentifier(), args)); + } } } } @@ -655,9 +669,24 @@ public class CallGraphBuilder { String className = baseCalled.substring(0, baseCalled.lastIndexOf('.')); String methodName = baseCalled.substring(baseCalled.lastIndexOf('.') + 1); - List 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 impls = context.getImplementations(className); + for (String impl : impls) { + allResolved.add(impl + "." + methodName); + } } } @@ -788,6 +817,53 @@ public class CallGraphBuilder { 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 findPath(String start, String target, Map> graph, Set visited) { if (start.equals(target)) return new ArrayList<>(List.of(start)); if (!visited.add(start)) return null; // Path-scoped cycle detection @@ -818,9 +894,19 @@ public class CallGraphBuilder { } 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); } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java index 4a8557d..44a998b 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java @@ -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 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 activeProfiles = new ArrayList<>(); private Map> allProperties = new HashMap<>(); private List libraryHints = new ArrayList<>(); @@ -167,6 +170,10 @@ public class CodebaseContext { scan(Set.of(rootDir), Collections.emptySet()); } + public BeanRegistry getBeanRegistry() { + return beanRegistry; + } + private final List allCus = new ArrayList<>(); private final Map> 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<>()); } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/GoldenUpdater.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/GoldenUpdater.java index f789b06..23740f4 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/GoldenUpdater.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/GoldenUpdater.java @@ -27,13 +27,18 @@ public class GoldenUpdater { public static void main(String[] args) throws Exception { List 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 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" ) ); } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BeanRegistryTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BeanRegistryTest.java new file mode 100644 index 0000000..a7da50c --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BeanRegistryTest.java @@ -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> impls = new HashMap<>(); + + public void setImpls(String fqn, List implList) { + impls.put(fqn, implList); + } + + @Override + public List 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"); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilderTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilderTest.java index 6e651af..76892e0 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilderTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilderTest.java @@ -1081,7 +1081,6 @@ class CallGraphBuilderTest { assertThat(chains).hasSize(1); assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).containsExactlyInAnyOrder("EVENT_A", "EVENT_B"); } - @Test void shouldResolveConstantsFromSwitchStatementInMethod() throws IOException { @@ -1897,4 +1896,73 @@ class CallGraphBuilderTest { 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 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"); + } } \ No newline at end of file diff --git a/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/MavenOrderStateMachine.png b/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/MavenOrderStateMachine.png new file mode 100644 index 0000000..0fc8768 Binary files /dev/null and b/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/MavenOrderStateMachine.png differ diff --git a/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/OrderStateMachineConfig.png b/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/OrderStateMachineConfig.png new file mode 100644 index 0000000..ba58e58 Binary files /dev/null and b/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfig/OrderStateMachineConfig.png differ diff --git a/state_machine_exporter/src/test/resources/golden/StateMachineConfig/StateMachineConfig.png b/state_machine_exporter/src/test/resources/golden/StateMachineConfig/StateMachineConfig.png new file mode 100644 index 0000000..66c98a8 Binary files /dev/null and b/state_machine_exporter/src/test/resources/golden/StateMachineConfig/StateMachineConfig.png differ