This commit is contained in:
2026-07-16 19:21:53 +02:00
parent 1d9c986793
commit 4b5de0e167
9 changed files with 254 additions and 40 deletions

View File

@@ -122,7 +122,7 @@ public final class CallChainLinkPolicy {
TriggerPoint trigger,
String machineEventTypeFqn,
CodebaseContext context) {
if (trigger == null || trigger.isExternal() || !trigger.isAmbiguous()) {
if (trigger == null || trigger.isExternal()) {
return false;
}
String eventTypeFqn = trigger.getEventTypeFqn();
@@ -131,6 +131,9 @@ public final class CallChainLinkPolicy {
}
List<String> concrete = concretePolymorphicCandidates(
trigger.getPolymorphicEvents(), eventTypeFqn, context);
// Multiple concrete events on a dynamic/ENUM_SET trigger must not link every transition
// (e.g. predicate-filtered PAY+SHIP, or an adjust endpoint sharing a wide dispatcher poly).
// Do not require trigger.ambiguous — that flag is often cleared incorrectly upstream.
if (concrete.size() <= 1) {
return false;
}

View File

@@ -70,6 +70,35 @@ public final class EnumMemberPredicateEvaluator {
return calls;
}
/**
* Evaluate a single no-arg boolean predicate on one enum constant (method name from dataflow/AST).
*/
public static Boolean evaluateConstantPredicate(
String canonicalConstantFqn,
String enumTypeFqn,
String methodName,
boolean negated,
CodebaseContext context) {
if (canonicalConstantFqn == null || enumTypeFqn == null || methodName == null || context == null) {
return null;
}
EnumDeclaration enumDecl = findEnumDeclaration(enumTypeFqn, context);
if (enumDecl == null) {
return null;
}
String constantName = constantSimpleName(canonicalConstantFqn);
if (constantName == null) {
return null;
}
Map<String, Boolean> boolFields = resolveConstantBooleanFields(enumDecl, constantName);
Boolean methodResult = evaluateBooleanMethod(
enumDecl, methodName, constantName, boolFields, context);
if (methodResult == null) {
return null;
}
return negated ? !methodResult : methodResult;
}
public static List<String> filterEnumConstants(
List<String> candidates,
String constraint,

View File

@@ -308,9 +308,12 @@ public final class MachineEnumCanonicalizer {
if (!narrowed.equals(expanded.getPolymorphicEvents())) {
return expanded.toBuilder()
.polymorphicEvents(narrowed)
.ambiguous(narrowed.size() > 1 && expanded.isAmbiguous())
.ambiguous(narrowed.size() > 1)
.build();
}
if (narrowed.size() > 1 && !expanded.isAmbiguous()) {
return expanded.toBuilder().ambiguous(true).build();
}
return expanded;
}
if (shouldInferPolymorphicEvents(expanded, machineTransitions, context)) {

View File

@@ -4,6 +4,7 @@ import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.service.TypeResolver;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.ASTVisitor;
import org.eclipse.jdt.core.dom.BooleanLiteral;
import org.eclipse.jdt.core.dom.ClassInstanceCreation;
import org.eclipse.jdt.core.dom.Expression;
import org.eclipse.jdt.core.dom.InfixExpression;
@@ -17,10 +18,13 @@ import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Evaluates boolean predicates on rich/domain event types (e.g. {@code myEvent.isCorrect()})
* and maps matching implementations to machine enum constants via {@code getType()}.
* and maps matching implementations to machine enum constants via type-accessor methods
* discovered from the trigger expression / receiver type — no hard-coded predicate names.
*/
public final class RichEventPredicateEvaluator {
@@ -50,14 +54,18 @@ public final class RichEventPredicateEvaluator {
ownerTypes.add(receiverTypeFqn);
ownerTypes.addAll(context.getImplementations(receiverTypeFqn));
List<String> typeAccessors = typeAccessorMethods(trigger, predicate.receiverName(), receiverTypeFqn, machineEnumFqn, context);
Set<String> matched = new LinkedHashSet<>();
for (String ownerFqn : ownerTypes) {
if (!predicateMatches(ownerFqn, predicate, context)) {
if (!predicateMatches(ownerFqn, predicate, machineEnumFqn, context)) {
continue;
}
String typeConstant = resolveDirectEnumFromPredicateMethod(ownerFqn, "getType", context);
if (typeConstant != null && matchesCandidate(typeConstant, candidates, machineEnumFqn, context)) {
matched.add(canonicalize(typeConstant, machineEnumFqn, context));
for (String accessor : typeAccessors) {
String typeConstant = resolveDirectEnumFromPredicateMethod(ownerFqn, accessor, context);
if (typeConstant != null && matchesCandidate(typeConstant, candidates, machineEnumFqn, context)) {
matched.add(canonicalize(typeConstant, machineEnumFqn, context));
}
}
String directConstant = resolveDirectEnumFromPredicateMethod(ownerFqn, predicate.methodName(), context);
if (directConstant != null && matchesCandidate(directConstant, candidates, machineEnumFqn, context)) {
@@ -74,6 +82,64 @@ public final class RichEventPredicateEvaluator {
return filtered;
}
/**
* Type-accessor method names come from the trigger event expression (dataflow), e.g.
* {@code myEvent.getType()} → {@code getType}, falling back to parameterless methods on the
* receiver type that return the machine enum.
*/
private static List<String> typeAccessorMethods(
TriggerPoint trigger,
String receiverName,
String receiverTypeFqn,
String machineEnumFqn,
CodebaseContext context) {
LinkedHashSet<String> names = new LinkedHashSet<>();
String event = trigger.getEvent();
if (event != null && receiverName != null && !receiverName.isBlank()) {
Pattern pattern = Pattern.compile(
Pattern.quote(receiverName) + "\\s*\\.\\s*([a-zA-Z_][\\w]*)\\s*\\(");
Matcher matcher = pattern.matcher(event);
while (matcher.find()) {
names.add(matcher.group(1));
}
}
if (names.isEmpty() && receiverTypeFqn != null && machineEnumFqn != null && context != null) {
names.addAll(enumReturningMethods(receiverTypeFqn, machineEnumFqn, context));
}
return List.copyOf(names);
}
private static List<String> enumReturningMethods(
String receiverTypeFqn,
String machineEnumFqn,
CodebaseContext context) {
TypeDeclaration type = context.getTypeDeclaration(receiverTypeFqn);
if (type == null) {
return List.of();
}
TypeResolver typeResolver = new TypeResolver(context);
LinkedHashSet<String> names = new LinkedHashSet<>();
for (Object declObj : type.bodyDeclarations()) {
if (!(declObj instanceof MethodDeclaration method) || method.isConstructor()) {
continue;
}
if (!method.parameters().isEmpty() || method.getReturnType2() == null) {
continue;
}
String returnFqn = typeResolver.resolveTypeToFqn(method.getReturnType2(), type.getRoot());
if (returnFqn != null) {
int generic = returnFqn.indexOf('<');
if (generic >= 0) {
returnFqn = returnFqn.substring(0, generic);
}
}
if (machineEnumFqn.equals(returnFqn)) {
names.add(method.getName().getIdentifier());
}
}
return List.copyOf(names);
}
private static String resolveReceiverTypeFqn(TriggerPoint trigger, String receiverName, CodebaseContext context) {
if (trigger.getClassName() == null || trigger.getMethodName() == null || receiverName == null) {
return null;
@@ -99,6 +165,7 @@ public final class RichEventPredicateEvaluator {
private static boolean predicateMatches(
String ownerFqn,
EnumMemberPredicateEvaluator.PredicateCall predicate,
String machineEnumFqn,
CodebaseContext context) {
TypeDeclaration type = context.getTypeDeclaration(ownerFqn);
if (type == null) {
@@ -113,7 +180,7 @@ public final class RichEventPredicateEvaluator {
@Override
public boolean visit(ReturnStatement node) {
if (result[0] == null && node.getExpression() != null) {
result[0] = evaluateBoolean(node.getExpression());
result[0] = evaluateBoolean(node.getExpression(), ownerFqn, machineEnumFqn, context);
}
return super.visit(node);
}
@@ -124,15 +191,30 @@ public final class RichEventPredicateEvaluator {
return predicate.negated() ? !result[0] : result[0];
}
private static Boolean evaluateBoolean(Expression expression) {
if (expression instanceof org.eclipse.jdt.core.dom.BooleanLiteral literal) {
private static Boolean evaluateBoolean(
Expression expression,
String ownerFqn,
String machineEnumFqn,
CodebaseContext context) {
if (expression instanceof BooleanLiteral literal) {
return literal.booleanValue();
}
// Delegate chained enum predicates (e.g. getCommand().isActive()) to enum-member evaluation —
// method names come from the AST, not a hard-coded list.
if (expression instanceof MethodInvocation invocation
&& "isEvent".equals(invocation.getName().getIdentifier())
&& invocation.getExpression() instanceof MethodInvocation getType
&& "getType".equals(getType.getName().getIdentifier())) {
return true;
&& invocation.arguments().isEmpty()
&& invocation.getExpression() instanceof MethodInvocation typeCall
&& typeCall.arguments().isEmpty()) {
String enumConstant = resolveDirectEnumFromPredicateMethod(
ownerFqn, typeCall.getName().getIdentifier(), context);
if (enumConstant != null && machineEnumFqn != null) {
return EnumMemberPredicateEvaluator.evaluateConstantPredicate(
enumConstant,
machineEnumFqn,
invocation.getName().getIdentifier(),
false,
context);
}
}
if (expression instanceof InfixExpression infix && infix.getOperator() == InfixExpression.Operator.EQUALS) {
return true;
@@ -178,13 +260,10 @@ public final class RichEventPredicateEvaluator {
return extractEnumConstant(infix.getRightOperand());
}
if (expression instanceof MethodInvocation invocation
&& invocation.getExpression() instanceof MethodInvocation getType
&& "getType".equals(getType.getName().getIdentifier())) {
if (invocation.getExpression() instanceof MethodInvocation inner
&& inner.getExpression() instanceof ClassInstanceCreation creation
&& !creation.arguments().isEmpty()) {
return extractEnumConstant((Expression) creation.arguments().get(0));
}
&& invocation.getExpression() instanceof MethodInvocation typeCall
&& typeCall.getExpression() instanceof ClassInstanceCreation creation
&& !creation.arguments().isEmpty()) {
return extractEnumConstant((Expression) creation.arguments().get(0));
}
return null;
}

View File

@@ -82,17 +82,15 @@ class CallChainLinkPolicyTest {
@Test
void shouldFailClosedWhenMultipleConcretePolyCandidatesRemain() {
TriggerPoint trigger = TriggerPoint.builder()
.ambiguous(true)
.ambiguous(false) // even when ambiguous flag was cleared upstream
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of("com.example.OrderEvent.PAY", "com.example.OrderEvent.SHIP"))
.build();
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")).isTrue();
// Cross-event dynamic widen still fail-closed for linking.
assertThat(CallChainLinkPolicy.shouldFailClosedOnAmbiguousCallGraphWiden(
trigger, "com.example.OrderEvent")).isTrue();
}

View File

@@ -78,6 +78,90 @@ class TransitionLinkerEnricherTest {
assertThat(updated.getMatchedTransitions()).hasSize(1);
}
@Test
void shouldNotLinkAllTransitionsWhenNonEventEnumConstantIsBound() {
Transition pay = new Transition();
pay.setSourceStates(List.of(State.of("NEW", "com.example.OrderState.NEW")));
pay.setTargetStates(List.of(State.of("PAID", "com.example.OrderState.PAID")));
pay.setEvent(Event.of("PAY", "com.example.OrderEvent.PAY"));
Transition ship = new Transition();
ship.setSourceStates(List.of(State.of("PAID", "com.example.OrderState.PAID")));
ship.setTargetStates(List.of(State.of("SHIPPED", "com.example.OrderState.SHIPPED")));
ship.setEvent(Event.of("SHIP", "com.example.OrderEvent.SHIP"));
// adjust/LOG-style: dataflow proves a non-transition / non-event constant.
CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /api/order/adjust")
.className("com.example.OrderController")
.methodName("adjust")
.build())
.pathBindings(java.util.Map.of("event", "com.example.OrderEvent.LOG"))
.triggerPoint(TriggerPoint.builder()
.event("com.example.OrderEvent.LOG")
.polymorphicEvents(List.of("com.example.OrderEvent.LOG"))
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("OrderStateMachineConfig")
.eventTypeFqn("com.example.OrderEvent")
.transitions(List.of(pay, ship))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain updated = result.getMetadata().getCallChains().get(0);
assertThat(updated.getMatchedTransitions()).isNullOrEmpty();
assertThat(updated.getLinkResolution()).isIn(LinkResolution.NO_MATCH, LinkResolution.AMBIGUOUS_WIDEN);
}
@Test
void shouldFailClosedWhenIsEventFilterLeavesMultipleTransitionEventsWithoutEndpointEvidence() {
Transition pay = new Transition();
pay.setSourceStates(List.of(State.of("NEW", "com.example.OrderState.NEW")));
pay.setTargetStates(List.of(State.of("PAID", "com.example.OrderState.PAID")));
pay.setEvent(Event.of("PAY", "com.example.OrderEvent.PAY"));
Transition ship = new Transition();
ship.setSourceStates(List.of(State.of("PAID", "com.example.OrderState.PAID")));
ship.setTargetStates(List.of(State.of("SHIPPED", "com.example.OrderState.SHIPPED")));
ship.setEvent(Event.of("SHIP", "com.example.OrderEvent.SHIP"));
CallChain chain = CallChain.builder()
.entryPoint(EntryPoint.builder()
.type(EntryPoint.Type.REST)
.name("POST /api/order/adjust")
.className("com.example.OrderController")
.methodName("adjust")
.build())
.triggerPoint(TriggerPoint.builder()
.ambiguous(false)
.event("OrderEvent.valueOf(eventStr)")
.polymorphicEvents(List.of(
"com.example.OrderEvent.PAY",
"com.example.OrderEvent.SHIP"))
// After dataflow predicate filter: only transition events remain, still multi.
.build())
.build();
AnalysisResult result = AnalysisResult.builder()
.name("OrderStateMachineConfig")
.eventTypeFqn("com.example.OrderEvent")
.transitions(List.of(pay, ship))
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
.build();
enricher.enrich(result, null, null);
CallChain updated = result.getMetadata().getCallChains().get(0);
assertThat(updated.getMatchedTransitions()).isNullOrEmpty();
assertThat(updated.getLinkResolution()).isEqualTo(LinkResolution.AMBIGUOUS_WIDEN);
}
@Test
void shouldExportUnresolvedExternalForGenericPathVariableEndpoint() {
CallChain chain = CallChain.builder()

View File

@@ -12,6 +12,7 @@ import java.nio.file.Path;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import static org.assertj.core.api.Assertions.assertThat;
@@ -74,22 +75,32 @@ class EnterpriseBooleanEnumPredicateExportTest {
assertThat(polyEvents.size()).isLessThan(4);
for (JsonNode dispatchChain : dispatchChains) {
long chainMatched = StreamSupport.stream(dispatchChain.get("matchedTransitions").spliterator(), false)
.count();
JsonNode matched = dispatchChain.get("matchedTransitions");
String resolution = dispatchChain.path("linkResolution").asText();
// Wide predicate-filtered poly without per-endpoint dataflow must not
// highlight every transition — fail closed instead.
if ("AMBIGUOUS_WIDEN".equals(resolution) || "UNRESOLVED_EXTERNAL".equals(resolution)) {
assertThat(matched == null || matched.isNull() || (matched.isArray() && matched.isEmpty()))
.as("fail-closed/unresolved chains must not carry matches")
.isTrue();
continue;
}
long chainMatched = matched == null || matched.isNull()
? 0
: StreamSupport.stream(matched.spliterator(), false).count();
assertThat(chainMatched)
.as("each dispatch chain must not cover all transitions")
.isLessThanOrEqualTo(transitionCount);
.as("each resolved dispatch chain must not cover all transitions without evidence")
.isLessThanOrEqualTo(1);
}
int matchedCount = dispatchChains.stream()
.mapToInt(chain -> StreamSupport.stream(chain.get("matchedTransitions").spliterator(), false)
.mapToInt(node -> 1)
.sum())
.sum();
assertThat(matchedCount)
.as("matchedTransitions must not cover every transition × every enum constant")
.isLessThan(transitionCount * dispatchChains.size());
Set<String> matchedEvents = dispatchChains.stream()
.flatMap(chain -> StreamSupport.stream(chain.get("matchedTransitions").spliterator(), false))
.filter(chain -> "RESOLVED".equals(chain.path("linkResolution").asText()))
.flatMap(chain -> {
JsonNode matched = chain.get("matchedTransitions");
if (matched == null || matched.isNull() || !matched.isArray()) {
return Stream.empty();
}
return StreamSupport.stream(matched.spliterator(), false);
})
.map(node -> node.get("event").asText())
.collect(Collectors.toSet());
assertThat(matchedEvents)

View File

@@ -242,7 +242,10 @@ class EnumMemberPredicatePolymorphicInferenceTest {
assertThat(tp.getPolymorphicEvents())
.doesNotContain("com.example.order.OrderCommand.META");
assertThat(tp.isAmbiguous()).isTrue();
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(2);
// Multiple predicate-true constants without per-endpoint dataflow must not highlight every transition.
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty();
assertThat(result.getMetadata().getCallChains().get(0).getLinkResolution())
.isEqualTo(click.kamil.springstatemachineexporter.analysis.model.LinkResolution.AMBIGUOUS_WIDEN);
}
@Test
@@ -405,7 +408,9 @@ class EnumMemberPredicatePolymorphicInferenceTest {
.containsExactlyInAnyOrder(
"com.example.order.OrderCommand.PAY",
"com.example.order.OrderCommand.SHIP");
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(2);
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty();
assertThat(result.getMetadata().getCallChains().get(0).getLinkResolution())
.isEqualTo(click.kamil.springstatemachineexporter.analysis.model.LinkResolution.AMBIGUOUS_WIDEN);
}
@Test

View File

@@ -104,7 +104,9 @@ class RichEventIsCorrectPredicateTest {
.containsExactlyInAnyOrder(
"com.example.order.OrderCommand.PAY",
"com.example.order.OrderCommand.SHIP");
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(2);
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty();
assertThat(result.getMetadata().getCallChains().get(0).getLinkResolution())
.isEqualTo(click.kamil.springstatemachineexporter.analysis.model.LinkResolution.AMBIGUOUS_WIDEN);
}
private static CodebaseContext scanProject(Path tempDir) throws Exception {