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> 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;
}

View File

@@ -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);
}

View File

@@ -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;
}
/**

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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);
}
}

View File

@@ -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();

View File

@@ -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();
}
}