This commit is contained in:
2026-07-16 18:13:38 +02:00
parent 4809423004
commit 9250e97055
8 changed files with 183 additions and 47 deletions

View File

@@ -200,11 +200,15 @@ public final class CallChainLinkPolicy {
} }
List<String> poly = trigger.getPolymorphicEvents(); List<String> poly = trigger.getPolymorphicEvents();
List<String> concrete = concretePolymorphicCandidates(poly, eventTypeFqn, context); List<String> concrete = concretePolymorphicCandidates(poly, eventTypeFqn, context);
if (concrete.size() != 1) { if (concrete.isEmpty()) {
return false; return false;
} }
return MachineEnumCanonicalizer.polymorphicEventMatchesMachineEventType( for (String pe : concrete) {
concrete.get(0), eventTypeFqn, context); if (!MachineEnumCanonicalizer.polymorphicEventMatchesMachineEventType(pe, eventTypeFqn, context)) {
return false;
}
}
return true;
} }
static List<String> concretePolymorphicCandidates( static List<String> concretePolymorphicCandidates(
@@ -262,12 +266,13 @@ public final class CallChainLinkPolicy {
if (shouldFailClosedOnAmbiguousCallGraphWiden(trigger, machineEventTypeFqn, context)) { if (shouldFailClosedOnAmbiguousCallGraphWiden(trigger, machineEventTypeFqn, context)) {
return LinkResolution.AMBIGUOUS_WIDEN; return LinkResolution.AMBIGUOUS_WIDEN;
} }
if (ambiguousSource) { // Prefer keeping useful matches over soft source ambiguity (e.g. multi-place same event).
return LinkResolution.AMBIGUOUS_WIDEN;
}
if (matched != null && !matched.isEmpty()) { if (matched != null && !matched.isEmpty()) {
return LinkResolution.RESOLVED; return LinkResolution.RESOLVED;
} }
if (ambiguousSource) {
return LinkResolution.AMBIGUOUS_WIDEN;
}
return LinkResolution.NO_MATCH; return LinkResolution.NO_MATCH;
} }

View File

@@ -139,10 +139,10 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
if (crossStateTypeConflict) { if (crossStateTypeConflict) {
ambiguousSource = true; ambiguousSource = true;
} else if (sourcesByEvent.values().stream().anyMatch(sources -> sources.size() > 1)) { } else if (sourcesByEvent.values().stream().anyMatch(sources -> sources.size() > 1)) {
ambiguousSource = !CallChainLinkPolicy.isEndpointNarrowPolyEvidence( // Same event from multiple sources within one state enum is normal — link all.
tp, // Do not require endpoint-narrow poly evidence (that conflates "which event?"
effectiveEventTypeFqn, // with "which place?").
context); ambiguousSource = false;
} else { } else {
Set<String> allDistinctSources = new LinkedHashSet<>(); Set<String> allDistinctSources = new LinkedHashSet<>();
sourcesByEvent.values().forEach(allDistinctSources::addAll); sourcesByEvent.values().forEach(allDistinctSources::addAll);
@@ -291,7 +291,8 @@ public class TransitionLinkerEnricher implements AnalysisEnricher {
} }
int lastDot = stateIdentifier.lastIndexOf('.'); int lastDot = stateIdentifier.lastIndexOf('.');
if (lastDot <= 0) { if (lastDot <= 0) {
return stateIdentifier; // Bare constant (NEW) — treat as same unknown type within one machine analysis.
return "";
} }
return stateIdentifier.substring(0, lastDot); return stateIdentifier.substring(0, lastDot);
} }

View File

