This commit is contained in:
2026-07-17 08:21:57 +02:00
parent 2c20b2c3fe
commit 32847d112b
9 changed files with 210 additions and 45 deletions

View File

@@ -4,6 +4,7 @@ import click.kamil.springstatemachineexporter.analysis.enricher.path.SymbolicPat
import click.kamil.springstatemachineexporter.analysis.model.CallChain; import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint; import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver; import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
import click.kamil.springstatemachineexporter.analysis.resolver.ConstraintClauses;
import click.kamil.springstatemachineexporter.analysis.resolver.DynamicTriggerExpressions; import click.kamil.springstatemachineexporter.analysis.resolver.DynamicTriggerExpressions;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer; import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver; import click.kamil.springstatemachineexporter.analysis.resolver.StateMachineTypeResolver;
@@ -43,10 +44,6 @@ public final class CallChainPolyNarrower {
private static final int MEDIUM = 2; private static final int MEDIUM = 2;
private static final int WEAK = 1; private static final int WEAK = 1;
private static final Pattern QUOTED_EQUALS_PARAM = Pattern.compile(
"\"([^\"]+)\"\\.equalsIgnoreCase\\((\\w+)\\)");
private static final Pattern PARAM_EQUALS_LITERAL = Pattern.compile(
"(\\w+)\\.equals(?:IgnoreCase)?\\(\"([^\"]+)\"\\)");
private static final Pattern ENUM_EQ = Pattern.compile( private static final Pattern ENUM_EQ = Pattern.compile(
"([A-Za-z_][\\w.]*)\\s*==\\s*([A-Za-z_][\\w.]*)"); "([A-Za-z_][\\w.]*)\\s*==\\s*([A-Za-z_][\\w.]*)");
private static final Pattern ROUTE_LITERAL = Pattern.compile( private static final Pattern ROUTE_LITERAL = Pattern.compile(
@@ -177,13 +174,8 @@ public final class CallChainPolyNarrower {
if (constraint == null || constraint.isBlank()) { if (constraint == null || constraint.isBlank()) {
return; return;
} }
Matcher quoted = QUOTED_EQUALS_PARAM.matcher(constraint); for (String literal : ConstraintClauses.carrierLiterals(constraint)) {
while (quoted.find()) { addPathToken(literal, configuredConstants, votes, MEDIUM);
String literal = quoted.group(1);
String param = quoted.group(2);
if (EventCarrierPolicy.isCarrierParamName(param)) {
addPathToken(literal, configuredConstants, votes, MEDIUM);
}
if (literal.contains(".")) { if (literal.contains(".")) {
addPathToken(literal, configuredConstants, votes, MEDIUM); addPathToken(literal, configuredConstants, votes, MEDIUM);
} }
@@ -197,14 +189,6 @@ public final class CallChainPolyNarrower {
addEnumReference(enumEq.group(1), configuredConstants, votes, MEDIUM); addEnumReference(enumEq.group(1), configuredConstants, votes, MEDIUM);
addEnumReference(enumEq.group(2), configuredConstants, votes, MEDIUM); addEnumReference(enumEq.group(2), configuredConstants, votes, MEDIUM);
} }
Matcher reverseEquals = PARAM_EQUALS_LITERAL.matcher(constraint);
while (reverseEquals.find()) {
String param = reverseEquals.group(1);
String literal = reverseEquals.group(2);
if (EventCarrierPolicy.isCarrierParamName(param)) {
addPathToken(literal, configuredConstants, votes, MEDIUM);
}
}
} }
private static void collectFromSendEventLiterals( private static void collectFromSendEventLiterals(

View File

@@ -8,7 +8,6 @@ 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;
@@ -72,7 +71,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
} }
tp = MachineEnumCanonicalizer.expandBoundValueOfFromConstraints( tp = MachineEnumCanonicalizer.expandBoundValueOfFromConstraints(
tp, effectiveMachineTypes, context, chain.getEntryPoint()); tp, effectiveMachineTypes, context, chain.getEntryPoint(), chain.getPathBindings());
tp = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents( tp = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
tp, effectiveMachineTypes, context, stateMachineTransitions, true); tp, effectiveMachineTypes, context, stateMachineTransitions, true);
tp = CallChainPolyNarrower.narrowForCallChain( tp = CallChainPolyNarrower.narrowForCallChain(
@@ -281,9 +280,8 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
if (constraint == null || machineName == null) { if (constraint == null || machineName == null) {
return true; return true;
} }
String machineConstraint = CarrierConstraintSupport.stripNonMachineClauses(constraint);
return BooleanConstraintEvaluator.isCompatibleWithMachineDomain( return BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
machineConstraint, MachineDomainKeys.extractMachineDomainKey(machineName)); constraint, MachineDomainKeys.extractMachineDomainKey(machineName));
} }
private String extractStateEnumType(String stateIdentifier) { private String extractStateEnumType(String stateIdentifier) {

View File

@@ -51,11 +51,17 @@ public final class BooleanConstraintEvaluator {
+ Pattern.quote(literal) + "[\"']?\\s*\\)"; + Pattern.quote(literal) + "[\"']?\\s*\\)";
String regex3 = "(?i)[a-zA-Z0-9._]+\\s*==\\s*[\"']?" + Pattern.quote(literal) + "[\"']?"; String regex3 = "(?i)[a-zA-Z0-9._]+\\s*==\\s*[\"']?" + Pattern.quote(literal) + "[\"']?";
String regex4 = "(?i)[a-zA-Z0-9._]+\\s*!=\\s*[\"']?" + Pattern.quote(literal) + "[\"']?"; String regex4 = "(?i)[a-zA-Z0-9._]+\\s*!=\\s*[\"']?" + Pattern.quote(literal) + "[\"']?";
String regexObjectsLitFirst = "(?i)(?:java\\.util\\.)?Objects\\.equals(?:IgnoreCase)?\\s*\\(\\s*[\"']?"
+ Pattern.quote(literal) + "[\"']?\\s*,\\s*[^)]+\\)";
String regexObjectsLitSecond = "(?i)(?:java\\.util\\.)?Objects\\.equals(?:IgnoreCase)?\\s*\\(\\s*[^,]+\\s*,\\s*[\"']?"
+ Pattern.quote(literal) + "[\"']?\\s*\\)";
expr = expr.replaceAll(regex1, replacement); expr = expr.replaceAll(regex1, replacement);
expr = expr.replaceAll(regex2, replacement); expr = expr.replaceAll(regex2, replacement);
expr = expr.replaceAll(regex3, replacement); expr = expr.replaceAll(regex3, replacement);
expr = expr.replaceAll(regex4, matchesMachine ? "false" : "true"); expr = expr.replaceAll(regex4, matchesMachine ? "false" : "true");
expr = expr.replaceAll(regexObjectsLitFirst, replacement);
expr = expr.replaceAll(regexObjectsLitSecond, replacement);
} }
return evaluateBooleanExpression(expr); return evaluateBooleanExpression(expr);

View File

@@ -25,6 +25,19 @@ public final class ConstraintClauses {
+ "|" + "|"
+ "(?i)(?:\"[^\"]+\"|'[^']+'|[A-Za-z_][A-Za-z0-9_.]*)\\s*(?:==|!=)\\s*([A-Za-z_][A-Za-z0-9_]*)"); + "(?i)(?:\"[^\"]+\"|'[^']+'|[A-Za-z_][A-Za-z0-9_.]*)\\s*(?:==|!=)\\s*([A-Za-z_][A-Za-z0-9_]*)");
/**
* {@code Objects.equals(a, b)} / {@code Objects.equalsIgnoreCase(a, b)} with either side a
* quoted literal and the other a simple identifier (both argument orders).
*/
private static final Pattern OBJECTS_EQUALS = Pattern.compile(
"(?i)(?:java\\.util\\.)?Objects\\.equals(?:IgnoreCase)?\\s*\\(\\s*"
+ "(?:\"([^\"]+)\"|'([^']+)'|([A-Za-z_][A-Za-z0-9_]*))"
+ "\\s*,\\s*"
+ "(?:\"([^\"]+)\"|'([^']+)'|([A-Za-z_][A-Za-z0-9_]*))"
+ "\\s*\\)");
private static final Pattern QUOTED_LITERAL = Pattern.compile("\"([^\"]+)\"|'([^']+)'");
private ConstraintClauses() { private ConstraintClauses() {
} }
@@ -82,6 +95,17 @@ public final class ConstraintClauses {
return null; return null;
} }
String trimmed = clause.trim(); String trimmed = clause.trim();
Matcher objects = OBJECTS_EQUALS.matcher(trimmed);
if (objects.find()) {
String leftIdent = objects.group(3);
String rightIdent = objects.group(6);
if (leftIdent != null && looksLikeSimpleIdent(leftIdent)) {
return leftIdent;
}
if (rightIdent != null && looksLikeSimpleIdent(rightIdent)) {
return rightIdent;
}
}
Matcher equals = EQUALS_CALL.matcher(trimmed); Matcher equals = EQUALS_CALL.matcher(trimmed);
if (equals.find()) { if (equals.find()) {
String a = equals.group(1); String a = equals.group(1);
@@ -100,6 +124,53 @@ public final class ConstraintClauses {
return null; return null;
} }
/**
* First quoted string literal in a clause (equals / {@code ==} / {@code Objects.equals}).
*/
public static String quotedLiteral(String clause) {
if (clause == null || clause.isBlank()) {
return null;
}
String trimmed = clause.trim();
Matcher objects = OBJECTS_EQUALS.matcher(trimmed);
if (objects.find()) {
String left = firstNonNull(objects.group(1), objects.group(2));
if (left != null) {
return left;
}
String right = firstNonNull(objects.group(4), objects.group(5));
if (right != null) {
return right;
}
}
Matcher quoted = QUOTED_LITERAL.matcher(trimmed);
if (quoted.find()) {
return firstNonNull(quoted.group(1), quoted.group(2));
}
return null;
}
/**
* Quoted literals from AND-clauses whose primary identifier is an event/payload carrier.
*/
public static List<String> carrierLiterals(String constraint) {
List<String> literals = new ArrayList<>();
if (constraint == null || constraint.isBlank()) {
return literals;
}
for (String clause : splitAndClauses(constraint)) {
String ident = primaryIdent(clause);
if (ident == null || !EventCarrierPolicy.isCarrierParamName(ident)) {
continue;
}
String literal = quotedLiteral(clause);
if (literal != null && !literal.isBlank()) {
literals.add(literal);
}
}
return literals;
}
/** /**
* Union of primary identifiers across AND clauses (for binding overlap checks). * Union of primary identifiers across AND clauses (for binding overlap checks).
*/ */
@@ -121,6 +192,12 @@ public final class ConstraintClauses {
char first = value.charAt(0); char first = value.charAt(0);
return Character.isJavaIdentifierStart(first) return Character.isJavaIdentifierStart(first)
&& value.chars().allMatch(Character::isJavaIdentifierPart) && value.chars().allMatch(Character::isJavaIdentifierPart)
&& (Character.isLowerCase(first) || EventCarrierPolicy.isCarrierParamName(value)); && (Character.isLowerCase(first)
|| EventCarrierPolicy.isCarrierParamName(value)
|| EventCarrierPolicy.isMachineDiscriminatorName(value));
}
private static String firstNonNull(String a, String b) {
return a != null ? a : b;
} }
} }

