diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainLinkPolicy.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainLinkPolicy.java index 8e0498c..cb7f1c1 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainLinkPolicy.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainLinkPolicy.java @@ -200,11 +200,15 @@ public final class CallChainLinkPolicy { } List poly = trigger.getPolymorphicEvents(); List concrete = concretePolymorphicCandidates(poly, eventTypeFqn, context); - if (concrete.size() != 1) { + if (concrete.isEmpty()) { return false; } - return MachineEnumCanonicalizer.polymorphicEventMatchesMachineEventType( - concrete.get(0), eventTypeFqn, context); + for (String pe : concrete) { + if (!MachineEnumCanonicalizer.polymorphicEventMatchesMachineEventType(pe, eventTypeFqn, context)) { + return false; + } + } + return true; } static List concretePolymorphicCandidates( @@ -262,12 +266,13 @@ public final class CallChainLinkPolicy { if (shouldFailClosedOnAmbiguousCallGraphWiden(trigger, machineEventTypeFqn, context)) { return LinkResolution.AMBIGUOUS_WIDEN; } - if (ambiguousSource) { - return LinkResolution.AMBIGUOUS_WIDEN; - } + // Prefer keeping useful matches over soft source ambiguity (e.g. multi-place same event). if (matched != null && !matched.isEmpty()) { return LinkResolution.RESOLVED; } + if (ambiguousSource) { + return LinkResolution.AMBIGUOUS_WIDEN; + } return LinkResolution.NO_MATCH; } 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 2d15e97..bd5ba8b 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 @@ -139,10 +139,10 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { if (crossStateTypeConflict) { ambiguousSource = true; } else if (sourcesByEvent.values().stream().anyMatch(sources -> sources.size() > 1)) { - ambiguousSource = !CallChainLinkPolicy.isEndpointNarrowPolyEvidence( - tp, - effectiveEventTypeFqn, - context); + // Same event from multiple sources within one state enum is normal — link all. + // Do not require endpoint-narrow poly evidence (that conflates "which event?" + // with "which place?"). + ambiguousSource = false; } else { Set allDistinctSources = new LinkedHashSet<>(); sourcesByEvent.values().forEach(allDistinctSources::addAll); @@ -291,7 +291,8 @@ public class TransitionLinkerEnricher implements AnalysisEnricher { } int lastDot = stateIdentifier.lastIndexOf('.'); if (lastDot <= 0) { - return stateIdentifier; + // Bare constant (NEW) — treat as same unknown type within one machine analysis. + return ""; } return stateIdentifier.substring(0, lastDot); } 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 e5d0cdf..28ac1b6 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 @@ -1,5 +1,7 @@ package click.kamil.springstatemachineexporter.analysis.resolver; +import click.kamil.springstatemachineexporter.ast.common.CodebaseContext; + import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -70,6 +72,18 @@ public final class BooleanConstraintEvaluator { * is used so {@code command == ORDER_PAY} matches {@code DomainCommand.ORDER_PAY}. */ public static boolean isCompatibleWithBindings(String constraint, Map bindings) { + return isCompatibleWithBindings(constraint, bindings, null, null); + } + + /** + * Like {@link #isCompatibleWithBindings(String, Map)} but reconstructs truncated enum refs + * using {@code preferredEnumTypeFqn} before treating them as concrete. + */ + public static boolean isCompatibleWithBindings( + String constraint, + Map bindings, + String preferredEnumTypeFqn, + CodebaseContext context) { if (constraint == null || constraint.isBlank()) { return true; } @@ -84,48 +98,72 @@ public final class BooleanConstraintEvaluator { } String expr = constraint; for (Map.Entry entry : bindings.entrySet()) { - if (!isConcreteBindingValue(entry.getKey(), entry.getValue())) { + String concreteValue = concreteBindingValue( + entry.getKey(), entry.getValue(), preferredEnumTypeFqn, context); + if (concreteValue == null) { continue; } - expr = substituteVariableBindings(expr, entry.getKey(), entry.getValue()); - expr = substituteEqualsLiteralBindings(expr, entry.getKey(), entry.getValue()); + expr = substituteVariableBindings(expr, entry.getKey(), concreteValue); + expr = substituteEqualsLiteralBindings(expr, entry.getKey(), concreteValue); } return evaluateBooleanExpression(expr); } private static boolean isConcreteBindingValue(String paramName, String boundValue) { + return concreteBindingValue(paramName, boundValue, null, null) != null; + } + + /** + * @return concrete value to substitute (possibly reconstructed), or {@code null} if not concrete + */ + private static String concreteBindingValue( + String paramName, + String boundValue, + String preferredEnumTypeFqn, + CodebaseContext context) { if (boundValue == null || boundValue.isBlank() || boundValue.equals(paramName)) { - return false; + return null; } if (isIncompleteDottedName(boundValue)) { - return false; + return null; } + String candidate = boundValue; if (TruncatedEnumRefReconstructor.looksLikePackageTruncatedEnumRef(boundValue)) { - return false; + if (context == null) { + return null; + } + candidate = TruncatedEnumRefReconstructor.reconstruct( + boundValue, preferredEnumTypeFqn, context); + if (TruncatedEnumRefReconstructor.looksLikePackageTruncatedEnumRef(candidate)) { + return null; + } } - if (boundValue.contains("(") && !boundValue.contains("valueOf")) { - return false; + if (candidate.contains("(") && !candidate.contains("valueOf")) { + return null; } - if (boundValue.startsWith("\"") && boundValue.endsWith("\"")) { - return true; + if (candidate.startsWith("\"") && candidate.endsWith("\"")) { + return candidate; } - if ("true".equalsIgnoreCase(boundValue) || "false".equalsIgnoreCase(boundValue)) { - return true; + if ("true".equalsIgnoreCase(candidate) || "false".equalsIgnoreCase(candidate)) { + return candidate; } - int lastDot = boundValue.lastIndexOf('.'); + int lastDot = candidate.lastIndexOf('.'); if (lastDot >= 0 - && lastDot + 1 < boundValue.length() - && Character.isUpperCase(boundValue.charAt(lastDot + 1))) { - return true; + && lastDot + 1 < candidate.length() + && Character.isUpperCase(candidate.charAt(lastDot + 1))) { + return candidate; } // Treat simple routing keys (e.g. order.pay) as concrete string bindings, but avoid // mistaking variable names (e.g. machineType) for concrete values. - if ((boundValue.contains(".") || boundValue.contains("/") || boundValue.contains("-")) - && boundValue.matches("^[a-zA-Z0-9._/-]+$")) { - return true; + if ((candidate.contains(".") || candidate.contains("/") || candidate.contains("-")) + && candidate.matches("^[a-zA-Z0-9._/-]+$")) { + return candidate; } - return boundValue.equals(boundValue.toUpperCase(Locale.ROOT)) && boundValue.chars().allMatch(ch -> - Character.isUpperCase(ch) || ch == '_'); + if (candidate.equals(candidate.toUpperCase(Locale.ROOT)) && candidate.chars().allMatch(ch -> + Character.isUpperCase(ch) || ch == '_')) { + return candidate; + } + return null; } /** 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 93a01c6..49a9ef8 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 @@ -913,6 +913,14 @@ public final class MachineEnumCanonicalizer { } if (typePart != null && context != null && context.isAmbiguousSimpleName(typePart)) { + // Prefer machine-scoped qualification when preferred enum type is known. + if (constant.matches("[A-Z_][A-Z0-9_]*") + && (importStyleEnumTypeMatches(typePart, enumTypeFqn) + || enumTypesMatch(enumTypeFqn, typePart, context) + || simpleName(enumTypeFqn).equals(typePart) + || simpleName(enumTypeFqn).equals(simpleName(typePart)))) { + return enumTypeFqn + "." + constant; + } return stripped; } diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingEvaluator.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingEvaluator.java index bbd05ec..8084f98 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingEvaluator.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/PathBindingEvaluator.java @@ -23,6 +23,8 @@ public class PathBindingEvaluator { private final TypeResolver typeResolver; private final PathBindingExpressionResolver expressionResolver; private final Map> traceBindingsCache = new HashMap<>(); + /** Optional machine/event enum FQN used to disambiguate truncated package+CONSTANT refs. */ + private String preferredEnumTypeFqn; public PathBindingEvaluator( CodebaseContext context, @@ -45,6 +47,10 @@ public class PathBindingEvaluator { this.expressionResolver = expressionResolver; } + public void setPreferredEnumTypeFqn(String preferredEnumTypeFqn) { + this.preferredEnumTypeFqn = preferredEnumTypeFqn; + } + /** Visible for tests: bindings accumulated while walking a path forward (ignores constraint rejection). */ Map traceBindings(List path, Map> callGraph, CallGraphPathFinder pathFinder) { if (path == null || path.size() < 2) { @@ -113,7 +119,8 @@ public class PathBindingEvaluator { } if (edge.getConstraint() != null && !edge.getConstraint().isBlank() - && !BooleanConstraintEvaluator.isCompatibleWithBindings(edge.getConstraint(), bindings)) { + && !BooleanConstraintEvaluator.isCompatibleWithBindings( + edge.getConstraint(), bindings, preferredEnumTypeFqn, context)) { return false; } enrichBindings(caller, target, edge, path, i + 1, callGraph, pathFinder, bindings); @@ -140,7 +147,8 @@ public class PathBindingEvaluator { Map bindings) { if (edge.getConstraint() != null && !edge.getConstraint().isBlank() - && !BooleanConstraintEvaluator.isCompatibleWithBindings(edge.getConstraint(), bindings)) { + && !BooleanConstraintEvaluator.isCompatibleWithBindings( + edge.getConstraint(), bindings, preferredEnumTypeFqn, context)) { return false; } if (variableTracer == null || typeResolver == null) { @@ -192,14 +200,14 @@ public class PathBindingEvaluator { if (constants.size() == 1) { String constant = maybeReconstruct(constants.get(0)); if (shouldStoreBinding(paramName, constant)) { - bindings.put(paramName, constant); + bindings.put(paramName, maybeReconstruct(constant)); } continue; } } String resolved = resolveBindingValue(caller, arg, bindings); if (shouldStoreBinding(paramName, resolved)) { - bindings.put(paramName, resolved); + bindings.put(paramName, maybeReconstruct(resolved)); } } } @@ -211,11 +219,12 @@ public class PathBindingEvaluator { if (BooleanConstraintEvaluator.isIncompleteDottedName(resolved)) { return false; } - // Unrepaired package+CONSTANT truncation (com.example.PAY) is not a trustworthy binding. - if (TruncatedEnumRefReconstructor.looksLikePackageTruncatedEnumRef(resolved)) { + // Reconstruct with preferred type before rejecting truncated package+CONSTANT refs. + String candidate = maybeReconstruct(resolved); + if (TruncatedEnumRefReconstructor.looksLikePackageTruncatedEnumRef(candidate)) { return false; } - if (resolved.contains("(") && !isEnumLikeConstant(resolved)) { + if (candidate.contains("(") && !isEnumLikeConstant(candidate)) { return false; } return true; @@ -228,7 +237,7 @@ public class PathBindingEvaluator { for (Map.Entry entry : paramValues.entrySet()) { String resolved = resolveBindingValue(caller, entry.getValue(), bindings); if (shouldStoreBinding(entry.getKey(), resolved)) { - bindings.put(entry.getKey(), resolved); + bindings.put(entry.getKey(), maybeReconstruct(resolved)); } } } @@ -263,7 +272,7 @@ public class PathBindingEvaluator { if (value == null || context == null) { return value; } - return TruncatedEnumRefReconstructor.reconstruct(value, context); + return TruncatedEnumRefReconstructor.reconstruct(value, preferredEnumTypeFqn, context); } private static String normalizeLiteral(String value) { diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainLinkPolicyTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainLinkPolicyTest.java index c2651f9..4ca9af4 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainLinkPolicyTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/CallChainLinkPolicyTest.java @@ -71,8 +71,10 @@ class CallChainLinkPolicyTest { assertThat(CallChainLinkPolicy.isEndpointNarrowPolyEvidence( trigger, "com.example.OrderEvent")).isFalse(); + // All candidates still belong to the machine event type (trusted for routing affinity). assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden( - trigger, "com.example.OrderEvent")).isFalse(); + trigger, "com.example.OrderEvent")).isTrue(); + // Cross-event dynamic widen still fail-closed for linking. assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden( trigger, "com.example.OrderEvent")).isTrue(); } @@ -130,4 +132,41 @@ class CallChainLinkPolicyTest { assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden( trigger, "com.example.order.OrderEvent")).isFalse(); } + + @Test + void shouldTrustBareConstantPolymorphicCandidatesWhenMachineEventTypeIsKnown() { + TriggerPoint trigger = TriggerPoint.builder() + .ambiguous(true) + .event("OrderEvent.valueOf(eventStr)") + .polymorphicEvents(List.of("PAY", "SHIP")) + .build(); + + assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden( + trigger, "com.example.order.OrderEvent")).isTrue(); + // Multiple concrete events on a dynamic trigger still fail-closed for linking. + assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden( + trigger, "com.example.order.OrderEvent")).isTrue(); + } + + @Test + void shouldPreferMatchedTransitionsOverSoftAmbiguousSource() { + TriggerPoint trigger = TriggerPoint.builder() + .event("OrderEvent.PAY") + .polymorphicEvents(List.of("OrderEvent.PAY")) + .build(); + + var matched = List.of( + click.kamil.springstatemachineexporter.analysis.model.MatchedTransition.builder() + .event("com.example.order.OrderEvent.PAY") + .sourceState("com.example.order.OrderState.NEW") + .build(), + click.kamil.springstatemachineexporter.analysis.model.MatchedTransition.builder() + .event("com.example.order.OrderEvent.PAY") + .sourceState("com.example.order.OrderState.PENDING") + .build()); + + assertThat(CallChainLinkPolicy.resolveLinkResolution( + trigger, matched, true, "com.example.order.OrderEvent")) + .isEqualTo(LinkResolution.RESOLVED); + } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java index 5bf8c1e..ac1362e 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/TransitionLinkerEnricherTest.java @@ -191,8 +191,10 @@ class TransitionLinkerEnricherTest { enricher.enrich(result, null, null); CallChain updatedChain = result.getMetadata().getCallChains().get(0); - assertThat(updatedChain.getMatchedTransitions()).isNullOrEmpty(); - assertThat(updatedChain.getTriggerPoint().isAmbiguous()).isTrue(); + // Same event from multiple places — link all (baseline / 33ccc4a behavior). + assertThat(updatedChain.getMatchedTransitions()).hasSize(2); + assertThat(updatedChain.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED); + assertThat(updatedChain.getTriggerPoint().isAmbiguous()).isFalse(); } @Test @@ -233,11 +235,10 @@ class TransitionLinkerEnricherTest { payFromPending.setTargetStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID"))); payFromPending.setEvent(Event.of("PAY", "com.example.order.OrderEvent.PAY")); + // Known concrete event, no endpoint-narrow poly list required for multi-source linking. CallChain chain = CallChain.builder() .triggerPoint(TriggerPoint.builder() - .ambiguous(true) - .event("OrderEvent.valueOf(eventStr)") - .polymorphicEvents(List.of("com.example.order.OrderEvent.PAY")) + .event("com.example.order.OrderEvent.PAY") .build()) .build(); diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluatorBindingsTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluatorBindingsTest.java index 7de7bc6..a9fbe2a 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluatorBindingsTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/resolver/BooleanConstraintEvaluatorBindingsTest.java @@ -1,6 +1,7 @@ package click.kamil.springstatemachineexporter.analysis.resolver; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import java.util.Map; @@ -101,4 +102,38 @@ class BooleanConstraintEvaluatorBindingsTest { "command == ORDER_PAY", Map.of("command", "com.example.DomainCommand."))).isTrue(); } + + @Test + void shouldReconstructTruncatedEnumBindingWithPreferredType(@TempDir java.nio.file.Path tempDir) + throws Exception { + java.nio.file.Path dir = tempDir.resolve("com/example"); + java.nio.file.Files.createDirectories(dir); + java.nio.file.Files.writeString(dir.resolve("OrderEvent.java"), + "package com.example;\npublic enum OrderEvent { PAY, SHIP }\n"); + java.nio.file.Files.writeString(dir.resolve("DocumentEvent.java"), + "package com.example;\npublic enum DocumentEvent { PAY, ARCHIVE }\n"); + + click.kamil.springstatemachineexporter.ast.common.CodebaseContext context = + new click.kamil.springstatemachineexporter.ast.common.CodebaseContext(); + context.scan(tempDir); + + // Without preferred type, truncated binding is not concrete → constraint left unevaluated (true). + assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings( + "event == PAY", + Map.of("event", "com.example.PAY"), + null, + context)).isTrue(); + + // With preferred type, truncated ref becomes OrderEvent.PAY and matches. + assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings( + "event == PAY", + Map.of("event", "com.example.PAY"), + "com.example.OrderEvent", + context)).isTrue(); + assertThat(BooleanConstraintEvaluator.isCompatibleWithBindings( + "event == SHIP", + Map.of("event", "com.example.PAY"), + "com.example.OrderEvent", + context)).isFalse(); + } }