diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrower.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrower.java index 293ce4e..a67ae09 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrower.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainPolyNarrower.java @@ -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.TriggerPoint; 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.MachineEnumCanonicalizer; 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 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( "([A-Za-z_][\\w.]*)\\s*==\\s*([A-Za-z_][\\w.]*)"); private static final Pattern ROUTE_LITERAL = Pattern.compile( @@ -177,13 +174,8 @@ public final class CallChainPolyNarrower { if (constraint == null || constraint.isBlank()) { return; } - Matcher quoted = QUOTED_EQUALS_PARAM.matcher(constraint); - while (quoted.find()) { - String literal = quoted.group(1); - String param = quoted.group(2); - if (EventCarrierPolicy.isCarrierParamName(param)) { - addPathToken(literal, configuredConstants, votes, MEDIUM); - } + for (String literal : ConstraintClauses.carrierLiterals(constraint)) { + addPathToken(literal, configuredConstants, votes, MEDIUM); if (literal.contains(".")) { addPathToken(literal, configuredConstants, votes, MEDIUM); } @@ -197,14 +189,6 @@ public final class CallChainPolyNarrower { addEnumReference(enumEq.group(1), 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( diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java index 3ef60e6..a2b0eb9 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricher.java @@ -8,7 +8,6 @@ import click.kamil.springstatemachineexporter.analysis.model.LifecycleTriggerMar import click.kamil.springstatemachineexporter.analysis.model.LinkResolution; import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition; 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.EventCarrierPolicy; import click.kamil.springstatemachineexporter.analysis.service.JsonExportContextFactory; @@ -72,7 +71,7 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { } tp = MachineEnumCanonicalizer.expandBoundValueOfFromConstraints( - tp, effectiveMachineTypes, context, chain.getEntryPoint()); + tp, effectiveMachineTypes, context, chain.getEntryPoint(), chain.getPathBindings()); tp = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents( tp, effectiveMachineTypes, context, stateMachineTransitions, true); tp = CallChainPolyNarrower.narrowForCallChain( @@ -281,9 +280,8 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { if (constraint == null || machineName == null) { return true; } - String machineConstraint = CarrierConstraintSupport.stripNonMachineClauses(constraint); return BooleanConstraintEvaluator.isCompatibleWithMachineDomain( - machineConstraint, MachineDomainKeys.extractMachineDomainKey(machineName)); + constraint, MachineDomainKeys.extractMachineDomainKey(machineName)); } private String extractStateEnumType(String stateIdentifier) { 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 8bce72a..ac99952 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 @@ -51,11 +51,17 @@ public final class BooleanConstraintEvaluator { + Pattern.quote(literal) + "[\"']?\\s*\\)"; 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 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(regex2, replacement); expr = expr.replaceAll(regex3, replacement); expr = expr.replaceAll(regex4, matchesMachine ? "false" : "true"); + expr = expr.replaceAll(regexObjectsLitFirst, replacement); + expr = expr.replaceAll(regexObjectsLitSecond, replacement); } return evaluateBooleanExpression(expr); diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstraintClauses.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstraintClauses.java index 18dd601..5db558f 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstraintClauses.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstraintClauses.java @@ -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_]*)"); + /** + * {@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() { } @@ -82,6 +95,17 @@ public final class ConstraintClauses { return null; } 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); if (equals.find()) { String a = equals.group(1); @@ -100,6 +124,53 @@ public final class ConstraintClauses { 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 carrierLiterals(String constraint) { + List 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). */ @@ -121,6 +192,12 @@ public final class ConstraintClauses { char first = value.charAt(0); return Character.isJavaIdentifierStart(first) && 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; } } 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 7fe1208..2705a87 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 @@ -12,10 +12,9 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Set; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import java.util.stream.Collectors; /** @@ -1152,7 +1151,7 @@ public final class MachineEnumCanonicalizer { TriggerPoint trigger, StateMachineTypeResolver.MachineTypes machineTypes, CodebaseContext context) { - return expandBoundValueOfFromConstraints(trigger, machineTypes, context, null); + return expandBoundValueOfFromConstraints(trigger, machineTypes, context, null, null); } public static TriggerPoint expandBoundValueOfFromConstraints( @@ -1160,6 +1159,15 @@ public final class MachineEnumCanonicalizer { StateMachineTypeResolver.MachineTypes machineTypes, CodebaseContext context, EntryPoint entryPoint) { + return expandBoundValueOfFromConstraints(trigger, machineTypes, context, entryPoint, null); + } + + public static TriggerPoint expandBoundValueOfFromConstraints( + TriggerPoint trigger, + StateMachineTypeResolver.MachineTypes machineTypes, + CodebaseContext context, + EntryPoint entryPoint, + Map pathBindings) { if (trigger == null || trigger.getEvent() == null || machineTypes == null) { return trigger; } @@ -1168,9 +1176,13 @@ public final class MachineEnumCanonicalizer { } String constraint = trigger.getConstraint(); List boundEventLiterals = constraint != null && !constraint.isBlank() - ? extractBoundEventLiteralsFromConstraint(constraint) + ? ConstraintClauses.carrierLiterals(constraint) : 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) { return trigger; } @@ -1199,26 +1211,33 @@ public final class MachineEnumCanonicalizer { .build(); } - private static List extractBoundEventLiteralsFromConstraint(String constraint) { - List literals = new ArrayList<>(); - if (constraint == null || constraint.isBlank()) { - return literals; + private static List carrierLiteralsFromPathBindings(Map pathBindings) { + if (pathBindings == null || pathBindings.isEmpty()) { + return List.of(); } - Pattern quoted = Pattern.compile("\"([^\"]+)\"|'([^']+)'"); - for (String clause : ConstraintClauses.splitAndClauses(constraint)) { - String ident = ConstraintClauses.primaryIdent(clause); - if (ident == null || !EventCarrierPolicy.isCarrierParamName(ident)) { + LinkedHashSet literals = new LinkedHashSet<>(); + for (Map.Entry entry : pathBindings.entrySet()) { + String key = entry.getKey(); + String value = entry.getValue(); + if (key == null || value == null || value.isBlank()) { 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); - } + if (!EventCarrierPolicy.isCarrierParamName(key)) { + continue; + } + // Skip unbound / self-referential expander placeholders. + 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(); List bound = constraint != null && !constraint.isBlank() - ? extractBoundEventLiteralsFromConstraint(constraint) + ? ConstraintClauses.carrierLiterals(constraint) : List.of(); return bound.size() != 1; } 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 b1fd19a..c8b75e4 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 @@ -70,6 +70,19 @@ class BooleanConstraintEvaluatorTest { "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 void evaluateBooleanExpressionShouldHandleLogicalOperators() { assertThat(BooleanConstraintEvaluator.evaluateBooleanExpression("true && false")).isFalse(); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstraintClausesTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstraintClausesTest.java index 7e569b7..0f57b42 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstraintClausesTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/ConstraintClausesTest.java @@ -46,4 +46,23 @@ class ConstraintClausesTest { "\"ORDER\".equalsIgnoreCase(machineType) && message == \"PAY\"")) .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"); + } } 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 205972d..b1f5abf 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 @@ -76,6 +76,46 @@ class MachineEnumCanonicalizerBoundValueOfTest { 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) { Transition t = new Transition(); t.setEvent(Event.of(raw, full)); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CarrierConstraintSupportTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CarrierConstraintSupportTest.java index fe66e69..42f9c01 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CarrierConstraintSupportTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/CarrierConstraintSupportTest.java @@ -16,6 +16,15 @@ class CarrierConstraintSupportTest { 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 void stripNonMachineClausesKeepsMachineDomainClauses() { String stripped = CarrierConstraintSupport.stripNonMachineClauses(