@@ -1,5 +1,7 @@
package click.kamil.springstatemachineexporter.analysis.resolver; package click.kamil.springstatemachineexporter.analysis.resolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
@@ -70,6 +72,18 @@ public final class BooleanConstraintEvaluator {
* is used so {@code command == ORDER_PAY} matches {@code DomainCommand.ORDER_PAY}. * is used so {@code command == ORDER_PAY} matches {@code DomainCommand.ORDER_PAY}.
*/ */
public static boolean isCompatibleWithBindings(String constraint, Map<String, String> bindings) { public static boolean isCompatibleWithBindings(String constraint, Map<String, String> 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<String, String> bindings,
String preferredEnumTypeFqn,
CodebaseContext context) {
if (constraint == null || constraint.isBlank()) { if (constraint == null || constraint.isBlank()) {
return true; return true;
} }
@@ -84,48 +98,72 @@ public final class BooleanConstraintEvaluator {
} }
String expr = constraint; String expr = constraint;
for (Map.Entry<String, String> entry : bindings.entrySet()) { for (Map.Entry<String, String> entry : bindings.entrySet()) {
if (!isConcreteBindingValue(entry.getKey(), entry.getValue())) { String concreteValue = concreteBindingValue(
entry.getKey(), entry.getValue(), preferredEnumTypeFqn, context);
if (concreteValue == null) {
continue; continue;
} }
expr = substituteVariableBindings(expr, entry.getKey(), entry.getValue()); expr = substituteVariableBindings(expr, entry.getKey(), concreteValue);
expr = substituteEqualsLiteralBindings(expr, entry.getKey(), entry.getValue()); expr = substituteEqualsLiteralBindings(expr, entry.getKey(), concreteValue);
} }
return evaluateBooleanExpression(expr); return evaluateBooleanExpression(expr);
} }
private static boolean isConcreteBindingValue(String paramName, String boundValue) { 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)) { if (boundValue == null || boundValue.isBlank() || boundValue.equals(paramName)) {
return false; return null;
} }
if (isIncompleteDottedName(boundValue)) { if (isIncompleteDottedName(boundValue)) {
return false; return null;
} }
String candidate = boundValue;
if (TruncatedEnumRefReconstructor.looksLikePackageTruncatedEnumRef(boundValue)) { if (TruncatedEnumRefReconstructor.looksLikePackageTruncatedEnumRef(boundValue)) {
return false; if (context == null) {
return null;
} }
if (boundValue.contains("(") && !boundValue.contains("valueOf")) { candidate = TruncatedEnumRefReconstructor.reconstruct(
return false; boundValue, preferredEnumTypeFqn, context);
if (TruncatedEnumRefReconstructor.looksLikePackageTruncatedEnumRef(candidate)) {
return null;
} }
if (boundValue.startsWith("\"") && boundValue.endsWith("\"")) {
return true;
} }
if ("true".equalsIgnoreCase(boundValue) || "false".equalsIgnoreCase(boundValue)) { if (candidate.contains("(") && !candidate.contains("valueOf")) {
return true; return null;
} }
int lastDot = boundValue.lastIndexOf('.'); if (candidate.startsWith("\"") && candidate.endsWith("\"")) {
return candidate;
}
if ("true".equalsIgnoreCase(candidate) || "false".equalsIgnoreCase(candidate)) {
return candidate;
}
int lastDot = candidate.lastIndexOf('.');
if (lastDot >= 0 if (lastDot >= 0
&& lastDot + 1 < boundValue.length() && lastDot + 1 < candidate.length()
&& Character.isUpperCase(boundValue.charAt(lastDot + 1))) { && Character.isUpperCase(candidate.charAt(lastDot + 1))) {
return true; return candidate;
} }
// Treat simple routing keys (e.g. order.pay) as concrete string bindings, but avoid // Treat simple routing keys (e.g. order.pay) as concrete string bindings, but avoid
// mistaking variable names (e.g. machineType) for concrete values. // mistaking variable names (e.g. machineType) for concrete values.
if ((boundValue.contains(".") || boundValue.contains("/") || boundValue.contains("-")) if ((candidate.contains(".") || candidate.contains("/") || candidate.contains("-"))
&& boundValue.matches("^[a-zA-Z0-9._/-]+$")) { && candidate.matches("^[a-zA-Z0-9._/-]+$")) {
return true; return candidate;
} }
return boundValue.equals(boundValue.toUpperCase(Locale.ROOT)) && boundValue.chars().allMatch(ch -> if (candidate.equals(candidate.toUpperCase(Locale.ROOT)) && candidate.chars().allMatch(ch ->
Character.isUpperCase(ch) || ch == '_'); Character.isUpperCase(ch) || ch == '_')) {
return candidate;
}
return null;
} }
/** /**

View File

@@ -913,6 +913,14 @@ public final class MachineEnumCanonicalizer {
} }
if (typePart != null && context != null && context.isAmbiguousSimpleName(typePart)) { 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; return stripped;
} }

View File

@@ -23,6 +23,8 @@ public class PathBindingEvaluator {
private final TypeResolver typeResolver; private final TypeResolver typeResolver;
private final PathBindingExpressionResolver expressionResolver; private final PathBindingExpressionResolver expressionResolver;
private final Map<String, Map<String, String>> traceBindingsCache = new HashMap<>(); private final Map<String, Map<String, String>> traceBindingsCache = new HashMap<>();
/** Optional machine/event enum FQN used to disambiguate truncated package+CONSTANT refs. */
private String preferredEnumTypeFqn;
public PathBindingEvaluator( public PathBindingEvaluator(
CodebaseContext context, CodebaseContext context,
@@ -45,6 +47,10 @@ public class PathBindingEvaluator {
this.expressionResolver = expressionResolver; this.expressionResolver = expressionResolver;
} }
public void setPreferredEnumTypeFqn(String preferredEnumTypeFqn) {
this.preferredEnumTypeFqn = preferredEnumTypeFqn;
}
/** Visible for tests: bindings accumulated while walking a path forward (ignores constraint rejection). */ /** Visible for tests: bindings accumulated while walking a path forward (ignores constraint rejection). */
Map<String, String> traceBindings(List<String> path, Map<String, List<CallEdge>> callGraph, CallGraphPathFinder pathFinder) { Map<String, String> traceBindings(List<String> path, Map<String, List<CallEdge>> callGraph, CallGraphPathFinder pathFinder) {
if (path == null || path.size() < 2) { if (path == null || path.size() < 2) {
@@ -113,7 +119,8 @@ public class PathBindingEvaluator {
} }
if (edge.getConstraint() != null if (edge.getConstraint() != null
&& !edge.getConstraint().isBlank() && !edge.getConstraint().isBlank()
&& !BooleanConstraintEvaluator.isCompatibleWithBindings(edge.getConstraint(), bindings)) { && !BooleanConstraintEvaluator.isCompatibleWithBindings(
edge.getConstraint(), bindings, preferredEnumTypeFqn, context)) {
return false; return false;
} }
enrichBindings(caller, target, edge, path, i + 1, callGraph, pathFinder, bindings); enrichBindings(caller, target, edge, path, i + 1, callGraph, pathFinder, bindings);
@@ -140,7 +147,8 @@ public class PathBindingEvaluator {
Map<String, String> bindings) { Map<String, String> bindings) {
if (edge.getConstraint() != null if (edge.getConstraint() != null
&& !edge.getConstraint().isBlank() && !edge.getConstraint().isBlank()
&& !BooleanConstraintEvaluator.isCompatibleWithBindings(edge.getConstraint(), bindings)) { && !BooleanConstraintEvaluator.isCompatibleWithBindings(
edge.getConstraint(), bindings, preferredEnumTypeFqn, context)) {
return false; return false;
} }
if (variableTracer == null || typeResolver == null) { if (variableTracer == null || typeResolver == null) {
@@ -192,14 +200,14 @@ public class PathBindingEvaluator {
if (constants.size() == 1) { if (constants.size() == 1) {
String constant = maybeReconstruct(constants.get(0)); String constant = maybeReconstruct(constants.get(0));
if (shouldStoreBinding(paramName, constant)) { if (shouldStoreBinding(paramName, constant)) {
bindings.put(paramName, constant); bindings.put(paramName, maybeReconstruct(constant));
} }
continue; continue;
} }
} }
String resolved = resolveBindingValue(caller, arg, bindings); String resolved = resolveBindingValue(caller, arg, bindings);
if (shouldStoreBinding(paramName, resolved)) { if (shouldStoreBinding(paramName, resolved)) {
bindings.put(paramName, resolved); bindings.put(paramName, maybeReconstruct(resolved));
} }
} }
} }
@@ -211,11 +219,12 @@ public class PathBindingEvaluator {
if (BooleanConstraintEvaluator.isIncompleteDottedName(resolved)) { if (BooleanConstraintEvaluator.isIncompleteDottedName(resolved)) {
return false; return false;
} }
// Unrepaired package+CONSTANT truncation (com.example.PAY) is not a trustworthy binding. // Reconstruct with preferred type before rejecting truncated package+CONSTANT refs.
if (TruncatedEnumRefReconstructor.looksLikePackageTruncatedEnumRef(resolved)) { String candidate = maybeReconstruct(resolved);
if (TruncatedEnumRefReconstructor.looksLikePackageTruncatedEnumRef(candidate)) {
return false; return false;
} }
if (resolved.contains("(") && !isEnumLikeConstant(resolved)) { if (candidate.contains("(") && !isEnumLikeConstant(candidate)) {
return false; return false;
} }
return true; return true;
@@ -228,7 +237,7 @@ public class PathBindingEvaluator {
for (Map.Entry<String, String> entry : paramValues.entrySet()) { for (Map.Entry<String, String> entry : paramValues.entrySet()) {
String resolved = resolveBindingValue(caller, entry.getValue(), bindings); String resolved = resolveBindingValue(caller, entry.getValue(), bindings);
if (shouldStoreBinding(entry.getKey(), resolved)) { 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) { if (value == null || context == null) {
return value; return value;
} }
return TruncatedEnumRefReconstructor.reconstruct(value, context); return TruncatedEnumRefReconstructor.reconstruct(value, preferredEnumTypeFqn, context);
} }
private static String normalizeLiteral(String value) { private static String normalizeLiteral(String value) {

View File

@@ -71,8 +71,10 @@ class CallChainLinkPolicyTest {
assertThat(CallChainLinkPolicy.isEndpointNarrowPolyEvidence( assertThat(CallChainLinkPolicy.isEndpointNarrowPolyEvidence(
trigger, "com.example.OrderEvent")).isFalse(); trigger, "com.example.OrderEvent")).isFalse();
// All candidates still belong to the machine event type (trusted for routing affinity).
assertThat(CallChainLinkPolicy.isTrustedEnumPolymorphicWiden( 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( assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(
trigger, "com.example.OrderEvent")).isTrue(); trigger, "com.example.OrderEvent")).isTrue();
} }
@@ -130,4 +132,41 @@ class CallChainLinkPolicyTest {
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden( assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(
trigger, "com.example.order.OrderEvent")).isFalse(); 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);
}
} }

View File

@@ -191,8 +191,10 @@ class TransitionLinkerEnricherTest {
enricher.enrich(result, null, null); enricher.enrich(result, null, null);
CallChain updatedChain = result.getMetadata().getCallChains().get(0); CallChain updatedChain = result.getMetadata().getCallChains().get(0);
assertThat(updatedChain.getMatchedTransitions()).isNullOrEmpty(); // Same event from multiple places — link all (baseline / 33ccc4a behavior).
assertThat(updatedChain.getTriggerPoint().isAmbiguous()).isTrue(); assertThat(updatedChain.getMatchedTransitions()).hasSize(2);
assertThat(updatedChain.getLinkResolution()).isEqualTo(LinkResolution.RESOLVED);
assertThat(updatedChain.getTriggerPoint().isAmbiguous()).isFalse();
} }
@Test @Test
@@ -233,11 +235,10 @@ class TransitionLinkerEnricherTest {
payFromPending.setTargetStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID"))); payFromPending.setTargetStates(List.of(State.of("PAID", "com.example.order.OrderState.PAID")));
payFromPending.setEvent(Event.of("PAY", "com.example.order.OrderEvent.PAY")); 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() CallChain chain = CallChain.builder()
.triggerPoint(TriggerPoint.builder() .triggerPoint(TriggerPoint.builder()
.ambiguous(true) .event("com.example.order.OrderEvent.PAY")
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of("com.example.order.OrderEvent.PAY"))
.build()) .build())
.build(); .build();

View File

@@ -1,6 +1,7 @@
package click.kamil.springstatemachineexporter.analysis.resolver; package click.kamil.springstatemachineexporter.analysis.resolver;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.util.Map; import java.util.Map;
@@ -101,4 +102,38 @@ class BooleanConstraintEvaluatorBindingsTest {
"command == ORDER_PAY", "command == ORDER_PAY",
Map.of("command", "com.example.DomainCommand."))).isTrue(); 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();
}
} }