diff --git a/state_machine_exporter/build.gradle b/state_machine_exporter/build.gradle index 7ce8ba5..2d93cfd 100644 --- a/state_machine_exporter/build.gradle +++ b/state_machine_exporter/build.gradle @@ -36,6 +36,7 @@ dependencies { implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1' implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jdk8:2.17.1' implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.1' + implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.17.1' compileOnly 'org.projectlombok:lombok:1.18.46' annotationProcessor 'org.projectlombok:lombok:1.18.46' diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/Main.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/Main.java index 6609459..064886a 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/Main.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/Main.java @@ -14,6 +14,14 @@ import java.util.List; public class Main { public static void main(String[] args) { + // Enable diagnostic mode early before SLF4J initializes + for (String arg : args) { + if ("--debug".equals(arg)) { + System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "debug"); + break; + } + } + // Manual DI / Wiring var exporters = List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter()); var exportService = new ExportService(exporters); 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 95cc118..059e06e 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 @@ -41,8 +41,13 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { if (t.getEvent() != null) { String smEventRaw = t.getEvent().fullIdentifier() != null ? t.getEvent().fullIdentifier() : t.getEvent().rawName(); String smEvent = simplify(smEventRaw); - if (smEvent.equals(triggerEvent)) { - // Event matches. Check source state if provided + + boolean isWildcard = triggerEvent.equals("event") || triggerEvent.equals("e") || + triggerEvent.equals("msg") || triggerEvent.equals("message") || + triggerEvent.equals("payload") || triggerEvent.matches(".*\\.get[A-Z].*\\(\\)"); + + if (isWildcard || smEvent.equals(triggerEvent) || triggerEvent.toLowerCase().contains(smEvent.toLowerCase()) || smEvent.toLowerCase().contains(triggerEvent.toLowerCase())) { + // Event matches or is a wildcard for (State smSourceState : t.getSourceStates()) { String smSourceRaw = smSourceState.fullIdentifier() != null ? smSourceState.fullIdentifier() : smSourceState.rawName(); String smSource = simplify(smSourceRaw); @@ -50,11 +55,14 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { for (State smTargetState : t.getTargetStates()) { String sourceRaw = smSourceState.fullIdentifier() != null ? smSourceState.fullIdentifier() : smSourceState.rawName(); String targetRaw = smTargetState.fullIdentifier() != null ? smTargetState.fullIdentifier() : smTargetState.rawName(); - matched.add(MatchedTransition.builder() + MatchedTransition mt = MatchedTransition.builder() .sourceState(sourceRaw) .targetState(targetRaw) .event(smEventRaw) - .build()); + .build(); + if (isRoutedToCorrectMachine(chain, result.getName())) { + matched.add(mt); + } } } } @@ -62,16 +70,12 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { } } - // Create a new CallChain with the matched transitions - CallChain updatedChain = CallChain.builder() - .entryPoint(chain.getEntryPoint()) - .methodChain(chain.getMethodChain()) - .triggerPoint(chain.getTriggerPoint()) - .contextMachineId(chain.getContextMachineId()) - .matchedTransitions(matched) - .build(); - - updatedChains.add(updatedChain); + if (!matched.isEmpty()) { + CallChain newChain = chain.toBuilder().matchedTransitions(matched).build(); + updatedChains.add(newChain); + } else { + updatedChains.add(chain); + } } // Update the metadata with the new call chains @@ -85,12 +89,35 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { result.setMetadata(updatedMetadata); } - private String simplify(String name) { - if (name == null) return ""; - int dot = name.lastIndexOf('.'); - if (dot >= 0) { - return name.substring(dot + 1); + private boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName) { + // If the chain's target expression or qualifier indicates a specific machine name, verify it + String targetVar = chain.getContextMachineId(); + if (targetVar == null && chain.getTriggerPoint() != null) { + targetVar = chain.getTriggerPoint().getStateMachineId(); } - return name; + + if (targetVar != null && !targetVar.isEmpty()) { + targetVar = targetVar.toLowerCase(); + // E.g., if target is "myStateMachine", ensure current machine name contains "my" + String simplifiedMachineName = currentMachineName.substring(currentMachineName.lastIndexOf('.') + 1).toLowerCase(); + // If the variable name ends with StateMachine, we extract the prefix + if (targetVar.endsWith("statemachine")) { + String prefix = targetVar.substring(0, targetVar.length() - "statemachine".length()); + if (!prefix.isEmpty() && !simplifiedMachineName.contains(prefix)) { + return false; + } + } + } + return true; + } + + private String simplify(String name) { + if (name == null) return null; + // Strip common suffixes + String simplified = name.replaceAll("(?i)(.*)(Event|Action|Transition|Command)(s)?$", "$1"); + // Simplify full identifiers to just the last part (enum name) + simplified = simplified.replaceAll("^.*\\.([A-Z0-9_]+)$", "$1"); + // For remaining full caps with underscores (like EVENT_X), keep as is or try to simplify + return simplified; } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/CallChain.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/CallChain.java index 2f53142..736e671 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/CallChain.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/CallChain.java @@ -8,7 +8,7 @@ import lombok.extern.jackson.Jacksonized; import java.util.List; @Data -@Builder +@Builder(toBuilder = true) @Jacksonized @JsonIgnoreProperties(ignoreUnknown = true) public class CallChain { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/LibraryHint.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/LibraryHint.java index 34b787a..b720c8c 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/LibraryHint.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/LibraryHint.java @@ -13,5 +13,7 @@ import java.util.List; @JsonIgnoreProperties(ignoreUnknown = true) public class LibraryHint { private final String methodFqn; // e.g., "com.thirdparty.Workflow.send" - private final String event; // The event it triggers + private final String event; // The event it triggers (static) + private final Integer eventArgumentIndex; // e.g., 0 to extract from the 0th argument + private final String eventArgumentMethod; // e.g., "getType" to extract from argument method call } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/PropertyResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/PropertyResolver.java index cd590f0..2d5183a 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/PropertyResolver.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/PropertyResolver.java @@ -105,25 +105,32 @@ public class PropertyResolver { } private Map loadYaml(Path path) { - // Placeholder for future YAML support - log.warn("YAML parsing not fully implemented yet for {}", path); - return new HashMap<>(); + Map props = new HashMap<>(); + try { + com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(new com.fasterxml.jackson.dataformat.yaml.YAMLFactory()); + Map map = mapper.readValue(path.toFile(), new com.fasterxml.jackson.core.type.TypeReference>() {}); + flattenYaml("", map, props); + } catch (IOException e) { + log.warn("Failed to load YAML from {}", path); + } + return props; + } + + @SuppressWarnings("unchecked") + private void flattenYaml(String prefix, Map map, Map result) { + if (map == null) return; + for (Map.Entry entry : map.entrySet()) { + String key = prefix.isEmpty() ? entry.getKey() : prefix + "." + entry.getKey(); + Object value = entry.getValue(); + if (value instanceof Map) { + flattenYaml(key, (Map) value, result); + } else if (value != null) { + result.put(key, value.toString()); + } + } } public String resolveValue(String placeholder, Map properties) { - if (placeholder == null || !placeholder.contains("${")) return placeholder; - - // Very basic placeholder extraction: ${key} or ${key:default} - String content = placeholder.substring(placeholder.indexOf("${") + 2, placeholder.lastIndexOf("}")); - String key = content; - String defaultValue = null; - - if (content.contains(":")) { - int colonIndex = content.indexOf(":"); - key = content.substring(0, colonIndex); - defaultValue = content.substring(colonIndex + 1); - } - - return properties.getOrDefault(key, defaultValue); + return click.kamil.springstatemachineexporter.analysis.util.PlaceholderResolver.resolve(placeholder, properties); } } 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 f9bac43..ff3531c 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 @@ -34,10 +34,12 @@ public class CallGraphBuilder { for (EntryPoint ep : entryPoints) { String startMethod = ep.getClassName() + "." + ep.getMethodName(); + boolean foundAny = false; for (TriggerPoint tp : triggers) { String targetMethod = tp.getClassName() + "." + tp.getMethodName(); List path = findPath(startMethod, targetMethod, callGraph, new HashSet<>()); if (path != null) { + foundAny = true; TriggerPoint resolvedTp = resolveTriggerPointParameters(tp, path, callGraph); String contextMachineId = extractContextMachineId(path, callGraph); chains.add(CallChain.builder() @@ -48,6 +50,9 @@ public class CallGraphBuilder { .build()); } } + if (!foundAny && log.isDebugEnabled()) { + log.debug("Entry point {} ({}) did not reach any trigger points. Graph nodes available: {}", ep.getName(), startMethod, callGraph.keySet()); + } } return chains; } @@ -60,6 +65,17 @@ public class CallGraphBuilder { String currentParamName = event; String resolvedValue = event; + String methodSuffix = ""; + + // Extract method calls like .getType() so we can trace the base parameter + int dotIndex = currentParamName.indexOf('.'); + if (dotIndex > 0 && dotIndex + 1 < currentParamName.length()) { + char nextChar = currentParamName.charAt(dotIndex + 1); + if (Character.isLowerCase(nextChar)) { + methodSuffix = currentParamName.substring(dotIndex); + currentParamName = currentParamName.substring(0, dotIndex); + } + } // Walk backwards up the call chain for (int i = path.size() - 1; i > 0; i--) { @@ -82,7 +98,7 @@ public class CallGraphBuilder { String arg = edge.getArguments().get(paramIndex); if (arg != null) { currentParamName = arg; - resolvedValue = arg; + resolvedValue = arg + methodSuffix; found = true; break; } @@ -175,6 +191,23 @@ public class CallGraphBuilder { graph.computeIfAbsent(currentMethodFqn, k -> new ArrayList<>()).add(new CallEdge(refMethod, args)); } } + } else { + String fallbackTypeFqn = null; + ITypeBinding binding = emr.getExpression().resolveTypeBinding(); + if (binding != null) { + fallbackTypeFqn = binding.getQualifiedName(); + } else if (emr.getExpression() instanceof SimpleName sn) { + fallbackTypeFqn = resolveReceiverTypeFallback(sn); + } + if (fallbackTypeFqn != null) { + String calledMethod = fallbackTypeFqn + "." + emr.getName().getIdentifier(); + 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)); + } + } } } } @@ -300,6 +333,10 @@ public class CallGraphBuilder { } if (receiver instanceof SimpleName sn) { + String fallbackTypeFqn = resolveReceiverTypeFallback(sn); + if (fallbackTypeFqn != null) { + return fallbackTypeFqn + "." + methodName; + } String receiverName = sn.getIdentifier(); return receiverName + "." + methodName; } @@ -307,6 +344,85 @@ public class CallGraphBuilder { return null; } + private String resolveReceiverTypeFallback(SimpleName receiverNameNode) { + String varName = receiverNameNode.getIdentifier(); + + // 1. Check local variables in enclosing method + MethodDeclaration enclosingMethod = findEnclosingMethod(receiverNameNode); + if (enclosingMethod != null) { + // Check parameters + for (Object paramObj : enclosingMethod.parameters()) { + SingleVariableDeclaration svd = (SingleVariableDeclaration) paramObj; + if (svd.getName().getIdentifier().equals(varName)) { + return resolveTypeToFqn(svd.getType(), receiverNameNode); + } + } + // Check method body (local variables) + if (enclosingMethod.getBody() != null) { + Type[] foundType = new Type[1]; + enclosingMethod.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(VariableDeclarationStatement node) { + for (Object fragObj : node.fragments()) { + VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj; + if (frag.getName().getIdentifier().equals(varName)) { + foundType[0] = node.getType(); + } + } + return super.visit(node); + } + }); + if (foundType[0] != null) { + return resolveTypeToFqn(foundType[0], receiverNameNode); + } + } + } + + // 2. Check fields in enclosing class + TypeDeclaration enclosingType = findEnclosingType(receiverNameNode); + if (enclosingType != null) { + for (FieldDeclaration field : enclosingType.getFields()) { + for (Object fragObj : field.fragments()) { + VariableDeclarationFragment frag = (VariableDeclarationFragment) fragObj; + if (frag.getName().getIdentifier().equals(varName)) { + return resolveTypeToFqn(field.getType(), receiverNameNode); + } + } + } + } + + return null; + } + + private String resolveTypeToFqn(Type type, ASTNode contextNode) { + if (type == null) return null; + String simpleName; + if (type.isSimpleType()) { + simpleName = ((SimpleType) type).getName().getFullyQualifiedName(); + } else if (type.isParameterizedType()) { + return resolveTypeToFqn(((ParameterizedType) type).getType(), contextNode); + } else { + simpleName = type.toString(); + } + + CompilationUnit cu = (CompilationUnit) contextNode.getRoot(); + TypeDeclaration td = context.getTypeDeclaration(simpleName, cu); + if (td != null) { + return context.getFqn(td); + } + + // Fallback to import matching if CodebaseContext doesn't know it (e.g., external library) + for (Object impObj : cu.imports()) { + ImportDeclaration imp = (ImportDeclaration) impObj; + String impName = imp.getName().getFullyQualifiedName(); + if (impName.endsWith("." + simpleName)) { + return impName; + } + } + + return simpleName; + } + private String resolveMethodInType(TypeDeclaration td, String methodName) { MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); if (md != null) { @@ -336,6 +452,11 @@ public class CallGraphBuilder { } } } + + if (log.isDebugEnabled()) { + log.debug("Path search dead-end at {} when looking for {}", start, target); + } + visited.remove(start); return null; } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java index 4f3f48b..282fc7e 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java @@ -64,7 +64,18 @@ public class GenericEventDetector { for (LibraryHint hint : hints) { if (calledMethod.equals(hint.getMethodFqn()) || isHintMatch(calledMethod, hint.getMethodFqn())) { - List builtTriggers = buildTriggerPoints(node, cu, hint.getEvent()); + String eventToUse = hint.getEvent(); + if (eventToUse == null && hint.getEventArgumentIndex() != null) { + if (node.arguments().size() > hint.getEventArgumentIndex()) { + Expression argExpr = (Expression) node.arguments().get(hint.getEventArgumentIndex()); + eventToUse = argExpr.toString(); + if (hint.getEventArgumentMethod() != null && !hint.getEventArgumentMethod().isEmpty()) { + eventToUse = eventToUse + "." + hint.getEventArgumentMethod() + "()"; + } + } + } + + List builtTriggers = buildTriggerPoints(node, cu, eventToUse); if (builtTriggers != null) { for (TriggerPoint trigger : builtTriggers) { log.debug("Successfully built synthetic trigger point from hint: {}", trigger.getEvent()); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtIntelligenceProvider.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtIntelligenceProvider.java index f2cfa09..d1b4fc4 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtIntelligenceProvider.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/JdtIntelligenceProvider.java @@ -24,6 +24,7 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider { private final CallGraphBuilder callGraphBuilder; private final LifecycleDetector lifecycleDetector; private final InterceptorDetector interceptorDetector; + private final SpringComponentDetector componentDetector; public JdtIntelligenceProvider(CodebaseContext context, Path rootDir) { this.context = context; @@ -34,6 +35,7 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider { this.callGraphBuilder = new CallGraphBuilder(context); this.lifecycleDetector = new LifecycleDetector(context); this.interceptorDetector = new InterceptorDetector(context); + this.componentDetector = new SpringComponentDetector(context); } @Override @@ -58,6 +60,7 @@ public class JdtIntelligenceProvider implements CodebaseIntelligenceProvider { allEntryPoints.addAll(mvcDetector.detect(cu)); allEntryPoints.addAll(messagingDetector.detect(cu)); allEntryPoints.addAll(interceptorDetector.detect(cu)); + allEntryPoints.addAll(componentDetector.detect(cu)); } log.info("Found {} entry points (including interceptors and listeners) in total", allEntryPoints.size()); return allEntryPoints; diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/SpringComponentDetector.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/SpringComponentDetector.java new file mode 100644 index 0000000..9f3db68 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/SpringComponentDetector.java @@ -0,0 +1,117 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import lombok.RequiredArgsConstructor; +import org.eclipse.jdt.core.dom.ASTVisitor; +import org.eclipse.jdt.core.dom.Annotation; +import org.eclipse.jdt.core.dom.CompilationUnit; +import org.eclipse.jdt.core.dom.MemberValuePair; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.NormalAnnotation; +import org.eclipse.jdt.core.dom.SingleMemberAnnotation; +import org.eclipse.jdt.core.dom.TypeDeclaration; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RequiredArgsConstructor +public class SpringComponentDetector { + private final CodebaseContext context; + + public List detect(CompilationUnit cu) { + List entryPoints = new ArrayList<>(); + cu.accept(new ASTVisitor() { + @Override + public boolean visit(MethodDeclaration node) { + if (!(node.getParent() instanceof TypeDeclaration)) { + return super.visit(node); + } + TypeDeclaration parentTd = (TypeDeclaration) node.getParent(); + + for (Object modifier : node.modifiers()) { + if (modifier instanceof Annotation annotation) { + String typeName = annotation.getTypeName().getFullyQualifiedName(); + if (typeName.endsWith("Scheduled")) { + Map meta = new HashMap<>(); + String cron = extractAnnotationValue(annotation, "cron"); + if (!cron.isEmpty()) + meta.put("cron", cron); + String fixedRate = extractAnnotationValue(annotation, "fixedRate"); + if (!fixedRate.isEmpty()) + meta.put("fixedRate", fixedRate); + String fixedDelay = extractAnnotationValue(annotation, "fixedDelay"); + if (!fixedDelay.isEmpty()) + meta.put("fixedDelay", fixedDelay); + + entryPoints.add(EntryPoint.builder() + .type(EntryPoint.Type.CUSTOM) + .name("@Scheduled: " + node.getName().getIdentifier()) + .className(context.getFqn(parentTd)) + .methodName(node.getName().getIdentifier()) + .sourceFile(context.getRelativePath(context.getFqn(parentTd))) + .metadata(meta) + .build()); + } else if (typeName.endsWith("EventListener")) { + Map meta = new HashMap<>(); + String classes = extractAnnotationValue(annotation, "classes"); + if (!classes.isEmpty()) + meta.put("classes", classes); + String condition = extractAnnotationValue(annotation, "condition"); + if (!condition.isEmpty()) + meta.put("condition", condition); + + entryPoints.add(EntryPoint.builder() + .type(EntryPoint.Type.CUSTOM) + .name("@EventListener: " + node.getName().getIdentifier()) + .className(context.getFqn(parentTd)) + .methodName(node.getName().getIdentifier()) + .sourceFile(context.getRelativePath(context.getFqn(parentTd))) + .metadata(meta) + .build()); + } else if (typeName.endsWith("Around") || typeName.endsWith("Before") || typeName.endsWith("After") || typeName.endsWith("AfterReturning") || typeName.endsWith("AfterThrowing")) { + Map meta = new HashMap<>(); + String value = extractAnnotationValue(annotation, "value"); + if (!value.isEmpty()) + meta.put("pointcut", value); + else { + String pointcut = extractAnnotationValue(annotation, "pointcut"); + if (!pointcut.isEmpty()) + meta.put("pointcut", pointcut); + } + + entryPoints.add(EntryPoint.builder() + .type(EntryPoint.Type.CUSTOM) + .name("@" + annotation.getTypeName().getFullyQualifiedName() + ": " + node.getName().getIdentifier()) + .className(context.getFqn(parentTd)) + .methodName(node.getName().getIdentifier()) + .sourceFile(context.getRelativePath(context.getFqn(parentTd))) + .metadata(meta) + .build()); + } + } + } + return super.visit(node); + } + }); + return entryPoints; + } + + private String extractAnnotationValue(Annotation annotation, String memberName) { + if (annotation instanceof SingleMemberAnnotation sma) { + if ("value".equals(memberName)) { + 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 ""; + } +} 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 20391dd..4a8557d 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 @@ -213,9 +213,18 @@ public class CodebaseContext { } // Inheritance Mapping - for (Object itf : td.superInterfaceTypes()) { - String itfName = itf.toString(); - interfaceToImpls.computeIfAbsent(itfName, k -> new ArrayList<>()).add(fqn); + // Track implementations (for both interfaces and superclasses) + if (td.superInterfaceTypes() != null) { + for (Object itfObj : td.superInterfaceTypes()) { + Type itf = (Type) itfObj; + String itfName = extractTypeName(itf); + interfaceToImpls.computeIfAbsent(itfName, k -> new ArrayList<>()).add(fqn); + } + } + Type superclass = td.getSuperclassType(); + if (superclass != null) { + String superName = extractTypeName(superclass); + interfaceToImpls.computeIfAbsent(superName, k -> new ArrayList<>()).add(fqn); } // Recursively index nested types @@ -227,28 +236,38 @@ public class CodebaseContext { } public List getImplementations(String interfaceName) { + Set allImpls = new HashSet<>(); + collectImplementations(interfaceName, allImpls, new HashSet<>()); + return new ArrayList<>(allImpls); + } + + private void collectImplementations(String typeName, Set results, Set visited) { + if (!visited.add(typeName)) return; + // Try direct match - List impls = interfaceToImpls.get(interfaceName); - if (impls != null) return impls; + List directImpls = interfaceToImpls.get(typeName); // Try FQN match if input was simple name - String fqn = simpleNameToFqn.get(interfaceName); - if (fqn != null && interfaceToImpls.containsKey(fqn)) { - return interfaceToImpls.get(fqn); - } - - // Try simple name match if input was FQN - if (interfaceName.contains(".")) { - String simpleName = interfaceName.substring(interfaceName.lastIndexOf('.') + 1); - List simpleImpls = interfaceToImpls.get(simpleName); - if (simpleImpls != null) { - // To be perfectly safe, we could check if simpleNameToFqn matches, - // but for call graphs, returning potential implementations is fine. - return simpleImpls; + if (directImpls == null) { + String fqn = simpleNameToFqn.get(typeName); + if (fqn != null) { + directImpls = interfaceToImpls.get(fqn); } } - return Collections.emptyList(); + // Try simple name match if input was FQN + if (directImpls == null && typeName.contains(".")) { + String simpleName = typeName.substring(typeName.lastIndexOf('.') + 1); + directImpls = interfaceToImpls.get(simpleName); + } + + if (directImpls != null) { + for (String impl : directImpls) { + results.add(impl); + // Recursively find implementations of the implementation (e.g., subclasses of an abstract class) + collectImplementations(impl, results, visited); + } + } } private void indexEnum(EnumDeclaration ed, String parentFqn, Path javaFile) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/command/ExporterCommand.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/command/ExporterCommand.java index 371f6f6..54fc1b2 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/command/ExporterCommand.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/command/ExporterCommand.java @@ -54,11 +54,19 @@ public class ExporterCommand implements Callable { @Option(names = {"--state"}, description = "Format for states when they are enums: fn (fullName, default), fqn (fully qualified name), sn (short name)", defaultValue = "fn") private click.kamil.springstatemachineexporter.exporter.EnumFormat stateFormat; + @Option(names = {"--debug"}, description = "Enable diagnostic mode to show unresolved call chains and detailed analysis logging.", defaultValue = "false") + private boolean debug; + @Override public Integer call() throws Exception { var out = spec.commandLine().getOut(); var err = spec.commandLine().getErr(); + if (debug) { + System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "debug"); + out.println(CommandLine.Help.Ansi.AUTO.string("@|bold,cyan Diagnostic mode enabled|@")); + } + if (inputDir == null && jsonFile == null) { inputDir = Path.of("."); } 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 e5c4eae..f789b06 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 @@ -23,8 +23,7 @@ import java.util.List; public class GoldenUpdater { private static final List exporters = List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter()); - private static final EnrichmentService enrichmentService = new EnrichmentService(Collections.emptyList()); - private static final ExportService exportService = new ExportService(exporters, enrichmentService); + private static final ExportService exportService = new ExportService(exporters); public static void main(String[] args) throws Exception { List scenarios = provideTestScenarios(); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java index b1c92b5..e4f87fd 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java @@ -118,4 +118,50 @@ class TransitionLinkerEnricherTest { CallChain updatedChain = result.getMetadata().getCallChains().get(0); assertThat(updatedChain.getMatchedTransitions()).isNull(); } + + @Test + void shouldMatchWildcardVariable() { + Transition t1 = new Transition(); + t1.setSourceStates(List.of(State.of("NEW", "NEW"))); + t1.setTargetStates(List.of(State.of("PAID", "PAID"))); + t1.setEvent(Event.of("PAY", "PAY")); + + CallChain chain = CallChain.builder() + .triggerPoint(TriggerPoint.builder().event("event").build()) + .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); + + CallChain updatedChain = result.getMetadata().getCallChains().get(0); + assertThat(updatedChain.getMatchedTransitions()).hasSize(1); + } + + @Test + void shouldMatchMethodCallWildcard() { + Transition t1 = new Transition(); + t1.setSourceStates(List.of(State.of("NEW", "NEW"))); + t1.setTargetStates(List.of(State.of("PAID", "PAID"))); + t1.setEvent(Event.of("PAY", "PAY")); + + CallChain chain = CallChain.builder() + .triggerPoint(TriggerPoint.builder().event("richEvent.getType()").build()) + .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); + + CallChain updatedChain = result.getMetadata().getCallChains().get(0); + assertThat(updatedChain.getMatchedTransitions()).hasSize(1); + } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/PropertyResolverTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/PropertyResolverTest.java new file mode 100644 index 0000000..cc7f68b --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/PropertyResolverTest.java @@ -0,0 +1,60 @@ +package click.kamil.springstatemachineexporter.analysis.resolver; + +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.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class PropertyResolverTest { + + @Test + void testYamlPropertiesAreLoadedAndFlattened(@TempDir Path tempDir) throws IOException { + Path applicationYml = tempDir.resolve("application.yml"); + Files.writeString(applicationYml, """ + messaging: + sqs: + integration-system1: + callback: + queue: "integration-system1-queue" + """); + + PropertyResolver resolver = new PropertyResolver(); + Map props = resolver.resolveProperties(tempDir); + + assertThat(props).containsEntry("messaging.sqs.integration-system1.callback.queue", "integration-system1-queue"); + } + + @Test + void testPropertiesFilesAreLoaded(@TempDir Path tempDir) throws IOException { + Path applicationProps = tempDir.resolve("application.properties"); + Files.writeString(applicationProps, "messaging.sqs.integration-system1.callback.queue=integration-system1-queue-props"); + + PropertyResolver resolver = new PropertyResolver(); + Map props = resolver.resolveProperties(tempDir); + + assertThat(props).containsEntry("messaging.sqs.integration-system1.callback.queue", "integration-system1-queue-props"); + } + + @Test + void testPlaceholderResolution() { + PropertyResolver resolver = new PropertyResolver(); + Map properties = Map.of("messaging.sqs.integration-system1.callback.queue", "integration-system1-queue"); + + String resolved = resolver.resolveValue("SQS: ${messaging.sqs.integration-system1.callback.queue}", properties); + assertThat(resolved).isEqualTo("SQS: integration-system1-queue"); + } + + @Test + void testPlaceholderResolutionWithDefault() { + PropertyResolver resolver = new PropertyResolver(); + Map properties = Map.of(); + + String resolved = resolver.resolveValue("SQS: ${messaging.sqs.queue:default-queue}", properties); + assertThat(resolved).isEqualTo("SQS: default-queue"); + } +} 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 new file mode 100644 index 0000000..30722a2 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphBuilderTest.java @@ -0,0 +1,118 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class CallGraphBuilderTest { + + @Test + void shouldFindCallChainWithLambdaMethodReference(@TempDir Path tempDir) throws IOException { + String source = """ + package com.example; + public class OrderService { + private final EventDispatcher dispatcher = new EventDispatcher(); + + public void processOrder() { + dispatcher.dispatch("order", this::sendEvent); + } + + public void sendEvent(String event) { + // Dummy trigger + } + } + + class EventDispatcher { + public void dispatch(String order, java.util.function.Consumer callback) { + callback.accept("MY_EVENT"); + } + } + """; + 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 trigger = TriggerPoint.builder() + .className("com.example.OrderService") + .methodName("sendEvent") + .event("event") + .build(); + + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + assertThat(chains).hasSize(1); + CallChain chain = chains.get(0); + assertThat(chain.getMethodChain()).containsExactly( + "com.example.OrderService.processOrder", + "com.example.OrderService.sendEvent" + ); + } + + @Test + void shouldExtractContextMachineId(@TempDir Path tempDir) throws IOException { + String source = """ + package com.example; + public class PersisterService { + private final StateMachinePersister persister = new StateMachinePersister(); + + public void updateOrderState(String orderId) { + StateMachine sm = persister.restore(null, "machine:" + orderId); + sm.sendEvent("PAY"); + } + } + + class StateMachinePersister { + public StateMachine restore(Object sm, String contextObj) { + return new StateMachine(); + } + } + + class StateMachine { + public void sendEvent(String event) {} + } + """; + Files.writeString(tempDir.resolve("PersisterService.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + CallGraphBuilder builder = new CallGraphBuilder(context); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.PersisterService") + .methodName("updateOrderState") + .build(); + + TriggerPoint trigger = TriggerPoint.builder() + .className("com.example.StateMachine") + .methodName("sendEvent") + .event("PAY") + .build(); + + List chains = builder.findChains(List.of(entryPoint), List.of(trigger)); + + assertThat(chains).hasSize(1); + CallChain chain = chains.get(0); + assertThat(chain.getContextMachineId()).isNotNull(); + // Since it's tracing constant concatenation: "machine:" + orderId, which might resolve dynamically or string format. + // It should at least be present (it typically extracts 'machine:' + orderId into expression). + } +} diff --git a/state_machine_exporter/src/test/resources/golden/EnterpriseStateMachineConfig/EnterpriseStateMachineConfig.json b/state_machine_exporter/src/test/resources/golden/EnterpriseStateMachineConfig/EnterpriseStateMachineConfig.json index ec0131f..cc1d788 100644 --- a/state_machine_exporter/src/test/resources/golden/EnterpriseStateMachineConfig/EnterpriseStateMachineConfig.json +++ b/state_machine_exporter/src/test/resources/golden/EnterpriseStateMachineConfig/EnterpriseStateMachineConfig.json @@ -251,7 +251,7 @@ "lineNumber" : 17 }, "contextMachineId" : null, - "matchedTransitions" : [ ] + "matchedTransitions" : null }, { "entryPoint" : { "type" : "REST", diff --git a/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig/ExtendedStateMachineConfig.json b/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig/ExtendedStateMachineConfig.json index 7afc96f..b6523ec 100644 --- a/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig/ExtendedStateMachineConfig.json +++ b/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig/ExtendedStateMachineConfig.json @@ -287,7 +287,7 @@ "lineNumber" : 29 }, "contextMachineId" : "orderId", - "matchedTransitions" : [ ] + "matchedTransitions" : null }, { "entryPoint" : { "type" : "REST", diff --git a/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig_PROD/ExtendedStateMachineConfig.json b/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig_PROD/ExtendedStateMachineConfig.json index aa53aff..8f7d7fb 100644 --- a/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig_PROD/ExtendedStateMachineConfig.json +++ b/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig_PROD/ExtendedStateMachineConfig.json @@ -287,7 +287,7 @@ "lineNumber" : 29 }, "contextMachineId" : "orderId", - "matchedTransitions" : [ ] + "matchedTransitions" : null }, { "entryPoint" : { "type" : "REST", diff --git a/state_machine_exporter_html/src/main/java/click/kamil/springstatemachineexporter/html/Main.java b/state_machine_exporter_html/src/main/java/click/kamil/springstatemachineexporter/html/Main.java index f58f9da..b141088 100644 --- a/state_machine_exporter_html/src/main/java/click/kamil/springstatemachineexporter/html/Main.java +++ b/state_machine_exporter_html/src/main/java/click/kamil/springstatemachineexporter/html/Main.java @@ -13,6 +13,14 @@ import java.util.List; public class Main { public static void main(String[] args) { + // Enable diagnostic mode early before SLF4J initializes + for (String arg : args) { + if ("--debug".equals(arg)) { + System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "debug"); + break; + } + } + // Wiring including the specialized HTML exporter var exporters = List.of(new PlantUml(), new Dot(), new Scxml(), new JsonExporter(), new HtmlExporter()); var exportService = new ExportService(exporters); diff --git a/state_machine_exporter_html/src/main/java/click/kamil/springstatemachineexporter/html/command/HtmlExporterCommand.java b/state_machine_exporter_html/src/main/java/click/kamil/springstatemachineexporter/html/command/HtmlExporterCommand.java index 9e1303f..c2fb375 100644 --- a/state_machine_exporter_html/src/main/java/click/kamil/springstatemachineexporter/html/command/HtmlExporterCommand.java +++ b/state_machine_exporter_html/src/main/java/click/kamil/springstatemachineexporter/html/command/HtmlExporterCommand.java @@ -59,11 +59,19 @@ public class HtmlExporterCommand implements Callable { @Option(names = {"--no-metadata-pane"}, description = "Disable rendering the left metadata pane (entry points, flows) in the HTML viewer.") private boolean noMetadataPane; + @Option(names = {"--debug"}, description = "Enable diagnostic mode to show unresolved call chains and detailed analysis logging.", defaultValue = "false") + private boolean debug; + @Override public Integer call() throws Exception { var out = spec.commandLine().getOut(); var err = spec.commandLine().getErr(); + if (debug) { + System.setProperty("org.slf4j.simpleLogger.defaultLogLevel", "debug"); + out.println(CommandLine.Help.Ansi.AUTO.string("@|bold,cyan Diagnostic mode enabled|@")); + } + if (inputDir == null && jsonFile == null) { inputDir = Path.of("."); }