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:
@@ -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