Link REST endpoints via source-proven call-graph scoping and branch expansion.

Scope machines from call-chain type evidence, expand path bindings from switch/if literals, unify external trigger policy, add linkResolution export metadata, and built-in trigger detection without hints.json.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-13 17:46:14 +02:00
parent 1bf75c8a34
commit f8f64487d1
57 changed files with 3202 additions and 297 deletions

View File

@@ -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<EntryPoint> entryPoints = intelligence.findEntryPoints();
entryPoints = MachineScopeFilter.filterEntryPointsForMachine(entryPoints, result.getName(), context);
result.addMetadata(CodebaseMetadata.builder()
.entryPoints(entryPoints)

View File

@@ -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<EntryPoint> scopeFromCallChains(List<CallChain> chains, List<EntryPoint> allEntryPoints) {
Map<String, EntryPoint> 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<CallChain> 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<String, String> 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<TriggerPoint> getOrCollectTriggers(CodebaseContext context) {
Object cached = context.getCache().get(TRIGGERS_CACHE);
if (cached instanceof List<?> list) {
return (List<TriggerPoint>) list;
}
List<TriggerPoint> 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();
}
}

View File

@@ -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<EntryPoint> 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) {

View File

@@ -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<TriggerPoint> scopedTriggers = MachineScopeFilter.filterTriggersForMachine(
result.getMetadata().getTriggers(), result.getName(), context);
List<EntryPoint> scopedEntryPoints = MachineScopeFilter.filterEntryPointsForMachine(
result.getMetadata().getEntryPoints(), result.getName(), context);
List<EntryPoint> 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<MatchedTransition> 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();
}
}

View File

@@ -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;
}
}

View File

@@ -17,4 +17,6 @@ public class CallChain {
private final TriggerPoint triggerPoint;
private final String contextMachineId;
private final List<MatchedTransition> matchedTransitions;
/** How this chain's trigger was linked; derived from trigger flags and matchedTransitions. */
private final LinkResolution linkResolution;
}

View File

@@ -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
}

View File

