This commit is contained in:
2026-07-17 08:10:39 +02:00
parent ed74a44c29
commit 2c20b2c3fe
11 changed files with 293 additions and 30 deletions

View File

@@ -1,5 +1,6 @@
package click.kamil.springstatemachineexporter.analysis.resolver; package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.analysis.service.CarrierConstraintSupport;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import java.util.HashMap; import java.util.HashMap;
@@ -29,6 +30,12 @@ public final class BooleanConstraintEvaluator {
return true; 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 cleanMachine = machineDomainKey.toUpperCase();
String expr = constraint; String expr = constraint;
Set<String> literals = extractStringLiterals(constraint); Set<String> literals = extractStringLiterals(constraint);

View File

@@ -2,6 +2,7 @@ package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint; import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; 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.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Event; import click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.State; import click.kamil.springstatemachineexporter.model.State;
@@ -13,6 +14,8 @@ import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@@ -296,8 +299,28 @@ public final class MachineEnumCanonicalizer {
expanded = expanded.toBuilder().polymorphicEvents(postExpand).build(); expanded = expanded.toBuilder().polymorphicEvents(postExpand).build();
} }
} }
if (hasConcretePolymorphicEvents(expanded.getPolymorphicEvents()) if (hasConcretePolymorphicEvents(expanded.getPolymorphicEvents())) {
|| (expanded.getPolymorphicEvents() != null && !expanded.getPolymorphicEvents().isEmpty())) { List<String> 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<String> narrowed = narrowPolymorphicCandidates( List<String> narrowed = narrowPolymorphicCandidates(
expanded.getPolymorphicEvents(), expanded.getPolymorphicEvents(),
expanded.getConstraint(), expanded.getConstraint(),
@@ -330,6 +353,16 @@ public final class MachineEnumCanonicalizer {
.build(); .build();
} }
} }
if (isUnboundValueOfWidenCandidate(expanded)) {
List<String> widened = widenUnboundValueOfEvents(
machineTypes.eventTypeFqn(), machineTransitions, context);
if (!widened.isEmpty()) {
return expanded.toBuilder()
.polymorphicEvents(widened)
.ambiguous(widened.size() > 1)
.build();
}
}
return expanded; return expanded;
} }
@@ -1168,16 +1201,70 @@ public final class MachineEnumCanonicalizer {
private static List<String> extractBoundEventLiteralsFromConstraint(String constraint) { private static List<String> extractBoundEventLiteralsFromConstraint(String constraint) {
List<String> literals = new ArrayList<>(); List<String> literals = new ArrayList<>();
java.util.regex.Matcher matcher = java.util.regex.Pattern if (constraint == null || constraint.isBlank()) {
.compile("\"([^\"]+)\"\\.equalsIgnoreCase\\((\\w+)\\)") return literals;
.matcher(constraint); }
while (matcher.find()) { Pattern quoted = Pattern.compile("\"([^\"]+)\"|'([^']+)'");
String param = matcher.group(2); for (String clause : ConstraintClauses.splitAndClauses(constraint)) {
if (click.kamil.springstatemachineexporter.analysis.service.EventCarrierPolicy String ident = ConstraintClauses.primaryIdent(clause);
.isCarrierParamName(param)) { if (ident == null || !EventCarrierPolicy.isCarrierParamName(ident)) {
literals.add(matcher.group(1)); 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; 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<String> bound = constraint != null && !constraint.isBlank()
? extractBoundEventLiteralsFromConstraint(constraint)
: List.of();
return bound.size() != 1;
}
private static List<String> widenUnboundValueOfEvents(
String eventTypeFqn,
List<Transition> machineTransitions,
CodebaseContext context) {
if (eventTypeFqn == null || eventTypeFqn.isBlank()) {
return List.of();
}
List<String> transitionEvents =
polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn, context);
if (!transitionEvents.isEmpty()) {
return transitionEvents;
}
if (context == null) {
return List.of();
}
List<String> enumValues = context.getEnumValues(eventTypeFqn);
if (enumValues == null || enumValues.isEmpty()) {
return List.of();
}
return List.copyOf(enumValues);
}
} }

View File

@@ -2898,6 +2898,17 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
shouldExpand = false; 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) { if (shouldExpand) {
boolean isAbstractOrInterface = td.isInterface() || Modifier.isAbstract(td.getModifiers()); boolean isAbstractOrInterface = td.isInterface() || Modifier.isAbstract(td.getModifiers());
Optional<List<String>> precomputed = Optional<List<String>> precomputed =
@@ -2921,18 +2932,43 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
List<String> frozen = List.copyOf(allResolved); List<String> frozen = List.copyOf(allResolved);
polymorphicCallCache.put(cacheKey, frozen); polymorphicCallCache.put(cacheKey, frozen);
if (log.isDebugEnabled()) {
log.debug("resolveCalledMethodsPolymorphic allResolved={} frozen={}", allResolved, frozen);
}
return frozen; return frozen;
} }
private boolean isAbstractOrInterfaceMethod(TypeDeclaration declaringType, String methodName) { private boolean isAbstractOrInterfaceMethod(TypeDeclaration declaringType, String methodName) {
if (declaringType == null || methodName == null) { if (declaringType == null || methodName == null) {
if (log.isDebugEnabled()) {
log.debug(
"isAbstractOrInterfaceMethod declaringTypeFqn=null md=null modifiers=n/a methodName={} result=false",
methodName);
}
return false; return false;
} }
String declaringTypeFqn = context.getFqn(declaringType);
if (declaringType.isInterface()) { if (declaringType.isInterface()) {
if (log.isDebugEnabled()) {
log.debug(
"isAbstractOrInterfaceMethod declaringTypeFqn={} md=n/a (interface) modifiers=n/a methodName={} result=true",
declaringTypeFqn,
methodName);
}
return true; return true;
} }
MethodDeclaration md = context.findMethodDeclaration(declaringType, methodName, false); 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) { private boolean isStaticResolvedMethod(String methodFqn) {

View File

@@ -400,14 +400,26 @@ public class CallGraphPathFinder {
* {@code receiverTypeFqn} was adopted as the dispatch self on the incoming edge. * {@code receiverTypeFqn} was adopted as the dispatch self on the incoming edge.
*/ */
private boolean acceptVirtualHop(String callerMethod, String targetMethod, String concreteSelfFqn) { private boolean acceptVirtualHop(String callerMethod, String targetMethod, String concreteSelfFqn) {
boolean accepted;
if (!isSelfHierarchyDispatch(callerMethod, concreteSelfFqn)) { 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)) { if (targetMethod != null
return true; && targetMethod.contains("doProcessMessage")
&& log.isDebugEnabled()) {
log.debug(
"acceptVirtualHop callerMethod={} targetMethod={} concreteSelfFqn={} accepted={}",
callerMethod,
targetMethod,
concreteSelfFqn,
accepted);
} }
String targetClass = classNameOf(targetMethod); return accepted;
return targetClass != null && context.isSubtypeOrSame(concreteSelfFqn, targetClass);
} }
private boolean acceptImplementationFallback( private boolean acceptImplementationFallback(

View File

@@ -41,13 +41,23 @@ public final class EventCarrierPolicy {
"sourcestate", "sourcestate",
"fromstate"); "fromstate");
/** Bare unresolved trigger tokens (not FQNs / expressions). */ private static final Set<String> 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<String> DYNAMIC_EVENT_TOKENS = Set.of( private static final Set<String> DYNAMIC_EVENT_TOKENS = Set.of(
"event", "event",
"e", "e",
"message", "message",
"msg", "msg",
"payload"); "payload")
.stream()
.filter(CARRIER_NAMES::contains)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
private EventCarrierPolicy() { private EventCarrierPolicy() {
} }
@@ -100,6 +110,21 @@ public final class EventCarrierPolicy {
return lower.endsWith("state") && !isCarrierParamName(name); 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. * Messaging {@code @Payload}, conventional carrier name, or the sole unannotated String body.
*/ */

View File

@@ -760,7 +760,7 @@ public class GenericEventDetector {
} }
if (expr instanceof MethodInvocation mi) { if (expr instanceof MethodInvocation mi) {
String name = mi.getName().getIdentifier(); String name = mi.getName().getIdentifier();
if ("getState".equals(name) || "getId".equals(name) || EventCarrierPolicy.isStateAccessorName(name)) { if (EventCarrierPolicy.isStateAccessorMethodName(name)) {
return true; return true;
} }
} }

View File

@@ -478,12 +478,16 @@ public class CodebaseContext {
// Try direct match // Try direct match
List<String> directImpls = interfaceToImpls.get(cleanName); List<String> directImpls = interfaceToImpls.get(cleanName);
String directImplsSource = directImpls != null ? "direct" : null;
// Try FQN match if input was simple name // Try FQN match if input was simple name
if (directImpls == null) { if (directImpls == null) {
String fqn = simpleNameToFqn.get(typeName); String fqn = simpleNameToFqn.get(typeName);
if (fqn != null) { if (fqn != null) {
directImpls = interfaceToImpls.get(fqn); directImpls = interfaceToImpls.get(fqn);
if (directImpls != null) {
directImplsSource = "simple→fqn";
}
} }
} }
@@ -504,8 +508,23 @@ public class CodebaseContext {
} else { } else {
directImpls = simpleImpls; 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) { if (directImpls != null) {
for (String impl : directImpls) { for (String impl : directImpls) {

View File

@@ -57,6 +57,19 @@ class BooleanConstraintEvaluatorTest {
"!(\"ORDER\".equals(type))", "OrderStateMachineConfiguration")).isFalse(); "!(\"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 @Test
void evaluateBooleanExpressionShouldHandleLogicalOperators() { void evaluateBooleanExpressionShouldHandleLogicalOperators() {
assertThat(BooleanConstraintEvaluator.evaluateBooleanExpression("true && false")).isFalse(); assertThat(BooleanConstraintEvaluator.evaluateBooleanExpression("true && false")).isFalse();

View File

@@ -2,6 +2,9 @@ package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver.MachineTypes; 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 org.junit.jupiter.api.Test;
import java.util.List; import java.util.List;
@@ -17,8 +20,8 @@ class MachineEnumCanonicalizerBoundValueOfTest {
.constraint("\"ORDER\".equalsIgnoreCase(machineType) && \"PAY\".equalsIgnoreCase(event)") .constraint("\"ORDER\".equalsIgnoreCase(machineType) && \"PAY\".equalsIgnoreCase(event)")
.external(true) .external(true)
.build(); .build();
StateMachineTypeResolver.MachineTypes machineTypes = MachineTypes machineTypes =
new StateMachineTypeResolver.MachineTypes( new MachineTypes(
"com.example.order.OrderState", "com.example.order.OrderState",
"com.example.order.OrderEvent"); "com.example.order.OrderEvent");
@@ -30,4 +33,54 @@ class MachineEnumCanonicalizerBoundValueOfTest {
assertThat(expanded.isExternal()).isFalse(); assertThat(expanded.isExternal()).isFalse();
assertThat(expanded.isAmbiguous()).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<Transition> 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;
}
} }

View File

@@ -374,7 +374,7 @@ class MachineEnumCanonicalizerTest {
} }
@Test @Test
void shouldNotInferMultipleTransitionEventsForUnresolvedValueOf() { void shouldWidenUnresolvedValueOfToConfiguredTransitionEvents() {
Transition pay = new Transition(); Transition pay = new Transition();
pay.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY")); pay.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY"));
Transition ship = new Transition(); Transition ship = new Transition();
@@ -390,8 +390,11 @@ class MachineEnumCanonicalizerTest {
TriggerPoint linked = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents( TriggerPoint linked = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
trigger, types, null, List.of(pay, ship)); trigger, types, null, List.of(pay, ship));
assertThat(linked.getPolymorphicEvents()).isNullOrEmpty(); assertThat(linked.getPolymorphicEvents())
assertThat(linked.isAmbiguous()).isFalse(); .containsExactly(
"com.example.order.OrderEvent.PAY",
"com.example.order.OrderEvent.SHIP");
assertThat(linked.isAmbiguous()).isTrue();
} }
@Test @Test

View File

@@ -45,6 +45,14 @@ class EventCarrierPolicyTest {
assertThat(EventCarrierPolicy.isStateAccessorName("message")).isFalse(); 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 @Test
void payloadAnnotationMakesMessagingCarrierRegardlessOfName() { void payloadAnnotationMakesMessagingCarrierRegardlessOfName() {
EntryPoint entryPoint = EntryPoint.builder() EntryPoint entryPoint = EntryPoint.builder()