B
This commit is contained in:
@@ -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<String> literals = extractStringLiterals(constraint);
|
||||
|
||||
@@ -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<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(
|
||||
expanded.getPolymorphicEvents(),
|
||||
expanded.getConstraint(),
|
||||
@@ -330,6 +353,16 @@ public final class MachineEnumCanonicalizer {
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -1168,16 +1201,70 @@ public final class MachineEnumCanonicalizer {
|
||||
|
||||
private static List<String> extractBoundEventLiteralsFromConstraint(String constraint) {
|
||||
List<String> 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<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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<List<String>> precomputed =
|
||||
@@ -2921,18 +2932,43 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
|
||||
|
||||
List<String> 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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -41,13 +41,23 @@ public final class EventCarrierPolicy {
|
||||
"sourcestate",
|
||||
"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(
|
||||
"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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,15 +478,19 @@ public class CodebaseContext {
|
||||
|
||||
// Try direct match
|
||||
List<String> 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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user