diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluator.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluator.java index 83fa1a1..8bce72a 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluator.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluator.java @@ -1,5 +1,6 @@ package click.kamil.springstatemachineexporter.analysis.resolver; +import click.kamil.springstatemachineexporter.analysis.service.CarrierConstraintSupport; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import java.util.HashMap; @@ -29,6 +30,12 @@ public final class BooleanConstraintEvaluator { return true; } + // Drop event/payload carrier clauses so they never enter the domain regex path. + constraint = CarrierConstraintSupport.stripNonMachineClauses(constraint); + if (constraint == null || constraint.isBlank()) { + return true; + } + String cleanMachine = machineDomainKey.toUpperCase(); String expr = constraint; Set literals = extractStringLiterals(constraint); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java index 10d1e44..7fe1208 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizer.java @@ -2,6 +2,7 @@ package click.kamil.springstatemachineexporter.analysis.resolver; import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; +import click.kamil.springstatemachineexporter.analysis.service.EventCarrierPolicy; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.model.Event; import click.kamil.springstatemachineexporter.model.State; @@ -13,6 +14,8 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; /** @@ -296,8 +299,28 @@ public final class MachineEnumCanonicalizer { expanded = expanded.toBuilder().polymorphicEvents(postExpand).build(); } } - if (hasConcretePolymorphicEvents(expanded.getPolymorphicEvents()) - || (expanded.getPolymorphicEvents() != null && !expanded.getPolymorphicEvents().isEmpty())) { + if (hasConcretePolymorphicEvents(expanded.getPolymorphicEvents())) { + List narrowed = narrowPolymorphicCandidates( + expanded.getPolymorphicEvents(), + expanded.getConstraint(), + machineTypes.eventTypeFqn(), + machineTransitions, + context, + expanded); + if (!narrowed.equals(expanded.getPolymorphicEvents())) { + return expanded.toBuilder() + .polymorphicEvents(narrowed) + .ambiguous(narrowed.size() > 1) + .build(); + } + if (narrowed.size() > 1 && !expanded.isAmbiguous()) { + return expanded.toBuilder().ambiguous(true).build(); + } + return expanded; + } + if (expanded.getPolymorphicEvents() != null + && !expanded.getPolymorphicEvents().isEmpty() + && !isUnboundValueOfWidenCandidate(expanded)) { List narrowed = narrowPolymorphicCandidates( expanded.getPolymorphicEvents(), expanded.getConstraint(), @@ -330,6 +353,16 @@ public final class MachineEnumCanonicalizer { .build(); } } + if (isUnboundValueOfWidenCandidate(expanded)) { + List widened = widenUnboundValueOfEvents( + machineTypes.eventTypeFqn(), machineTransitions, context); + if (!widened.isEmpty()) { + return expanded.toBuilder() + .polymorphicEvents(widened) + .ambiguous(widened.size() > 1) + .build(); + } + } return expanded; } @@ -1168,16 +1201,70 @@ public final class MachineEnumCanonicalizer { private static List extractBoundEventLiteralsFromConstraint(String constraint) { List literals = new ArrayList<>(); - java.util.regex.Matcher matcher = java.util.regex.Pattern - .compile("\"([^\"]+)\"\\.equalsIgnoreCase\\((\\w+)\\)") - .matcher(constraint); - while (matcher.find()) { - String param = matcher.group(2); - if (click.kamil.springstatemachineexporter.analysis.service.EventCarrierPolicy - .isCarrierParamName(param)) { - literals.add(matcher.group(1)); + if (constraint == null || constraint.isBlank()) { + return literals; + } + Pattern quoted = Pattern.compile("\"([^\"]+)\"|'([^']+)'"); + for (String clause : ConstraintClauses.splitAndClauses(constraint)) { + String ident = ConstraintClauses.primaryIdent(clause); + if (ident == null || !EventCarrierPolicy.isCarrierParamName(ident)) { + continue; + } + Matcher matcher = quoted.matcher(clause); + if (matcher.find()) { + String literal = matcher.group(1) != null ? matcher.group(1) : matcher.group(2); + if (literal != null && !literal.isBlank()) { + literals.add(literal); + } } } return literals; } + + /** + * True when the trigger is {@code Enum.valueOf(...)} without a single bound carrier literal + * and without concrete polymorphic events — eligible for enum-wide / transition-wide widen. + */ + private static boolean isUnboundValueOfWidenCandidate(TriggerPoint trigger) { + if (trigger == null || trigger.getEvent() == null) { + return false; + } + if (!isDynamicTriggerExpression(trigger.getEvent()) || !trigger.getEvent().contains(".valueOf(")) { + return false; + } + // Pre-marked ambiguous without a binding stays fail-closed (cross-package / unresolved). + if (trigger.isAmbiguous()) { + return false; + } + if (hasConcretePolymorphicEvents(trigger.getPolymorphicEvents())) { + return false; + } + String constraint = trigger.getConstraint(); + List bound = constraint != null && !constraint.isBlank() + ? extractBoundEventLiteralsFromConstraint(constraint) + : List.of(); + return bound.size() != 1; + } + + private static List widenUnboundValueOfEvents( + String eventTypeFqn, + List machineTransitions, + CodebaseContext context) { + if (eventTypeFqn == null || eventTypeFqn.isBlank()) { + return List.of(); + } + List transitionEvents = + polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn, context); + if (!transitionEvents.isEmpty()) { + return transitionEvents; + } + if (context == null) { + return List.of(); + } + List enumValues = context.getEnumValues(eventTypeFqn); + if (enumValues == null || enumValues.isEmpty()) { + return List.of(); + } + return List.copyOf(enumValues); + } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java index 6823588..88db217 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/AbstractCallGraphEngine.java @@ -2898,6 +2898,17 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { shouldExpand = false; } + if (log.isDebugEnabled()) { + log.debug( + "resolveCalledMethodsPolymorphic baseCalled={} isImplicitThis={} className={} td={} impls={} shouldExpand={}", + baseCalled, + isImplicitThis, + className, + td == null ? null : context.getFqn(td), + impls, + shouldExpand); + } + if (shouldExpand) { boolean isAbstractOrInterface = td.isInterface() || Modifier.isAbstract(td.getModifiers()); Optional> precomputed = @@ -2921,18 +2932,43 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine { List frozen = List.copyOf(allResolved); polymorphicCallCache.put(cacheKey, frozen); + if (log.isDebugEnabled()) { + log.debug("resolveCalledMethodsPolymorphic allResolved={} frozen={}", allResolved, frozen); + } return frozen; } private boolean isAbstractOrInterfaceMethod(TypeDeclaration declaringType, String methodName) { if (declaringType == null || methodName == null) { + if (log.isDebugEnabled()) { + log.debug( + "isAbstractOrInterfaceMethod declaringTypeFqn=null md=null modifiers=n/a methodName={} result=false", + methodName); + } return false; } + String declaringTypeFqn = context.getFqn(declaringType); if (declaringType.isInterface()) { + if (log.isDebugEnabled()) { + log.debug( + "isAbstractOrInterfaceMethod declaringTypeFqn={} md=n/a (interface) modifiers=n/a methodName={} result=true", + declaringTypeFqn, + methodName); + } return true; } MethodDeclaration md = context.findMethodDeclaration(declaringType, methodName, false); - return md != null && Modifier.isAbstract(md.getModifiers()); + boolean result = md != null && Modifier.isAbstract(md.getModifiers()); + if (log.isDebugEnabled()) { + log.debug( + "isAbstractOrInterfaceMethod declaringTypeFqn={} mdNull={} modifiers={} methodName={} result={}", + declaringTypeFqn, + md == null, + md == null ? null : md.getModifiers(), + methodName, + result); + } + return result; } private boolean isStaticResolvedMethod(String methodFqn) { diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinder.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinder.java index 7c2b27b..f50ff3f 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinder.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/CallGraphPathFinder.java @@ -400,14 +400,26 @@ public class CallGraphPathFinder { * {@code receiverTypeFqn} was adopted as the dispatch self on the incoming edge. */ private boolean acceptVirtualHop(String callerMethod, String targetMethod, String concreteSelfFqn) { + boolean accepted; if (!isSelfHierarchyDispatch(callerMethod, concreteSelfFqn)) { - return true; + accepted = true; + } else if (!isVirtualDispatchCandidate(callerMethod, targetMethod)) { + accepted = true; + } else { + String targetClass = classNameOf(targetMethod); + accepted = targetClass != null && context.isSubtypeOrSame(concreteSelfFqn, targetClass); } - if (!isVirtualDispatchCandidate(callerMethod, targetMethod)) { - return true; + if (targetMethod != null + && targetMethod.contains("doProcessMessage") + && log.isDebugEnabled()) { + log.debug( + "acceptVirtualHop callerMethod={} targetMethod={} concreteSelfFqn={} accepted={}", + callerMethod, + targetMethod, + concreteSelfFqn, + accepted); } - String targetClass = classNameOf(targetMethod); - return targetClass != null && context.isSubtypeOrSame(concreteSelfFqn, targetClass); + return accepted; } private boolean acceptImplementationFallback( diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EventCarrierPolicy.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EventCarrierPolicy.java index 8643d9e..119d10f 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EventCarrierPolicy.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EventCarrierPolicy.java @@ -41,13 +41,23 @@ public final class EventCarrierPolicy { "sourcestate", "fromstate"); - /** Bare unresolved trigger tokens (not FQNs / expressions). */ + private static final Set STATE_ACCESSOR_METHOD_NAMES = Set.of( + "getstate", + "getid"); + + /** + * Bare unresolved trigger tokens derived from {@link #CARRIER_NAMES} so the sets cannot drift. + * Kept as an explicit subset of common payload/event identifiers. + */ private static final Set DYNAMIC_EVENT_TOKENS = Set.of( - "event", - "e", - "message", - "msg", - "payload"); + "event", + "e", + "message", + "msg", + "payload") + .stream() + .filter(CARRIER_NAMES::contains) + .collect(java.util.stream.Collectors.toUnmodifiableSet()); private EventCarrierPolicy() { } @@ -100,6 +110,21 @@ public final class EventCarrierPolicy { return lower.endsWith("state") && !isCarrierParamName(name); } + /** + * Method names that positively indicate an SM source-state selector + * ({@code getState()}, {@code getId()}, or names already treated as state accessors). + */ + public static boolean isStateAccessorMethodName(String name) { + if (name == null || name.isBlank()) { + return false; + } + String lower = name.toLowerCase(Locale.ROOT); + if (STATE_ACCESSOR_METHOD_NAMES.contains(lower)) { + return true; + } + return isStateAccessorName(name); + } + /** * Messaging {@code @Payload}, conventional carrier name, or the sole unannotated String body. */ diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java index 0796e18..035bb96 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/GenericEventDetector.java @@ -760,7 +760,7 @@ public class GenericEventDetector { } if (expr instanceof MethodInvocation mi) { String name = mi.getName().getIdentifier(); - if ("getState".equals(name) || "getId".equals(name) || EventCarrierPolicy.isStateAccessorName(name)) { + if (EventCarrierPolicy.isStateAccessorMethodName(name)) { return true; } } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java index 642ac92..3a3ccb2 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/ast/common/CodebaseContext.java @@ -478,15 +478,19 @@ public class CodebaseContext { // Try direct match List directImpls = interfaceToImpls.get(cleanName); - + String directImplsSource = directImpls != null ? "direct" : null; + // Try FQN match if input was simple name if (directImpls == null) { String fqn = simpleNameToFqn.get(typeName); if (fqn != null) { directImpls = interfaceToImpls.get(fqn); + if (directImpls != null) { + directImplsSource = "simple→fqn"; + } } } - + // Try simple name match if input was FQN if (directImpls == null && cleanName.contains(".")) { String simpleName = cleanName.substring(cleanName.lastIndexOf('.') + 1); @@ -504,8 +508,23 @@ public class CodebaseContext { } else { directImpls = simpleImpls; } + if (directImpls != null) { + directImplsSource = "fqn→simple"; + } } } + if (directImplsSource == null) { + directImplsSource = "none"; + } + + if (log.isDebugEnabled()) { + log.debug( + "collectImplementations typeName={} cleanName={} directImplsSource={} directImpls={}", + typeName, + cleanName, + directImplsSource, + directImpls); + } if (directImpls != null) { for (String impl : directImpls) { diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluatorTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluatorTest.java index 5ea976d..b1fd19a 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluatorTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluatorTest.java @@ -57,6 +57,19 @@ class BooleanConstraintEvaluatorTest { "!(\"ORDER\".equals(type))", "OrderStateMachineConfiguration")).isFalse(); } + @Test + void shouldIgnoreCarrierClausesWhenMatchingMachineDomain() { + assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain( + "\"ORDER\".equalsIgnoreCase(machineType) && message == \"PAY\"", + "OrderStateMachineConfiguration")).isTrue(); + assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain( + "\"PAYMENT\".equalsIgnoreCase(machineType) && message == \"PAY\"", + "OrderStateMachineConfiguration")).isFalse(); + assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain( + "\"PAY\".equalsIgnoreCase(message) && message == \"PAY\"", + "OrderStateMachineConfiguration")).isTrue(); + } + @Test void evaluateBooleanExpressionShouldHandleLogicalOperators() { assertThat(BooleanConstraintEvaluator.evaluateBooleanExpression("true && false")).isFalse(); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerBoundValueOfTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerBoundValueOfTest.java index eac2826..205972d 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerBoundValueOfTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerBoundValueOfTest.java @@ -2,6 +2,9 @@ package click.kamil.springstatemachineexporter.analysis.resolver; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver.MachineTypes; +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; @@ -17,8 +20,8 @@ class MachineEnumCanonicalizerBoundValueOfTest { .constraint("\"ORDER\".equalsIgnoreCase(machineType) && \"PAY\".equalsIgnoreCase(event)") .external(true) .build(); - StateMachineTypeResolver.MachineTypes machineTypes = - new StateMachineTypeResolver.MachineTypes( + MachineTypes machineTypes = + new MachineTypes( "com.example.order.OrderState", "com.example.order.OrderEvent"); @@ -30,4 +33,54 @@ class MachineEnumCanonicalizerBoundValueOfTest { assertThat(expanded.isExternal()).isFalse(); assertThat(expanded.isAmbiguous()).isFalse(); } + + @Test + void shouldExpandBoundValueOfFromEqCompareConstraint() { + TriggerPoint trigger = TriggerPoint.builder() + .event("OrderEvent.valueOf(message)") + .constraint("message == \"PAY\"") + .external(true) + .build(); + MachineTypes machineTypes = new MachineTypes( + "com.example.order.OrderState", + "com.example.order.OrderEvent"); + + TriggerPoint expanded = MachineEnumCanonicalizer.expandBoundValueOfFromConstraints( + trigger, machineTypes, null); + + assertThat(expanded.getPolymorphicEvents()) + .containsExactly("com.example.order.OrderEvent.PAY"); + assertThat(expanded.isExternal()).isFalse(); + } + + @Test + void shouldWidenUnboundValueOfToTransitionEvents() { + TriggerPoint trigger = TriggerPoint.builder() + .event("OrderEvent.valueOf(message)") + .external(true) + .build(); + MachineTypes machineTypes = new MachineTypes( + "com.example.order.OrderState", + "com.example.order.OrderEvent"); + List transitions = List.of( + transition("PAY", "com.example.order.OrderEvent.PAY"), + transition("SHIP", "com.example.order.OrderEvent.SHIP")); + + TriggerPoint expanded = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents( + trigger, machineTypes, null, transitions, true); + + assertThat(expanded.getPolymorphicEvents()) + .containsExactly( + "com.example.order.OrderEvent.PAY", + "com.example.order.OrderEvent.SHIP"); + assertThat(expanded.isAmbiguous()).isTrue(); + } + + private static Transition transition(String raw, String full) { + Transition t = new Transition(); + t.setEvent(Event.of(raw, full)); + t.setSourceStates(List.of(State.of("S1", "S1"))); + t.setTargetStates(List.of(State.of("S2", "S2"))); + return t; + } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerTest.java index 421083e..0d7a52b 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/MachineEnumCanonicalizerTest.java @@ -374,7 +374,7 @@ class MachineEnumCanonicalizerTest { } @Test - void shouldNotInferMultipleTransitionEventsForUnresolvedValueOf() { + void shouldWidenUnresolvedValueOfToConfiguredTransitionEvents() { Transition pay = new Transition(); pay.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY")); Transition ship = new Transition(); @@ -390,8 +390,11 @@ class MachineEnumCanonicalizerTest { TriggerPoint linked = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents( trigger, types, null, List.of(pay, ship)); - assertThat(linked.getPolymorphicEvents()).isNullOrEmpty(); - assertThat(linked.isAmbiguous()).isFalse(); + assertThat(linked.getPolymorphicEvents()) + .containsExactly( + "com.example.order.OrderEvent.PAY", + "com.example.order.OrderEvent.SHIP"); + assertThat(linked.isAmbiguous()).isTrue(); } @Test diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EventCarrierPolicyTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EventCarrierPolicyTest.java index e985eb1..ec81e3c 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EventCarrierPolicyTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EventCarrierPolicyTest.java @@ -45,6 +45,14 @@ class EventCarrierPolicyTest { assertThat(EventCarrierPolicy.isStateAccessorName("message")).isFalse(); } + @Test + void stateAccessorMethodNamesIncludeGetStateAndGetId() { + assertThat(EventCarrierPolicy.isStateAccessorMethodName("getState")).isTrue(); + assertThat(EventCarrierPolicy.isStateAccessorMethodName("getId")).isTrue(); + assertThat(EventCarrierPolicy.isStateAccessorMethodName("currentState")).isTrue(); + assertThat(EventCarrierPolicy.isStateAccessorMethodName("message")).isFalse(); + } + @Test void payloadAnnotationMakesMessagingCarrierRegardlessOfName() { EntryPoint entryPoint = EntryPoint.builder()