diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/EntryPointEnricher.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/EntryPointEnricher.java index ccda638..5b65777 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/EntryPointEnricher.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/EntryPointEnricher.java @@ -16,8 +16,9 @@ public class EntryPointEnricher implements AnalysisEnricher { public void enrich(AnalysisResult result, CodebaseContext context, CodebaseIntelligenceProvider intelligence) { log.info("Enriching {} with entry points", result.getName()); + // Keep all entry points; machine scoping is applied after call-chain resolution + // when source evidence (trigger type FQNs, literals, constraints) is available. List entryPoints = intelligence.findEntryPoints(); - entryPoints = MachineScopeFilter.filterEntryPointsForMachine(entryPoints, result.getName(), context); result.addMetadata(CodebaseMetadata.builder() .entryPoints(entryPoints) diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/EntryPointScopeResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/EntryPointScopeResolver.java new file mode 100644 index 0000000..6d7ecdc --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/EntryPointScopeResolver.java @@ -0,0 +1,160 @@ +package click.kamil.springstatemachineexporter.analysis.enricher; + +import click.kamil.springstatemachineexporter.analysis.enricher.routing.BeanResolutionEngine; +import click.kamil.springstatemachineexporter.analysis.enricher.routing.HeuristicBeanResolutionEngine; +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.MachineEnumCanonicalizer; +import click.kamil.springstatemachineexporter.analysis.service.CallGraphEngine; +import click.kamil.springstatemachineexporter.analysis.service.GenericEventDetector; +import click.kamil.springstatemachineexporter.analysis.service.JdtCallGraphEngine; +import click.kamil.springstatemachineexporter.analysis.spring.InjectionPointAnalyzer; +import click.kamil.springstatemachineexporter.analysis.spring.SpringBeanRegistry; +import click.kamil.springstatemachineexporter.analysis.spring.SpringContextScanner; +import click.kamil.springstatemachineexporter.analysis.spring.SpringDependencyResolver; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.CompilationUnit; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Scopes REST entry points to a machine using call-graph reachability and trigger type evidence, + * not URL path segment guessing. + */ +public final class EntryPointScopeResolver { + + private static final BeanResolutionEngine ROUTING = new HeuristicBeanResolutionEngine(); + private static final String TRIGGERS_CACHE = "entryPointScope.triggers"; + + public enum Affinity { + FOR_MACHINE, + AGAINST_MACHINE, + UNKNOWN + } + + private EntryPointScopeResolver() { + } + + public static List scopeFromCallChains(List chains, List allEntryPoints) { + Map scoped = new LinkedHashMap<>(); + if (chains != null) { + for (CallChain chain : chains) { + EntryPoint entryPoint = chain.getEntryPoint(); + if (entryPoint != null) { + scoped.putIfAbsent(entryPointKey(entryPoint), entryPoint); + } + } + } + if (allEntryPoints != null) { + for (EntryPoint entryPoint : allEntryPoints) { + if (hasPathVariablePlaceholder(entryPoint)) { + scoped.putIfAbsent(entryPointKey(entryPoint), entryPoint); + } + } + } + return new ArrayList<>(scoped.values()); + } + + public static Affinity resolveAffinity(EntryPoint entryPoint, String machineName, CodebaseContext context) { + if (entryPoint == null || machineName == null) { + return Affinity.UNKNOWN; + } + if (entryPoint.getClassName() == null || entryPoint.getMethodName() == null) { + return Affinity.UNKNOWN; + } + if (context == null || context.getCompilationUnits().isEmpty()) { + return Affinity.UNKNOWN; + } + + CallGraphEngine engine = createCallGraphEngine(context); + List chains = engine.findChains(List.of(entryPoint), getOrCollectTriggers(context)); + if (chains.isEmpty()) { + return Affinity.UNKNOWN; + } + + boolean matched = false; + boolean mismatched = false; + for (CallChain chain : chains) { + TriggerPoint trigger = chain.getTriggerPoint(); + if (trigger == null || !hasTypeEvidence(trigger)) { + continue; + } + if (ROUTING.isRoutedToCorrectMachine(chain, machineName, context)) { + matched = true; + } else { + mismatched = true; + } + } + + if (matched) { + return Affinity.FOR_MACHINE; + } + if (mismatched) { + return Affinity.AGAINST_MACHINE; + } + return Affinity.UNKNOWN; + } + + public static boolean hasPathVariablePlaceholder(EntryPoint entryPoint) { + if (entryPoint == null) { + return false; + } + String name = entryPoint.getName(); + if (name != null && name.contains("{")) { + return true; + } + Map metadata = entryPoint.getMetadata(); + if (metadata != null) { + String path = metadata.get("path"); + return path != null && path.contains("{"); + } + return false; + } + + private static boolean hasTypeEvidence(TriggerPoint trigger) { + if (trigger.getEventTypeFqn() != null || trigger.getStateTypeFqn() != null) { + return true; + } + return MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent()) + == MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM; + } + + @SuppressWarnings("unchecked") + private static List getOrCollectTriggers(CodebaseContext context) { + Object cached = context.getCache().get(TRIGGERS_CACHE); + if (cached instanceof List list) { + return (List) list; + } + List triggers = new ArrayList<>(); + GenericEventDetector detector = + new GenericEventDetector(context, context.getConstantResolver(), context.getLibraryHints()); + for (CompilationUnit cu : context.getCompilationUnits()) { + triggers.addAll(detector.detect(cu)); + } + context.getCache().put(TRIGGERS_CACHE, triggers); + return triggers; + } + + private static CallGraphEngine createCallGraphEngine(CodebaseContext context) { + SpringBeanRegistry registry = new SpringBeanRegistry(); + SpringContextScanner scanner = new SpringContextScanner(registry); + for (CompilationUnit cu : context.getCompilationUnits()) { + cu.accept(scanner); + } + SpringDependencyResolver dependencyResolver = new SpringDependencyResolver(registry); + InjectionPointAnalyzer injectionAnalyzer = new InjectionPointAnalyzer(dependencyResolver); + return new JdtCallGraphEngine(context, injectionAnalyzer); + } + + private static String entryPointKey(EntryPoint entryPoint) { + if (entryPoint.getName() != null && !entryPoint.getName().isBlank()) { + return entryPoint.getName(); + } + return entryPoint.getClassName() + "#" + entryPoint.getMethodName(); + } +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/MachineScopeFilter.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/MachineScopeFilter.java index c9b9732..aa8333f 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/MachineScopeFilter.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/MachineScopeFilter.java @@ -9,7 +9,6 @@ import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import java.util.ArrayList; import java.util.List; -import java.util.Locale; /** * Filters codebase-wide analysis artifacts to those relevant for a single state machine config. @@ -54,10 +53,9 @@ public final class MachineScopeFilter { if (entryPoints == null || machineName == null) { return entryPoints == null ? List.of() : entryPoints; } - String machineDomain = MachineDomainKeys.extractMachineDomainKey(machineName); List filtered = new ArrayList<>(); for (EntryPoint entryPoint : entryPoints) { - if (isEntryPointForMachine(entryPoint, machineDomain, machineName, context)) { + if (isEntryPointForMachine(entryPoint, machineName, context)) { filtered.add(entryPoint); } } @@ -65,54 +63,25 @@ public final class MachineScopeFilter { } private static boolean isEntryPointForMachine( - EntryPoint entryPoint, String machineDomain, String machineName, CodebaseContext context) { + EntryPoint entryPoint, String machineName, CodebaseContext context) { if (entryPoint == null) { return false; } - String path = entryPoint.getMetadata() != null ? entryPoint.getMetadata().get("path") : null; - if (path != null) { - String pathDomain = inferDomainFromPath(path); - if (pathDomain != null && machineDomain != null && isKnownDomainScopedMachine(machineDomain) - && domainsMatch(pathDomain, machineDomain)) { - return true; - } - if (pathDomain != null && machineDomain != null && isKnownDomainScopedMachine(machineDomain) - && !domainsMatch(pathDomain, machineDomain)) { - return false; - } - if (pathDomain == null && path.contains("{")) { - return true; - } + if (EntryPointScopeResolver.hasPathVariablePlaceholder(entryPoint)) { + EntryPointScopeResolver.Affinity affinity = + EntryPointScopeResolver.resolveAffinity(entryPoint, machineName, context); + return affinity != EntryPointScopeResolver.Affinity.AGAINST_MACHINE; } - CallChain probe = CallChain.builder() - .entryPoint(entryPoint) - .methodChain(List.of(entryPoint.getClassName() + "." + entryPoint.getMethodName())) - .build(); - return ROUTING.isRoutedToCorrectMachine(probe, machineName, context); - } - private static boolean isKnownDomainScopedMachine(String machineDomain) { - return "ORDER".equals(machineDomain) || "DOCUMENT".equals(machineDomain) || "USER".equals(machineDomain); - } - - private static String inferDomainFromPath(String path) { - String lower = path.toLowerCase(Locale.ROOT); - if (lower.contains("/orders") || lower.contains("order.")) { - return "ORDER"; + EntryPointScopeResolver.Affinity affinity = + EntryPointScopeResolver.resolveAffinity(entryPoint, machineName, context); + if (affinity == EntryPointScopeResolver.Affinity.FOR_MACHINE) { + return true; } - if (lower.contains("/documents") || lower.contains("document.")) { - return "DOCUMENT"; - } - return null; - } - - private static boolean domainsMatch(String pathDomain, String machineDomain) { - if (pathDomain == null || machineDomain == null) { + if (affinity == EntryPointScopeResolver.Affinity.AGAINST_MACHINE) { return false; } - return pathDomain.equalsIgnoreCase(machineDomain) - || machineDomain.startsWith(pathDomain) - || pathDomain.startsWith(machineDomain); + return false; } private static boolean isTriggerForMachine(TriggerPoint trigger, String machineName, CodebaseContext context) { 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 45fafd7..1710ed0 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 @@ -5,9 +5,11 @@ import click.kamil.springstatemachineexporter.analysis.model.CallChain; import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata; import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; import click.kamil.springstatemachineexporter.analysis.model.LifecycleTriggerMarkers; +import click.kamil.springstatemachineexporter.analysis.model.LinkResolution; import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider; +import click.kamil.springstatemachineexporter.analysis.service.ExternalTriggerPolicy; import click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.model.State; @@ -57,7 +59,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { tp = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents( tp, machineTypes, context, stateMachineTransitions, true); - tp = markRestEndpointExternal(tp, chain); + tp = markRestEndpointExternal(tp, chain, context); if (LifecycleTriggerMarkers.isLifecycle(tp.getEvent())) { updatedChains.add(chain); @@ -69,6 +71,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { updatedChains.add(chain.toBuilder() .triggerPoint(tp) .matchedTransitions(null) + .linkResolution(LinkResolution.AMBIGUOUS_WIDEN) .build()); continue; } @@ -150,24 +153,28 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { tp = tp.toBuilder().ambiguous(true).build(); } + LinkResolution linkResolution = resolveLinkResolution(tp, matched, ambiguousSource); + if (!matched.isEmpty()) { CallChain newChain = chain.toBuilder() .triggerPoint(tp) .matchedTransitions(matched) + .linkResolution(linkResolution) .build(); updatedChains.add(newChain); } else { updatedChains.add(chain.toBuilder() .triggerPoint(tp) .matchedTransitions(null) + .linkResolution(linkResolution) .build()); } } List scopedTriggers = MachineScopeFilter.filterTriggersForMachine( result.getMetadata().getTriggers(), result.getName(), context); - List scopedEntryPoints = MachineScopeFilter.filterEntryPointsForMachine( - result.getMetadata().getEntryPoints(), result.getName(), context); + List scopedEntryPoints = EntryPointScopeResolver.scopeFromCallChains( + updatedChains, result.getMetadata().getEntryPoints()); CodebaseMetadata updatedMetadata = CodebaseMetadata.builder() .triggers(scopedTriggers) @@ -226,7 +233,23 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { return event.fullIdentifier() != null ? event.fullIdentifier() : event.rawName(); } - private boolean shouldFailClosedOnAmbiguousCallGraphWiden(TriggerPoint tp) { + private static LinkResolution resolveLinkResolution( + TriggerPoint trigger, List matched, boolean ambiguousSource) { + if (trigger != null && trigger.isExternal()) { + return LinkResolution.UNRESOLVED_EXTERNAL; + } + if (ambiguousSource + || (trigger != null && trigger.isAmbiguous()) + || shouldFailClosedOnAmbiguousCallGraphWiden(trigger)) { + return LinkResolution.AMBIGUOUS_WIDEN; + } + if (matched != null && !matched.isEmpty()) { + return LinkResolution.RESOLVED; + } + return LinkResolution.NO_MATCH; + } + + private static boolean shouldFailClosedOnAmbiguousCallGraphWiden(TriggerPoint tp) { if (tp == null || tp.isExternal() || !tp.isAmbiguous()) { return false; } @@ -243,23 +266,18 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { && !event.contains("valueOf"); } - private TriggerPoint markRestEndpointExternal(TriggerPoint trigger, CallChain chain) { - if (trigger == null || trigger.isExternal() || chain.getEntryPoint() == null) { + private TriggerPoint markRestEndpointExternal(TriggerPoint trigger, CallChain chain, CodebaseContext context) { + if (trigger == null || chain.getEntryPoint() == null) { return trigger; } EntryPoint entryPoint = chain.getEntryPoint(); - if (entryPoint.getType() != EntryPoint.Type.REST) { + String entryMethod = entryPoint.getClassName() + "." + entryPoint.getMethodName(); + boolean external = ExternalTriggerPolicy.isExternalFromSource( + entryPoint, trigger, entryMethod, trigger.getEvent(), context); + if (external == trigger.isExternal()) { return trigger; } - String endpointName = entryPoint.getName(); - if (endpointName == null || !endpointName.contains("{")) { - return trigger; - } - if (MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent()) - != MachineEnumCanonicalizer.TriggerEventKind.DYNAMIC_EXPRESSION) { - return trigger; - } - return trigger.toBuilder().external(true).build(); + return trigger.toBuilder().external(external).build(); } } \ No newline at end of file diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicBeanResolutionEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicBeanResolutionEngine.java index e0b4848..bd3356c 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicBeanResolutionEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicBeanResolutionEngine.java @@ -12,7 +12,7 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine { @Override public boolean isRoutedToCorrectMachine(CallChain chain, String currentMachineName, CodebaseContext context) { - // Precise FQN Type argument match + // Precise FQN Type argument match from JDT bindings at sendEvent site if (chain.getTriggerPoint() != null && context != null && currentMachineName != null) { String triggerEventFqn = chain.getTriggerPoint().getEventTypeFqn(); String triggerStateFqn = chain.getTriggerPoint().getStateTypeFqn(); @@ -20,31 +20,37 @@ public class HeuristicBeanResolutionEngine implements BeanResolutionEngine { String[] machineTypes = StateMachineTypeResolver.resolve(currentMachineName, context); String machineStateFqn = machineTypes[0]; String machineEventFqn = machineTypes[1]; + boolean matched = false; + boolean mismatched = false; if (triggerEventFqn != null && machineEventFqn != null) { if (eraseGenerics(triggerEventFqn).equals(eraseGenerics(machineEventFqn))) { - return true; - } - if (isTypeMismatched(triggerEventFqn, machineEventFqn)) { - return false; - } - if (context != null && isSingleStateMachine(context) + matched = true; + } else if (isTypeMismatched(triggerEventFqn, machineEventFqn)) { + mismatched = true; + } else if (isSingleStateMachine(context) && (isErasedOrOpaqueType(triggerEventFqn) || isErasedOrOpaqueType(machineEventFqn))) { - return true; + matched = true; } } if (triggerStateFqn != null && machineStateFqn != null) { if (eraseGenerics(triggerStateFqn).equals(eraseGenerics(machineStateFqn))) { - return true; // Match! - } - if (isTypeMismatched(triggerStateFqn, machineStateFqn)) { - return false; // Mismatch! - } - if (context != null && isSingleStateMachine(context) + matched = true; + } else if (isTypeMismatched(triggerStateFqn, machineStateFqn)) { + mismatched = true; + } else if (isSingleStateMachine(context) && (isErasedOrOpaqueType(triggerStateFqn) || isErasedOrOpaqueType(machineStateFqn))) { - return true; + matched = true; } } + if (matched) { + return true; + } + if (mismatched) { + return false; + } + // Provable generic types but no machine match — fail closed (no package-name fallback). + return false; } } 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 736e671..0f6a42c 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 @@ -17,4 +17,6 @@ public class CallChain { private final TriggerPoint triggerPoint; private final String contextMachineId; private final List matchedTransitions; + /** How this chain's trigger was linked; derived from trigger flags and matchedTransitions. */ + private final LinkResolution linkResolution; } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/LinkResolution.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/LinkResolution.java new file mode 100644 index 0000000..eaa8407 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/model/LinkResolution.java @@ -0,0 +1,16 @@ +package click.kamil.springstatemachineexporter.analysis.model; + +/** + * Export metadata describing how a call-chain trigger was linked to state-machine transitions. + * Derived from existing trigger flags only — no additional heuristics. + */ +public enum LinkResolution { + /** Concrete trigger linked to one or more matched transitions. */ + RESOLVED, + /** REST or other external trigger that cannot be statically linked. */ + UNRESOLVED_EXTERNAL, + /** Call-graph widening or source-state ambiguity prevented linking. */ + AMBIGUOUS_WIDEN, + /** Static trigger with no matching transition link. */ + NO_MATCH +} diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluator.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluator.java index 45b6cb4..d059ee4 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluator.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluator.java @@ -3,6 +3,7 @@ package click.kamil.springstatemachineexporter.analysis.resolver; import java.util.HashMap; import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.regex.Matcher; @@ -82,11 +83,64 @@ public final class BooleanConstraintEvaluator { } String expr = constraint; for (Map.Entry entry : bindings.entrySet()) { + if (!isConcreteBindingValue(entry.getKey(), entry.getValue())) { + continue; + } expr = substituteVariableBindings(expr, entry.getKey(), entry.getValue()); + expr = substituteEqualsLiteralBindings(expr, entry.getKey(), entry.getValue()); } return evaluateBooleanExpression(expr); } + private static boolean isConcreteBindingValue(String paramName, String boundValue) { + if (boundValue == null || boundValue.isBlank() || boundValue.equals(paramName)) { + return false; + } + if (boundValue.contains("(") && !boundValue.contains("valueOf")) { + return false; + } + if (boundValue.startsWith("\"") && boundValue.endsWith("\"")) { + return true; + } + if (boundValue.contains(".") && Character.isUpperCase(boundValue.charAt(boundValue.lastIndexOf('.') + 1))) { + return true; + } + return boundValue.equals(boundValue.toUpperCase(Locale.ROOT)) && boundValue.chars().allMatch(ch -> + Character.isUpperCase(ch) || ch == '_'); + } + + private static String substituteEqualsLiteralBindings(String expr, String varName, String boundValue) { + if (boundValue == null || boundValue.isBlank()) { + return expr; + } + String cleanValue = boundValue; + if (cleanValue.startsWith("\"") && cleanValue.endsWith("\"")) { + cleanValue = cleanValue.substring(1, cleanValue.length() - 1); + } + String literalPattern = Pattern.quote(cleanValue); + String receiverEquals = "(?i)[\"']?" + literalPattern + "[\"']?\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*" + + Pattern.quote(varName) + "\\s*\\)"; + String argumentEquals = "(?i)" + Pattern.quote(varName) + "\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?" + + literalPattern + "[\"']?\\s*\\)"; + expr = expr.replaceAll(receiverEquals, "true"); + expr = expr.replaceAll(argumentEquals, "true"); + + Set allLiterals = extractStringLiterals(expr); + for (String literal : allLiterals) { + if (literal.equalsIgnoreCase(cleanValue)) { + continue; + } + String otherLiteral = Pattern.quote(literal); + String otherReceiver = "(?i)[\"']?" + otherLiteral + "[\"']?\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*" + + Pattern.quote(varName) + "\\s*\\)"; + String otherArgument = "(?i)" + Pattern.quote(varName) + "\\s*\\.\\s*(equalsIgnoreCase|equals)\\s*\\(\\s*[\"']?" + + otherLiteral + "[\"']?\\s*\\)"; + expr = expr.replaceAll(otherReceiver, "false"); + expr = expr.replaceAll(otherArgument, "false"); + } + return expr; + } + private static Set extractEqualityVariables(String constraint) { Set vars = new HashSet<>(); Pattern pattern = Pattern.compile("([a-zA-Z][\\w]*)\\s*=="); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java index af7850f..d821f94 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java @@ -132,17 +132,25 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { String targetMethod = tp.getClassName() + "." + tp.getMethodName(); List> allPaths = new ArrayList<>(); for (String sMethod : startMethods) { - allPaths.addAll(pathFinder.findAllPaths( - sMethod, targetMethod, callGraph, new HashSet<>(), - pathBindingEvaluator, initialBindings)); + if (initialBindings.isEmpty()) { + allPaths.addAll(pathFinder.findAllPaths( + sMethod, targetMethod, callGraph, new HashSet<>())); + } else { + allPaths.addAll(pathFinder.findAllPaths( + sMethod, targetMethod, callGraph, new HashSet<>(), + pathBindingEvaluator, initialBindings)); + } } Set> uniquePaths = new LinkedHashSet<>(allPaths); for (List path : uniquePaths) { - if (!pathBindingEvaluator.isPathCompatible(path, callGraph, pathFinder, initialBindings)) { + boolean compatible = initialBindings.isEmpty() + ? pathBindingEvaluator.isPathCompatible(path, callGraph, pathFinder) + : pathBindingEvaluator.isPathCompatible(path, callGraph, pathFinder, initialBindings); + if (!compatible) { continue; } foundAny = true; - TriggerPoint resolvedTp = resolveTriggerPointParameters(tp, path, callGraph); + TriggerPoint resolvedTp = resolveTriggerPointParameters(tp, path, callGraph, initialBindings); if (resolvedTp != null) { String contextMachineId = pathFinder.extractContextMachineId(path, callGraph); chains.add(CallChain.builder() @@ -164,6 +172,14 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } protected TriggerPoint resolveTriggerPointParameters(TriggerPoint tp, List path, Map> callGraph) { + return resolveTriggerPointParameters(tp, path, callGraph, Map.of()); + } + + protected TriggerPoint resolveTriggerPointParameters( + TriggerPoint tp, + List path, + Map> callGraph, + Map initialBindings) { if (path.size() < 2) { boolean isExternal = isExternalParameter(tp.getClassName() + "." + tp.getMethodName(), tp.getEvent()); return tp.toBuilder().external(isExternal).constraint(tp.getConstraint()).build(); @@ -175,26 +191,42 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } String[] finalParamNameRef = { event }; - TriggerPoint resolved = resolveTriggerPointParametersOriginal(tp, path, callGraph, finalParamNameRef); + TriggerPoint resolved = resolveTriggerPointParametersOriginal(tp, path, callGraph, finalParamNameRef, initialBindings); if (resolved == null) return null; String entryMethod = path.isEmpty() ? (tp.getClassName() + "." + tp.getMethodName()) : path.get(0); - boolean isExternal = isExternalTrigger(entryMethod, finalParamNameRef[0], path, callGraph); + boolean isExternal = isExternalTrigger(entryMethod, finalParamNameRef[0], path, callGraph, initialBindings); - String pathConstraint = extractPathConstraints(path, callGraph); + String pathConstraint = extractPathConstraints(path, callGraph, initialBindings); String triggerConstraint = resolved.getConstraint(); - String finalConstraint = null; - if (pathConstraint != null && triggerConstraint != null) { - finalConstraint = pathConstraint + " && " + triggerConstraint; - } else if (pathConstraint != null) { - finalConstraint = pathConstraint; - } else { - finalConstraint = triggerConstraint; - } + String finalConstraint = mergeConstraints(pathConstraint, triggerConstraint); return resolved.toBuilder().external(isExternal).constraint(finalConstraint).build(); } - protected TriggerPoint resolveTriggerPointParametersOriginal(TriggerPoint tp, List path, Map> callGraph, String[] finalParamNameRef) { + private static String mergeConstraints(String pathConstraint, String triggerConstraint) { + if (pathConstraint != null && triggerConstraint != null) { + return pathConstraint + " && " + triggerConstraint; + } + if (pathConstraint != null) { + return pathConstraint; + } + return triggerConstraint; + } + + protected TriggerPoint resolveTriggerPointParametersOriginal( + TriggerPoint tp, + List path, + Map> callGraph, + String[] finalParamNameRef) { + return resolveTriggerPointParametersOriginal(tp, path, callGraph, finalParamNameRef, Map.of()); + } + + protected TriggerPoint resolveTriggerPointParametersOriginal( + TriggerPoint tp, + List path, + Map> callGraph, + String[] finalParamNameRef, + Map initialBindings) { click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel.setCurrentPath(path); final boolean debug = log.isDebugEnabled(); try { @@ -243,7 +275,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } if (paramIndex < 0) { - Map parameterValues = variableTracer.buildParameterValuesMap(caller, target, callGraph, path, i); + Map parameterValues = mergeBindings( + initialBindings, + variableTracer.buildParameterValuesMap(caller, target, callGraph, path, i)); String tracedVar = variableTracer.traceLocalVariable(target, currentParamName, parameterValues); if (tracedVar != null && !tracedVar.equals(currentParamName)) { String[] extractedTraced = extractMethodSuffix(tracedVar, methodSuffix, target); @@ -264,7 +298,10 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { if (edges != null) { for (CallEdge edge : edges) { if (edge.getTargetMethod().equals(target) || pathFinder.isHeuristicMatch(edge.getTargetMethod(), target)) { - String expectedType = typeResolver.getParameterType(target, paramIndex); + String expectedType = typeResolver.getParameterTypeAtCallSite(caller, target, paramIndex); + if (expectedType == null) { + expectedType = typeResolver.getParameterType(target, paramIndex); + } if (expectedType != null && paramIndex < edge.getArguments().size()) { String argValue = edge.getArguments().get(paramIndex); String actualType = null; @@ -394,7 +431,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { } } - String tracedVar = variableTracer.traceLocalVariable(entryMethod, currentParamName); + String tracedVar = variableTracer.traceLocalVariable(entryMethod, currentParamName, initialBindings); if (tracedVar != null && !tracedVar.equals(currentParamName)) { String[] extractedFinalTraced = extractMethodSuffix(tracedVar, methodSuffix, entryMethod); tracedVar = extractedFinalTraced[0]; @@ -2098,10 +2135,17 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { for (Object modifier : svd.modifiers()) { if (modifier instanceof Annotation annotation) { String typeName = annotation.getTypeName().getFullyQualifiedName(); - if (typeName.endsWith("PathVariable") || typeName.endsWith("RequestBody") || typeName.endsWith("RequestParam") + if (typeName.endsWith("PathVariable") || typeName.endsWith("RequestParam") || typeName.endsWith("PathParam") || typeName.endsWith("QueryParam")) { return true; } + if (typeName.endsWith("RequestBody")) { + ITypeBinding typeBinding = svd.getType().resolveBinding(); + if (typeBinding != null && typeBinding.isEnum()) { + return false; + } + return true; + } } } } @@ -2112,8 +2156,13 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return false; } - private String extractPathConstraints(List path, Map> callGraph) { + private String extractPathConstraints( + List path, Map> callGraph, Map initialBindings) { List constraints = new ArrayList<>(); + String bindingConstraint = constraintFromBindings(initialBindings); + if (bindingConstraint != null) { + constraints.add(bindingConstraint); + } for (int i = 0; i < path.size() - 1; i++) { String caller = path.get(i); String target = path.get(i + 1); @@ -2133,6 +2182,33 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { return String.join(" && ", constraints); } + private static String constraintFromBindings(Map bindings) { + if (bindings == null || bindings.isEmpty()) { + return null; + } + List parts = new ArrayList<>(); + for (Map.Entry entry : bindings.entrySet()) { + String value = entry.getValue(); + if (value == null || value.isBlank() || value.contains("(")) { + continue; + } + parts.add("\"" + value + "\".equalsIgnoreCase(" + entry.getKey() + ")"); + } + return parts.isEmpty() ? null : String.join(" && ", parts); + } + + private static Map mergeBindings( + Map initialBindings, Map tracedBindings) { + Map merged = new HashMap<>(); + if (tracedBindings != null) { + merged.putAll(tracedBindings); + } + if (initialBindings != null) { + merged.putAll(initialBindings); + } + return merged; + } + protected String resolveConstraint(MethodInvocation node, String calledMethod, String baseConstraint) { String resolvedConstraint = baseConstraint; if (node.getExpression() instanceof MethodInvocation miReceiver) { @@ -2295,7 +2371,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { protected abstract String resolveCalledMethod(MethodInvocation node); @SuppressWarnings("unchecked") - protected Map> buildCallGraph() { + public Map> buildCallGraph() { Map> cached = (Map>) context.getCache().get(callGraphCacheKey()); if (cached != null) { @@ -2838,11 +2914,13 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { String entryMethod, String finalParamName, List path, - Map> callGraph) { + Map> callGraph, + Map initialBindings) { if (isExternalParameter(entryMethod, finalParamName)) { return true; } Map bindings = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder); + bindings = mergeBindings(initialBindings, bindings); for (String boundName : bindings.keySet()) { if (isExternalParameter(entryMethod, boundName)) { return true; diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java index 54f8f73..c4e6436 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java @@ -27,6 +27,9 @@ public final class EntryPointBindingExpander { if (!isPathOrQueryVariable(parameter)) { continue; } + if (isPlaceholderResolvedInPath(entryPoint, parameter.getName())) { + continue; + } List cases = resolveStringCasesForParameter( entryPoint.getClassName() + "." + entryPoint.getMethodName(), parameter.getName(), @@ -73,6 +76,22 @@ public final class EntryPointBindingExpander { .build(); } + private static boolean isPlaceholderResolvedInPath(EntryPoint entryPoint, String paramName) { + String placeholder = "{" + paramName + "}"; + String name = entryPoint.getName(); + if (name != null && !name.contains(placeholder)) { + return true; + } + Map metadata = entryPoint.getMetadata(); + if (metadata != null) { + String path = metadata.get("path"); + if (path != null && !path.contains(placeholder)) { + return true; + } + } + return false; + } + private static boolean isPathOrQueryVariable(EntryPoint.Parameter parameter) { if (parameter.getAnnotations() == null) { return false; @@ -135,6 +154,12 @@ public final class EntryPointBindingExpander { collectCasesFromSwitch(node.getExpression(), node.statements(), paramName, cases); return super.visit(node); } + + @Override + public boolean visit(IfStatement node) { + collectCasesFromIfChain(node, paramName, cases); + return super.visit(node); + } }); return List.copyOf(cases); } @@ -158,6 +183,62 @@ public final class EntryPointBindingExpander { } } + private static void collectCasesFromIfChain(IfStatement ifStatement, String paramName, Set cases) { + IfStatement current = ifStatement; + while (current != null) { + String literal = extractEqualsLiteralForParameter(current.getExpression(), paramName); + if (literal != null) { + cases.add(literal); + } + Statement elseStatement = current.getElseStatement(); + if (elseStatement instanceof IfStatement nestedIf) { + current = nestedIf; + } else { + if (elseStatement != null) { + String elseLiteral = extractEqualsLiteralForParameterFromStatement(elseStatement, paramName); + if (elseLiteral != null) { + cases.add(elseLiteral); + } + } + break; + } + } + } + + private static String extractEqualsLiteralForParameterFromStatement(Statement statement, String paramName) { + if (statement instanceof IfStatement ifStatement) { + return extractEqualsLiteralForParameter(ifStatement.getExpression(), paramName); + } + return null; + } + + private static String extractEqualsLiteralForParameter(Expression expression, String paramName) { + if (!(expression instanceof MethodInvocation invocation)) { + return null; + } + String methodName = invocation.getName().getIdentifier(); + if (!"equals".equals(methodName) && !"equalsIgnoreCase".equals(methodName)) { + return null; + } + if (invocation.arguments().size() != 1) { + return null; + } + Expression argument = (Expression) invocation.arguments().get(0); + Expression receiver = invocation.getExpression(); + + if (receiver instanceof StringLiteral stringLiteral + && argument instanceof SimpleName simpleName + && paramName.equals(simpleName.getIdentifier())) { + return stringLiteral.getLiteralValue(); + } + if (receiver instanceof SimpleName simpleName + && paramName.equals(simpleName.getIdentifier()) + && argument instanceof StringLiteral paramLiteral) { + return paramLiteral.getLiteralValue(); + } + return null; + } + private static boolean hasParameter(MethodDeclaration method, String paramName) { for (Object paramObj : method.parameters()) { if (paramObj instanceof SingleVariableDeclaration param diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicy.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicy.java new file mode 100644 index 0000000..50bf852 --- /dev/null +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicy.java @@ -0,0 +1,270 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.*; + +/** + * Single source-derived policy for marking REST triggers as external vs internal. + */ +public final class ExternalTriggerPolicy { + + private ExternalTriggerPolicy() { + } + + public static boolean isExternalFromSource( + EntryPoint entryPoint, + TriggerPoint trigger, + String entryMethodFqn, + String resolvedEventParamName, + CodebaseContext context) { + if (trigger == null) { + return false; + } + if (MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent()) + == MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM) { + return false; + } + if (trigger.getPolymorphicEvents() != null + && !trigger.getPolymorphicEvents().isEmpty() + && MachineEnumCanonicalizer.hasOnlyConcretePolymorphicEvents(trigger.getPolymorphicEvents())) { + return false; + } + if (entryPoint != null && entryPoint.getType() == EntryPoint.Type.REST) { + String paramName = resolveRestEventParameterName(entryPoint, resolvedEventParamName); + if (paramName != null) { + RestParamBinding binding = findRestParameterBinding(entryPoint, paramName, context); + if (binding != null) { + if (binding.pathOrQueryVariable()) { + return true; + } + if (binding.requestBodyEnum()) { + return false; + } + } + if (entryPoint.getClassName() != null && entryPoint.getMethodName() != null) { + RestParamBinding requestBodyField = findRequestBodyEnumFieldBinding( + entryPoint.getClassName() + "." + entryPoint.getMethodName(), paramName, context); + if (requestBodyField != null && requestBodyField.requestBodyEnum()) { + return false; + } + } + } + if (entryPoint.getName() != null && entryPoint.getName().contains("{") + && MachineEnumCanonicalizer.isDynamicTriggerExpression(trigger.getEvent())) { + return true; + } + } + if (entryMethodFqn != null && resolvedEventParamName != null) { + RestParamBinding binding = findMethodParameterBinding(entryMethodFqn, resolvedEventParamName, context); + if (binding != null) { + if (binding.pathOrQueryVariable()) { + return true; + } + if (binding.requestBodyEnum()) { + return false; + } + } + RestParamBinding requestBodyField = findRequestBodyEnumFieldBinding( + entryMethodFqn, resolvedEventParamName, context); + if (requestBodyField != null && requestBodyField.requestBodyEnum()) { + return false; + } + } + return trigger.isExternal(); + } + + private static String resolveRestEventParameterName(EntryPoint entryPoint, String resolvedEventParamName) { + if (resolvedEventParamName != null && !resolvedEventParamName.isBlank()) { + return resolvedEventParamName; + } + if (entryPoint.getParameters() == null) { + return null; + } + for (EntryPoint.Parameter parameter : entryPoint.getParameters()) { + if (parameter.getAnnotations() == null) { + continue; + } + for (String annotation : parameter.getAnnotations()) { + if ("PathVariable".equals(annotation) || "RequestParam".equals(annotation)) { + return parameter.getName(); + } + } + } + return null; + } + + private static RestParamBinding findRestParameterBinding( + EntryPoint entryPoint, String paramName, CodebaseContext context) { + if (entryPoint.getParameters() == null || paramName == null) { + return null; + } + for (EntryPoint.Parameter parameter : entryPoint.getParameters()) { + if (!paramName.equals(parameter.getName())) { + continue; + } + return RestParamBinding.fromEntryPointParameter(parameter); + } + if (entryPoint.getClassName() != null && entryPoint.getMethodName() != null) { + return findMethodParameterBinding( + entryPoint.getClassName() + "." + entryPoint.getMethodName(), paramName, context); + } + return null; + } + + private static RestParamBinding findMethodParameterBinding( + String methodFqn, String paramName, CodebaseContext context) { + if (methodFqn == null || !methodFqn.contains(".") || paramName == null || context == null) { + return null; + } + String className = methodFqn.substring(0, methodFqn.lastIndexOf('.')); + String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1); + TypeDeclaration typeDeclaration = context.getTypeDeclaration(className); + if (typeDeclaration == null) { + return null; + } + MethodDeclaration method = context.findMethodDeclaration(typeDeclaration, methodName, true); + if (method == null) { + return null; + } + for (Object paramObj : method.parameters()) { + if (!(paramObj instanceof SingleVariableDeclaration param)) { + continue; + } + if (!paramName.equals(param.getName().getIdentifier())) { + continue; + } + return RestParamBinding.fromAstParameter(param); + } + return null; + } + + private static RestParamBinding findRequestBodyEnumFieldBinding( + String methodFqn, String fieldName, CodebaseContext context) { + if (methodFqn == null || !methodFqn.contains(".") || fieldName == null || context == null) { + return null; + } + String className = methodFqn.substring(0, methodFqn.lastIndexOf('.')); + String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1); + TypeDeclaration typeDeclaration = context.getTypeDeclaration(className); + if (typeDeclaration == null) { + return null; + } + MethodDeclaration method = context.findMethodDeclaration(typeDeclaration, methodName, true); + if (method == null) { + return null; + } + for (Object paramObj : method.parameters()) { + if (!(paramObj instanceof SingleVariableDeclaration param)) { + continue; + } + if (!hasParameterAstAnnotation(param, "RequestBody") || param.getType() == null) { + continue; + } + ITypeBinding bodyBinding = param.getType().resolveBinding(); + if (bodyBinding == null || bodyBinding.isEnum()) { + continue; + } + if (isEnumRecordComponent(bodyBinding, fieldName)) { + return new RestParamBinding(false, false, true, true); + } + if (isEnumDtoField(bodyBinding, fieldName, context)) { + return new RestParamBinding(false, false, true, true); + } + } + return null; + } + + private static boolean isEnumRecordComponent(ITypeBinding bodyBinding, String fieldName) { + if (bodyBinding == null || fieldName == null) { + return false; + } + for (IVariableBinding component : bodyBinding.getDeclaredFields()) { + if (!fieldName.equals(component.getName())) { + continue; + } + ITypeBinding fieldType = component.getType(); + return fieldType != null && fieldType.isEnum(); + } + return false; + } + + private static boolean isEnumDtoField(ITypeBinding bodyBinding, String fieldName, CodebaseContext context) { + if (bodyBinding == null || fieldName == null || context == null) { + return false; + } + String bodyFqn = bodyBinding.getQualifiedName(); + if (bodyFqn == null) { + return false; + } + AbstractTypeDeclaration bodyType = context.getAbstractTypeDeclaration(bodyFqn); + if (!(bodyType instanceof TypeDeclaration td)) { + return false; + } + for (FieldDeclaration field : td.getFields()) { + for (Object fragObj : field.fragments()) { + if (!(fragObj instanceof VariableDeclarationFragment fragment)) { + continue; + } + if (!fieldName.equals(fragment.getName().getIdentifier())) { + continue; + } + ITypeBinding fieldBinding = field.getType().resolveBinding(); + return fieldBinding != null && fieldBinding.isEnum(); + } + } + return false; + } + + private static boolean hasParameterAstAnnotation(SingleVariableDeclaration param, String simpleName) { + for (Object modifier : param.modifiers()) { + if (modifier instanceof Annotation annotation + && annotation.getTypeName().getFullyQualifiedName().endsWith(simpleName)) { + return true; + } + } + return false; + } + + private record RestParamBinding(boolean pathVariable, boolean requestParam, boolean requestBody, boolean enumType) { + boolean pathOrQueryVariable() { + return pathVariable || requestParam; + } + + boolean requestBodyEnum() { + return requestBody && enumType; + } + + static RestParamBinding fromEntryPointParameter(EntryPoint.Parameter parameter) { + boolean pathVariable = hasAnnotation(parameter.getAnnotations(), "PathVariable"); + boolean requestParam = hasAnnotation(parameter.getAnnotations(), "RequestParam"); + boolean requestBody = hasAnnotation(parameter.getAnnotations(), "RequestBody"); + boolean enumType = isEnumTypeName(parameter.getType()); + return new RestParamBinding(pathVariable, requestParam, requestBody, enumType); + } + + static RestParamBinding fromAstParameter(SingleVariableDeclaration param) { + boolean pathVariable = hasParameterAstAnnotation(param, "PathVariable"); + boolean requestParam = hasParameterAstAnnotation(param, "RequestParam"); + boolean requestBody = hasParameterAstAnnotation(param, "RequestBody"); + boolean enumType = param.getType() != null && param.getType().resolveBinding() != null + && param.getType().resolveBinding().isEnum(); + return new RestParamBinding(pathVariable, requestParam, requestBody, enumType); + } + + private static boolean hasAnnotation(java.util.List annotations, String name) { + return annotations != null && annotations.stream().anyMatch(name::equals); + } + + private static boolean isEnumTypeName(String typeName) { + if (typeName == null || typeName.isBlank()) { + return false; + } + int lastDot = typeName.lastIndexOf('.'); + String simple = lastDot >= 0 ? typeName.substring(lastDot + 1) : typeName; + return Character.isUpperCase(simple.charAt(0)) && !simple.endsWith("String"); + } + } +} 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 3299c74..fabb8ca 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 @@ -41,6 +41,11 @@ public class GenericEventDetector { private static final Set TRIGGER_METHOD_NAMES = Set.of( "sendEvent", "sendEvents", "sendEventCollect", "sendEventMono", "fire", "trigger"); + /** Built-in source patterns for Spring State Machine and messaging APIs (no hints.json required). */ + private static final List BUILTIN_SOURCE_PATTERNS = List.of( + LibraryHint.builder().methodFqn("org.springframework.messaging.support.MessageBuilder.build").eventArgumentIndex(0).build(), + LibraryHint.builder().methodFqn("org.springframework.messaging.support.GenericMessage.getPayload").eventArgumentMethod("getPayload").build()); + public List detect(CompilationUnit cu) { List triggers = new ArrayList<>(); String fileName = "unknown"; @@ -68,6 +73,7 @@ public class GenericEventDetector { } } + processBuiltInPatterns(node, cu, triggers); processHints(node, cu, triggers); return super.visit(node); @@ -88,6 +94,9 @@ public class GenericEventDetector { if (type == null) return; String sourceState = extractSourceState(node); + if (sourceState == null) { + sourceState = extractSourceStateFromArguments(node); + } String[] smTypes = resolveStateMachineTypeArgumentsForExpression(emr.getExpression(), node); boolean external = false; @@ -176,40 +185,78 @@ public class GenericEventDetector { } } - private void processHints(MethodInvocation node, CompilationUnit cu, List triggers) { - if (hints == null || hints.isEmpty()) return; - + private void processBuiltInPatterns(MethodInvocation node, CompilationUnit cu, List triggers) { + String methodName = node.getName().getIdentifier(); + if (TRIGGER_METHOD_NAMES.contains(methodName)) { + return; + } String calledMethod = resolveCalledMethodName(node); - if (calledMethod == null) return; - - for (LibraryHint hint : hints) { - if (calledMethod.equals(hint.getMethodFqn()) || isHintMatch(calledMethod, hint.getMethodFqn())) { - 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) { - if (log.isDebugEnabled()) { - log.debug("Successfully built synthetic trigger point from hint: {}", trigger.getEvent()); - } - triggers.add(trigger); + if (calledMethod == null) { + return; + } + for (LibraryHint pattern : BUILTIN_SOURCE_PATTERNS) { + if (!matchesHint(calledMethod, pattern.getMethodFqn())) { + continue; + } + String eventToUse = resolveHintEvent(node, pattern); + List builtTriggers = buildTriggerPoints(node, cu, eventToUse); + if (builtTriggers != null) { + for (TriggerPoint trigger : builtTriggers) { + if (log.isDebugEnabled()) { + log.debug("Built trigger from built-in source pattern {}: {}", pattern.getMethodFqn(), trigger.getEvent()); } + triggers.add(trigger); } } } } - private boolean isHintMatch(String called, String hintFqn) { - return hintFqn.endsWith("." + called); + private void processHints(MethodInvocation node, CompilationUnit cu, List triggers) { + if (hints == null || hints.isEmpty()) { + return; + } + + String calledMethod = resolveCalledMethodName(node); + if (calledMethod == null) { + return; + } + + for (LibraryHint hint : hints) { + if (!matchesHint(calledMethod, hint.getMethodFqn())) { + continue; + } + if (log.isDebugEnabled()) { + log.debug("Applying optional hints.json override for {}", hint.getMethodFqn()); + } + String eventToUse = resolveHintEvent(node, hint); + List builtTriggers = buildTriggerPoints(node, cu, eventToUse); + if (builtTriggers != null) { + for (TriggerPoint trigger : builtTriggers) { + if (log.isDebugEnabled()) { + log.debug("Successfully built synthetic trigger point from hint: {}", trigger.getEvent()); + } + triggers.add(trigger); + } + } + } + } + + private static String resolveHintEvent(MethodInvocation node, LibraryHint hint) { + 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() + "()"; + } + } + } + return eventToUse; + } + + private static boolean matchesHint(String calledMethod, String hintFqn) { + return calledMethod.equals(hintFqn) || hintFqn.endsWith("." + calledMethod); } private String resolveCalledMethodName(MethodInvocation node) { @@ -261,6 +308,9 @@ public class GenericEventDetector { if (type == null) return Collections.emptyList(); String sourceState = extractSourceState(node); + if (sourceState == null && node instanceof MethodInvocation mi) { + sourceState = extractSourceStateFromArguments(mi); + } String[] smTypes = resolveStateMachineTypeArguments(node); boolean external = false; @@ -340,6 +390,40 @@ public class GenericEventDetector { return null; } + /** + * Infers source state from a literal second argument to sendEvent(event, sourceState) when provable from AST. + */ + private String extractSourceStateFromArguments(MethodInvocation node) { + if (node.arguments().size() < 2) { + return null; + } + Expression stateExpr = (Expression) node.arguments().get(1); + return resolveProvableStateLiteral(stateExpr); + } + + private String resolveProvableStateLiteral(Expression expr) { + if (expr == null) { + return null; + } + Expression peeled = expr; + while (peeled instanceof ParenthesizedExpression pe) { + peeled = pe.getExpression(); + } + while (peeled instanceof CastExpression ce) { + peeled = ce.getExpression(); + } + if (peeled instanceof QualifiedName || peeled instanceof FieldAccess || peeled instanceof StringLiteral) { + String resolved = constantResolver.resolve(peeled, context); + if (resolved != null && !resolved.isBlank()) { + return getSimpleNameString(peeled); + } + if (peeled instanceof StringLiteral sl) { + return sl.getLiteralValue(); + } + } + return null; + } + private String extractStateFromSiblings(ASTNode currentNode, List statements) { int index = statements.indexOf(currentNode); if (index <= 0) return null; // No previous siblings diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/TypeResolver.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/TypeResolver.java index 157ecfa..648fc4d 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/TypeResolver.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/TypeResolver.java @@ -170,6 +170,43 @@ public class TypeResolver { return null; } + /** + * Resolves parameter type from the actual call site in {@code callerFqn}, so overloaded + * targets (e.g. {@code send(OrderEvent)} vs {@code send(DocumentEvent)}) use the invoked overload. + */ + public String getParameterTypeAtCallSite(String callerFqn, String calleeFqn, int paramIndex) { + if (callerFqn == null || calleeFqn == null || paramIndex < 0 + || !callerFqn.contains(".") || !calleeFqn.contains(".")) { + return null; + } + String callerClass = callerFqn.substring(0, callerFqn.lastIndexOf('.')); + String callerMethod = callerFqn.substring(callerFqn.lastIndexOf('.') + 1); + String calleeMethod = calleeFqn.substring(calleeFqn.lastIndexOf('.') + 1); + TypeDeclaration td = context.getTypeDeclaration(callerClass); + if (td == null) { + return null; + } + MethodDeclaration md = context.findMethodDeclaration(td, callerMethod, true); + if (md == null || md.getBody() == null) { + return null; + } + final String[] result = new String[1]; + md.getBody().accept(new org.eclipse.jdt.core.dom.ASTVisitor() { + @Override + public boolean visit(org.eclipse.jdt.core.dom.MethodInvocation node) { + if (!calleeMethod.equals(node.getName().getIdentifier())) { + return super.visit(node); + } + org.eclipse.jdt.core.dom.IMethodBinding binding = node.resolveMethodBinding(); + if (binding != null && binding.getParameterTypes().length > paramIndex) { + result[0] = binding.getParameterTypes()[paramIndex].getErasure().getQualifiedName(); + } + return super.visit(node); + } + }); + return result[0]; + } + public boolean isTypeCompatible(String actualType, String expectedType) { if (actualType == null || expectedType == null) return true; if (expectedType.equals("Object") || expectedType.equals("java.lang.Object")) return true; diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracer.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracer.java index 674a49f..a2d1a90 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracer.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracer.java @@ -1,6 +1,7 @@ package click.kamil.springstatemachineexporter.analysis.service; import click.kamil.springstatemachineexporter.analysis.model.CallEdge; +import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator; import click.kamil.springstatemachineexporter.analysis.pipeline.FieldInitializerFinder; import click.kamil.springstatemachineexporter.analysis.pipeline.MethodInvocationUnwrapper; import click.kamil.springstatemachineexporter.analysis.pipeline.ResolutionBudget; @@ -280,6 +281,21 @@ public class VariableTracer { } public String traceLocalVariable(String methodFqn, String varName) { + return traceLocalVariable(methodFqn, varName, null); + } + + public String traceLocalVariable(String methodFqn, String varName, Map branchBindings) { + String result = traceLocalVariableInternal(methodFqn, varName, branchBindings); + if (result != null && branchBindings != null && !branchBindings.isEmpty()) { + String switchEvaluated = evaluateSwitchAssignment(methodFqn, varName, branchBindings); + if (switchEvaluated != null) { + return switchEvaluated; + } + } + return result; + } + + private String traceLocalVariableInternal(String methodFqn, String varName, Map branchBindings) { if (methodFqn == null || !methodFqn.contains(".")) return null; String className = methodFqn.substring(0, methodFqn.lastIndexOf('.')); String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1); @@ -287,27 +303,33 @@ public class VariableTracer { if (td != null) { MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); if (md != null && md.getBody() != null) { - List initializers = new ArrayList<>(); + List assignments = new ArrayList<>(); md.getBody().accept(new ASTVisitor() { @Override public boolean visit(VariableDeclarationFragment node) { if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) { - initializers.add(peelExpression(node.getInitializer())); + assignments.add(new BranchAssignment( + peelExpression(node.getInitializer()), + click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node))); } return super.visit(node); } @Override public boolean visit(Assignment node) { if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) { - initializers.add(peelExpression(node.getRightHandSide())); + assignments.add(new BranchAssignment( + peelExpression(node.getRightHandSide()), + click.kamil.springstatemachineexporter.ast.common.AstUtils.findConditionConstraint(node))); } return super.visit(node); } }); - - if (!initializers.isEmpty()) { + + List filtered = filterAssignmentsByBindings(assignments, branchBindings); + if (!filtered.isEmpty()) { List stringified = new ArrayList<>(); - for (Expression expr : initializers) { + for (BranchAssignment assignment : filtered) { + Expression expr = assignment.expression(); Expression traced = traceVariable(expr); if (traced instanceof MethodInvocation mi) { Expression innerMost = unwrapMethodInvocation(mi, 0, methodFqn); @@ -321,7 +343,7 @@ public class VariableTracer { if (stringified.size() == 1) { return stringified.get(0).replaceAll("->\\s*yield\\s+", "-> "); } - + StringBuilder sb = new StringBuilder(); for (int i = 0; i < stringified.size() - 1; i++) { sb.append("true ? ").append(stringified.get(i)).append(" : "); @@ -380,62 +402,79 @@ public class VariableTracer { return null; } - public String traceLocalVariable(String methodFqn, String varName, Map parameterValues) { - String result = traceLocalVariable(methodFqn, varName); - if (result != null && parameterValues != null && !parameterValues.isEmpty()) { - TypeDeclaration td = context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.'))); - if (td != null) { - String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1); - MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); - if (md != null && md.getBody() != null) { - final ASTNode[] switchNode = new ASTNode[1]; - md.getBody().accept(new ASTVisitor() { - @Override - public boolean visit(VariableDeclarationFragment node) { - if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) { - Expression init = node.getInitializer(); - if (init.getNodeType() == ASTNode.SWITCH_EXPRESSION) { - switchNode[0] = init; - return false; - } - if (init.getNodeType() == ASTNode.SWITCH_STATEMENT) { - switchNode[0] = init; - return false; - } - } - return super.visit(node); - } - @Override - public boolean visit(Assignment node) { - if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) { - Expression rhs = node.getRightHandSide(); - if (rhs.getNodeType() == ASTNode.SWITCH_EXPRESSION) { - switchNode[0] = rhs; - return false; - } - if (rhs.getNodeType() == ASTNode.SWITCH_STATEMENT) { - switchNode[0] = rhs; - return false; - } - } - return super.visit(node); - } - }); - if (switchNode[0] != null) { - if (switchNode[0] instanceof SwitchExpression se) { - String evaluated = constantResolver.evaluateSwitchWithParams(se, parameterValues, context); - if (evaluated != null) return evaluated; - } else if (switchNode[0] instanceof SwitchStatement ss) { - String evaluated = constantResolver.evaluateSwitchWithParams(ss, parameterValues, context); - if (evaluated != null) return evaluated; - } + private String evaluateSwitchAssignment(String methodFqn, String varName, Map parameterValues) { + TypeDeclaration td = context.getTypeDeclaration(methodFqn.substring(0, methodFqn.lastIndexOf('.'))); + if (td == null) { + return null; + } + String methodName = methodFqn.substring(methodFqn.lastIndexOf('.') + 1); + MethodDeclaration md = context.findMethodDeclaration(td, methodName, true); + if (md == null || md.getBody() == null) { + return null; + } + final ASTNode[] switchNode = new ASTNode[1]; + md.getBody().accept(new ASTVisitor() { + @Override + public boolean visit(VariableDeclarationFragment node) { + if (node.getName().getIdentifier().equals(varName) && node.getInitializer() != null) { + Expression init = node.getInitializer(); + if (init.getNodeType() == ASTNode.SWITCH_EXPRESSION || init.getNodeType() == ASTNode.SWITCH_STATEMENT) { + switchNode[0] = init; + return false; } } + return super.visit(node); + } + + @Override + public boolean visit(Assignment node) { + if (node.getLeftHandSide() instanceof SimpleName asn && asn.getIdentifier().equals(varName)) { + Expression rhs = node.getRightHandSide(); + if (rhs.getNodeType() == ASTNode.SWITCH_EXPRESSION || rhs.getNodeType() == ASTNode.SWITCH_STATEMENT) { + switchNode[0] = rhs; + return false; + } + } + return super.visit(node); + } + }); + if (switchNode[0] instanceof SwitchExpression se) { + return constantResolver.evaluateSwitchWithParams(se, parameterValues, context); + } + if (switchNode[0] instanceof SwitchStatement ss) { + return constantResolver.evaluateSwitchWithParams(ss, parameterValues, context); + } + return null; + } + + private static List filterAssignmentsByBindings( + List assignments, Map branchBindings) { + if (assignments.isEmpty()) { + return assignments; + } + if (branchBindings == null || branchBindings.isEmpty()) { + return assignments; + } + List compatible = new ArrayList<>(); + List unconstrained = new ArrayList<>(); + for (BranchAssignment assignment : assignments) { + String constraint = assignment.constraint(); + if (constraint == null || constraint.isBlank()) { + unconstrained.add(assignment); + continue; + } + if (BooleanConstraintEvaluator.isCompatibleWithBindings(constraint, branchBindings)) { + compatible.add(assignment); } } - return result; + if (!compatible.isEmpty()) { + return compatible; + } + return unconstrained; } + private record BranchAssignment(Expression expression, String constraint) {} + public Map buildParameterValuesMap(String caller, String target, Map> callGraph, List path, int pathIndex) { Map paramValues = new HashMap<>(); List edges = callGraph.get(caller); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/validation/AnalysisCanonicalFormValidator.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/validation/AnalysisCanonicalFormValidator.java index d6895e0..b6939fc 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/validation/AnalysisCanonicalFormValidator.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/validation/AnalysisCanonicalFormValidator.java @@ -419,19 +419,31 @@ public final class AnalysisCanonicalFormValidator { if (LifecycleTriggerMarkers.isLifecycle(trigger.getEvent())) { return; } - if (trigger.isAmbiguous() && !trigger.isExternal()) { - return; - } - if (!MachineEnumCanonicalizer.hasOnlyConcretePolymorphicEvents(trigger.getPolymorphicEvents())) { - return; - } - if (MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent()) - != MachineEnumCanonicalizer.TriggerEventKind.DYNAMIC_EXPRESSION) { + if (isIntentionalAmbiguousFailClosed(trigger)) { return; } if (chain.getMatchedTransitions() != null && !chain.getMatchedTransitions().isEmpty()) { return; } + + MachineEnumCanonicalizer.TriggerEventKind eventKind = + MachineEnumCanonicalizer.classifyTriggerEvent(trigger.getEvent()); + if (eventKind == MachineEnumCanonicalizer.TriggerEventKind.CANONICAL_ENUM) { + if (eventMatchesAnyTransition(trigger.getEvent(), transitions, eventTypeFqn)) { + violations.add(new Violation( + chainPrefix + ".matchedTransitions", + "empty", + "non-empty when trigger has concrete machine enum event matching transitions")); + } + return; + } + + if (!MachineEnumCanonicalizer.hasOnlyConcretePolymorphicEvents(trigger.getPolymorphicEvents())) { + return; + } + if (eventKind != MachineEnumCanonicalizer.TriggerEventKind.DYNAMIC_EXPRESSION) { + return; + } if (!polymorphicEventsMatchAnyTransition(trigger.getPolymorphicEvents(), transitions, eventTypeFqn)) { return; } @@ -441,6 +453,47 @@ public final class AnalysisCanonicalFormValidator { "non-empty when dynamic trigger has concrete polymorphicEvents matching transitions")); } + private static boolean isIntentionalAmbiguousFailClosed(TriggerPoint trigger) { + if (trigger == null) { + return false; + } + if (trigger.isAmbiguous() && !trigger.isExternal()) { + return true; + } + String event = trigger.getEvent(); + if (event != null && event.startsWith("ENUM_SET:")) { + return true; + } + if (trigger.isExternal() + && trigger.isAmbiguous() + && MachineEnumCanonicalizer.isDynamicTriggerExpression(event) + && (trigger.getPolymorphicEvents() == null || trigger.getPolymorphicEvents().isEmpty())) { + return true; + } + return false; + } + + private static boolean eventMatchesAnyTransition( + String triggerEvent, + List transitions, + String eventTypeFqn) { + if (transitions == null || transitions.isEmpty() || triggerEvent == null) { + return false; + } + for (Transition transition : transitions) { + if (transition.getEvent() == null) { + continue; + } + String smEvent = transition.getEvent().fullIdentifier() != null + ? transition.getEvent().fullIdentifier() + : transition.getEvent().rawName(); + if (eventsMatch(triggerEvent, smEvent, eventTypeFqn)) { + return true; + } + } + return false; + } + private static boolean polymorphicEventsMatchAnyTransition( List polymorphicEvents, List transitions, 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 761ba95..4913b18 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 @@ -139,9 +139,9 @@ public class CodebaseContext { public void loadLibraryHints(Path hintsFile) throws IOException { if (Files.exists(hintsFile)) { - System.out.println("Loading hints from " + hintsFile.toAbsolutePath()); + log.debug("Loading optional hints.json override from {}", hintsFile.toAbsolutePath()); this.libraryHints = objectMapper.readValue(hintsFile.toFile(), new TypeReference>() {}); - System.out.println("Loaded " + libraryHints.size() + " library hints"); + log.debug("Loaded {} optional library hints from hints.json", libraryHints.size()); } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java index 17eb861..d22051a 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/service/ExportService.java @@ -125,7 +125,7 @@ public class ExportService { } if (Files.exists(hintsFile)) { - log.info("Loading hints from {}", hintsFile.toAbsolutePath()); + log.debug("Loading optional hints.json override from {}", hintsFile.toAbsolutePath()); context.loadLibraryHints(hintsFile); } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/MachineScopeFilterEntryPointTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/MachineScopeFilterEntryPointTest.java index 347097e..c1eb9f5 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/MachineScopeFilterEntryPointTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/MachineScopeFilterEntryPointTest.java @@ -1,9 +1,14 @@ package click.kamil.springstatemachineexporter.analysis.enricher; +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.nio.file.Files; +import java.nio.file.Path; import java.util.List; import java.util.Map; @@ -12,77 +17,121 @@ import static org.assertj.core.api.Assertions.assertThat; class MachineScopeFilterEntryPointTest { @Test - void shouldKeepOrderEndpointsOnOrderMachineOnly() { + void shouldDeriveScopedEntryPointsFromCallChainsAndKeepGenericPathVariables() { EntryPoint orderPay = EntryPoint.builder() .type(EntryPoint.Type.REST) - .name("POST /api/orders/pay") - .className("click.kamil.examples.statemachine.layered.web.OrderController") - .methodName("pay") - .metadata(Map.of("path", "/api/orders/pay")) + .name("POST /api/machine/order/pay") + .className("com.example.StateMachineController") + .methodName("payOrder") .build(); - EntryPoint documentSubmit = EntryPoint.builder() + EntryPoint genericTransition = EntryPoint.builder() .type(EntryPoint.Type.REST) - .name("POST /api/documents/submit") - .className("click.kamil.examples.statemachine.layered.web.DocumentController") - .methodName("submit") - .metadata(Map.of("path", "/api/documents/submit")) - .build(); - EntryPoint genericCommand = EntryPoint.builder() - .type(EntryPoint.Type.REST) - .name("POST /api/commands/{commandKey}") - .className("click.kamil.examples.statemachine.layered.web.GenericCommandController") - .methodName("execute") - .metadata(Map.of("path", "/api/commands/{commandKey}")) + .name("POST /api/machine/{machineType}/transition/{event}") + .className("com.example.StateMachineController") + .methodName("transition") + .metadata(Map.of("path", "/api/machine/{machineType}/transition/{event}")) .build(); + CallChain payChain = CallChain.builder() + .entryPoint(orderPay) + .triggerPoint(TriggerPoint.builder() + .event("com.example.OrderEvent.PAY") + .eventTypeFqn("com.example.OrderEvent") + .build()) + .build(); + + List scoped = EntryPointScopeResolver.scopeFromCallChains( + List.of(payChain), + List.of(orderPay, genericTransition)); + + assertThat(scoped).extracting(EntryPoint::getName) + .containsExactlyInAnyOrder( + "POST /api/machine/order/pay", + "POST /api/machine/{machineType}/transition/{event}"); + } + + @Test + void shouldScopeEnterpriseDedicatedPayEndpointToOrderMachine(@TempDir Path tempDir) throws Exception { + writeEnterpriseWebLayer(tempDir); + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + EntryPoint payOrder = EntryPoint.builder() + .type(EntryPoint.Type.REST) + .name("POST /api/machine/order/pay") + .className("com.example.StateMachineController") + .methodName("payOrder") + .metadata(Map.of("path", "/api/machine/order/pay")) + .build(); + EntryPoint submitDocument = EntryPoint.builder() + .type(EntryPoint.Type.REST) + .name("POST /api/machine/document/submit") + .className("com.example.StateMachineController") + .methodName("submitDocument") + .metadata(Map.of("path", "/api/machine/document/submit")) + .build(); + List orderScoped = MachineScopeFilter.filterEntryPointsForMachine( - List.of(orderPay, documentSubmit, genericCommand), - "click.kamil.examples.statemachine.layered.order.config.StandardOrderStateMachineConfiguration", + List.of(payOrder, submitDocument), + "com.example.OrderStateMachineConfiguration", context); assertThat(orderScoped).extracting(EntryPoint::getName) - .contains("POST /api/orders/pay", "POST /api/commands/{commandKey}") - .doesNotContain("POST /api/documents/submit"); + .containsExactly("POST /api/machine/order/pay"); } @Test - void shouldKeepDedicatedOrderEndpointOnStandardOrderMachineInMultiModuleCodebase() { - EntryPoint orderPay = EntryPoint.builder() - .type(EntryPoint.Type.REST) - .name("POST /api/orders/pay") - .className("click.kamil.examples.statemachine.layered.web.OrderController") - .methodName("pay") - .metadata(Map.of("path", "/api/orders/pay")) - .build(); + void shouldExcludeDocumentDedicatedEndpointFromOrderMachine(@TempDir Path tempDir) throws Exception { + writeEnterpriseWebLayer(tempDir); CodebaseContext context = new CodebaseContext(); - List scoped = MachineScopeFilter.filterEntryPointsForMachine( - List.of(orderPay), - "click.kamil.examples.statemachine.layered.order.config.StandardOrderStateMachineConfiguration", + context.setResolveBindings(true); + context.scan(tempDir); + + EntryPoint submitDocument = EntryPoint.builder() + .type(EntryPoint.Type.REST) + .name("POST /api/machine/document/submit") + .className("com.example.StateMachineController") + .methodName("submitDocument") + .metadata(Map.of("path", "/api/machine/document/submit")) + .build(); + + List orderScoped = MachineScopeFilter.filterEntryPointsForMachine( + List.of(submitDocument), + "com.example.OrderStateMachineConfiguration", context); - assertThat(scoped).extracting(EntryPoint::getName) - .containsExactly("POST /api/orders/pay"); + assertThat(orderScoped).isEmpty(); } - @Test - void shouldKeepOrdersPathOnNonDomainNamedMachineConfig() { - EntryPoint ordersSubmit = EntryPoint.builder() - .type(EntryPoint.Type.REST) - .name("POST /api/v2/orders/submit") - .className("click.kamil.examples.statemachine.inheritance.api.OrderControllerImpl") - .methodName("submitOrder") - .metadata(Map.of("path", "/api/v2/orders/submit")) - .build(); - - CodebaseContext context = new CodebaseContext(); - List scoped = MachineScopeFilter.filterEntryPointsForMachine( - List.of(ordersSubmit), - "click.kamil.examples.statemachine.inheritance.config.InheritanceStateMachineConfig", - context); - - assertThat(scoped).extracting(EntryPoint::getName) - .containsExactly("POST /api/v2/orders/submit"); + private static void writeEnterpriseWebLayer(Path tempDir) throws Exception { + Files.writeString(tempDir.resolve("App.java"), """ + package com.example; + public class StateMachineController { + private final StateMachineDispatcher dispatcher = new StateMachineDispatcher(); + public void payOrder() { dispatcher.payOrder(); } + public void submitDocument() { dispatcher.submitDocument(); } + } + class StateMachineDispatcher { + void payOrder() { + org.springframework.statemachine.StateMachine machine = null; + machine.sendEvent(OrderEvent.PAY); + } + void submitDocument() { + org.springframework.statemachine.StateMachine machine = null; + machine.sendEvent(DocumentEvent.SUBMIT); + } + } + enum OrderEvent { PAY } + enum OrderState { NEW, PAID } + enum DocumentEvent { SUBMIT } + enum DocumentState { DRAFT } + class OrderStateMachineConfiguration + extends org.springframework.statemachine.config.StateMachineConfigurerAdapter {} + class DocumentStateMachineConfiguration + extends org.springframework.statemachine.config.StateMachineConfigurerAdapter {} + """); } } 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 7cf8896..0ba0d87 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 @@ -3,6 +3,8 @@ package click.kamil.springstatemachineexporter.analysis.enricher; import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult; import click.kamil.springstatemachineexporter.analysis.model.CallChain; import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata; +import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; +import click.kamil.springstatemachineexporter.analysis.model.LinkResolution; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.model.Event; import click.kamil.springstatemachineexporter.model.State; @@ -42,6 +44,74 @@ class TransitionLinkerEnricherTest { assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1); } + @Test + void shouldExportResolvedLinkResolutionForDedicatedLiteralEndpoint() { + Transition payT = new Transition(); + payT.setSourceStates(List.of(State.of("NEW", "OrderState.NEW"))); + payT.setTargetStates(List.of(State.of("PAID", "OrderState.PAID"))); + payT.setEvent(Event.of("OrderEvent.PAY", "com.example.OrderEvent.PAY")); + + CallChain chain = CallChain.builder() + .entryPoint(EntryPoint.builder() + .type(EntryPoint.Type.REST) + .name("POST /api/order/pay") + .className("com.example.Api") + .methodName("pay") + .build()) + .triggerPoint(TriggerPoint.builder() + .event("OrderEvent.PAY") + .polymorphicEvents(List.of("OrderEvent.PAY")) + .external(false) + .build()) + .build(); + + AnalysisResult result = AnalysisResult.builder() + .name("OrderStateMachineConfig") + .transitions(List.of(payT)) + .metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build()) + .build(); + + enricher.enrich(result, null, null); + + CallChain updated = result.getMetadata().getCallChains().get(0); + assertThat(updated.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED); + assertThat(updated.getMatchedTransitions()).hasSize(1); + } + + @Test + void shouldExportUnresolvedExternalForGenericPathVariableEndpoint() { + CallChain chain = CallChain.builder() + .entryPoint(EntryPoint.builder() + .type(EntryPoint.Type.REST) + .name("POST /api/machine/{machineType}/transition/{event}") + .className("com.example.Api") + .methodName("transition") + .build()) + .triggerPoint(TriggerPoint.builder() + .event("eventString.toUpperCase()") + .external(true) + .ambiguous(true) + .build()) + .build(); + + Transition payT = new Transition(); + payT.setSourceStates(List.of(State.of("NEW", "OrderState.NEW"))); + payT.setTargetStates(List.of(State.of("PAID", "OrderState.PAID"))); + payT.setEvent(Event.of("OrderEvent.PAY", "com.example.OrderEvent.PAY")); + + AnalysisResult result = AnalysisResult.builder() + .name("OrderStateMachineConfig") + .transitions(List.of(payT)) + .metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build()) + .build(); + + enricher.enrich(result, null, null); + + CallChain updated = result.getMetadata().getCallChains().get(0); + assertThat(updated.getLinkResolution()).isEqualTo(LinkResolution.UNRESOLVED_EXTERNAL); + assertThat(updated.getMatchedTransitions()).isNullOrEmpty(); + } + @Test void shouldLinkWhenSymbolicPolymorphicEventsExpandToMachineEnumConstants() { Transition payT = new Transition(); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerMultiSourceStateTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerMultiSourceStateTest.java new file mode 100644 index 0000000..e193e83 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerMultiSourceStateTest.java @@ -0,0 +1,72 @@ +package click.kamil.springstatemachineexporter.analysis.enricher; + +import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult; +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata; +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import click.kamil.springstatemachineexporter.model.Event; +import click.kamil.springstatemachineexporter.model.State; +import click.kamil.springstatemachineexporter.model.Transition; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class TransitionLinkerMultiSourceStateTest { + + @Test + void shouldLinkEachSourceStatePairWithoutCrossMultiplication() { + TriggerPoint trigger = TriggerPoint.builder() + .event("com.example.OrderEvent.CANCEL") + .sourceState("com.example.OrderState.PENDING") + .build(); + CallChain chain = CallChain.builder() + .entryPoint(click.kamil.springstatemachineexporter.analysis.model.EntryPoint.builder() + .type(click.kamil.springstatemachineexporter.analysis.model.EntryPoint.Type.REST) + .name("POST /cancel") + .className("com.example.Api") + .methodName("cancel") + .build()) + .triggerPoint(trigger) + .methodChain(List.of("com.example.Api.cancel", "com.example.Service.fire")) + .build(); + + Transition fromPending = transition( + "com.example.OrderEvent.CANCEL", + "com.example.OrderState.PENDING", + "com.example.OrderState.CANCELLED"); + Transition fromPaid = transition( + "com.example.OrderEvent.CANCEL", + "com.example.OrderState.PAID", + "com.example.OrderState.CANCELLED"); + + AnalysisResult result = AnalysisResult.builder() + .name("com.example.config.OrderStateMachineConfiguration") + .stateTypeFqn("com.example.OrderState") + .eventTypeFqn("com.example.OrderEvent") + .transitions(List.of(fromPending, fromPaid)) + .metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build()) + .build(); + + new TransitionLinkerEnricher().enrich(result, null, null); + + assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()) + .hasSize(1) + .first() + .satisfies(mt -> { + assertThat(mt.getSourceState()).isEqualTo("com.example.OrderState.PENDING"); + assertThat(mt.getEvent()).isEqualTo("com.example.OrderEvent.CANCEL"); + }); + } + + private static Transition transition(String event, String source, String target) { + Transition transition = new Transition(); + transition.setEvent(Event.of(event, event)); + transition.setSourceStates(List.of( + State.of(source, source), + State.of("com.example.OrderState.OTHER", "com.example.OrderState.OTHER"))); + transition.setTargetStates(List.of(State.of(target, target))); + return transition; + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicBeanResolutionEngineRoutingTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicBeanResolutionEngineRoutingTest.java index e66c532..ac1b0cb 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicBeanResolutionEngineRoutingTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/HeuristicBeanResolutionEngineRoutingTest.java @@ -35,6 +35,65 @@ class HeuristicBeanResolutionEngineRoutingTest { context)).isFalse(); } + @Test + void sharedServiceWithProvenOrderTypesShouldOnlyRouteToOrderMachine(@TempDir Path tempDir) throws Exception { + Files.writeString(tempDir.resolve("SharedDispatcher.java"), """ + package com.example; + public class SharedDispatcher { + void payOrder() { + org.springframework.statemachine.StateMachine machine = null; + machine.sendEvent(OrderEvent.PAY); + } + void submitDocument() { + org.springframework.statemachine.StateMachine machine = null; + machine.sendEvent(DocumentEvent.SUBMIT); + } + } + enum OrderEvent { PAY } + enum OrderState { NEW, PAID } + enum DocumentEvent { SUBMIT } + enum DocumentState { DRAFT } + class OrderStateMachineConfiguration + extends org.springframework.statemachine.config.StateMachineConfigurerAdapter {} + class DocumentStateMachineConfiguration + extends org.springframework.statemachine.config.StateMachineConfigurerAdapter {} + """); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + CallChain orderChain = CallChain.builder() + .triggerPoint(TriggerPoint.builder() + .event("com.example.OrderEvent.PAY") + .eventTypeFqn("com.example.OrderEvent") + .stateTypeFqn("com.example.OrderState") + .className("com.example.SharedDispatcher") + .methodName("payOrder") + .build()) + .methodChain(List.of("com.example.SharedDispatcher.payOrder()")) + .build(); + CallChain documentChain = CallChain.builder() + .triggerPoint(TriggerPoint.builder() + .event("com.example.DocumentEvent.SUBMIT") + .eventTypeFqn("com.example.DocumentEvent") + .stateTypeFqn("com.example.DocumentState") + .className("com.example.SharedDispatcher") + .methodName("submitDocument") + .build()) + .methodChain(List.of("com.example.SharedDispatcher.submitDocument()")) + .build(); + + assertThat(engine.isRoutedToCorrectMachine( + orderChain, "com.example.OrderStateMachineConfiguration", context)).isTrue(); + assertThat(engine.isRoutedToCorrectMachine( + orderChain, "com.example.DocumentStateMachineConfiguration", context)).isFalse(); + assertThat(engine.isRoutedToCorrectMachine( + documentChain, "com.example.DocumentStateMachineConfiguration", context)).isTrue(); + assertThat(engine.isRoutedToCorrectMachine( + documentChain, "com.example.OrderStateMachineConfiguration", context)).isFalse(); + } + private static void writeTwoMachineSample(Path tempDir) throws Exception { Path orderPkg = tempDir.resolve("com/example/order"); Path invoicePkg = tempDir.resolve("com/example/invoice"); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluatorBindingTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluatorBindingTest.java new file mode 100644 index 0000000..676489d --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluatorBindingTest.java @@ -0,0 +1,28 @@ +package click.kamil.springstatemachineexporter.analysis.resolver; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class BooleanConstraintEvaluatorBindingTest { + + @Test + void shouldEvaluateEqualsIgnoreCaseAgainstPathVariableBinding() { + String constraint = "\"ORDER\".equalsIgnoreCase(machineType)"; + assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(constraint, Map.of("machineType", "ORDER"))) + .isTrue(); + assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(constraint, Map.of("machineType", "DOCUMENT"))) + .isFalse(); + } + + @Test + void shouldEvaluateDocumentBranchAgainstBinding() { + String constraint = "\"DOCUMENT\".equalsIgnoreCase(machineType)"; + assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(constraint, Map.of("machineType", "DOCUMENT"))) + .isTrue(); + assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings(constraint, Map.of("machineType", "ORDER"))) + .isFalse(); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnterpriseDedicatedEndpointLinkingTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnterpriseDedicatedEndpointLinkingTest.java new file mode 100644 index 0000000..54a8eea --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnterpriseDedicatedEndpointLinkingTest.java @@ -0,0 +1,72 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.exporter.JsonExporter; +import click.kamil.springstatemachineexporter.service.ExportService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Enterprise dedicated REST endpoints must link to transitions when call-graph resolution + * proves a concrete machine enum literal on the dispatcher branch. + */ +class EnterpriseDedicatedEndpointLinkingTest { + + private static final String PAY_ENDPOINT = "POST /api/machine/order/pay"; + + @Test + void astExportDedicatedPayEndpointShouldLinkToOrderPayTransition(@TempDir Path tempDir) throws Exception { + Path enterprise = findProjectRoot().resolve("state_machines/state_machine_enterprise"); + ExportService exportService = new ExportService(List.of(new JsonExporter())); + exportService.runExporter(enterprise, tempDir, List.of("json"), true, List.of(), null, null, + click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, + click.kamil.springstatemachineexporter.exporter.EnumFormat.fn); + + JsonNode json = readMachineJson(tempDir, "OrderStateMachineConfiguration", new ObjectMapper()); + JsonNode chain = findPayEndpointChain(json); + + assertThat(chain.path("triggerPoint").path("event").asText()) + .isEqualTo("click.kamil.enterprise.machines.order.OrderEvent.PAY"); + assertThat(chain.path("matchedTransitions").isArray()).isTrue(); + assertThat(chain.path("matchedTransitions")).isNotEmpty(); + assertThat(chain.path("matchedTransitions").get(0).path("event").asText()) + .isEqualTo("click.kamil.enterprise.machines.order.OrderEvent.PAY"); + } + + private static JsonNode findPayEndpointChain(JsonNode machineJson) { + for (JsonNode chain : machineJson.path("metadata").path("callChains")) { + if (PAY_ENDPOINT.equals(chain.path("entryPoint").path("name").asText())) { + return chain; + } + } + throw new IllegalStateException("pay endpoint chain not found"); + } + + private static JsonNode readMachineJson(Path outputDir, String configBaseName, ObjectMapper mapper) + throws Exception { + Path machineDir; + try (var stream = Files.list(outputDir)) { + machineDir = stream + .filter(Files::isDirectory) + .filter(path -> path.getFileName().toString().endsWith(configBaseName)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("No output for " + configBaseName)); + } + return mapper.readTree(machineDir.resolve(machineDir.getFileName() + ".json").toFile()); + } + + private static Path findProjectRoot() { + Path current = Path.of(".").toAbsolutePath(); + while (current != null && !Files.exists(current.resolve("settings.gradle"))) { + current = current.getParent(); + } + return current; + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnterpriseDispatcherBranchSplitTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnterpriseDispatcherBranchSplitTest.java new file mode 100644 index 0000000..2a12c65 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnterpriseDispatcherBranchSplitTest.java @@ -0,0 +1,175 @@ +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.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Per-branch dispatcher splitting: if-else machineType arms must resolve to branch-specific + * valueOf triggers and constraints, not a collapsed ternary across machines. + */ +class EnterpriseDispatcherBranchSplitTest { + + @Test + void shouldResolveBranchSpecificValueOfForEachMachineTypeBinding(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class MachineController { + StateMachineDispatcher dispatcher; + public void transition(String machineType, String event) { + dispatcher.dispatch(machineType, event); + } + } + class StateMachineDispatcher { + void dispatch(String machineType, String eventString) { + if ("ORDER".equalsIgnoreCase(machineType)) { + fireOrder(eventString); + } else if ("DOCUMENT".equalsIgnoreCase(machineType)) { + fireDocument(eventString); + } else if ("USER".equalsIgnoreCase(machineType)) { + fireUser(eventString); + } + } + void fireOrder(String eventString) { + OrderEvent event = OrderEvent.valueOf(eventString.toUpperCase()); + send(event); + } + void fireDocument(String eventString) { + DocumentEvent event = DocumentEvent.valueOf(eventString.toUpperCase()); + send(event); + } + void fireUser(String eventString) { + UserEvent event = UserEvent.valueOf(eventString.toUpperCase()); + send(event); + } + void send(OrderEvent event) {} + void send(DocumentEvent event) {} + void send(UserEvent event) {} + } + enum OrderEvent { PAY } + enum DocumentEvent { SUBMIT } + enum UserEvent { VERIFY } + """; + Files.writeString(tempDir.resolve("App.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.MachineController") + .methodName("transition") + .name("POST /api/machine/{machineType}/transition/{event}") + .parameters(List.of( + EntryPoint.Parameter.builder() + .name("machineType") + .type("String") + .annotations(List.of("PathVariable")) + .build(), + EntryPoint.Parameter.builder() + .name("event") + .type("String") + .annotations(List.of("PathVariable")) + .build())) + .build(); + + TriggerPoint orderTrigger = TriggerPoint.builder() + .className("com.example.StateMachineDispatcher") + .methodName("send") + .event("event") + .build(); + + List chains = engine.findChains(List.of(entryPoint), List.of(orderTrigger)); + + CallChain orderChain = chains.stream() + .filter(c -> c.getEntryPoint().getName().contains("/ORDER/")) + .findFirst() + .orElseThrow(); + List documentChains = chains.stream() + .filter(c -> c.getEntryPoint().getName().contains("/DOCUMENT/")) + .toList(); + assertThat(documentChains).isNotEmpty(); + CallChain documentChain = documentChains.stream() + .filter(c -> c.getMethodChain().contains("com.example.StateMachineDispatcher.fireDocument")) + .findFirst() + .orElseThrow(); + + assertThat(orderChain.getTriggerPoint().getEvent()) + .contains("OrderEvent.valueOf") + .doesNotContain("DocumentEvent") + .doesNotContain("UserEvent") + .doesNotContain("true ?"); + assertThat(orderChain.getTriggerPoint().getConstraint()) + .contains("ORDER") + .doesNotContain("DOCUMENT"); + + assertThat(documentChain.getMethodChain()) + .contains("com.example.StateMachineDispatcher.fireDocument") + .doesNotContain("com.example.StateMachineDispatcher.fireOrder"); + assertThat(documentChain.getTriggerPoint().getEvent()) + .satisfiesAnyOf( + e -> assertThat(e).contains("DocumentEvent.valueOf"), + e -> assertThat(e).isEqualTo("event")); + assertThat(documentChain.getTriggerPoint().getConstraint()) + .contains("DOCUMENT") + .doesNotContain("USER"); + } + + @Test + void bindingExpanderShouldProduceMachineTypeVariants(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class MachineController { + StateMachineDispatcher dispatcher; + public void transition(String machineType, String event) { + dispatcher.dispatch(machineType, event); + } + } + class StateMachineDispatcher { + void dispatch(String machineType, String eventString) { + if ("ORDER".equalsIgnoreCase(machineType)) { + order(eventString); + } else if ("DOCUMENT".equalsIgnoreCase(machineType)) { + document(eventString); + } + } + void order(String eventString) {} + void document(String eventString) {} + } + """; + Files.writeString(tempDir.resolve("App.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.MachineController") + .methodName("transition") + .parameters(List.of( + EntryPoint.Parameter.builder() + .name("machineType") + .type("String") + .annotations(List.of("PathVariable")) + .build())) + .build(); + + List> variants = EntryPointBindingExpander.expandPathVariableBindings( + entryPoint, context, engine.buildCallGraph()); + + assertThat(variants).extracting(map -> map.get("machineType")) + .containsExactlyInAnyOrder("ORDER", "DOCUMENT"); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnterpriseDispatcherMatchedTransitionsRegressionTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnterpriseDispatcherMatchedTransitionsRegressionTest.java index 3abce9f..b91c9cd 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnterpriseDispatcherMatchedTransitionsRegressionTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EnterpriseDispatcherMatchedTransitionsRegressionTest.java @@ -41,7 +41,9 @@ class EnterpriseDispatcherMatchedTransitionsRegressionTest { private static JsonNode findTransitionEndpointChain(JsonNode machineJson) { for (JsonNode chain : machineJson.path("metadata").path("callChains")) { - if (TRANSITION_ENDPOINT.equals(chain.path("entryPoint").path("name").asText())) { + String name = chain.path("entryPoint").path("name").asText(); + if (TRANSITION_ENDPOINT.equals(name) + || (name.contains("/transition/{event}") && name.contains("POST /api/machine/"))) { return chain; } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpanderTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpanderTest.java index 3fbec7f..b74e202 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpanderTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpanderTest.java @@ -143,4 +143,59 @@ class EntryPointBindingExpanderTest { assertThat(chains.stream().filter(chain -> chain.getEntryPoint().getName().endsWith("order.pay")).findFirst().orElseThrow() .getMethodChain()).contains("com.example.CentralDispatcher.orderPay"); } + + @Test + void shouldExpandPathVariableFromIfElseEqualsChain(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class MachineController { + StateMachineDispatcher dispatcher; + public void transition(String machineType, String event) { + dispatcher.dispatch(machineType, event); + } + } + class StateMachineDispatcher { + void dispatch(String machineType, String eventString) { + if ("ORDER".equalsIgnoreCase(machineType)) { + fireOrder(eventString); + } else if ("DOCUMENT".equalsIgnoreCase(machineType)) { + fireDocument(eventString); + } + } + void fireOrder(String eventString) {} + void fireDocument(String eventString) {} + } + """; + Files.writeString(tempDir.resolve("App.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + Map> graph = engine.buildCallGraph(); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.MachineController") + .methodName("transition") + .name("POST /api/machine/{machineType}/transition/{event}") + .parameters(List.of( + EntryPoint.Parameter.builder() + .name("machineType") + .type("String") + .annotations(List.of("PathVariable")) + .build(), + EntryPoint.Parameter.builder() + .name("event") + .type("String") + .annotations(List.of("PathVariable")) + .build())) + .build(); + + List> variants = + EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph); + + assertThat(variants).extracting(map -> map.get("machineType")) + .containsExactlyInAnyOrder("ORDER", "DOCUMENT"); + } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ExportWithoutHintsTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ExportWithoutHintsTest.java new file mode 100644 index 0000000..849cd9b --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ExportWithoutHintsTest.java @@ -0,0 +1,50 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.eclipse.jdt.core.dom.CompilationUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Export must work on standard Spring State Machine patterns without hints.json. + */ +class ExportWithoutHintsTest { + + @Test + void shouldDetectSendEventTriggersWithoutHintsJson(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + import org.springframework.statemachine.StateMachine; + public class OrderService { + public void pay(StateMachine sm) { + sm.sendEvent(OrderEvent.PAY); + } + } + enum OrderState { NEW, PAID } + enum OrderEvent { PAY } + """; + Files.writeString(tempDir.resolve("OrderService.java"), source); + assertThat(tempDir.resolve("hints.json")).doesNotExist(); + + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + assertThat(context.getLibraryHints()).isEmpty(); + + GenericEventDetector detector = new GenericEventDetector( + context, context.getConstantResolver(), context.getLibraryHints()); + List triggers = detector.detect( + context.getCompilationUnits().iterator().next()); + + assertThat(triggers).isNotEmpty(); + assertThat(triggers) + .extracting(TriggerPoint::getEvent) + .anyMatch(e -> e != null && e.contains("PAY")); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicyTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicyTest.java new file mode 100644 index 0000000..5d5002e --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/ExternalTriggerPolicyTest.java @@ -0,0 +1,122 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +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.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ExternalTriggerPolicyTest { + + @Test + void pathVariableEventParameterShouldBeExternal() { + EntryPoint entryPoint = EntryPoint.builder() + .type(EntryPoint.Type.REST) + .name("POST /api/{event}") + .className("com.example.Api") + .methodName("trigger") + .parameters(List.of(EntryPoint.Parameter.builder() + .name("event") + .type("String") + .annotations(List.of("PathVariable")) + .build())) + .build(); + TriggerPoint trigger = TriggerPoint.builder() + .event("eventString.toUpperCase()") + .build(); + + assertThat(ExternalTriggerPolicy.isExternalFromSource( + entryPoint, trigger, "com.example.Api.trigger", "event", null)).isTrue(); + } + + @Test + void requestBodyEnumShouldBeInternal(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + import org.springframework.web.bind.annotation.*; + public class Api { + @PostMapping("/pay") + public void pay(@RequestBody OrderEvent event) { + fire(event); + } + void fire(OrderEvent event) {} + } + enum OrderEvent { PAY } + """; + Files.writeString(tempDir.resolve("Api.java"), source); + CodebaseContext context = new CodebaseContext(); + context.scan(tempDir); + + EntryPoint entryPoint = EntryPoint.builder() + .type(EntryPoint.Type.REST) + .name("POST /pay") + .className("com.example.Api") + .methodName("pay") + .build(); + TriggerPoint trigger = TriggerPoint.builder() + .event("com.example.OrderEvent.PAY") + .polymorphicEvents(List.of("com.example.OrderEvent.PAY")) + .build(); + + assertThat(ExternalTriggerPolicy.isExternalFromSource( + entryPoint, trigger, "com.example.Api.pay", "event", context)).isFalse(); + } + + @Test + void concreteEnumLiteralFromDedicatedEndpointShouldBeInternal() { + EntryPoint entryPoint = EntryPoint.builder() + .type(EntryPoint.Type.REST) + .name("POST /api/order/pay") + .className("com.example.Api") + .methodName("pay") + .build(); + TriggerPoint trigger = TriggerPoint.builder() + .event("com.example.OrderEvent.PAY") + .polymorphicEvents(List.of("com.example.OrderEvent.PAY")) + .build(); + + assertThat(ExternalTriggerPolicy.isExternalFromSource( + entryPoint, trigger, "com.example.Api.pay", null, null)).isFalse(); + } + + @Test + void requestBodyRecordEnumComponentShouldBeInternal(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + import org.springframework.web.bind.annotation.*; + public class Api { + @PostMapping("/pay") + public void pay(@RequestBody OrderRequest request) { + fire(request.event()); + } + void fire(OrderEvent event) {} + } + record OrderRequest(OrderEvent event) {} + enum OrderEvent { PAY } + """; + Files.writeString(tempDir.resolve("Api.java"), source); + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + EntryPoint entryPoint = EntryPoint.builder() + .type(EntryPoint.Type.REST) + .name("POST /pay") + .className("com.example.Api") + .methodName("pay") + .build(); + TriggerPoint trigger = TriggerPoint.builder() + .event("com.example.OrderEvent.PAY") + .polymorphicEvents(List.of("com.example.OrderEvent.PAY")) + .build(); + + assertThat(ExternalTriggerPolicy.isExternalFromSource( + entryPoint, trigger, "com.example.Api.pay", "event", context)).isFalse(); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetectorControlFlowTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetectorControlFlowTest.java index 5338bac..aafaed6 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetectorControlFlowTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetectorControlFlowTest.java @@ -158,4 +158,66 @@ class GenericEventDetectorControlFlowTest { assertThat(triggers).hasSize(1); assertThat(triggers.get(0).getSourceState()).isNull(); } + + @Test + void shouldDetectSourceStateFromLiteralSendEventArgument(@TempDir Path tempDir) throws IOException { + String source = """ + package com.example; + public class OrderService { + private StateMachine sm; + public void pay() { + sm.sendEvent(OrderEvent.PAY, OrderState.PENDING); + } + } + + class StateMachine { + public void sendEvent(OrderEvent e, OrderState s) {} + } + + 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 triggers = detector.detect( + (org.eclipse.jdt.core.dom.CompilationUnit) context.getTypeDeclaration("com.example.OrderService").getRoot()); + + assertThat(triggers).hasSize(1); + assertThat(triggers.get(0).getSourceState()).isEqualTo("PENDING"); + } + + @Test + void shouldNotGuessSourceStateFromVariableSendEventArgument(@TempDir Path tempDir) throws IOException { + String source = """ + package com.example; + public class OrderService { + private StateMachine sm; + public void pay(OrderState current) { + sm.sendEvent(OrderEvent.PAY, current); + } + } + + class StateMachine { + public void sendEvent(OrderEvent e, OrderState s) {} + } + + 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 triggers = detector.detect( + (org.eclipse.jdt.core.dom.CompilationUnit) context.getTypeDeclaration("com.example.OrderService").getRoot()); + + assertThat(triggers).hasSize(1); + assertThat(triggers.get(0).getSourceState()).isNull(); + } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngineTypeTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngineTypeTest.java index d9228ab..7c24ff4 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngineTypeTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/HeuristicCallGraphEngineTypeTest.java @@ -525,6 +525,18 @@ class HeuristicCallGraphEngineTypeTest { EntryPoint entry = EntryPoint.builder() .className("click.kamil.enterprise.web.StateMachineController") .methodName("transition") + .name("POST /api/machine/{machineType}/transition/{event}") + .parameters(java.util.List.of( + EntryPoint.Parameter.builder() + .name("machineType") + .type("String") + .annotations(java.util.List.of("PathVariable")) + .build(), + EntryPoint.Parameter.builder() + .name("event") + .type("String") + .annotations(java.util.List.of("PathVariable")) + .build())) .build(); TriggerPoint trigger = TriggerPoint.builder() .className("click.kamil.enterprise.web.StateMachineDispatcher") @@ -534,7 +546,15 @@ class HeuristicCallGraphEngineTypeTest { List chains = builder.findChains(List.of(entry), List.of(trigger)); assertThat(chains).isNotEmpty(); - assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()) - .containsExactlyInAnyOrder("", "", ""); + java.util.Set polymorphicEvents = new java.util.LinkedHashSet<>(); + for (CallChain chain : chains) { + if (chain.getTriggerPoint().getPolymorphicEvents() != null) { + polymorphicEvents.addAll(chain.getTriggerPoint().getPolymorphicEvents()); + } + assertThat(chain.getTriggerPoint().getEvent()) + .doesNotContain("true ?"); + } + assertThat(polymorphicEvents) + .contains("", "", ""); } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngineIntegrationTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngineIntegrationTest.java index b1821cc..bf0c408 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngineIntegrationTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/JdtCallGraphEngineIntegrationTest.java @@ -304,6 +304,18 @@ class JdtCallGraphEngineIntegrationTest { EntryPoint entry = EntryPoint.builder() .className("click.kamil.enterprise.web.StateMachineController") .methodName("transition") + .name("POST /api/machine/{machineType}/transition/{event}") + .parameters(java.util.List.of( + EntryPoint.Parameter.builder() + .name("machineType") + .type("String") + .annotations(java.util.List.of("PathVariable")) + .build(), + EntryPoint.Parameter.builder() + .name("event") + .type("String") + .annotations(java.util.List.of("PathVariable")) + .build())) .build(); TriggerPoint trigger = TriggerPoint.builder() .className("click.kamil.enterprise.web.StateMachineDispatcher") @@ -313,7 +325,15 @@ class JdtCallGraphEngineIntegrationTest { List chains = jdtEngine.findChains(List.of(entry), List.of(trigger)); assertThat(chains).isNotEmpty(); - assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()) - .containsExactlyInAnyOrder("", "", ""); + java.util.Set polymorphicEvents = new java.util.LinkedHashSet<>(); + for (CallChain chain : chains) { + if (chain.getTriggerPoint().getPolymorphicEvents() != null) { + polymorphicEvents.addAll(chain.getTriggerPoint().getPolymorphicEvents()); + } + assertThat(chain.getTriggerPoint().getEvent()) + .doesNotContain("true ?"); + } + assertThat(polymorphicEvents) + .contains("", "", ""); } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracerBranchFilterTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracerBranchFilterTest.java new file mode 100644 index 0000000..bbaacbb --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/VariableTracerBranchFilterTest.java @@ -0,0 +1,42 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class VariableTracerBranchFilterTest { + + @Test + void shouldReturnBranchLocalInitializerWithoutCrossBranchTernary(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + class StateMachineDispatcher { + void fireDocument(String eventString) { + DocumentEvent event = DocumentEvent.valueOf(eventString.toUpperCase()); + send(event); + } + void send(DocumentEvent event) {} + } + enum DocumentEvent { SUBMIT } + """; + Files.writeString(tempDir.resolve("App.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + String traced = engine.variableTracer.traceLocalVariable( + "com.example.StateMachineDispatcher.fireDocument", + "event", + Map.of("machineType", "DOCUMENT")); + + assertThat(traced).contains("DocumentEvent.valueOf"); + } +} diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/validation/AnalysisCanonicalFormValidatorTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/validation/AnalysisCanonicalFormValidatorTest.java index 4d99a5a..a93572a 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/validation/AnalysisCanonicalFormValidatorTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/validation/AnalysisCanonicalFormValidatorTest.java @@ -322,6 +322,46 @@ class AnalysisCanonicalFormValidatorTest { .contains("metadata.callChains[0].matchedTransitions"); } + @Test + void shouldFailWhenDedicatedEndpointHasConcreteLiteralEventButNoMatchedTransitions() { + AnalysisResult result = AnalysisResult.builder() + .name("com.example.config.OrderStateMachineConfiguration") + .stateTypeFqn("com.example.order.OrderState") + .eventTypeFqn("com.example.order.OrderEvent") + .transitions(List.of(transition( + "com.example.order.OrderEvent.PAY", + "com.example.order.OrderState.NEW", + "com.example.order.OrderState.PAID"))) + .metadata(CodebaseMetadata.builder() + .callChains(List.of(CallChain.builder() + .entryPoint(click.kamil.springstatemachineexporter.analysis.model.EntryPoint.builder() + .type(click.kamil.springstatemachineexporter.analysis.model.EntryPoint.Type.REST) + .name("POST /api/machine/order/pay") + .className("com.example.web.StateMachineController") + .methodName("payOrder") + .build()) + .triggerPoint(TriggerPoint.builder() + .event("com.example.order.OrderEvent.PAY") + .className("com.example.web.StateMachineDispatcher") + .methodName("payOrder") + .sourceFile("StateMachineDispatcher.java") + .eventTypeFqn("com.example.order.OrderEvent") + .build()) + .build())) + .build()) + .build(); + + List violations = + AnalysisCanonicalFormValidator.validateWithMachineTypes( + result, + new click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver.MachineTypes( + "com.example.order.OrderState", "com.example.order.OrderEvent")); + + assertThat(violations) + .extracting(AnalysisCanonicalFormValidator.Violation::path) + .contains("metadata.callChains[0].matchedTransitions"); + } + @Test void shouldAllowSymbolicPolymorphicEventsWithoutMatchedTransitions() { AnalysisResult result = AnalysisResult.builder() diff --git a/state_machine_exporter/src/test/resources/golden/ComplexStateMachineConfig/ComplexStateMachineConfig.png b/state_machine_exporter/src/test/resources/golden/ComplexStateMachineConfig/ComplexStateMachineConfig.png index 69621e2..6dd9abf 100644 Binary files a/state_machine_exporter/src/test/resources/golden/ComplexStateMachineConfig/ComplexStateMachineConfig.png and b/state_machine_exporter/src/test/resources/golden/ComplexStateMachineConfig/ComplexStateMachineConfig.png differ diff --git a/state_machine_exporter/src/test/resources/golden/DocumentStateMachineConfiguration/DocumentStateMachineConfiguration.json b/state_machine_exporter/src/test/resources/golden/DocumentStateMachineConfiguration/DocumentStateMachineConfiguration.json index 2b946fb..89edca0 100644 --- a/state_machine_exporter/src/test/resources/golden/DocumentStateMachineConfiguration/DocumentStateMachineConfiguration.json +++ b/state_machine_exporter/src/test/resources/golden/DocumentStateMachineConfiguration/DocumentStateMachineConfiguration.json @@ -123,6 +123,120 @@ "ambiguous" : false } ], "entryPoints" : [ { + "type" : "REST", + "name" : "POST /api/machine/document/submit", + "className" : "click.kamil.enterprise.web.StateMachineController", + "methodName" : "submitDocument", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", + "metadata" : { + "path" : "/api/machine/document/submit", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "userId", + "type" : "String", + "annotations" : [ "RequestParam" ] + } ] + }, { + "type" : "REST", + "name" : "POST /api/machine/document/approve", + "className" : "click.kamil.enterprise.web.StateMachineController", + "methodName" : "approveDocument", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", + "metadata" : { + "path" : "/api/machine/document/approve", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "userId", + "type" : "String", + "annotations" : [ "RequestParam" ] + } ] + }, { + "type" : "REST", + "name" : "POST /api/machine/document/reject", + "className" : "click.kamil.enterprise.web.StateMachineController", + "methodName" : "rejectDocument", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", + "metadata" : { + "path" : "/api/machine/document/reject", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "userId", + "type" : "String", + "annotations" : [ "RequestParam" ] + } ] + }, { + "type" : "REST", + "name" : "POST /api/machine/ORDER/transition/{event}", + "className" : "click.kamil.enterprise.web.StateMachineController", + "methodName" : "transition", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", + "metadata" : { + "path" : "/api/machine/ORDER/transition/{event}", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "machineType", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "event", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "userId", + "type" : "String", + "annotations" : [ "RequestParam" ] + } ] + }, { + "type" : "REST", + "name" : "POST /api/machine/DOCUMENT/transition/{event}", + "className" : "click.kamil.enterprise.web.StateMachineController", + "methodName" : "transition", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", + "metadata" : { + "path" : "/api/machine/DOCUMENT/transition/{event}", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "machineType", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "event", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "userId", + "type" : "String", + "annotations" : [ "RequestParam" ] + } ] + }, { + "type" : "REST", + "name" : "POST /api/machine/USER/transition/{event}", + "className" : "click.kamil.enterprise.web.StateMachineController", + "methodName" : "transition", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", + "metadata" : { + "path" : "/api/machine/USER/transition/{event}", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "machineType", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "event", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "userId", + "type" : "String", + "annotations" : [ "RequestParam" ] + } ] + }, { "type" : "REST", "name" : "POST /api/machine/{machineType}/transition/{event}", "className" : "click.kamil.enterprise.web.StateMachineController", @@ -149,12 +263,126 @@ "callChains" : [ { "entryPoint" : { "type" : "REST", - "name" : "POST /api/machine/{machineType}/transition/{event}", + "name" : "POST /api/machine/document/submit", + "className" : "click.kamil.enterprise.web.StateMachineController", + "methodName" : "submitDocument", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", + "metadata" : { + "path" : "/api/machine/document/submit", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "userId", + "type" : "String", + "annotations" : [ "RequestParam" ] + } ] + }, + "methodChain" : [ "click.kamil.enterprise.web.StateMachineController.submitDocument", "click.kamil.enterprise.web.StateMachineDispatcher.submitDocument" ], + "triggerPoint" : { + "event" : "click.kamil.enterprise.machines.document.DocumentEvent.SUBMIT", + "className" : "click.kamil.enterprise.web.StateMachineDispatcher", + "methodName" : "submitDocument", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineDispatcher.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : "click.kamil.enterprise.machines.document.DocumentState.DRAFT", + "lineNumber" : 44, + "polymorphicEvents" : [ "click.kamil.enterprise.machines.document.DocumentEvent.SUBMIT" ], + "external" : false, + "constraint" : null, + "ambiguous" : false + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "click.kamil.enterprise.machines.document.DocumentState.DRAFT", + "targetState" : "click.kamil.enterprise.machines.document.DocumentState.IN_REVIEW", + "event" : "click.kamil.enterprise.machines.document.DocumentEvent.SUBMIT" + } ] + }, { + "entryPoint" : { + "type" : "REST", + "name" : "POST /api/machine/document/approve", + "className" : "click.kamil.enterprise.web.StateMachineController", + "methodName" : "approveDocument", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", + "metadata" : { + "path" : "/api/machine/document/approve", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "userId", + "type" : "String", + "annotations" : [ "RequestParam" ] + } ] + }, + "methodChain" : [ "click.kamil.enterprise.web.StateMachineController.approveDocument", "click.kamil.enterprise.web.StateMachineDispatcher.approveDocument" ], + "triggerPoint" : { + "event" : "click.kamil.enterprise.machines.document.DocumentEvent.APPROVE", + "className" : "click.kamil.enterprise.web.StateMachineDispatcher", + "methodName" : "approveDocument", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineDispatcher.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : "click.kamil.enterprise.machines.document.DocumentState.IN_REVIEW", + "lineNumber" : 51, + "polymorphicEvents" : [ "click.kamil.enterprise.machines.document.DocumentEvent.APPROVE" ], + "external" : false, + "constraint" : null, + "ambiguous" : false + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "click.kamil.enterprise.machines.document.DocumentState.IN_REVIEW", + "targetState" : "click.kamil.enterprise.machines.document.DocumentState.APPROVED", + "event" : "click.kamil.enterprise.machines.document.DocumentEvent.APPROVE" + } ] + }, { + "entryPoint" : { + "type" : "REST", + "name" : "POST /api/machine/document/reject", + "className" : "click.kamil.enterprise.web.StateMachineController", + "methodName" : "rejectDocument", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", + "metadata" : { + "path" : "/api/machine/document/reject", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "userId", + "type" : "String", + "annotations" : [ "RequestParam" ] + } ] + }, + "methodChain" : [ "click.kamil.enterprise.web.StateMachineController.rejectDocument", "click.kamil.enterprise.web.StateMachineDispatcher.rejectDocument" ], + "triggerPoint" : { + "event" : "click.kamil.enterprise.machines.document.DocumentEvent.REJECT", + "className" : "click.kamil.enterprise.web.StateMachineDispatcher", + "methodName" : "rejectDocument", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineDispatcher.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : "click.kamil.enterprise.machines.document.DocumentState.IN_REVIEW", + "lineNumber" : 58, + "polymorphicEvents" : [ "click.kamil.enterprise.machines.document.DocumentEvent.REJECT" ], + "external" : false, + "constraint" : null, + "ambiguous" : false + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "click.kamil.enterprise.machines.document.DocumentState.IN_REVIEW", + "targetState" : "click.kamil.enterprise.machines.document.DocumentState.DRAFT", + "event" : "click.kamil.enterprise.machines.document.DocumentEvent.REJECT" + } ] + }, { + "entryPoint" : { + "type" : "REST", + "name" : "POST /api/machine/ORDER/transition/{event}", "className" : "click.kamil.enterprise.web.StateMachineController", "methodName" : "transition", "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", "metadata" : { - "path" : "/api/machine/{machineType}/transition/{event}", + "path" : "/api/machine/ORDER/transition/{event}", "verb" : "POST" }, "parameters" : [ { @@ -173,7 +401,7 @@ }, "methodChain" : [ "click.kamil.enterprise.web.StateMachineController.transition", "click.kamil.enterprise.web.StateMachineDispatcher.dispatch" ], "triggerPoint" : { - "event" : "true ? OrderEvent.valueOf(eventString.toUpperCase()) : true ? DocumentEvent.valueOf(eventString.toUpperCase()) : UserEvent.valueOf(eventString.toUpperCase())", + "event" : "OrderEvent.valueOf(eventString.toUpperCase())", "className" : "click.kamil.enterprise.web.StateMachineDispatcher", "methodName" : "dispatch", "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineDispatcher.java", @@ -183,8 +411,92 @@ "lineNumber" : 87, "polymorphicEvents" : [ ], "external" : true, - "constraint" : "!(\"ORDER\".equalsIgnoreCase(machineType)) && \"DOCUMENT\".equalsIgnoreCase(machineType)", - "ambiguous" : true + "constraint" : "\"ORDER\".equalsIgnoreCase(machineType) && !(\"ORDER\".equalsIgnoreCase(machineType)) && \"DOCUMENT\".equalsIgnoreCase(machineType)", + "ambiguous" : false + }, + "contextMachineId" : null, + "matchedTransitions" : null + }, { + "entryPoint" : { + "type" : "REST", + "name" : "POST /api/machine/DOCUMENT/transition/{event}", + "className" : "click.kamil.enterprise.web.StateMachineController", + "methodName" : "transition", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", + "metadata" : { + "path" : "/api/machine/DOCUMENT/transition/{event}", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "machineType", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "event", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "userId", + "type" : "String", + "annotations" : [ "RequestParam" ] + } ] + }, + "methodChain" : [ "click.kamil.enterprise.web.StateMachineController.transition", "click.kamil.enterprise.web.StateMachineDispatcher.dispatch" ], + "triggerPoint" : { + "event" : "DocumentEvent.valueOf(eventString.toUpperCase())", + "className" : "click.kamil.enterprise.web.StateMachineDispatcher", + "methodName" : "dispatch", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineDispatcher.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : null, + "lineNumber" : 87, + "polymorphicEvents" : [ ], + "external" : true, + "constraint" : "\"DOCUMENT\".equalsIgnoreCase(machineType) && !(\"ORDER\".equalsIgnoreCase(machineType)) && \"DOCUMENT\".equalsIgnoreCase(machineType)", + "ambiguous" : false + }, + "contextMachineId" : null, + "matchedTransitions" : null + }, { + "entryPoint" : { + "type" : "REST", + "name" : "POST /api/machine/USER/transition/{event}", + "className" : "click.kamil.enterprise.web.StateMachineController", + "methodName" : "transition", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", + "metadata" : { + "path" : "/api/machine/USER/transition/{event}", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "machineType", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "event", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "userId", + "type" : "String", + "annotations" : [ "RequestParam" ] + } ] + }, + "methodChain" : [ "click.kamil.enterprise.web.StateMachineController.transition", "click.kamil.enterprise.web.StateMachineDispatcher.dispatch" ], + "triggerPoint" : { + "event" : "UserEvent.valueOf(eventString.toUpperCase())", + "className" : "click.kamil.enterprise.web.StateMachineDispatcher", + "methodName" : "dispatch", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineDispatcher.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : null, + "lineNumber" : 87, + "polymorphicEvents" : [ ], + "external" : true, + "constraint" : "\"USER\".equalsIgnoreCase(machineType) && !(\"ORDER\".equalsIgnoreCase(machineType)) && \"DOCUMENT\".equalsIgnoreCase(machineType)", + "ambiguous" : false }, "contextMachineId" : null, "matchedTransitions" : null 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 f29cdfd..8d59b7f 100644 --- a/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig/ExtendedStateMachineConfig.json +++ b/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig/ExtendedStateMachineConfig.json @@ -152,18 +152,18 @@ } ], "entryPoints" : [ { "type" : "REST", - "name" : "GET /api/base/{id}", - "className" : "click.kamil.examples.statemachine.extended.web.PaymentController", - "methodName" : "processBaseEndpoint", - "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/web/PaymentController.java", + "name" : "POST /api/orders/resume", + "className" : "click.kamil.examples.statemachine.extended.web.OrderController", + "methodName" : "resumeOrder", + "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/web/OrderController.java", "metadata" : { - "path" : "/api/base/{id}", - "verb" : "GET" + "path" : "/api/orders/resume", + "verb" : "POST" }, "parameters" : [ { - "name" : "id", + "name" : "orderId", "type" : "String", - "annotations" : [ "PathVariable" ] + "annotations" : [ ] } ] }, { "type" : "REST", @@ -180,8 +180,57 @@ "type" : "String", "annotations" : [ "PathVariable" ] } ] + }, { + "type" : "REST", + "name" : "GET /api/base/{id}", + "className" : "click.kamil.examples.statemachine.extended.web.PaymentController", + "methodName" : "processBaseEndpoint", + "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/web/PaymentController.java", + "metadata" : { + "path" : "/api/base/{id}", + "verb" : "GET" + }, + "parameters" : [ { + "name" : "id", + "type" : "String", + "annotations" : [ "PathVariable" ] + } ] } ], "callChains" : [ { + "entryPoint" : { + "type" : "REST", + "name" : "POST /api/orders/resume", + "className" : "click.kamil.examples.statemachine.extended.web.OrderController", + "methodName" : "resumeOrder", + "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/web/OrderController.java", + "metadata" : { + "path" : "/api/orders/resume", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "orderId", + "type" : "String", + "annotations" : [ ] + } ] + }, + "methodChain" : [ "click.kamil.examples.statemachine.extended.web.OrderController.resumeOrder", "click.kamil.examples.statemachine.extended.service.OrderService.resumeOrder" ], + "triggerPoint" : { + "event" : "[LIFECYCLE:RESTORE]", + "className" : "click.kamil.examples.statemachine.extended.service.OrderService", + "methodName" : "resumeOrder", + "sourceFile" : null, + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : null, + "lineNumber" : 29, + "polymorphicEvents" : null, + "external" : false, + "constraint" : null, + "ambiguous" : false + }, + "contextMachineId" : "orderId", + "matchedTransitions" : null + }, { "entryPoint" : { "type" : "REST", "name" : "POST /api/payment/{id}/capture", diff --git a/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig/ExtendedStateMachineConfig.png b/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig/ExtendedStateMachineConfig.png index b111943..f50a2f9 100644 Binary files a/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig/ExtendedStateMachineConfig.png and b/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig/ExtendedStateMachineConfig.png differ 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 f29cdfd..8d59b7f 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 @@ -152,18 +152,18 @@ } ], "entryPoints" : [ { "type" : "REST", - "name" : "GET /api/base/{id}", - "className" : "click.kamil.examples.statemachine.extended.web.PaymentController", - "methodName" : "processBaseEndpoint", - "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/web/PaymentController.java", + "name" : "POST /api/orders/resume", + "className" : "click.kamil.examples.statemachine.extended.web.OrderController", + "methodName" : "resumeOrder", + "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/web/OrderController.java", "metadata" : { - "path" : "/api/base/{id}", - "verb" : "GET" + "path" : "/api/orders/resume", + "verb" : "POST" }, "parameters" : [ { - "name" : "id", + "name" : "orderId", "type" : "String", - "annotations" : [ "PathVariable" ] + "annotations" : [ ] } ] }, { "type" : "REST", @@ -180,8 +180,57 @@ "type" : "String", "annotations" : [ "PathVariable" ] } ] + }, { + "type" : "REST", + "name" : "GET /api/base/{id}", + "className" : "click.kamil.examples.statemachine.extended.web.PaymentController", + "methodName" : "processBaseEndpoint", + "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/web/PaymentController.java", + "metadata" : { + "path" : "/api/base/{id}", + "verb" : "GET" + }, + "parameters" : [ { + "name" : "id", + "type" : "String", + "annotations" : [ "PathVariable" ] + } ] } ], "callChains" : [ { + "entryPoint" : { + "type" : "REST", + "name" : "POST /api/orders/resume", + "className" : "click.kamil.examples.statemachine.extended.web.OrderController", + "methodName" : "resumeOrder", + "sourceFile" : "src/main/java/click/kamil/examples/statemachine/extended/web/OrderController.java", + "metadata" : { + "path" : "/api/orders/resume", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "orderId", + "type" : "String", + "annotations" : [ ] + } ] + }, + "methodChain" : [ "click.kamil.examples.statemachine.extended.web.OrderController.resumeOrder", "click.kamil.examples.statemachine.extended.service.OrderService.resumeOrder" ], + "triggerPoint" : { + "event" : "[LIFECYCLE:RESTORE]", + "className" : "click.kamil.examples.statemachine.extended.service.OrderService", + "methodName" : "resumeOrder", + "sourceFile" : null, + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : null, + "lineNumber" : 29, + "polymorphicEvents" : null, + "external" : false, + "constraint" : null, + "ambiguous" : false + }, + "contextMachineId" : "orderId", + "matchedTransitions" : null + }, { "entryPoint" : { "type" : "REST", "name" : "POST /api/payment/{id}/capture", diff --git a/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig_PROD/ExtendedStateMachineConfig.png b/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig_PROD/ExtendedStateMachineConfig.png index 8299353..f50a2f9 100644 Binary files a/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig_PROD/ExtendedStateMachineConfig.png and b/state_machine_exporter/src/test/resources/golden/ExtendedStateMachineConfig_PROD/ExtendedStateMachineConfig.png differ diff --git a/state_machine_exporter/src/test/resources/golden/F1StateMachineConfiguration/F1StateMachineConfiguration.png b/state_machine_exporter/src/test/resources/golden/F1StateMachineConfiguration/F1StateMachineConfiguration.png index 50dfe8e..8da832e 100644 Binary files a/state_machine_exporter/src/test/resources/golden/F1StateMachineConfiguration/F1StateMachineConfiguration.png and b/state_machine_exporter/src/test/resources/golden/F1StateMachineConfiguration/F1StateMachineConfiguration.png differ diff --git a/state_machine_exporter/src/test/resources/golden/F2StateMachineConfiguration/F2StateMachineConfiguration.png b/state_machine_exporter/src/test/resources/golden/F2StateMachineConfiguration/F2StateMachineConfiguration.png index fb85b47..43ad388 100644 Binary files a/state_machine_exporter/src/test/resources/golden/F2StateMachineConfiguration/F2StateMachineConfiguration.png and b/state_machine_exporter/src/test/resources/golden/F2StateMachineConfiguration/F2StateMachineConfiguration.png differ diff --git a/state_machine_exporter/src/test/resources/golden/ForkJoinStateMachineConfig/ForkJoinStateMachineConfig.png b/state_machine_exporter/src/test/resources/golden/ForkJoinStateMachineConfig/ForkJoinStateMachineConfig.png index 1aba880..2118dfc 100644 Binary files a/state_machine_exporter/src/test/resources/golden/ForkJoinStateMachineConfig/ForkJoinStateMachineConfig.png and b/state_machine_exporter/src/test/resources/golden/ForkJoinStateMachineConfig/ForkJoinStateMachineConfig.png differ diff --git a/state_machine_exporter/src/test/resources/golden/G1StateMachineConfiguration/G1StateMachineConfiguration.png b/state_machine_exporter/src/test/resources/golden/G1StateMachineConfiguration/G1StateMachineConfiguration.png index 6485c99..186f510 100644 Binary files a/state_machine_exporter/src/test/resources/golden/G1StateMachineConfiguration/G1StateMachineConfiguration.png and b/state_machine_exporter/src/test/resources/golden/G1StateMachineConfiguration/G1StateMachineConfiguration.png differ diff --git a/state_machine_exporter/src/test/resources/golden/G2StateMachineConfiguration/G2StateMachineConfiguration.png b/state_machine_exporter/src/test/resources/golden/G2StateMachineConfiguration/G2StateMachineConfiguration.png index afc9eb8..122df2e 100644 Binary files a/state_machine_exporter/src/test/resources/golden/G2StateMachineConfiguration/G2StateMachineConfiguration.png and b/state_machine_exporter/src/test/resources/golden/G2StateMachineConfiguration/G2StateMachineConfiguration.png differ diff --git a/state_machine_exporter/src/test/resources/golden/KamilEnumStateMachineConfiguration/KamilEnumStateMachineConfiguration.png b/state_machine_exporter/src/test/resources/golden/KamilEnumStateMachineConfiguration/KamilEnumStateMachineConfiguration.png index 0acb1a6..ce4c122 100644 Binary files a/state_machine_exporter/src/test/resources/golden/KamilEnumStateMachineConfiguration/KamilEnumStateMachineConfiguration.png and b/state_machine_exporter/src/test/resources/golden/KamilEnumStateMachineConfiguration/KamilEnumStateMachineConfiguration.png differ diff --git a/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/MavenOrderStateMachine.json b/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/MavenOrderStateMachine.json index f92eabc..09f2406 100644 --- a/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/MavenOrderStateMachine.json +++ b/state_machine_exporter/src/test/resources/golden/MavenOrderStateMachine/MavenOrderStateMachine.json @@ -75,6 +75,21 @@ "type" : "String", "annotations" : [ "RequestBody" ] } ] + }, { + "type" : "JMS", + "name" : "JMS: order.queue", + "className" : "click.kamil.maven.api.JmsOrderListener", + "methodName" : "onMessage", + "sourceFile" : "api-module/src/main/java/click/kamil/maven/api/JmsOrderListener.java", + "metadata" : { + "protocol" : "JMS", + "destination" : "order.queue" + }, + "parameters" : [ { + "name" : "message", + "type" : "String", + "annotations" : [ ] + } ] } ], "callChains" : [ { "entryPoint" : { @@ -114,6 +129,44 @@ "targetState" : "String.BUSY", "event" : "String.SUBMIT" } ] + }, { + "entryPoint" : { + "type" : "JMS", + "name" : "JMS: order.queue", + "className" : "click.kamil.maven.api.JmsOrderListener", + "methodName" : "onMessage", + "sourceFile" : "api-module/src/main/java/click/kamil/maven/api/JmsOrderListener.java", + "metadata" : { + "protocol" : "JMS", + "destination" : "order.queue" + }, + "parameters" : [ { + "name" : "message", + "type" : "String", + "annotations" : [ ] + } ] + }, + "methodChain" : [ "click.kamil.maven.api.JmsOrderListener.onMessage", "click.kamil.maven.core.MavenOrderStateMachine.OrderService.onMessage" ], + "triggerPoint" : { + "event" : "java.lang.String.ORDER_EVENT", + "className" : "click.kamil.maven.core.MavenOrderStateMachine.OrderService", + "methodName" : "onMessage", + "sourceFile" : "core-module/src/main/java/click/kamil/maven/core/MavenOrderStateMachine.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : "java.lang.String.BUSY", + "lineNumber" : 52, + "polymorphicEvents" : [ "java.lang.String.ORDER_EVENT" ], + "external" : false, + "constraint" : null, + "ambiguous" : false + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "String.BUSY", + "targetState" : "String.DONE", + "event" : "String.ORDER_EVENT" + } ] } ], "properties" : { "default" : { } diff --git a/state_machine_exporter/src/test/resources/golden/OneStateMachineConfiguration/OneStateMachineConfiguration.png b/state_machine_exporter/src/test/resources/golden/OneStateMachineConfiguration/OneStateMachineConfiguration.png index 1bc2205..b749fe7 100644 Binary files a/state_machine_exporter/src/test/resources/golden/OneStateMachineConfiguration/OneStateMachineConfiguration.png and b/state_machine_exporter/src/test/resources/golden/OneStateMachineConfiguration/OneStateMachineConfiguration.png differ diff --git a/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfiguration/OrderStateMachineConfiguration.json b/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfiguration/OrderStateMachineConfiguration.json index f7b5021..7583329 100644 --- a/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfiguration/OrderStateMachineConfiguration.json +++ b/state_machine_exporter/src/test/resources/golden/OrderStateMachineConfiguration/OrderStateMachineConfiguration.json @@ -100,6 +100,105 @@ "ambiguous" : false } ], "entryPoints" : [ { + "type" : "REST", + "name" : "POST /api/machine/order/pay", + "className" : "click.kamil.enterprise.web.StateMachineController", + "methodName" : "payOrder", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", + "metadata" : { + "path" : "/api/machine/order/pay", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "userId", + "type" : "String", + "annotations" : [ "RequestParam" ] + } ] + }, { + "type" : "REST", + "name" : "POST /api/machine/order/ship", + "className" : "click.kamil.enterprise.web.StateMachineController", + "methodName" : "shipOrder", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", + "metadata" : { + "path" : "/api/machine/order/ship", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "userId", + "type" : "String", + "annotations" : [ "RequestParam" ] + } ] + }, { + "type" : "REST", + "name" : "POST /api/machine/ORDER/transition/{event}", + "className" : "click.kamil.enterprise.web.StateMachineController", + "methodName" : "transition", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", + "metadata" : { + "path" : "/api/machine/ORDER/transition/{event}", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "machineType", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "event", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "userId", + "type" : "String", + "annotations" : [ "RequestParam" ] + } ] + }, { + "type" : "REST", + "name" : "POST /api/machine/DOCUMENT/transition/{event}", + "className" : "click.kamil.enterprise.web.StateMachineController", + "methodName" : "transition", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", + "metadata" : { + "path" : "/api/machine/DOCUMENT/transition/{event}", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "machineType", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "event", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "userId", + "type" : "String", + "annotations" : [ "RequestParam" ] + } ] + }, { + "type" : "REST", + "name" : "POST /api/machine/USER/transition/{event}", + "className" : "click.kamil.enterprise.web.StateMachineController", + "methodName" : "transition", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", + "metadata" : { + "path" : "/api/machine/USER/transition/{event}", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "machineType", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "event", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "userId", + "type" : "String", + "annotations" : [ "RequestParam" ] + } ] + }, { "type" : "REST", "name" : "POST /api/machine/{machineType}/transition/{event}", "className" : "click.kamil.enterprise.web.StateMachineController", @@ -126,12 +225,88 @@ "callChains" : [ { "entryPoint" : { "type" : "REST", - "name" : "POST /api/machine/{machineType}/transition/{event}", + "name" : "POST /api/machine/order/pay", + "className" : "click.kamil.enterprise.web.StateMachineController", + "methodName" : "payOrder", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", + "metadata" : { + "path" : "/api/machine/order/pay", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "userId", + "type" : "String", + "annotations" : [ "RequestParam" ] + } ] + }, + "methodChain" : [ "click.kamil.enterprise.web.StateMachineController.payOrder", "click.kamil.enterprise.web.StateMachineDispatcher.payOrder" ], + "triggerPoint" : { + "event" : "click.kamil.enterprise.machines.order.OrderEvent.PAY", + "className" : "click.kamil.enterprise.web.StateMachineDispatcher", + "methodName" : "payOrder", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineDispatcher.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : "click.kamil.enterprise.machines.order.OrderState.NEW", + "lineNumber" : 30, + "polymorphicEvents" : [ "click.kamil.enterprise.machines.order.OrderEvent.PAY" ], + "external" : false, + "constraint" : null, + "ambiguous" : false + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "click.kamil.enterprise.machines.order.OrderState.NEW", + "targetState" : "click.kamil.enterprise.machines.order.OrderState.PENDING", + "event" : "click.kamil.enterprise.machines.order.OrderEvent.PAY" + } ] + }, { + "entryPoint" : { + "type" : "REST", + "name" : "POST /api/machine/order/ship", + "className" : "click.kamil.enterprise.web.StateMachineController", + "methodName" : "shipOrder", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", + "metadata" : { + "path" : "/api/machine/order/ship", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "userId", + "type" : "String", + "annotations" : [ "RequestParam" ] + } ] + }, + "methodChain" : [ "click.kamil.enterprise.web.StateMachineController.shipOrder", "click.kamil.enterprise.web.StateMachineDispatcher.shipOrder" ], + "triggerPoint" : { + "event" : "click.kamil.enterprise.machines.order.OrderEvent.SHIP", + "className" : "click.kamil.enterprise.web.StateMachineDispatcher", + "methodName" : "shipOrder", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineDispatcher.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : "click.kamil.enterprise.machines.order.OrderState.PENDING", + "lineNumber" : 37, + "polymorphicEvents" : [ "click.kamil.enterprise.machines.order.OrderEvent.SHIP" ], + "external" : false, + "constraint" : null, + "ambiguous" : false + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "click.kamil.enterprise.machines.order.OrderState.PENDING", + "targetState" : "click.kamil.enterprise.machines.order.OrderState.SHIPPED", + "event" : "click.kamil.enterprise.machines.order.OrderEvent.SHIP" + } ] + }, { + "entryPoint" : { + "type" : "REST", + "name" : "POST /api/machine/ORDER/transition/{event}", "className" : "click.kamil.enterprise.web.StateMachineController", "methodName" : "transition", "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", "metadata" : { - "path" : "/api/machine/{machineType}/transition/{event}", + "path" : "/api/machine/ORDER/transition/{event}", "verb" : "POST" }, "parameters" : [ { @@ -150,7 +325,7 @@ }, "methodChain" : [ "click.kamil.enterprise.web.StateMachineController.transition", "click.kamil.enterprise.web.StateMachineDispatcher.dispatch" ], "triggerPoint" : { - "event" : "true ? OrderEvent.valueOf(eventString.toUpperCase()) : true ? DocumentEvent.valueOf(eventString.toUpperCase()) : UserEvent.valueOf(eventString.toUpperCase())", + "event" : "OrderEvent.valueOf(eventString.toUpperCase())", "className" : "click.kamil.enterprise.web.StateMachineDispatcher", "methodName" : "dispatch", "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineDispatcher.java", @@ -160,8 +335,92 @@ "lineNumber" : 81, "polymorphicEvents" : [ ], "external" : true, - "constraint" : "\"ORDER\".equalsIgnoreCase(machineType)", - "ambiguous" : true + "constraint" : "\"ORDER\".equalsIgnoreCase(machineType) && \"ORDER\".equalsIgnoreCase(machineType)", + "ambiguous" : false + }, + "contextMachineId" : null, + "matchedTransitions" : null + }, { + "entryPoint" : { + "type" : "REST", + "name" : "POST /api/machine/DOCUMENT/transition/{event}", + "className" : "click.kamil.enterprise.web.StateMachineController", + "methodName" : "transition", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", + "metadata" : { + "path" : "/api/machine/DOCUMENT/transition/{event}", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "machineType", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "event", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "userId", + "type" : "String", + "annotations" : [ "RequestParam" ] + } ] + }, + "methodChain" : [ "click.kamil.enterprise.web.StateMachineController.transition", "click.kamil.enterprise.web.StateMachineDispatcher.dispatch" ], + "triggerPoint" : { + "event" : "DocumentEvent.valueOf(eventString.toUpperCase())", + "className" : "click.kamil.enterprise.web.StateMachineDispatcher", + "methodName" : "dispatch", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineDispatcher.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : null, + "lineNumber" : 81, + "polymorphicEvents" : [ ], + "external" : true, + "constraint" : "\"DOCUMENT\".equalsIgnoreCase(machineType) && \"ORDER\".equalsIgnoreCase(machineType)", + "ambiguous" : false + }, + "contextMachineId" : null, + "matchedTransitions" : null + }, { + "entryPoint" : { + "type" : "REST", + "name" : "POST /api/machine/USER/transition/{event}", + "className" : "click.kamil.enterprise.web.StateMachineController", + "methodName" : "transition", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", + "metadata" : { + "path" : "/api/machine/USER/transition/{event}", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "machineType", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "event", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "userId", + "type" : "String", + "annotations" : [ "RequestParam" ] + } ] + }, + "methodChain" : [ "click.kamil.enterprise.web.StateMachineController.transition", "click.kamil.enterprise.web.StateMachineDispatcher.dispatch" ], + "triggerPoint" : { + "event" : "UserEvent.valueOf(eventString.toUpperCase())", + "className" : "click.kamil.enterprise.web.StateMachineDispatcher", + "methodName" : "dispatch", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineDispatcher.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : null, + "lineNumber" : 81, + "polymorphicEvents" : [ ], + "external" : true, + "constraint" : "\"USER\".equalsIgnoreCase(machineType) && \"ORDER\".equalsIgnoreCase(machineType)", + "ambiguous" : false }, "contextMachineId" : null, "matchedTransitions" : null diff --git a/state_machine_exporter/src/test/resources/golden/SimpleEnumStateMachineConfiguration/SimpleEnumStateMachineConfiguration.png b/state_machine_exporter/src/test/resources/golden/SimpleEnumStateMachineConfiguration/SimpleEnumStateMachineConfiguration.png index 4fcdd35..d57c055 100644 Binary files a/state_machine_exporter/src/test/resources/golden/SimpleEnumStateMachineConfiguration/SimpleEnumStateMachineConfiguration.png and b/state_machine_exporter/src/test/resources/golden/SimpleEnumStateMachineConfiguration/SimpleEnumStateMachineConfiguration.png differ diff --git a/state_machine_exporter/src/test/resources/golden/StandardDocumentStateMachineConfiguration/StandardDocumentStateMachineConfiguration.json b/state_machine_exporter/src/test/resources/golden/StandardDocumentStateMachineConfiguration/StandardDocumentStateMachineConfiguration.json index cdbebca..08bc5f7 100644 --- a/state_machine_exporter/src/test/resources/golden/StandardDocumentStateMachineConfiguration/StandardDocumentStateMachineConfiguration.json +++ b/state_machine_exporter/src/test/resources/golden/StandardDocumentStateMachineConfiguration/StandardDocumentStateMachineConfiguration.json @@ -119,6 +119,51 @@ "verb" : "POST" }, "parameters" : [ ] + }, { + "type" : "REST", + "name" : "POST /api/commands/document.submit", + "className" : "click.kamil.examples.statemachine.layered.web.GenericCommandController", + "methodName" : "execute", + "sourceFile" : "src/main/java/click/kamil/examples/statemachine/layered/web/GenericCommandController.java", + "metadata" : { + "path" : "/api/commands/document.submit", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "commandKey", + "type" : "String", + "annotations" : [ "PathVariable" ] + } ] + }, { + "type" : "REST", + "name" : "POST /api/commands/document.approve", + "className" : "click.kamil.examples.statemachine.layered.web.GenericCommandController", + "methodName" : "execute", + "sourceFile" : "src/main/java/click/kamil/examples/statemachine/layered/web/GenericCommandController.java", + "metadata" : { + "path" : "/api/commands/document.approve", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "commandKey", + "type" : "String", + "annotations" : [ "PathVariable" ] + } ] + }, { + "type" : "REST", + "name" : "POST /api/commands/document.reject", + "className" : "click.kamil.examples.statemachine.layered.web.GenericCommandController", + "methodName" : "execute", + "sourceFile" : "src/main/java/click/kamil/examples/statemachine/layered/web/GenericCommandController.java", + "metadata" : { + "path" : "/api/commands/document.reject", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "commandKey", + "type" : "String", + "annotations" : [ "PathVariable" ] + } ] }, { "type" : "REST", "name" : "POST /api/commands/{commandKey}", @@ -266,7 +311,7 @@ "lineNumber" : 81, "polymorphicEvents" : [ "click.kamil.examples.statemachine.layered.document.DocumentTransitionEvent.SUBMIT" ], "external" : false, - "constraint" : "command == DOCUMENT_SUBMIT", + "constraint" : "\"document.submit\".equalsIgnoreCase(commandKey) && command == DOCUMENT_SUBMIT", "ambiguous" : false }, "contextMachineId" : null, @@ -304,7 +349,7 @@ "lineNumber" : 81, "polymorphicEvents" : [ "click.kamil.examples.statemachine.layered.document.DocumentTransitionEvent.APPROVE" ], "external" : false, - "constraint" : "command == DOCUMENT_APPROVE", + "constraint" : "\"document.approve\".equalsIgnoreCase(commandKey) && command == DOCUMENT_APPROVE", "ambiguous" : false }, "contextMachineId" : null, @@ -342,7 +387,7 @@ "lineNumber" : 81, "polymorphicEvents" : [ "click.kamil.examples.statemachine.layered.document.DocumentTransitionEvent.REJECT" ], "external" : false, - "constraint" : "command == DOCUMENT_REJECT", + "constraint" : "\"document.reject\".equalsIgnoreCase(commandKey) && command == DOCUMENT_REJECT", "ambiguous" : false }, "contextMachineId" : null, diff --git a/state_machine_exporter/src/test/resources/golden/StandardOrderStateMachineConfiguration/StandardOrderStateMachineConfiguration.json b/state_machine_exporter/src/test/resources/golden/StandardOrderStateMachineConfiguration/StandardOrderStateMachineConfiguration.json index eadc7ac..3e173d9 100644 --- a/state_machine_exporter/src/test/resources/golden/StandardOrderStateMachineConfiguration/StandardOrderStateMachineConfiguration.json +++ b/state_machine_exporter/src/test/resources/golden/StandardOrderStateMachineConfiguration/StandardOrderStateMachineConfiguration.json @@ -163,6 +163,51 @@ "verb" : "POST" }, "parameters" : [ ] + }, { + "type" : "REST", + "name" : "POST /api/commands/order.pay", + "className" : "click.kamil.examples.statemachine.layered.web.GenericCommandController", + "methodName" : "execute", + "sourceFile" : "src/main/java/click/kamil/examples/statemachine/layered/web/GenericCommandController.java", + "metadata" : { + "path" : "/api/commands/order.pay", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "commandKey", + "type" : "String", + "annotations" : [ "PathVariable" ] + } ] + }, { + "type" : "REST", + "name" : "POST /api/commands/order.ship", + "className" : "click.kamil.examples.statemachine.layered.web.GenericCommandController", + "methodName" : "execute", + "sourceFile" : "src/main/java/click/kamil/examples/statemachine/layered/web/GenericCommandController.java", + "metadata" : { + "path" : "/api/commands/order.ship", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "commandKey", + "type" : "String", + "annotations" : [ "PathVariable" ] + } ] + }, { + "type" : "REST", + "name" : "POST /api/commands/order.cancel", + "className" : "click.kamil.examples.statemachine.layered.web.GenericCommandController", + "methodName" : "execute", + "sourceFile" : "src/main/java/click/kamil/examples/statemachine/layered/web/GenericCommandController.java", + "metadata" : { + "path" : "/api/commands/order.cancel", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "commandKey", + "type" : "String", + "annotations" : [ "PathVariable" ] + } ] }, { "type" : "REST", "name" : "POST /api/commands/{commandKey}", @@ -446,7 +491,7 @@ "lineNumber" : 75, "polymorphicEvents" : [ "click.kamil.examples.statemachine.layered.order.OrderTransitionEvent.PAY" ], "external" : false, - "constraint" : "command == ORDER_PAY", + "constraint" : "\"order.pay\".equalsIgnoreCase(commandKey) && command == ORDER_PAY", "ambiguous" : false }, "contextMachineId" : null, @@ -484,7 +529,7 @@ "lineNumber" : 75, "polymorphicEvents" : [ "click.kamil.examples.statemachine.layered.order.OrderTransitionEvent.SHIP" ], "external" : false, - "constraint" : "command == ORDER_SHIP", + "constraint" : "\"order.ship\".equalsIgnoreCase(commandKey) && command == ORDER_SHIP", "ambiguous" : false }, "contextMachineId" : null, @@ -522,7 +567,7 @@ "lineNumber" : 75, "polymorphicEvents" : [ "click.kamil.examples.statemachine.layered.order.OrderTransitionEvent.CANCEL" ], "external" : false, - "constraint" : "command == ORDER_CANCEL", + "constraint" : "\"order.cancel\".equalsIgnoreCase(commandKey) && command == ORDER_CANCEL", "ambiguous" : false }, "contextMachineId" : null, diff --git a/state_machine_exporter/src/test/resources/golden/StateMachineConfig/StateMachineConfig.json b/state_machine_exporter/src/test/resources/golden/StateMachineConfig/StateMachineConfig.json index 62c2506..40a5ead 100644 --- a/state_machine_exporter/src/test/resources/golden/StateMachineConfig/StateMachineConfig.json +++ b/state_machine_exporter/src/test/resources/golden/StateMachineConfig/StateMachineConfig.json @@ -446,7 +446,7 @@ }, "methodChain" : [ "click.kamil.web.JmsOrderListener.receiveCancelCommand", "click.kamil.service.StateMachineServiceImpl.sendMessageWithProvider" ], "triggerPoint" : { - "event" : "click.kamil.domain.OrderEvent.CANCEL", + "event" : "eventProvider", "className" : "click.kamil.service.StateMachineServiceImpl", "methodName" : "sendMessageWithProvider", "sourceFile" : "service/src/main/java/click/kamil/service/StateMachineServiceImpl.java", @@ -454,10 +454,10 @@ "stateMachineId" : null, "sourceState" : null, "lineNumber" : 50, - "polymorphicEvents" : [ "click.kamil.domain.OrderEvent.CANCEL" ], + "polymorphicEvents" : null, "external" : false, "constraint" : "event != null", - "ambiguous" : true + "ambiguous" : false }, "contextMachineId" : null, "matchedTransitions" : null diff --git a/state_machine_exporter/src/test/resources/golden/ThreeStateMachineConfiguration/ThreeStateMachineConfiguration.png b/state_machine_exporter/src/test/resources/golden/ThreeStateMachineConfiguration/ThreeStateMachineConfiguration.png index f47eb97..8da832e 100644 Binary files a/state_machine_exporter/src/test/resources/golden/ThreeStateMachineConfiguration/ThreeStateMachineConfiguration.png and b/state_machine_exporter/src/test/resources/golden/ThreeStateMachineConfiguration/ThreeStateMachineConfiguration.png differ diff --git a/state_machine_exporter/src/test/resources/golden/TwoStateMachineExtraFunctionsConfiguration/TwoStateMachineConfiguration.png b/state_machine_exporter/src/test/resources/golden/TwoStateMachineExtraFunctionsConfiguration/TwoStateMachineConfiguration.png index 02f6524..cf4c706 100644 Binary files a/state_machine_exporter/src/test/resources/golden/TwoStateMachineExtraFunctionsConfiguration/TwoStateMachineConfiguration.png and b/state_machine_exporter/src/test/resources/golden/TwoStateMachineExtraFunctionsConfiguration/TwoStateMachineConfiguration.png differ diff --git a/state_machine_exporter/src/test/resources/golden/UserStateMachineConfiguration/UserStateMachineConfiguration.json b/state_machine_exporter/src/test/resources/golden/UserStateMachineConfiguration/UserStateMachineConfiguration.json index 14bae3f..665f567 100644 --- a/state_machine_exporter/src/test/resources/golden/UserStateMachineConfiguration/UserStateMachineConfiguration.json +++ b/state_machine_exporter/src/test/resources/golden/UserStateMachineConfiguration/UserStateMachineConfiguration.json @@ -93,6 +93,105 @@ "ambiguous" : false } ], "entryPoints" : [ { + "type" : "REST", + "name" : "POST /api/machine/user/verify", + "className" : "click.kamil.enterprise.web.StateMachineController", + "methodName" : "verifyUser", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", + "metadata" : { + "path" : "/api/machine/user/verify", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "userId", + "type" : "String", + "annotations" : [ "RequestParam" ] + } ] + }, { + "type" : "REST", + "name" : "POST /api/machine/user/suspend", + "className" : "click.kamil.enterprise.web.StateMachineController", + "methodName" : "suspendUser", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", + "metadata" : { + "path" : "/api/machine/user/suspend", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "userId", + "type" : "String", + "annotations" : [ "RequestParam" ] + } ] + }, { + "type" : "REST", + "name" : "POST /api/machine/ORDER/transition/{event}", + "className" : "click.kamil.enterprise.web.StateMachineController", + "methodName" : "transition", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", + "metadata" : { + "path" : "/api/machine/ORDER/transition/{event}", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "machineType", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "event", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "userId", + "type" : "String", + "annotations" : [ "RequestParam" ] + } ] + }, { + "type" : "REST", + "name" : "POST /api/machine/DOCUMENT/transition/{event}", + "className" : "click.kamil.enterprise.web.StateMachineController", + "methodName" : "transition", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", + "metadata" : { + "path" : "/api/machine/DOCUMENT/transition/{event}", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "machineType", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "event", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "userId", + "type" : "String", + "annotations" : [ "RequestParam" ] + } ] + }, { + "type" : "REST", + "name" : "POST /api/machine/USER/transition/{event}", + "className" : "click.kamil.enterprise.web.StateMachineController", + "methodName" : "transition", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", + "metadata" : { + "path" : "/api/machine/USER/transition/{event}", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "machineType", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "event", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "userId", + "type" : "String", + "annotations" : [ "RequestParam" ] + } ] + }, { "type" : "REST", "name" : "POST /api/machine/{machineType}/transition/{event}", "className" : "click.kamil.enterprise.web.StateMachineController", @@ -119,12 +218,84 @@ "callChains" : [ { "entryPoint" : { "type" : "REST", - "name" : "POST /api/machine/{machineType}/transition/{event}", + "name" : "POST /api/machine/user/verify", + "className" : "click.kamil.enterprise.web.StateMachineController", + "methodName" : "verifyUser", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", + "metadata" : { + "path" : "/api/machine/user/verify", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "userId", + "type" : "String", + "annotations" : [ "RequestParam" ] + } ] + }, + "methodChain" : [ "click.kamil.enterprise.web.StateMachineController.verifyUser", "click.kamil.enterprise.web.StateMachineDispatcher.verifyUser" ], + "triggerPoint" : { + "event" : "click.kamil.enterprise.machines.user.UserEvent.VERIFY", + "className" : "click.kamil.enterprise.web.StateMachineDispatcher", + "methodName" : "verifyUser", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineDispatcher.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : "click.kamil.enterprise.machines.user.UserState.REGISTERED", + "lineNumber" : 65, + "polymorphicEvents" : [ "click.kamil.enterprise.machines.user.UserEvent.VERIFY" ], + "external" : false, + "constraint" : null, + "ambiguous" : false + }, + "contextMachineId" : null, + "matchedTransitions" : [ { + "sourceState" : "click.kamil.enterprise.machines.user.UserState.REGISTERED", + "targetState" : "click.kamil.enterprise.machines.user.UserState.VERIFIED", + "event" : "click.kamil.enterprise.machines.user.UserEvent.VERIFY" + } ] + }, { + "entryPoint" : { + "type" : "REST", + "name" : "POST /api/machine/user/suspend", + "className" : "click.kamil.enterprise.web.StateMachineController", + "methodName" : "suspendUser", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", + "metadata" : { + "path" : "/api/machine/user/suspend", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "userId", + "type" : "String", + "annotations" : [ "RequestParam" ] + } ] + }, + "methodChain" : [ "click.kamil.enterprise.web.StateMachineController.suspendUser", "click.kamil.enterprise.web.StateMachineDispatcher.suspendUser" ], + "triggerPoint" : { + "event" : "click.kamil.enterprise.machines.user.UserEvent.SUSPEND", + "className" : "click.kamil.enterprise.web.StateMachineDispatcher", + "methodName" : "suspendUser", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineDispatcher.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : null, + "lineNumber" : 72, + "polymorphicEvents" : [ ], + "external" : false, + "constraint" : null, + "ambiguous" : false + }, + "contextMachineId" : null, + "matchedTransitions" : null + }, { + "entryPoint" : { + "type" : "REST", + "name" : "POST /api/machine/ORDER/transition/{event}", "className" : "click.kamil.enterprise.web.StateMachineController", "methodName" : "transition", "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", "metadata" : { - "path" : "/api/machine/{machineType}/transition/{event}", + "path" : "/api/machine/ORDER/transition/{event}", "verb" : "POST" }, "parameters" : [ { @@ -143,7 +314,7 @@ }, "methodChain" : [ "click.kamil.enterprise.web.StateMachineController.transition", "click.kamil.enterprise.web.StateMachineDispatcher.dispatch" ], "triggerPoint" : { - "event" : "true ? OrderEvent.valueOf(eventString.toUpperCase()) : true ? DocumentEvent.valueOf(eventString.toUpperCase()) : UserEvent.valueOf(eventString.toUpperCase())", + "event" : "OrderEvent.valueOf(eventString.toUpperCase())", "className" : "click.kamil.enterprise.web.StateMachineDispatcher", "methodName" : "dispatch", "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineDispatcher.java", @@ -153,8 +324,92 @@ "lineNumber" : 93, "polymorphicEvents" : [ ], "external" : true, - "constraint" : "!(\"ORDER\".equalsIgnoreCase(machineType)) && !(\"DOCUMENT\".equalsIgnoreCase(machineType)) && \"USER\".equalsIgnoreCase(machineType)", - "ambiguous" : true + "constraint" : "\"ORDER\".equalsIgnoreCase(machineType) && !(\"ORDER\".equalsIgnoreCase(machineType)) && !(\"DOCUMENT\".equalsIgnoreCase(machineType)) && \"USER\".equalsIgnoreCase(machineType)", + "ambiguous" : false + }, + "contextMachineId" : null, + "matchedTransitions" : null + }, { + "entryPoint" : { + "type" : "REST", + "name" : "POST /api/machine/DOCUMENT/transition/{event}", + "className" : "click.kamil.enterprise.web.StateMachineController", + "methodName" : "transition", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", + "metadata" : { + "path" : "/api/machine/DOCUMENT/transition/{event}", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "machineType", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "event", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "userId", + "type" : "String", + "annotations" : [ "RequestParam" ] + } ] + }, + "methodChain" : [ "click.kamil.enterprise.web.StateMachineController.transition", "click.kamil.enterprise.web.StateMachineDispatcher.dispatch" ], + "triggerPoint" : { + "event" : "DocumentEvent.valueOf(eventString.toUpperCase())", + "className" : "click.kamil.enterprise.web.StateMachineDispatcher", + "methodName" : "dispatch", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineDispatcher.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : null, + "lineNumber" : 93, + "polymorphicEvents" : [ ], + "external" : true, + "constraint" : "\"DOCUMENT\".equalsIgnoreCase(machineType) && !(\"ORDER\".equalsIgnoreCase(machineType)) && !(\"DOCUMENT\".equalsIgnoreCase(machineType)) && \"USER\".equalsIgnoreCase(machineType)", + "ambiguous" : false + }, + "contextMachineId" : null, + "matchedTransitions" : null + }, { + "entryPoint" : { + "type" : "REST", + "name" : "POST /api/machine/USER/transition/{event}", + "className" : "click.kamil.enterprise.web.StateMachineController", + "methodName" : "transition", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineController.java", + "metadata" : { + "path" : "/api/machine/USER/transition/{event}", + "verb" : "POST" + }, + "parameters" : [ { + "name" : "machineType", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "event", + "type" : "String", + "annotations" : [ "PathVariable" ] + }, { + "name" : "userId", + "type" : "String", + "annotations" : [ "RequestParam" ] + } ] + }, + "methodChain" : [ "click.kamil.enterprise.web.StateMachineController.transition", "click.kamil.enterprise.web.StateMachineDispatcher.dispatch" ], + "triggerPoint" : { + "event" : "UserEvent.valueOf(eventString.toUpperCase())", + "className" : "click.kamil.enterprise.web.StateMachineDispatcher", + "methodName" : "dispatch", + "sourceFile" : "src/main/java/click/kamil/enterprise/web/StateMachineDispatcher.java", + "sourceModule" : null, + "stateMachineId" : null, + "sourceState" : null, + "lineNumber" : 93, + "polymorphicEvents" : [ ], + "external" : true, + "constraint" : "\"USER\".equalsIgnoreCase(machineType) && !(\"ORDER\".equalsIgnoreCase(machineType)) && !(\"DOCUMENT\".equalsIgnoreCase(machineType)) && \"USER\".equalsIgnoreCase(machineType)", + "ambiguous" : false }, "contextMachineId" : null, "matchedTransitions" : null diff --git a/state_machine_exporter_html/src/main/resources/html/template.html b/state_machine_exporter_html/src/main/resources/html/template.html index a94c714..4ae920d 100644 --- a/state_machine_exporter_html/src/main/resources/html/template.html +++ b/state_machine_exporter_html/src/main/resources/html/template.html @@ -494,7 +494,8 @@ const parts = step.split("->"); targetAnon = "#link_anon_" + normalize(parts[0]) + "_" + normalize(parts[1]); } else { - targetLinkKey = "__" + normalize(formatForLinkId(step)); + // Event-only string steps without source are not precise enough to highlight. + return; } if (!targetLinkKey && !targetAnon) return; @@ -506,11 +507,8 @@ let matches = false; if (targetAnon) { matches = (href === targetAnon); - } else if (targetLinkKey.includes("__")) { + } else if (targetLinkKey) { matches = (href === "#link_" + targetLinkKey); - } else { - // Business-flow step: event name only, match that event from any source - matches = href.startsWith("#link_") && href.endsWith(targetLinkKey); } if (matches) { diff --git a/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlTransitionLinkConsistencyTest.java b/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlTransitionLinkConsistencyTest.java index 0bde92a..a076194 100644 --- a/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlTransitionLinkConsistencyTest.java +++ b/state_machine_exporter_html/src/test/java/click/kamil/springstatemachineexporter/html/HtmlTransitionLinkConsistencyTest.java @@ -114,7 +114,41 @@ class HtmlTransitionLinkConsistencyTest { } @Test - void enterpriseAstExportHtmlShouldContainTransitionLinkKeys(@TempDir Path tempDir) throws Exception { + void flowHighlightShouldRequireSourceAndEventNotEventSuffixOnly(@TempDir Path tempDir) throws Exception { + writeSampleProject(tempDir); + + AnalysisResult finalizedShape = AnalysisResult.builder() + .name("com.example.config.OrderStateMachineConfiguration") + .stateTypeFqn("com.example.order.OrderState") + .eventTypeFqn("com.example.order.OrderEvent") + .transitions(List.of( + shortTransition( + "com.example.order.OrderEvent.PAY", + "com.example.order.OrderState.NEW", + "com.example.order.OrderState.PAID"), + shortTransition( + "com.example.order.OrderEvent.SHIP", + "com.example.order.OrderState.PAID", + "com.example.order.OrderState.SHIPPED"))) + .startStates(Set.of("com.example.order.OrderState.NEW")) + .endStates(Set.of("com.example.order.OrderState.SHIPPED")) + .metadata(CodebaseMetadata.builder() + .flows(List.of(click.kamil.springstatemachineexporter.analysis.model.BusinessFlow.builder() + .name("Ship only") + .description("Should not light PAY") + .steps(List.of("SHIP")) + .build())) + .build()) + .build(); + + String html = new HtmlExporter().export(finalizedShape, ExportOptions.builder().build()); + assertThat(html).contains("function highlightEvents(steps)"); + assertThat(html).contains("Event-only string steps without source are not precise enough"); + assertThat(html).doesNotContain("match that event from any source"); + } + + @Test + void enterpriseHtmlShouldContainMatchedTransitionLinkKeys(@TempDir Path tempDir) throws Exception { Path enterprise = findProjectRoot().resolve("state_machines/state_machine_enterprise"); Path outputDir = tempDir.resolve("enterprise-out");