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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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" : [ {
|
||||
|
||||
Reference in New Issue
Block a user