@@ -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<String, String> 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<String> 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<String> extractEqualityVariables(String constraint) {
Set<String> vars = new HashSet<>();
Pattern pattern = Pattern.compile("([a-zA-Z][\\w]*)\\s*==");

View File

@@ -132,17 +132,25 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
String targetMethod = tp.getClassName() + "." + tp.getMethodName();
List<List<String>> 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<List<String>> uniquePaths = new LinkedHashSet<>(allPaths);
for (List<String> 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<String> path, Map<String, List<CallEdge>> callGraph) {
return resolveTriggerPointParameters(tp, path, callGraph, Map.of());
}
protected TriggerPoint resolveTriggerPointParameters(
TriggerPoint tp,
List<String> path,
Map<String, List<CallEdge>> callGraph,
Map<String, String> 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<String> path, Map<String, List<CallEdge>> 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<String> path,
Map<String, List<CallEdge>> callGraph,
String[] finalParamNameRef) {
return resolveTriggerPointParametersOriginal(tp, path, callGraph, finalParamNameRef, Map.of());
}
protected TriggerPoint resolveTriggerPointParametersOriginal(
TriggerPoint tp,
List<String> path,
Map<String, List<CallEdge>> callGraph,
String[] finalParamNameRef,
Map<String, String> 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<String, String> parameterValues = variableTracer.buildParameterValuesMap(caller, target, callGraph, path, i);
Map<String, String> 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<String> path, Map<String, List<CallEdge>> callGraph) {
private String extractPathConstraints(
List<String> path, Map<String, List<CallEdge>> callGraph, Map<String, String> initialBindings) {
List<String> 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<String, String> bindings) {
if (bindings == null || bindings.isEmpty()) {
return null;
}
List<String> parts = new ArrayList<>();
for (Map.Entry<String, String> 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<String, String> mergeBindings(
Map<String, String> initialBindings, Map<String, String> tracedBindings) {
Map<String, String> 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<String, List<CallEdge>> buildCallGraph() {
public Map<String, List<CallEdge>> buildCallGraph() {
Map<String, List<CallEdge>> cached =
(Map<String, List<CallEdge>>) context.getCache().get(callGraphCacheKey());
if (cached != null) {
@@ -2838,11 +2914,13 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
String entryMethod,
String finalParamName,
List<String> path,
Map<String, List<CallEdge>> callGraph) {
Map<String, List<CallEdge>> callGraph,
Map<String, String> initialBindings) {
if (isExternalParameter(entryMethod, finalParamName)) {
return true;
}
Map<String, String> bindings = pathBindingEvaluator.traceBindings(path, callGraph, pathFinder);
bindings = mergeBindings(initialBindings, bindings);
for (String boundName : bindings.keySet()) {
if (isExternalParameter(entryMethod, boundName)) {
return true;

View File

@@ -27,6 +27,9 @@ public final class EntryPointBindingExpander {
if (!isPathOrQueryVariable(parameter)) {
continue;
}
if (isPlaceholderResolvedInPath(entryPoint, parameter.getName())) {
continue;
}
List<String> 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<String, String> 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<String> 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

View File

@@ -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<String> 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");
}
}
}

View File

@@ -41,6 +41,11 @@ public class GenericEventDetector {
private static final Set<String> 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<LibraryHint> 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<TriggerPoint> detect(CompilationUnit cu) {
List<TriggerPoint> 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<TriggerPoint> triggers) {
if (hints == null || hints.isEmpty()) return;
private void processBuiltInPatterns(MethodInvocation node, CompilationUnit cu, List<TriggerPoint> 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<TriggerPoint> 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<TriggerPoint> 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<TriggerPoint> 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<TriggerPoint> 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

View File

@@ -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;

View File

@@ -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<String, String> 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<String, String> 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<Expression> initializers = new ArrayList<>();
List<BranchAssignment> 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<BranchAssignment> filtered = filterAssignmentsByBindings(assignments, branchBindings);
if (!filtered.isEmpty()) {
List<String> 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<String, String> 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<String, String> 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<BranchAssignment> filterAssignmentsByBindings(
List<BranchAssignment> assignments, Map<String, String> branchBindings) {
if (assignments.isEmpty()) {
return assignments;
}
if (branchBindings == null || branchBindings.isEmpty()) {
return assignments;
}
List<BranchAssignment> compatible = new ArrayList<>();
List<BranchAssignment> 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<String, String> buildParameterValuesMap(String caller, String target, Map<String, List<CallEdge>> callGraph, List<String> path, int pathIndex) {
Map<String, String> paramValues = new HashMap<>();
List<CallEdge> edges = callGraph.get(caller);

View File

@@ -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<Transition> 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<String> polymorphicEvents,
List<Transition> transitions,

View File

@@ -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<List<LibraryHint>>() {});
System.out.println("Loaded " + libraryHints.size() + " library hints");
log.debug("Loaded {} optional library hints from hints.json", libraryHints.size());
}
}

View File

@@ -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);
}

View File

@@ -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<EntryPoint> 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<EntryPoint> 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<EntryPoint> 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<EntryPoint> 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<EntryPoint> 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<OrderState, OrderEvent> machine = null;
machine.sendEvent(OrderEvent.PAY);
}
void submitDocument() {
org.springframework.statemachine.StateMachine<DocumentState, DocumentEvent> 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<OrderState, OrderEvent> {}
class DocumentStateMachineConfiguration
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<DocumentState, DocumentEvent> {}
""");
}
}

View File

@@ -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();

View File

@@ -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;
}
}

View File

@@ -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<OrderState, OrderEvent> machine = null;
machine.sendEvent(OrderEvent.PAY);
}
void submitDocument() {
org.springframework.statemachine.StateMachine<DocumentState, DocumentEvent> 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<OrderState, OrderEvent> {}
class DocumentStateMachineConfiguration
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<DocumentState, DocumentEvent> {}
""");
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");

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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<CallChain> chains = engine.findChains(List.of(entryPoint), List.of(orderTrigger));
CallChain orderChain = chains.stream()
.filter(c -> c.getEntryPoint().getName().contains("/ORDER/"))
.findFirst()
.orElseThrow();
List<CallChain> 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<Map<String, String>> variants = EntryPointBindingExpander.expandPathVariableBindings(
entryPoint, context, engine.buildCallGraph());
assertThat(variants).extracting(map -> map.get("machineType"))
.containsExactlyInAnyOrder("ORDER", "DOCUMENT");
}
}

View File

@@ -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;
}
}

View File

@@ -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<String, List<click.kamil.springstatemachineexporter.analysis.model.CallEdge>> 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<Map<String, String>> variants =
EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph);
assertThat(variants).extracting(map -> map.get("machineType"))
.containsExactlyInAnyOrder("ORDER", "DOCUMENT");
}
}

View File

@@ -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<OrderState, OrderEvent> 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<TriggerPoint> triggers = detector.detect(
context.getCompilationUnits().iterator().next());
assertThat(triggers).isNotEmpty();
assertThat(triggers)
.extracting(TriggerPoint::getEvent)
.anyMatch(e -> e != null && e.contains("PAY"));
}
}

View File

@@ -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();
}
}

View File

@@ -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<TriggerPoint> 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<TriggerPoint> 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();
}
}

View File

@@ -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<CallChain> chains = builder.findChains(List.of(entry), List.of(trigger));
assertThat(chains).isNotEmpty();
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
.containsExactlyInAnyOrder("<SYMBOLIC: OrderEvent.*>", "<SYMBOLIC: DocumentEvent.*>", "<SYMBOLIC: UserEvent.*>");
java.util.Set<String> 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("<SYMBOLIC: OrderEvent.*>", "<SYMBOLIC: DocumentEvent.*>", "<SYMBOLIC: UserEvent.*>");
}
}

View File

@@ -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<CallChain> chains = jdtEngine.findChains(List.of(entry), List.of(trigger));
assertThat(chains).isNotEmpty();
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents())
.containsExactlyInAnyOrder("<SYMBOLIC: OrderEvent.*>", "<SYMBOLIC: DocumentEvent.*>", "<SYMBOLIC: UserEvent.*>");
java.util.Set<String> 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("<SYMBOLIC: OrderEvent.*>", "<SYMBOLIC: DocumentEvent.*>", "<SYMBOLIC: UserEvent.*>");
}
}

View File

@@ -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");
}
}

View File

@@ -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<AnalysisCanonicalFormValidator.Violation> 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()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 157 KiB

View File

@@ -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

View File

@@ -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",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -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",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -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" : { }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 168 KiB

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 55 KiB

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

After

Width:  |  Height:  |  Size: 211 KiB

View File

@@ -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