A
This commit is contained in:
@@ -200,11 +200,15 @@ public final class CallChainLinkPolicy {
|
||||
}
|
||||
List<String> poly = trigger.getPolymorphicEvents();
|
||||
List<String> 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<String> 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> 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);
|
||||
}
|
||||
|
||||
@@ -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<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()) {
|
||||
return true;
|
||||
}
|
||||
@@ -84,48 +98,72 @@ public final class BooleanConstraintEvaluator {
|
||||
}
|
||||
String expr = constraint;
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ public class PathBindingEvaluator {
|
||||
private final TypeResolver typeResolver;
|
||||
private final PathBindingExpressionResolver expressionResolver;
|
||||
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(
|
||||
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<String, String> traceBindings(List<String> path, Map<String, List<CallEdge>> 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<String, String> 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<String, String> 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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user