Resolve inheritance call targets and cap polymorphic event transitions.

Add InheritanceCallTargetResolver for super/this/implicit dispatch across
call-graph engines, apply transition-ceiling to polymorphic events in
MachineEnumCanonicalizer, and cover JDT parity, deep hierarchy, and
pipeline behavior with new tests plus golden update.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-12 19:46:48 +02:00
parent e6effa3dcd
commit 2f853fb971
13 changed files with 900 additions and 124 deletions

View File

@@ -95,6 +95,34 @@ public final class EnumMemberPredicateEvaluator {
return filtered;
}
/**
* True when every predicate method in the constraint exists on the enum (or its interfaces).
* Used to distinguish inconclusive filtering from an intentional empty result.
*/
public static boolean hasResolvablePredicateMethods(
String constraint,
String enumTypeFqn,
CodebaseContext context) {
List<PredicateCall> predicates = extractPredicateCalls(constraint);
if (predicates.isEmpty()) {
return true;
}
EnumDeclaration enumDecl = findEnumDeclaration(enumTypeFqn, context);
if (enumDecl == null) {
return false;
}
for (PredicateCall predicate : predicates) {
MethodDeclaration method = findParameterlessMethod(enumDecl, predicate.methodName());
if (method == null && context != null) {
method = findInterfaceParameterlessMethod(enumDecl, predicate.methodName(), context);
}
if (method == null) {
return false;
}
}
return true;
}
private static boolean satisfiesPredicates(
String canonicalConstantFqn,
List<PredicateCall> predicates,

View File

@@ -101,6 +101,14 @@ public final class MachineEnumCanonicalizer {
List<String> polymorphicEvents,
String machineEventTypeFqn,
CodebaseContext context) {
return expandSymbolicPolymorphicEvents(polymorphicEvents, machineEventTypeFqn, context, null);
}
public static List<String> expandSymbolicPolymorphicEvents(
List<String> polymorphicEvents,
String machineEventTypeFqn,
CodebaseContext context,
List<Transition> machineTransitions) {
if (polymorphicEvents == null || polymorphicEvents.isEmpty()) {
return polymorphicEvents;
}
@@ -108,6 +116,8 @@ public final class MachineEnumCanonicalizer {
return polymorphicEvents;
}
List<String> transitionEvents = polymorphicEventsFromTransitions(machineTransitions, machineEventTypeFqn);
List<String> expanded = new ArrayList<>();
for (String pe : polymorphicEvents) {
if (pe == null) {
@@ -118,6 +128,10 @@ public final class MachineEnumCanonicalizer {
if (!enumTypesMatch(machineEventTypeFqn, symbolicType)) {
continue;
}
if (!transitionEvents.isEmpty()) {
expanded.addAll(transitionEvents);
continue;
}
if (context != null) {
List<String> enumValues = context.getEnumValues(stripGenerics(machineEventTypeFqn));
if (enumValues != null) {
@@ -202,7 +216,7 @@ public final class MachineEnumCanonicalizer {
List<Transition> machineTransitions,
boolean skipCanonicalization) {
TriggerPoint expanded = skipCanonicalization
? expandSymbolicOnly(trigger, machineTypes, context)
? expandSymbolicOnly(trigger, machineTypes, context, machineTransitions)
: canonicalizeAndExpandTriggerPoint(trigger, machineTypes, context);
if (expanded == null || machineTypes == null || machineTypes.eventTypeFqn() == null) {
return expanded;
@@ -210,7 +224,7 @@ public final class MachineEnumCanonicalizer {
List<String> postExpand = expanded.getPolymorphicEvents();
if (postExpand != null && postExpand.stream().anyMatch(pe -> pe != null && pe.startsWith("<SYMBOLIC:"))) {
postExpand = expandSymbolicPolymorphicEvents(
postExpand, machineTypes.eventTypeFqn(), context);
postExpand, machineTypes.eventTypeFqn(), context, machineTransitions);
postExpand = narrowExpandedPolymorphicEvents(
postExpand,
expanded.getConstraint(),
@@ -221,7 +235,8 @@ public final class MachineEnumCanonicalizer {
expanded = expanded.toBuilder().polymorphicEvents(postExpand).build();
}
}
if (hasConcretePolymorphicEvents(expanded.getPolymorphicEvents())) {
if (hasConcretePolymorphicEvents(expanded.getPolymorphicEvents())
|| (expanded.getPolymorphicEvents() != null && !expanded.getPolymorphicEvents().isEmpty())) {
List<String> narrowed = narrowPolymorphicCandidates(
expanded.getPolymorphicEvents(),
expanded.getConstraint(),
@@ -255,12 +270,13 @@ public final class MachineEnumCanonicalizer {
private static TriggerPoint expandSymbolicOnly(
TriggerPoint trigger,
StateMachineTypeResolver.MachineTypes machineTypes,
CodebaseContext context) {
CodebaseContext context,
List<Transition> machineTransitions) {
if (trigger == null || machineTypes == null || machineTypes.eventTypeFqn() == null) {
return trigger;
}
List<String> expanded = expandSymbolicPolymorphicEvents(
trigger.getPolymorphicEvents(), machineTypes.eventTypeFqn(), context);
trigger.getPolymorphicEvents(), machineTypes.eventTypeFqn(), context, machineTransitions);
if (java.util.Objects.equals(expanded, trigger.getPolymorphicEvents())) {
return trigger;
}
@@ -315,21 +331,21 @@ public final class MachineEnumCanonicalizer {
List<Transition> machineTransitions,
CodebaseContext context) {
List<String> transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn);
if (!transitionEvents.isEmpty()) {
if (transitionEvents.isEmpty()) {
return List.of();
}
List<String> filtered = EnumMemberPredicateEvaluator.filterEnumConstants(
transitionEvents, constraint, eventTypeFqn, context);
return filtered.isEmpty() ? transitionEvents : filtered;
}
List<String> enumConstants = allPackageCanonicalEnumConstants(eventTypeFqn, context);
List<String> filtered = EnumMemberPredicateEvaluator.filterEnumConstants(
enumConstants, constraint, eventTypeFqn, context);
if (!filtered.isEmpty()) {
return filtered;
}
if (!transitionEvents.isEmpty()) {
if (EnumMemberPredicateEvaluator.hasEnumMemberPredicates(constraint)) {
if (EnumMemberPredicateEvaluator.hasResolvablePredicateMethods(constraint, eventTypeFqn, context)) {
return List.of();
}
return transitionEvents;
}
return List.of();
return transitionEvents;
}
private static List<String> narrowPolymorphicCandidates(
@@ -339,56 +355,65 @@ public final class MachineEnumCanonicalizer {
List<Transition> machineTransitions,
CodebaseContext context) {
List<String> transitionEvents = polymorphicEventsFromTransitions(machineTransitions, eventTypeFqn);
List<String> result = EnumMemberPredicateEvaluator.filterEnumConstants(
current, constraint, eventTypeFqn, context);
if (result.isEmpty() && !transitionEvents.isEmpty()) {
result = new ArrayList<>(transitionEvents);
}
if (!looksOverBroad(result, transitionEvents, constraint)) {
return result;
}
if (!transitionEvents.isEmpty()) {
List<String> intersected = new ArrayList<>();
for (String event : result) {
if (transitionEvents.contains(event)) {
intersected.add(event);
}
}
if (!intersected.isEmpty()) {
return intersected;
}
List<String> transitionFiltered = EnumMemberPredicateEvaluator.filterEnumConstants(
List<String> result = current == null ? List.of() : new ArrayList<>(current);
if (EnumMemberPredicateEvaluator.hasEnumMemberPredicates(constraint)) {
List<String> filtered = EnumMemberPredicateEvaluator.filterEnumConstants(
result, constraint, eventTypeFqn, context);
if (!filtered.isEmpty()) {
result = filtered;
} else if (!transitionEvents.isEmpty()) {
filtered = EnumMemberPredicateEvaluator.filterEnumConstants(
transitionEvents, constraint, eventTypeFqn, context);
return transitionFiltered.isEmpty() ? transitionEvents : transitionFiltered;
if (!filtered.isEmpty()) {
result = filtered;
} else if (EnumMemberPredicateEvaluator.hasResolvablePredicateMethods(
constraint, eventTypeFqn, context)) {
result = List.of();
} else {
result = transitionEvents;
}
} else {
result = List.of();
}
}
if (!transitionEvents.isEmpty()) {
result = capToConfiguredTransitionEvents(result, transitionEvents);
}
return result;
}
private static boolean looksOverBroad(
List<String> current,
List<String> transitionEvents,
String constraint) {
if (current == null || current.isEmpty()) {
return false;
/**
* Fail-closed ceiling: polymorphic candidates must never exceed events configured on the machine.
*/
static List<String> capToConfiguredTransitionEvents(
List<String> candidates,
List<String> transitionEvents) {
if (transitionEvents == null || transitionEvents.isEmpty()) {
return candidates == null ? List.of() : candidates;
}
if (EnumMemberPredicateEvaluator.hasEnumMemberPredicates(constraint)) {
return true;
if (candidates == null || candidates.isEmpty()) {
return List.of();
}
if (current.size() <= 1) {
return false;
List<String> capped = new ArrayList<>();
for (String candidate : candidates) {
if (candidate == null) {
continue;
}
if (transitionEvents.isEmpty()) {
return current.size() > 1;
if (transitionEvents.contains(candidate)) {
capped.add(candidate);
continue;
}
if (current.size() > transitionEvents.size()) {
return true;
}
for (String event : current) {
if (!transitionEvents.contains(event)) {
return true;
String constant = constantName(candidate);
for (String transitionEvent : transitionEvents) {
if (constantName(transitionEvent).equals(constant)) {
capped.add(transitionEvent);
break;
}
}
return false;
}
return capped;
}
public static List<String> polymorphicEventsFromTransitions(
@@ -423,23 +448,6 @@ public final class MachineEnumCanonicalizer {
.anyMatch(pe -> classifyTriggerEvent(pe) == TriggerEventKind.CANONICAL_ENUM);
}
private static List<String> allPackageCanonicalEnumConstants(String eventTypeFqn, CodebaseContext context) {
if (eventTypeFqn == null || eventTypeFqn.isBlank() || context == null) {
return List.of();
}
String enumType = stripGenerics(eventTypeFqn);
List<String> enumValues = context.getEnumValues(enumType);
if (enumValues == null || enumValues.isEmpty()) {
return List.of();
}
List<String> canonical = new ArrayList<>();
for (String value : enumValues) {
String constant = value.contains(".") ? value.substring(value.lastIndexOf('.') + 1) : value;
canonical.add(enumType + "." + constant);
}
return canonical;
}
public static String qualifyEventIdentifier(String event, String eventTypeFqn) {
if (event == null || eventTypeFqn == null || event.isEmpty()) {
return event;

View File

@@ -971,14 +971,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
polymorphicEvents.add(methodReturn);
}
}
if (!hasConcreteEnumConstants(polymorphicEvents, declaredType, context)
&& polymorphicEvents.stream().anyMatch(pe -> pe != null && pe.startsWith("<SYMBOLIC:"))
&& declaredType != null) {
List<String> expandedEnumValues = expandDeclaredEnumValues(declaredType);
if (!expandedEnumValues.isEmpty()) {
polymorphicEvents = new ArrayList<>(expandedEnumValues);
}
}
}
}
}
@@ -2403,18 +2395,9 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
if (tdOuter != null) {
String callerFqn = context.getFqn(tdOuter) + "." + md.getName().getIdentifier();
String methodName = node.getName().getIdentifier();
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
String superFqn = context.getSuperclassFqn(td);
if (superFqn != null) {
TypeDeclaration superTd = context.getTypeDeclaration(superFqn);
String calledMethod = null;
if (superTd != null) {
calledMethod = resolveMethodInType(superTd, methodName);
}
if (calledMethod == null) {
calledMethod = superFqn + "." + methodName;
}
String calledMethod = InheritanceCallTargetResolver.resolveSuperMethod(
context, tdOuter, methodName);
if (calledMethod != null) {
List<String> args = resolveArguments(node.arguments());
String receiver = "super";
String constraint = click.kamil.springstatemachineexporter.ast.common.AstUtils
@@ -2425,7 +2408,6 @@ public abstract class AbstractCallGraphEngine implements CallGraphEngine {
}
}
}
}
return super.visit(node);
}
});

View File

@@ -28,12 +28,14 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine {
}
}
if (receiver == null) {
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
return resolveMethodInTypeHierarchy(td, methodName);
TypeDeclaration enclosingType = findEnclosingType(node);
if (receiver instanceof SuperMethodInvocation) {
return InheritanceCallTargetResolver.resolveSuperMethod(context, enclosingType, methodName);
}
return null;
if (receiver == null) {
return InheritanceCallTargetResolver.resolveInstanceMethod(context, enclosingType, methodName);
}
ITypeBinding binding = receiver.resolveTypeBinding();
@@ -42,10 +44,7 @@ public class HeuristicCallGraphEngine extends AbstractCallGraphEngine {
}
if (receiver instanceof ThisExpression) {
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
return context.getFqn(td) + "." + methodName;
}
return InheritanceCallTargetResolver.resolveInstanceMethod(context, enclosingType, methodName);
}
if (receiver instanceof SimpleName sn) {

View File

@@ -0,0 +1,65 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.ASTNode;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
/**
* Resolves call-graph target FQNs for {@code this}, implicit, and {@code super} dispatch hops.
* Uses the declaring type of the resolved method (parent class for inherited members).
*/
final class InheritanceCallTargetResolver {
private InheritanceCallTargetResolver() {
}
static String resolveInstanceMethod(
CodebaseContext context,
TypeDeclaration enclosingType,
String methodName) {
if (context == null || enclosingType == null || methodName == null || methodName.isBlank()) {
return null;
}
MethodDeclaration method = context.findMethodDeclaration(enclosingType, methodName, true);
if (method == null) {
return null;
}
TypeDeclaration declaringType = findEnclosingTypeDeclaration(method);
if (declaringType == null) {
return null;
}
String fqn = context.getFqn(declaringType);
return fqn == null ? null : fqn + "." + methodName;
}
static String resolveSuperMethod(
CodebaseContext context,
TypeDeclaration enclosingType,
String methodName) {
if (context == null || enclosingType == null || methodName == null || methodName.isBlank()) {
return null;
}
String superFqn = context.getSuperclassFqn(enclosingType);
if (superFqn == null) {
return null;
}
TypeDeclaration superType = context.getTypeDeclaration(superFqn);
if (superType == null) {
return superFqn + "." + methodName;
}
String resolved = resolveInstanceMethod(context, superType, methodName);
return resolved != null ? resolved : superFqn + "." + methodName;
}
private static TypeDeclaration findEnclosingTypeDeclaration(ASTNode node) {
ASTNode current = node;
while (current != null) {
if (current instanceof TypeDeclaration typeDeclaration) {
return typeDeclaration;
}
current = current.getParent();
}
return null;
}
}

View File

@@ -56,12 +56,14 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
}
}
if (receiver == null) {
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
return resolveMethodInType(td, methodName);
TypeDeclaration enclosingType = findEnclosingType(node);
if (receiver instanceof SuperMethodInvocation) {
return InheritanceCallTargetResolver.resolveSuperMethod(context, enclosingType, methodName);
}
return null;
if (receiver == null) {
return InheritanceCallTargetResolver.resolveInstanceMethod(context, enclosingType, methodName);
}
if (injectionAnalyzer != null) {
@@ -122,10 +124,7 @@ public class JdtCallGraphEngine extends AbstractCallGraphEngine {
}
if (receiver instanceof ThisExpression) {
TypeDeclaration td = findEnclosingType(node);
if (td != null) {
return context.getFqn(td) + "." + methodName;
}
return InheritanceCallTargetResolver.resolveInstanceMethod(context, enclosingType, methodName);
}
if (receiver instanceof SimpleName sn) {

View File

@@ -247,6 +247,51 @@ class MachineEnumCanonicalizerTest {
.isEqualTo("com.example.order.OrderEvent.PAY");
}
@Test
void shouldCapFullEnumPolymorphicListToConfiguredTransitionsOnly() {
Transition pay = new Transition();
pay.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY"));
Transition ship = new Transition();
ship.setEvent(Event.of("OrderEvent.SHIP", "com.example.order.OrderEvent.SHIP"));
List<String> fullEnum = List.of(
"com.example.order.OrderEvent.PAY",
"com.example.order.OrderEvent.SHIP",
"com.example.order.OrderEvent.CANCEL",
"com.example.order.OrderEvent.LOG");
TriggerPoint trigger = TriggerPoint.builder()
.event("eventType")
.polymorphicEvents(fullEnum)
.external(true)
.build();
StateMachineTypeResolver.MachineTypes types = new StateMachineTypeResolver.MachineTypes(
"com.example.order.OrderState", "com.example.order.OrderEvent");
TriggerPoint linked = MachineEnumCanonicalizer.ensureCallChainPolymorphicEvents(
trigger, types, null, List.of(pay, ship));
assertThat(linked.getPolymorphicEvents())
.containsExactlyInAnyOrder(
"com.example.order.OrderEvent.PAY",
"com.example.order.OrderEvent.SHIP");
}
@Test
void shouldExpandSymbolicPolymorphicEventsToConfiguredTransitionsWhenAvailable() {
Transition pay = new Transition();
pay.setEvent(Event.of("OrderEvent.PAY", "com.example.order.OrderEvent.PAY"));
List<String> expanded = MachineEnumCanonicalizer.expandSymbolicPolymorphicEvents(
List.of("<SYMBOLIC: com.example.order.OrderEvent.*>"),
"com.example.order.OrderEvent",
null,
List.of(pay));
assertThat(expanded).containsExactly("com.example.order.OrderEvent.PAY");
}
@Test
void shouldInferPolymorphicEventsFromTransitionsWhenContextIsUnavailable() {
Transition pay = new Transition();

View File

@@ -7,6 +7,7 @@ import click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata;
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.analysis.resolver.MachineEnumCanonicalizer;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Event;
import click.kamil.springstatemachineexporter.model.State;
@@ -43,7 +44,7 @@ final class CentralDispatcherTestSupport {
String controllerClass,
String controllerMethod,
String stateMachineClass) {
return resolveChain(context, controllerClass, controllerMethod, stateMachineClass, EngineKind.HEURISTIC);
return resolveChain(context, controllerClass, controllerMethod, stateMachineClass, "sendEvent", EngineKind.HEURISTIC);
}
static CallChain resolveChain(
@@ -52,26 +53,64 @@ final class CentralDispatcherTestSupport {
String controllerMethod,
String stateMachineClass,
EngineKind engineKind) {
return resolveChain(context, controllerClass, controllerMethod, stateMachineClass, "sendEvent", engineKind);
}
static CallChain resolveChain(
CodebaseContext context,
String entryClass,
String entryMethod,
String triggerClass,
String triggerMethod,
EngineKind engineKind) {
AbstractCallGraphEngine engine = engineKind == EngineKind.JDT
? new JdtCallGraphEngine(context, null)
: new HeuristicCallGraphEngine(context);
EntryPoint entryPoint = EntryPoint.builder()
.className(controllerClass)
.methodName(controllerMethod)
.className(entryClass)
.methodName(entryMethod)
.build();
TriggerPoint trigger = TriggerPoint.builder()
.className(stateMachineClass)
.methodName("sendEvent")
.className(triggerClass)
.methodName(triggerMethod)
.event("e")
.build();
List<CallChain> chains = engine.findChains(List.of(entryPoint), List.of(trigger));
assertThat(chains)
.as("expected one call chain from %s.%s to %s.sendEvent via %s",
controllerClass, controllerMethod, stateMachineClass, engineKind)
.as("expected one call chain from %s.%s to %s.%s via %s",
entryClass, entryMethod, triggerClass, triggerMethod, engineKind)
.hasSize(1);
return chains.get(0);
}
static void assertParity(
CodebaseContext context,
String entryClass,
String entryMethod,
String triggerClass,
String triggerMethod) {
CallChain heuristic = resolveChain(
context, entryClass, entryMethod, triggerClass, triggerMethod, EngineKind.HEURISTIC);
CallChain jdt = resolveChain(
context, entryClass, entryMethod, triggerClass, triggerMethod, EngineKind.JDT);
assertThat(jdt.getMethodChain())
.containsExactlyElementsOf(heuristic.getMethodChain());
assertThat(jdt.getTriggerPoint().getEvent()).isEqualTo(heuristic.getTriggerPoint().getEvent());
assertThat(jdt.getTriggerPoint().getPolymorphicEvents())
.containsExactlyInAnyOrderElementsOf(
heuristic.getTriggerPoint().getPolymorphicEvents() == null
? List.of()
: heuristic.getTriggerPoint().getPolymorphicEvents());
}
static void assertMethodChainContains(CallChain chain, String... methodFqns) {
assertThat(chain.getMethodChain()).contains(methodFqns);
}
static void assertMethodChainContainsExact(CallChain chain, String... methodFqns) {
assertThat(chain.getMethodChain()).containsExactly(methodFqns);
}
static void assertPolyEvents(CallChain chain, String... expectedEvents) {
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
.containsExactlyInAnyOrder(expectedEvents);
@@ -105,4 +144,76 @@ final class CentralDispatcherTestSupport {
static void assertLinkedEvent(MatchedTransition matched, String expectedEvent) {
assertThat(matched.getEvent()).isEqualTo(expectedEvent);
}
static CallChain linkChain(
CodebaseContext context,
CallChain rawChain,
String machineConfig,
String eventTypeFqn,
String stateTypeFqn,
Transition... machineTransitions) {
AnalysisResult result = AnalysisResult.builder()
.name(machineConfig)
.eventTypeFqn(eventTypeFqn)
.stateTypeFqn(stateTypeFqn)
.transitions(List.of(machineTransitions))
.metadata(CodebaseMetadata.builder().callChains(List.of(rawChain)).build())
.build();
new TransitionLinkerEnricher().enrich(result, context, null);
return result.getMetadata().getCallChains().get(0);
}
static CallChain resolveLinkAndAssertSingleMatch(
CodebaseContext context,
String entryClass,
String entryMethod,
String triggerClass,
String triggerMethod,
String machineConfig,
String eventTypeFqn,
String stateTypeFqn,
String expectedLinkedEvent,
EngineKind engineKind,
Transition... machineTransitions) {
CallChain raw = resolveChain(context, entryClass, entryMethod, triggerClass, triggerMethod, engineKind);
CallChain linked = linkChain(context, raw, machineConfig, eventTypeFqn, stateTypeFqn, machineTransitions);
assertPolyWithinTransitions(linked, machineTransitions.length);
assertMatchedWithinTransitions(linked, machineTransitions.length);
assertThat(linked.getMatchedTransitions())
.as("expected exactly one matched transition")
.hasSize(1);
assertLinkedEvent(linked.getMatchedTransitions().get(0), expectedLinkedEvent);
return linked;
}
static void assertPolyWithinTransitions(CallChain chain, int configuredTransitionCount) {
List<String> poly = chain.getTriggerPoint().getPolymorphicEvents();
assertThat(poly)
.as("polymorphicEvents must be present after linking")
.isNotNull();
assertThat(poly.size())
.as("polymorphicEvents must not exceed configured transition count")
.isLessThanOrEqualTo(configuredTransitionCount);
assertThat(MachineEnumCanonicalizer.hasOnlyConcretePolymorphicEvents(poly)).isTrue();
}
static void assertMatchedWithinTransitions(CallChain chain, int configuredTransitionCount) {
assertThat(chain.getMatchedTransitions())
.as("matchedTransitions should be populated after linking")
.isNotNull();
assertThat(chain.getMatchedTransitions().size())
.as("matchedTransitions must not exceed configured transition count")
.isLessThanOrEqualTo(configuredTransitionCount);
}
static void assertPolyCappedToTransitionEvents(
CallChain chain,
String eventTypeFqn,
Transition... machineTransitions) {
List<String> allowed = MachineEnumCanonicalizer.polymorphicEventsFromTransitions(
List.of(machineTransitions), eventTypeFqn);
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
.isNotNull()
.allSatisfy(pe -> assertThat(allowed).contains(pe));
}
}

View File

@@ -0,0 +1,174 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Transition;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.util.List;
import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.*;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Deep inheritance dispatcher stacks: multi-level {@code super} and template-method patterns.
*/
class DeepDispatcherHierarchyTest {
private static final String MACHINE_CONFIG = "com.example.OrderStateMachineConfig";
private static final String EVENT_TYPE = "com.example.OrderEvent";
private static final String STATE_TYPE = "com.example.OrderState";
@Test
void jdtShouldMatchHeuristicForMultiLevelSuperDelegation(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
GrandChildHandler handler;
public void pay() { handler.entry(OrderEvent.PAY); }
}
class GrandChildHandler extends MiddleHandler {
void entry(OrderEvent event) { super.route(event); }
}
class MiddleHandler extends BaseHandler {
protected void route(OrderEvent event) { super.dispatch(event); }
}
class BaseHandler {
StateMachine machine;
protected void dispatch(OrderEvent event) { machine.sendEvent(event); }
}
class StateMachine { public void sendEvent(OrderEvent e) {} }
enum OrderEvent { PAY, SHIP, LOG, META }
""";
CodebaseContext context = scanSource(source, tempDir);
assertParity(context, "com.example.ApiController", "pay", "com.example.StateMachine", "sendEvent");
CallChain chain = resolveChain(
context, "com.example.ApiController", "pay", "com.example.StateMachine", "sendEvent", EngineKind.JDT);
assertMethodChainContains(chain,
"com.example.GrandChildHandler.entry",
"com.example.MiddleHandler.route",
"com.example.BaseHandler.dispatch");
assertPolyEvents(chain, "OrderEvent.PAY");
}
@Test
void jdtShouldMatchHeuristicForTemplateMethodProtectedDispatch(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
OrderHandler handler;
public void pay() { handler.pay(); }
}
abstract class AbstractHandler {
StateMachine machine;
public void pay() { dispatch(buildPayEvent()); }
protected abstract OrderEvent buildPayEvent();
protected void dispatch(OrderEvent event) { machine.sendEvent(event); }
}
class OrderHandler extends AbstractHandler {
@Override protected OrderEvent buildPayEvent() { return OrderEvent.PAY; }
}
class StateMachine { public void sendEvent(OrderEvent e) {} }
enum OrderEvent { PAY, SHIP, LOG, META }
""";
CodebaseContext context = scanSource(source, tempDir);
assertParity(context, "com.example.ApiController", "pay", "com.example.StateMachine", "sendEvent");
assertPolyEvents(
resolveChain(context, "com.example.ApiController", "pay", "com.example.StateMachine", "sendEvent", EngineKind.HEURISTIC),
"OrderEvent.PAY");
}
@Test
void pipelineShouldLinkMultiLevelSuperToSingleConfiguredTransition(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
GrandChildHandler handler;
public void pay() { handler.entry(OrderEvent.PAY); }
}
class GrandChildHandler extends MiddleHandler {
void entry(OrderEvent event) { super.route(event); }
}
class MiddleHandler extends BaseHandler {
protected void route(OrderEvent event) { super.dispatch(event); }
}
class BaseHandler {
StateMachine machine;
protected void dispatch(OrderEvent event) { machine.sendEvent(event); }
}
class StateMachine { public void sendEvent(OrderEvent e) {} }
enum OrderEvent { PAY, SHIP, LOG, META }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY");
Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP");
CallChain linked = resolveLinkAndAssertSingleMatch(
context,
"com.example.ApiController",
"pay",
"com.example.StateMachine",
"sendEvent",
MACHINE_CONFIG,
EVENT_TYPE,
STATE_TYPE,
EVENT_TYPE + ".PAY",
EngineKind.JDT,
pay,
ship);
assertPolyCappedToTransitionEvents(linked, EVENT_TYPE, pay, ship);
assertThat(linked.getTriggerPoint().getPolymorphicEvents())
.containsExactly(EVENT_TYPE + ".PAY");
}
@Test
void pipelineShouldLinkTemplateMethodDispatchToSingleConfiguredTransition(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
OrderHandler handler;
public void pay() { handler.pay(); }
}
abstract class AbstractHandler {
StateMachine machine;
public void pay() { dispatch(buildPayEvent()); }
protected abstract OrderEvent buildPayEvent();
protected void dispatch(OrderEvent event) { machine.sendEvent(event); }
}
class OrderHandler extends AbstractHandler {
@Override protected OrderEvent buildPayEvent() { return OrderEvent.PAY; }
}
class StateMachine { public void sendEvent(OrderEvent e) {} }
enum OrderEvent { PAY, SHIP, LOG, META }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY");
Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP");
CallChain linked = resolveLinkAndAssertSingleMatch(
context,
"com.example.ApiController",
"pay",
"com.example.StateMachine",
"sendEvent",
MACHINE_CONFIG,
EVENT_TYPE,
STATE_TYPE,
EVENT_TYPE + ".PAY",
EngineKind.HEURISTIC,
pay,
ship);
assertPolyCappedToTransitionEvents(linked, EVENT_TYPE, pay, ship);
}
}

View File

@@ -0,0 +1,122 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import click.kamil.springstatemachineexporter.model.Transition;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import java.util.List;
import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.*;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Ensures linker-stage polymorphic narrowing caps over-broad call-graph enum lists to configured transitions.
*/
class DispatcherPolyCeilingPipelineTest {
private static final String MACHINE_CONFIG = "com.example.OrderStateMachineConfig";
private static final String EVENT_TYPE = "com.example.OrderEvent";
private static final String STATE_TYPE = "com.example.OrderState";
@Test
void linkerShouldCapInjectedFullEnumPolymorphicListToConfiguredTransitions(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
CentralDispatcher dispatcher;
public void pay() { dispatcher.route("PAY"); }
}
class CentralDispatcher {
StateMachine machine;
public void route(String action) { machine.sendEvent(OrderEvent.valueOf(action)); }
}
class StateMachine { public void sendEvent(OrderEvent e) {} }
enum OrderEvent { PAY, SHIP, LOG, META }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
CallChain raw = resolveChain(
context,
"com.example.ApiController",
"pay",
"com.example.StateMachine",
"sendEvent",
EngineKind.JDT);
List<String> bloatedEnum = List.of(
EVENT_TYPE + ".PAY",
EVENT_TYPE + ".SHIP",
EVENT_TYPE + ".LOG",
EVENT_TYPE + ".META");
TriggerPoint bloatedTrigger = raw.getTriggerPoint().toBuilder()
.polymorphicEvents(bloatedEnum)
.external(true)
.build();
CallChain bloatedChain = raw.toBuilder().triggerPoint(bloatedTrigger).build();
Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY");
Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP");
CallChain linked = linkChain(
context, bloatedChain, MACHINE_CONFIG, EVENT_TYPE, STATE_TYPE, pay, ship);
assertPolyWithinTransitions(linked, 2);
assertPolyCappedToTransitionEvents(linked, EVENT_TYPE, pay, ship);
assertThat(linked.getTriggerPoint().getPolymorphicEvents())
.containsExactlyInAnyOrder(EVENT_TYPE + ".PAY", EVENT_TYPE + ".SHIP");
assertMatchedWithinTransitions(linked, 2);
assertThat(linked.getMatchedTransitions())
.extracting(MatchedTransition::getEvent)
.containsExactlyInAnyOrder(EVENT_TYPE + ".PAY", EVENT_TYPE + ".SHIP");
}
@Test
void linkerShouldCapCallGraphPolyForDeepDispatcherToTransitionCeiling(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
OrderService orderService;
public void pay() { orderService.handlePay(); }
}
class OrderService {
CentralDispatcher dispatcher;
protected void handlePay() { dispatcher.route("PAY"); }
}
class CentralDispatcher {
StateMachine machine;
public void route(String action) { machine.sendEvent(OrderEvent.valueOf(action)); }
}
class StateMachine { public void sendEvent(OrderEvent e) {} }
enum OrderEvent { PAY, SHIP, LOG, META }
class OrderStateMachineConfig {}
""";
CodebaseContext context = scanSource(source, tempDir);
Transition pay = transition("NEW", "PAID", EVENT_TYPE + ".PAY");
Transition ship = transition("PAID", "SHIPPED", EVENT_TYPE + ".SHIP");
CallChain linked = resolveLinkAndAssertSingleMatch(
context,
"com.example.ApiController",
"pay",
"com.example.StateMachine",
"sendEvent",
MACHINE_CONFIG,
EVENT_TYPE,
STATE_TYPE,
EVENT_TYPE + ".PAY",
EngineKind.JDT,
pay,
ship);
assertThat(linked.getTriggerPoint().getPolymorphicEvents().size()).isLessThan(4);
assertPolyCappedToTransitionEvents(linked, EVENT_TYPE, pay, ship);
}
}

View File

@@ -0,0 +1,58 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.eclipse.jdt.core.dom.MethodDeclaration;
import org.eclipse.jdt.core.dom.TypeDeclaration;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class InheritanceCallTargetResolverTest {
@Test
void shouldResolveInheritedMethodOnDeclaringParentType(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class Child extends Parent {
void run() { dispatch(); }
}
class Parent {
protected void dispatch() {}
}
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration child = context.getTypeDeclaration("com.example.Child");
MethodDeclaration run = context.findMethodDeclaration(child, "run", false);
String target = InheritanceCallTargetResolver.resolveInstanceMethod(context, child, "dispatch");
assertThat(target).isEqualTo("com.example.Parent.dispatch");
assertThat(run).isNotNull();
}
@Test
void shouldResolveSuperCallToParentMethod(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class Child extends Parent {
void run() { super.dispatch(); }
}
class Parent {
protected void dispatch() {}
}
""";
Files.writeString(tempDir.resolve("App.java"), source);
CodebaseContext context = new CodebaseContext();
context.scan(tempDir);
TypeDeclaration child = context.getTypeDeclaration("com.example.Child");
String target = InheritanceCallTargetResolver.resolveSuperMethod(context, child, "dispatch");
assertThat(target).isEqualTo("com.example.Parent.dispatch");
}
}

View File

@@ -0,0 +1,185 @@
package click.kamil.springstatemachineexporter.analysis.service;
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Path;
import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.*;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Heuristic vs JDT parity for inheritance dispatcher patterns ({@code super}, protected, deep chains).
*/
class JdtInheritanceDispatcherParityTest {
private static final String API = "com.example.ApiEndpoint";
private static final String CONTROLLER = "com.example.ChildController";
private static final String STATE_MACHINE = "com.example.StateMachine";
@Test
void jdtShouldMatchHeuristicForSuperOverrideSwitchDelegation(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiEndpoint {
CustomHandler handler;
public void triggerCheckout() { handler.handleEvent(ActionType.BEGIN_CHECKOUT); }
}
abstract class BaseHandler {
StateMachine machine;
public void handleEvent(ActionType action) {
TransitionEnum transition = switch (action) {
case BEGIN_CHECKOUT -> TransitionEnum.CHECKOUT_STARTED;
case ABORT_CHECKOUT -> TransitionEnum.CHECKOUT_CANCELLED;
};
machine.fire(transition);
}
}
class CustomHandler extends BaseHandler {
@Override
public void handleEvent(ActionType action) {
if (action == null) return;
super.handleEvent(action);
}
}
class StateMachine { public void fire(TransitionEnum event) {} }
enum ActionType { BEGIN_CHECKOUT, ABORT_CHECKOUT }
enum TransitionEnum { CHECKOUT_STARTED, CHECKOUT_CANCELLED }
""";
CodebaseContext context = scanSource(source, tempDir);
assertParity(context, API, "triggerCheckout", STATE_MACHINE, "fire");
CallChain chain = resolveChain(context, API, "triggerCheckout", STATE_MACHINE, "fire", EngineKind.HEURISTIC);
assertMethodChainContains(chain,
"com.example.ApiEndpoint.triggerCheckout",
"com.example.CustomHandler.handleEvent",
"com.example.BaseHandler.handleEvent");
assertPolyEvents(chain, "TransitionEnum.CHECKOUT_STARTED");
}
@Test
void jdtShouldMatchHeuristicForImplicitInheritedProtectedMethod(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ChildController extends ParentController {
StateMachine machine;
public void process() { machine.fire(getInheritedEvent()); }
}
abstract class ParentController {
protected TransitionEnum getInheritedEvent() { return TransitionEnum.STATE_P; }
}
class StateMachine { public void fire(TransitionEnum event) {} }
enum TransitionEnum { STATE_P, STATE_Q }
""";
CodebaseContext context = scanSource(source, tempDir);
assertParity(context, CONTROLLER, "process", STATE_MACHINE, "fire");
assertPolyEvents(
resolveChain(context, CONTROLLER, "process", STATE_MACHINE, "fire", EngineKind.JDT),
"TransitionEnum.STATE_P");
}
@Test
void jdtShouldMatchHeuristicForThisCallToInheritedProtectedMethod(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ChildController extends ParentController {
StateMachine machine;
public void process() { machine.fire(this.getInheritedEvent()); }
}
abstract class ParentController {
protected TransitionEnum getInheritedEvent() { return TransitionEnum.STATE_P; }
}
class StateMachine { public void fire(TransitionEnum event) {} }
enum TransitionEnum { STATE_P, STATE_Q }
""";
CodebaseContext context = scanSource(source, tempDir);
assertParity(context, CONTROLLER, "process", STATE_MACHINE, "fire");
assertPolyEvents(
resolveChain(context, CONTROLLER, "process", STATE_MACHINE, "fire", EngineKind.JDT),
"TransitionEnum.STATE_P");
}
@Test
void jdtShouldMatchHeuristicForSuperProtectedSendHelper(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ChildController extends BaseController {
public void trigger() { super.send("MY_EVENT"); }
}
abstract class BaseController {
StateMachine machine;
protected void send(String event) { machine.sendEvent(event); }
}
class StateMachine { public void sendEvent(String event) {} }
""";
CodebaseContext context = scanSource(source, tempDir);
assertParity(context, "com.example.ChildController", "trigger", "com.example.StateMachine", "sendEvent");
CallChain chain = resolveChain(
context, "com.example.ChildController", "trigger", "com.example.StateMachine", "sendEvent", EngineKind.JDT);
assertMethodChainContains(chain,
"com.example.ChildController.trigger",
"com.example.BaseController.send");
assertThat(chain.getTriggerPoint().getEvent()).isEqualTo("MY_EVENT");
}
@Test
void jdtShouldMatchHeuristicForSuperGetterArgument(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ChildController extends ParentController {
StateMachine machine;
@Override public TransitionEnum getEvent() { return TransitionEnum.STATE_X; }
public void process() { machine.fire(super.getEvent()); }
}
class ParentController {
public TransitionEnum getEvent() { return TransitionEnum.STATE_Y; }
}
class StateMachine { public void fire(TransitionEnum event) {} }
enum TransitionEnum { STATE_X, STATE_Y }
""";
CodebaseContext context = scanSource(source, tempDir);
assertParity(context, CONTROLLER, "process", STATE_MACHINE, "fire");
assertPolyEvents(
resolveChain(context, CONTROLLER, "process", STATE_MACHINE, "fire", EngineKind.JDT),
"TransitionEnum.STATE_Y");
}
@Test
void jdtShouldMatchHeuristicForControllerServiceCentralDispatcher(@TempDir Path tempDir) throws Exception {
String source = """
package com.example;
public class ApiController {
OrderService orderService;
public void pay() { orderService.handlePay(); }
}
class OrderService {
CentralDispatcher dispatcher;
protected void handlePay() { dispatcher.route("PAY"); }
}
class CentralDispatcher {
StateMachine machine;
public void route(String action) {
machine.sendEvent(OrderEvent.valueOf(action));
}
}
class StateMachine { public void sendEvent(OrderEvent e) {} }
enum OrderEvent { PAY, SHIP }
""";
CodebaseContext context = scanSource(source, tempDir);
assertParity(context, "com.example.ApiController", "pay", "com.example.StateMachine", "sendEvent");
CallChain chain = resolveChain(
context, "com.example.ApiController", "pay", "com.example.StateMachine", "sendEvent", EngineKind.JDT);
assertMethodChainContains(chain,
"com.example.ApiController.pay",
"com.example.OrderService.handlePay",
"com.example.CentralDispatcher.route");
}
}

View File

@@ -154,7 +154,7 @@
"polymorphicEvents" : [ "click.kamil.enterprise.machines.user.UserEvent.REGISTER", "click.kamil.enterprise.machines.user.UserEvent.VERIFY" ],
"external" : true,
"constraint" : "!(\"ORDER\".equalsIgnoreCase(machineType)) && !(\"DOCUMENT\".equalsIgnoreCase(machineType)) && \"USER\".equalsIgnoreCase(machineType)",
"ambiguous" : true
"ambiguous" : false
},
"contextMachineId" : null,
"matchedTransitions" : [ {