This commit is contained in:
2026-07-17 07:40:54 +02:00
parent 40bcde26a3
commit 9b62be913c
9 changed files with 294 additions and 86 deletions

View File

@@ -8,6 +8,7 @@ import click.kamil.springstatemachineexporter.analysis.model.LifecycleTriggerMar
import click.kamil.springstatemachineexporter.analysis.model.LinkResolution; import click.kamil.springstatemachineexporter.analysis.model.LinkResolution;
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition; import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.service.CarrierConstraintSupport;
import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider; import click.kamil.springstatemachineexporter.analysis.service.CodebaseIntelligenceProvider;
import click.kamil.springstatemachineexporter.analysis.service.EventCarrierPolicy; import click.kamil.springstatemachineexporter.analysis.service.EventCarrierPolicy;
import click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory; import click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory;
@@ -279,49 +280,11 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
if (constraint == null || machineName == null) { if (constraint == null || machineName == null) {
return true; return true;
} }
String machineConstraint = stripEventBindingClauses(constraint); String machineConstraint = CarrierConstraintSupport.stripNonMachineClauses(constraint);
return BooleanConstraintEvaluator.isCompatibleWithMachineDomain( return BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
machineConstraint, MachineDomainKeys.extractMachineDomainKey(machineName)); machineConstraint, MachineDomainKeys.extractMachineDomainKey(machineName));
} }
private static String stripEventBindingClauses(String constraint) {
if (constraint == null || constraint.isBlank()) {
return constraint;
}
// Event/payload binding clauses prove which event was selected — not which machine.
// Keep machineType/domain comparisons for isCompatibleWithMachineDomain.
String eventLike = "(?i)(" + EventCarrierPolicy.carrierNameAlternation() + ")";
String stripped = constraint.replaceAll(
"\"[^\"]+\"\\s*\\.\\s*equalsIgnoreCase\\s*\\(\\s*" + eventLike + "\\s*\\)",
"true");
stripped = stripped.replaceAll(
eventLike + "\\s*\\.\\s*equalsIgnoreCase\\s*\\(\\s*\"[^\"]+\"\\s*\\)",
"true");
stripped = stripped.replaceAll(
"\"[^\"]+\"\\s*\\.\\s*equals\\s*\\(\\s*" + eventLike + "\\s*\\)",
"true");
stripped = stripped.replaceAll(
eventLike + "\\s*\\.\\s*equals\\s*\\(\\s*\"[^\"]+\"\\s*\\)",
"true");
// Switch-case / arrow constraints often use == on the payload param.
stripped = stripped.replaceAll(
eventLike + "\\s*==\\s*\"[^\"]+\"",
"true");
stripped = stripped.replaceAll(
"\"[^\"]+\"\\s*==\\s*" + eventLike,
"true");
stripped = stripped.replaceAll(
eventLike + "\\s*!=\\s*\"[^\"]+\"",
"true");
stripped = stripped.replaceAll(
"\"[^\"]+\"\\s*!=\\s*" + eventLike,
"true");
stripped = stripped.replaceAll("&&\\s*&&+", "&&");
stripped = stripped.replaceAll("^\\s*&&\\s*", "");
stripped = stripped.replaceAll("\\s*&&\\s*$", "");
return stripped.trim();
}
private String extractStateEnumType(String stateIdentifier) { private String extractStateEnumType(String stateIdentifier) {
if (stateIdentifier == null || stateIdentifier.isBlank()) { if (stateIdentifier == null || stateIdentifier.isBlank()) {
return stateIdentifier; return stateIdentifier;
@@ -452,14 +415,8 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
if (colon > 0) { if (colon > 0) {
lower = lower.substring(0, colon); lower = lower.substring(0, colon);
} }
return lower.equals("event") return EventCarrierPolicy.isCarrierParamName(lower)
|| lower.equals("e") || EventCarrierPolicy.isMachineDiscriminatorName(lower);
|| lower.endsWith("event")
|| lower.equals("command")
|| lower.equals("commandkey")
|| lower.equals("transition")
|| lower.equals("action")
|| lower.equals("machinetype");
} }
private static boolean hasConcreteEventEvidence(TriggerPoint trigger) { private static boolean hasConcreteEventEvidence(TriggerPoint trigger) {

View File

@@ -159,9 +159,8 @@ public class HeuristicEventMatchingEngine implements EventMatchingEngine {
private boolean isWildcardVariable(String eventStr) { private boolean isWildcardVariable(String eventStr) {
if (eventStr == null) return false; if (eventStr == null) return false;
if (eventStr.equals("event") || eventStr.equals("e") || if (click.kamil.springstatemachineexporter.analysis.service.EventCarrierPolicy
eventStr.equals("msg") || eventStr.equals("message") || .isDynamicEventToken(eventStr)) {
eventStr.equals("payload")) {
return true; return true;
} }

View File

@@ -681,9 +681,8 @@ public final class MachineEnumCanonicalizer {
if (eventStr.contains("${")) { if (eventStr.contains("${")) {
return true; return true;
} }
if (eventStr.equals("event") || eventStr.equals("e") if (click.kamil.springstatemachineexporter.analysis.service.EventCarrierPolicy
|| eventStr.equals("msg") || eventStr.equals("message") .isDynamicEventToken(eventStr)) {
|| eventStr.equals("payload")) {
return true; return true;
} }
if (eventStr.contains(".valueOf(") || eventStr.contains("valueOf (")) { if (eventStr.contains(".valueOf(") || eventStr.contains("valueOf (")) {
@@ -1199,11 +1198,8 @@ public final class MachineEnumCanonicalizer {
.matcher(constraint); .matcher(constraint);
while (matcher.find()) { while (matcher.find()) {
String param = matcher.group(2); String param = matcher.group(2);
if ("event".equals(param) if (click.kamil.springstatemachineexporter.analysis.service.EventCarrierPolicy
|| "eventString".equals(param) .isCarrierParamName(param)) {
|| "actionKey".equals(param)
|| "action".equals(param)
|| "commandKey".equals(param)) {
literals.add(matcher.group(1)); literals.add(matcher.group(1));
} }
} }

View File

@@ -12,7 +12,6 @@ import click.kamil.springstatemachineexporter.analysis.model.CallEdge;
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.ast.common.CodebaseContext; import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer; import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -144,7 +143,7 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
&& resolvedTp.getConstraint() != null && resolvedTp.getConstraint() != null
&& constraintBindings != null && constraintBindings != null
&& !constraintBindings.isEmpty() && !constraintBindings.isEmpty()
&& !BooleanConstraintEvaluator.isCompatibleWithKnownBindings( && CarrierConstraintSupport.contradictsCarrierBindings(
resolvedTp.getConstraint(), constraintBindings)) { resolvedTp.getConstraint(), constraintBindings)) {
continue; continue;
} }

View File

@@ -0,0 +1,89 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.resolver.BooleanConstraintEvaluator;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Pattern;
/**
* Carrier-aware constraint helpers shared by call-graph filtering and transition linking.
* Payload/event binding clauses are not machine-domain evidence.
*/
public final class CarrierConstraintSupport {
private CarrierConstraintSupport() {
}
/**
* Removes equals / {@code ==} / {@code !=} clauses whose parameter is an event/payload carrier,
* leaving machine-domain discriminators for {@link BooleanConstraintEvaluator#isCompatibleWithMachineDomain}.
*/
public static String stripNonMachineClauses(String constraint) {
if (constraint == null || constraint.isBlank()) {
return constraint;
}
String eventLike = "(?i)(" + EventCarrierPolicy.carrierNameAlternation() + ")";
String stripped = constraint.replaceAll(
"\"[^\"]+\"\\s*\\.\\s*equalsIgnoreCase\\s*\\(\\s*" + eventLike + "\\s*\\)",
"true");
stripped = stripped.replaceAll(
eventLike + "\\s*\\.\\s*equalsIgnoreCase\\s*\\(\\s*\"[^\"]+\"\\s*\\)",
"true");
stripped = stripped.replaceAll(
"\"[^\"]+\"\\s*\\.\\s*equals\\s*\\(\\s*" + eventLike + "\\s*\\)",
"true");
stripped = stripped.replaceAll(
eventLike + "\\s*\\.\\s*equals\\s*\\(\\s*\"[^\"]+\"\\s*\\)",
"true");
stripped = stripped.replaceAll(
eventLike + "\\s*==\\s*\"[^\"]+\"",
"true");
stripped = stripped.replaceAll(
"\"[^\"]+\"\\s*==\\s*" + eventLike,
"true");
stripped = stripped.replaceAll(
eventLike + "\\s*!=\\s*\"[^\"]+\"",
"true");
stripped = stripped.replaceAll(
"\"[^\"]+\"\\s*!=\\s*" + eventLike,
"true");
stripped = stripped.replaceAll("&&\\s*&&+", "&&");
stripped = stripped.replaceAll("^\\s*&&\\s*", "");
stripped = stripped.replaceAll("\\s*&&\\s*$", "");
return stripped.trim();
}
/**
* True when the constraint contradicts entry/path bindings on overlapping carrier keys.
* Unrelated locals (e.g. {@code command} vs binding {@code commandKey}) are ignored.
*/
public static boolean contradictsCarrierBindings(String constraint, Map<String, String> bindings) {
if (constraint == null || constraint.isBlank() || bindings == null || bindings.isEmpty()) {
return false;
}
Map<String, String> carrierBindings = new LinkedHashMap<>();
for (Map.Entry<String, String> entry : bindings.entrySet()) {
String key = entry.getKey();
if (key == null || !EventCarrierPolicy.isCarrierParamName(key)) {
continue;
}
if (!constraintMentionsIdent(constraint, key)) {
continue;
}
carrierBindings.put(key, entry.getValue());
}
if (carrierBindings.isEmpty()) {
return false;
}
return !BooleanConstraintEvaluator.isCompatibleWithKnownBindings(constraint, carrierBindings);
}
private static boolean constraintMentionsIdent(String constraint, String ident) {
if (constraint == null || ident == null || ident.isBlank()) {
return false;
}
Pattern word = Pattern.compile("(?i)\\b" + Pattern.quote(ident) + "\\b");
return word.matcher(constraint).find();
}
}

View File

@@ -43,6 +43,14 @@ public final class EventCarrierPolicy {
"sourcestate", "sourcestate",
"fromstate"); "fromstate");
/** Bare unresolved trigger tokens (not FQNs / expressions). */
private static final Set<String> DYNAMIC_EVENT_TOKENS = Set.of(
"event",
"e",
"message",
"msg",
"payload");
private EventCarrierPolicy() { private EventCarrierPolicy() {
} }
@@ -71,6 +79,17 @@ public final class EventCarrierPolicy {
return isCarrierParamName(name) || isMachineDiscriminatorName(name); return isCarrierParamName(name) || isMachineDiscriminatorName(name);
} }
/**
* Bare identifier used as an unresolved trigger event expression
* ({@code event}, {@code message}, {@code payload}, …).
*/
public static boolean isDynamicEventToken(String eventStr) {
if (eventStr == null || eventStr.isBlank()) {
return false;
}
return DYNAMIC_EVENT_TOKENS.contains(eventStr.toLowerCase(Locale.ROOT));
}
/** Clear state-variable / accessor names used for positive source-state inference. */ /** Clear state-variable / accessor names used for positive source-state inference. */
public static boolean isStateAccessorName(String name) { public static boolean isStateAccessorName(String name) {
if (name == null || name.isBlank()) { if (name == null || name.isBlank()) {
@@ -84,7 +103,7 @@ public final class EventCarrierPolicy {
} }
/** /**
* Messaging {@code @Payload} or unannotated String body that looks like an event carrier. * Messaging {@code @Payload}, conventional carrier name, or the sole unannotated String body.
*/ */
public static boolean isMessagingPayloadParameter(EntryPoint entryPoint, EntryPoint.Parameter parameter) { public static boolean isMessagingPayloadParameter(EntryPoint entryPoint, EntryPoint.Parameter parameter) {
if (entryPoint == null || parameter == null || !isMessagingEntryPoint(entryPoint)) { if (entryPoint == null || parameter == null || !isMessagingEntryPoint(entryPoint)) {
@@ -100,7 +119,41 @@ public final class EventCarrierPolicy {
if (!isStringType(parameter.getType())) { if (!isStringType(parameter.getType())) {
return false; return false;
} }
return isCarrierParamName(parameter.getName()); if (isCarrierParamName(parameter.getName())) {
return true;
}
return isSoleUnannotatedStringMessagingBody(entryPoint, parameter);
}
/**
* Unannotated {@code String} body that is the only such param on a messaging entry —
* treat as carrier even when named {@code cmd}.
*/
public static boolean isSoleUnannotatedStringMessagingBody(
EntryPoint entryPoint, EntryPoint.Parameter parameter) {
if (entryPoint == null || parameter == null || !isMessagingEntryPoint(entryPoint)) {
return false;
}
if (parameter.getAnnotations() != null && !parameter.getAnnotations().isEmpty()) {
return false;
}
if (!isStringType(parameter.getType())) {
return false;
}
if (entryPoint.getParameters() == null) {
return false;
}
int unannotatedStringBodies = 0;
for (EntryPoint.Parameter candidate : entryPoint.getParameters()) {
if (candidate == null) {
continue;
}
boolean unannotated = candidate.getAnnotations() == null || candidate.getAnnotations().isEmpty();
if (unannotated && isStringType(candidate.getType())) {
unannotatedStringBodies++;
}
}
return unannotatedStringBodies == 1;
} }
/** /**
@@ -148,24 +201,16 @@ public final class EventCarrierPolicy {
} }
/** /**
* Alternation for regex consumers (linker constraint stripping). Names are already lowercased * Alternation for carrier-clause regex stripping ({@link CarrierConstraintSupport}).
* where case-insensitive flags are used by the caller. * Includes camelCase aliases so source that uses {@code commandKey} matches under {@code (?i)}.
*/ */
public static String carrierNameAlternation() { static String carrierNameAlternation() {
return Stream.concat( return Stream.concat(CARRIER_NAMES.stream(), Stream.of("commandKey", "eventString", "actionKey"))
CARRIER_NAMES.stream(),
Stream.of("commandKey"))
.distinct() .distinct()
.sorted() .sorted()
.collect(Collectors.joining("|")); .collect(Collectors.joining("|"));
} }
public static String routingDiscriminantAlternation() {
return Stream.concat(CARRIER_NAMES.stream(), MACHINE_DISCRIMINATOR_NAMES.stream())
.sorted()
.collect(Collectors.joining("|"));
}
private static boolean hasAnnotation(EntryPoint.Parameter parameter, String simpleName) { private static boolean hasAnnotation(EntryPoint.Parameter parameter, String simpleName) {
if (parameter.getAnnotations() == null || simpleName == null) { if (parameter.getAnnotations() == null || simpleName == null) {
return false; return false;

View File

@@ -800,11 +800,38 @@ public class GenericEventDetector {
if (hasPayloadAnnotation(param)) { if (hasPayloadAnnotation(param)) {
return true; return true;
} }
return EventCarrierPolicy.isCarrierParamName(param.getName().getIdentifier()); if (EventCarrierPolicy.isCarrierParamName(param.getName().getIdentifier())) {
return true;
}
// Sole unannotated String param on the method — same rule as messaging body carrier.
return countUnannotatedStringParams(method) == 1;
} }
return false; return false;
} }
private static int countUnannotatedStringParams(MethodDeclaration method) {
int count = 0;
for (Object paramObj : method.parameters()) {
if (!(paramObj instanceof SingleVariableDeclaration param)) {
continue;
}
if (hasPayloadAnnotation(param)) {
continue;
}
boolean unannotated = true;
for (Object modifier : param.modifiers()) {
if (modifier instanceof Annotation) {
unannotated = false;
break;
}
}
if (unannotated && isStringType(param.getType())) {
count++;
}
}
return count;
}
private static boolean isStringType(org.eclipse.jdt.core.dom.Type type) { private static boolean isStringType(org.eclipse.jdt.core.dom.Type type) {
if (type == null) { if (type == null) {
return false; return false;

View File

@@ -0,0 +1,46 @@
package click.kamil.springstatemachineexporter.analysis.service;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class CarrierConstraintSupportTest {
@Test
void stripNonMachineClausesRemovesPayloadEquals() {
String stripped = CarrierConstraintSupport.stripNonMachineClauses(
"\"PAY\".equalsIgnoreCase(message) && message == \"PAY\"");
assertThat(stripped).isEqualToIgnoringWhitespace("true && true");
}
@Test
void stripNonMachineClausesKeepsMachineDomainClauses() {
String stripped = CarrierConstraintSupport.stripNonMachineClauses(
"\"ORDER\".equalsIgnoreCase(machineType) && message == \"PAY\"");
assertThat(stripped).contains("machineType");
assertThat(stripped).doesNotContain("message == ");
}
@Test
void contradictsWhenCarrierBindingConflictsWithConstraint() {
assertThat(CarrierConstraintSupport.contradictsCarrierBindings(
"message == \"SHIP\"",
Map.of("message", "PAY"))).isTrue();
}
@Test
void doesNotContradictWhenCarrierBindingMatchesConstraint() {
assertThat(CarrierConstraintSupport.contradictsCarrierBindings(
"\"PAY\".equalsIgnoreCase(message) && message == \"PAY\"",
Map.of("message", "PAY"))).isFalse();
}
@Test
void doesNotContradictWhenConstraintLocalsDoNotOverlapCarrierBindings() {
assertThat(CarrierConstraintSupport.contradictsCarrierBindings(
"command == ORDER_PAY",
Map.of("commandKey", "order.pay"))).isFalse();
}
}

View File

@@ -4,6 +4,7 @@ import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.List; import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -19,6 +20,15 @@ class EventCarrierPolicyTest {
assertThat(EventCarrierPolicy.isCarrierParamName("cmd")).isFalse(); assertThat(EventCarrierPolicy.isCarrierParamName("cmd")).isFalse();
} }
@Test
void dynamicEventTokensMatchBareUnresolvedTriggers() {
assertThat(EventCarrierPolicy.isDynamicEventToken("event")).isTrue();
assertThat(EventCarrierPolicy.isDynamicEventToken("message")).isTrue();
assertThat(EventCarrierPolicy.isDynamicEventToken("payload")).isTrue();
assertThat(EventCarrierPolicy.isDynamicEventToken("OrderEvent.PAY")).isFalse();
assertThat(EventCarrierPolicy.isDynamicEventToken("cmd")).isFalse();
}
@Test @Test
void machineDiscriminatorsAreRoutingButNotCarriers() { void machineDiscriminatorsAreRoutingButNotCarriers() {
assertThat(EventCarrierPolicy.isMachineDiscriminatorName("machineType")).isTrue(); assertThat(EventCarrierPolicy.isMachineDiscriminatorName("machineType")).isTrue();
@@ -55,6 +65,54 @@ class EventCarrierPolicyTest {
entryPoint, entryPoint.getParameters().get(0))).isTrue(); entryPoint, entryPoint.getParameters().get(0))).isTrue();
} }
@Test
void soleUnannotatedStringMessagingBodyIsCarrierEvenWhenNamedCmd() {
EntryPoint entryPoint = EntryPoint.builder()
.type(EntryPoint.Type.JMS)
.name("JMS: q")
.className("com.example.L")
.methodName("onMessage")
.parameters(List.of(EntryPoint.Parameter.builder()
.name("cmd")
.type("String")
.annotations(List.of())
.build()))
.build();
assertThat(EventCarrierPolicy.isSoleUnannotatedStringMessagingBody(
entryPoint, entryPoint.getParameters().get(0))).isTrue();
assertThat(EventCarrierPolicy.isMessagingPayloadParameter(
entryPoint, entryPoint.getParameters().get(0))).isTrue();
}
@Test
void twoUnannotatedStringBodiesAreNotAutoCarriersBySoleBodyRule() {
EntryPoint entryPoint = EntryPoint.builder()
.type(EntryPoint.Type.JMS)
.name("JMS: q")
.className("com.example.L")
.methodName("onMessage")
.parameters(List.of(
EntryPoint.Parameter.builder()
.name("cmd")
.type("String")
.annotations(List.of())
.build(),
EntryPoint.Parameter.builder()
.name("extra")
.type("String")
.annotations(List.of())
.build()))
.build();
assertThat(EventCarrierPolicy.isSoleUnannotatedStringMessagingBody(
entryPoint, entryPoint.getParameters().get(0))).isFalse();
assertThat(EventCarrierPolicy.isMessagingPayloadParameter(
entryPoint, entryPoint.getParameters().get(0))).isFalse();
assertThat(EventCarrierPolicy.isMessagingPayloadParameter(
entryPoint, entryPoint.getParameters().get(1))).isFalse();
}
@Test @Test
void unannotatedMessagingStringUsesCarrierNameConvention() { void unannotatedMessagingStringUsesCarrierNameConvention() {
EntryPoint entryPoint = EntryPoint.builder() EntryPoint entryPoint = EntryPoint.builder()
@@ -72,12 +130,4 @@ class EventCarrierPolicyTest {
assertThat(EventCarrierPolicy.isMessagingPayloadParameter( assertThat(EventCarrierPolicy.isMessagingPayloadParameter(
entryPoint, entryPoint.getParameters().get(0))).isTrue(); entryPoint, entryPoint.getParameters().get(0))).isTrue();
} }
@Test
void carrierNameAlternationIsNonEmptyForLinkerRegex() {
assertThat(EventCarrierPolicy.carrierNameAlternation())
.contains("message")
.contains("payload")
.contains("event");
}
} }