View File

@@ -12,10 +12,9 @@ import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map;
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;
/** /**
@@ -1152,7 +1151,7 @@ public final class MachineEnumCanonicalizer {
TriggerPoint trigger, TriggerPoint trigger,
StateMachineTypeResolver.MachineTypes machineTypes, StateMachineTypeResolver.MachineTypes machineTypes,
CodebaseContext context) { CodebaseContext context) {
return expandBoundValueOfFromConstraints(trigger, machineTypes, context, null); return expandBoundValueOfFromConstraints(trigger, machineTypes, context, null, null);
} }
public static TriggerPoint expandBoundValueOfFromConstraints( public static TriggerPoint expandBoundValueOfFromConstraints(
@@ -1160,6 +1159,15 @@ public final class MachineEnumCanonicalizer {
StateMachineTypeResolver.MachineTypes machineTypes, StateMachineTypeResolver.MachineTypes machineTypes,
CodebaseContext context, CodebaseContext context,
EntryPoint entryPoint) { EntryPoint entryPoint) {
return expandBoundValueOfFromConstraints(trigger, machineTypes, context, entryPoint, null);
}
public static TriggerPoint expandBoundValueOfFromConstraints(
TriggerPoint trigger,
StateMachineTypeResolver.MachineTypes machineTypes,
CodebaseContext context,
EntryPoint entryPoint,
Map<String, String> pathBindings) {
if (trigger == null || trigger.getEvent() == null || machineTypes == null) { if (trigger == null || trigger.getEvent() == null || machineTypes == null) {
return trigger; return trigger;
} }
@@ -1168,9 +1176,13 @@ public final class MachineEnumCanonicalizer {
} }
String constraint = trigger.getConstraint(); String constraint = trigger.getConstraint();
List<String> boundEventLiterals = constraint != null && !constraint.isBlank() List<String> boundEventLiterals = constraint != null && !constraint.isBlank()
? extractBoundEventLiteralsFromConstraint(constraint) ? ConstraintClauses.carrierLiterals(constraint)
: List.of(); : List.of();
// Dataflow-only: never invent event constants from REST URL path segments. if (boundEventLiterals.size() != 1) {
boundEventLiterals = carrierLiteralsFromPathBindings(pathBindings);
}
// Dataflow-only: never invent event constants from REST URL path segments /
// unbound templates — only concrete expander bindings or constraint literals.
if (boundEventLiterals.size() != 1) { if (boundEventLiterals.size() != 1) {
return trigger; return trigger;
} }
@@ -1199,26 +1211,33 @@ public final class MachineEnumCanonicalizer {
.build(); .build();
} }
private static List<String> extractBoundEventLiteralsFromConstraint(String constraint) { private static List<String> carrierLiteralsFromPathBindings(Map<String, String> pathBindings) {
List<String> literals = new ArrayList<>(); if (pathBindings == null || pathBindings.isEmpty()) {
if (constraint == null || constraint.isBlank()) { return List.of();
return literals;
} }
Pattern quoted = Pattern.compile("\"([^\"]+)\"|'([^']+)'"); LinkedHashSet<String> literals = new LinkedHashSet<>();
for (String clause : ConstraintClauses.splitAndClauses(constraint)) { for (Map.Entry<String, String> entry : pathBindings.entrySet()) {
String ident = ConstraintClauses.primaryIdent(clause); String key = entry.getKey();
if (ident == null || !EventCarrierPolicy.isCarrierParamName(ident)) { String value = entry.getValue();
if (key == null || value == null || value.isBlank()) {
continue; continue;
} }
Matcher matcher = quoted.matcher(clause); if (!EventCarrierPolicy.isCarrierParamName(key)) {
if (matcher.find()) { continue;
String literal = matcher.group(1) != null ? matcher.group(1) : matcher.group(2); }
if (literal != null && !literal.isBlank()) { // Skip unbound / self-referential expander placeholders.
literals.add(literal); if (value.equals(key) || value.contains("{") || value.contains("(")) {
} continue;
}
String cleaned = value;
if (cleaned.startsWith("\"") && cleaned.endsWith("\"") && cleaned.length() >= 2) {
cleaned = cleaned.substring(1, cleaned.length() - 1);
}
if (!cleaned.isBlank()) {
literals.add(cleaned);
} }
} }
return literals; return new ArrayList<>(literals);
} }
/** /**
@@ -1241,7 +1260,7 @@ public final class MachineEnumCanonicalizer {
} }
String constraint = trigger.getConstraint(); String constraint = trigger.getConstraint();
List<String> bound = constraint != null && !constraint.isBlank() List<String> bound = constraint != null && !constraint.isBlank()
? extractBoundEventLiteralsFromConstraint(constraint) ? ConstraintClauses.carrierLiterals(constraint)
: List.of(); : List.of();
return bound.size() != 1; return bound.size() != 1;
} }

View File

@@ -70,6 +70,19 @@ class BooleanConstraintEvaluatorTest {
"OrderStateMachineConfiguration")).isTrue(); "OrderStateMachineConfiguration")).isTrue();
} }
@Test
void shouldEvaluateObjectsEqualsAgainstMachineDomain() {
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
"Objects.equals(\"ORDER\", machineType)",
"OrderStateMachineConfiguration")).isTrue();
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
"Objects.equals(machineType, \"PAYMENT\")",
"OrderStateMachineConfiguration")).isFalse();
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
"Objects.equals(\"ORDER\", machineType) && Objects.equals(\"PAY\", message)",
"OrderStateMachineConfiguration")).isTrue();
}
@Test @Test
void evaluateBooleanExpressionShouldHandleLogicalOperators() { void evaluateBooleanExpressionShouldHandleLogicalOperators() {
assertThat(BooleanConstraintEvaluator.evaluateBooleanExpression("true && false")).isFalse(); assertThat(BooleanConstraintEvaluator.evaluateBooleanExpression("true && false")).isFalse();

View File

@@ -46,4 +46,23 @@ class ConstraintClausesTest {
"\"ORDER\".equalsIgnoreCase(machineType) && message == \"PAY\"")) "\"ORDER\".equalsIgnoreCase(machineType) && message == \"PAY\""))
.containsExactly("machineType", "message"); .containsExactly("machineType", "message");
} }
@Test
void primaryIdentExtractsObjectsEqualsIdent() {
assertThat(ConstraintClauses.primaryIdent("Objects.equals(\"PAY\", message)"))
.isEqualTo("message");
assertThat(ConstraintClauses.primaryIdent("Objects.equals(machineType, \"ORDER\")"))
.isEqualTo("machineType");
assertThat(ConstraintClauses.primaryIdent("java.util.Objects.equalsIgnoreCase(\"ORDER\", type)"))
.isEqualTo("type");
}
@Test
void carrierLiteralsIncludeObjectsEqualsForms() {
assertThat(ConstraintClauses.carrierLiterals(
"Objects.equals(\"PAY\", message) && Objects.equals(machineType, \"ORDER\")"))
.containsExactly("PAY");
assertThat(ConstraintClauses.carrierLiterals("message == \"SHIP\""))
.containsExactly("SHIP");
}
} }

View File

@@ -76,6 +76,46 @@ class MachineEnumCanonicalizerBoundValueOfTest {
assertThat(expanded.isAmbiguous()).isTrue(); assertThat(expanded.isAmbiguous()).isTrue();
} }
@Test
void shouldExpandBoundValueOfFromPathBindingsAlone() {
TriggerPoint trigger = TriggerPoint.builder()
.event("OrderEvent.valueOf(message)")
.external(true)
.build();
MachineTypes machineTypes = new MachineTypes(
"com.example.order.OrderState",
"com.example.order.OrderEvent");
TriggerPoint expanded = MachineEnumCanonicalizer.expandBoundValueOfFromConstraints(
trigger,
machineTypes,
null,
null,
java.util.Map.of("message", "PAY"));
assertThat(expanded.getPolymorphicEvents())
.containsExactly("com.example.order.OrderEvent.PAY");
assertThat(expanded.isExternal()).isFalse();
}
@Test
void shouldExpandBoundValueOfFromObjectsEqualsConstraint() {
TriggerPoint trigger = TriggerPoint.builder()
.event("OrderEvent.valueOf(message)")
.constraint("Objects.equals(\"PAY\", message)")
.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");
}
private static Transition transition(String raw, String full) { private static Transition transition(String raw, String full) {
Transition t = new Transition(); Transition t = new Transition();
t.setEvent(Event.of(raw, full)); t.setEvent(Event.of(raw, full));

View File

@@ -16,6 +16,15 @@ class CarrierConstraintSupportTest {
assertThat(stripped).isEqualToIgnoringWhitespace("true && true"); assertThat(stripped).isEqualToIgnoringWhitespace("true && true");
} }
@Test
void stripNonMachineClausesRemovesObjectsEqualsCarrier() {
String stripped = CarrierConstraintSupport.stripNonMachineClauses(
"Objects.equals(\"PAY\", message) && Objects.equals(\"ORDER\", machineType)");
assertThat(stripped).contains("machineType");
assertThat(stripped).doesNotContain("message");
assertThat(stripped).contains("true");
}
@Test @Test
void stripNonMachineClausesKeepsMachineDomainClauses() { void stripNonMachineClausesKeepsMachineDomainClauses() {
String stripped = CarrierConstraintSupport.stripNonMachineClauses( String stripped = CarrierConstraintSupport.stripNonMachineClauses(