Split boolean ternary endpoints into provable branches and scope payment exports.
Boolean request-param ternaries now expand into true/false binding variants so each call chain resolves a single event instead of AMBIGUOUS_WIDEN. Setter/constructor @Qualifier injection is traced for stateMachineId, and PaymentStateMachineConfig gets a dedicated golden with payment endpoints linked via paymentStateMachine. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -172,6 +172,12 @@ public class GoldenUpdater {
|
||||
Path.of("src/test/resources/golden/ExtendedStateMachineConfig"),
|
||||
"ExtendedStateMachineConfig"
|
||||
),
|
||||
new TestScenario(
|
||||
"Payment State Machine (Extended Sample)",
|
||||
Path.of("state_machines/extended_analysis_sample"),
|
||||
Path.of("src/test/resources/golden/PaymentStateMachineConfig"),
|
||||
"PaymentStateMachineConfig"
|
||||
),
|
||||
new TestScenario(
|
||||
"Extended Analysis Sample (PROD)",
|
||||
Path.of("state_machines/extended_analysis_sample"),
|
||||
|
||||
@@ -113,6 +113,12 @@ public class PlantUmlE2ETest {
|
||||
Path.of("src/test/resources/golden/ExtendedStateMachineConfig"),
|
||||
"ExtendedStateMachineConfig"
|
||||
),
|
||||
new TestScenario(
|
||||
"Payment State Machine (Extended Sample)",
|
||||
root.resolve("state_machines/extended_analysis_sample"),
|
||||
Path.of("src/test/resources/golden/PaymentStateMachineConfig"),
|
||||
"PaymentStateMachineConfig"
|
||||
),
|
||||
new TestScenario(
|
||||
"Extended Analysis Sample (PROD)",
|
||||
root.resolve("state_machines/extended_analysis_sample"),
|
||||
|
||||
@@ -113,6 +113,12 @@ public class RegressionTest {
|
||||
Path.of("src/test/resources/golden/ExtendedStateMachineConfig"),
|
||||
"ExtendedStateMachineConfig"
|
||||
),
|
||||
new TestScenario(
|
||||
"Payment State Machine (Extended Sample)",
|
||||
root.resolve("state_machines/extended_analysis_sample"),
|
||||
Path.of("src/test/resources/golden/PaymentStateMachineConfig"),
|
||||
"PaymentStateMachineConfig"
|
||||
),
|
||||
new TestScenario(
|
||||
"Extended Analysis Sample (PROD)",
|
||||
root.resolve("state_machines/extended_analysis_sample"),
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.EntryPoint;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.TriggerPoint;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class BooleanTernaryBranchSplitTest {
|
||||
|
||||
@Test
|
||||
void bindingExpanderShouldProduceBooleanVariantsForTernaryParameter(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class PolymorphicController {
|
||||
OrderService orderService;
|
||||
public void payTernary(boolean isPay) {
|
||||
orderService.processEvent(isPay ? new PayEvent() : new CancelEvent());
|
||||
}
|
||||
}
|
||||
class OrderService {
|
||||
void processEvent(BaseEvent event) {}
|
||||
}
|
||||
class BaseEvent {}
|
||||
class PayEvent extends BaseEvent {}
|
||||
class CancelEvent extends BaseEvent {}
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("App.java"), source);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
EntryPoint entryPoint = EntryPoint.builder()
|
||||
.className("com.example.PolymorphicController")
|
||||
.methodName("payTernary")
|
||||
.name("POST /pay-ternary")
|
||||
.parameters(List.of(EntryPoint.Parameter.builder()
|
||||
.name("isPay")
|
||||
.type("boolean")
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
List<Map<String, String>> variants = EntryPointBindingExpander.expandEntryPointBindings(
|
||||
entryPoint, context, engine.buildCallGraph());
|
||||
|
||||
assertThat(variants).extracting(map -> map.get("isPay"))
|
||||
.containsExactlyInAnyOrder("true", "false");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveSingleEventPerBooleanBinding(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class PolymorphicController {
|
||||
OrderService orderService;
|
||||
public void payTernary(boolean isPay) {
|
||||
orderService.processEvent(isPay ? new PayEvent() : new CancelEvent());
|
||||
}
|
||||
}
|
||||
class OrderService {
|
||||
void processEvent(BaseEvent event) {}
|
||||
}
|
||||
class BaseEvent {}
|
||||
class PayEvent extends BaseEvent {}
|
||||
class CancelEvent extends BaseEvent {}
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("App.java"), source);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
EntryPoint entryPoint = EntryPoint.builder()
|
||||
.className("com.example.PolymorphicController")
|
||||
.methodName("payTernary")
|
||||
.name("POST /pay-ternary")
|
||||
.parameters(List.of(EntryPoint.Parameter.builder()
|
||||
.name("isPay")
|
||||
.type("boolean")
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className("com.example.OrderService")
|
||||
.methodName("processEvent")
|
||||
.event("event")
|
||||
.build();
|
||||
|
||||
List<CallChain> chains = engine.findChains(List.of(entryPoint), List.of(trigger));
|
||||
|
||||
CallChain payChain = chains.stream()
|
||||
.filter(c -> c.getEntryPoint().getName().contains("isPay=true"))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
CallChain cancelChain = chains.stream()
|
||||
.filter(c -> c.getEntryPoint().getName().contains("isPay=false"))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
|
||||
assertThat(payChain.getTriggerPoint().isAmbiguous()).isFalse();
|
||||
assertThat(cancelChain.getTriggerPoint().isAmbiguous()).isFalse();
|
||||
assertThat(payChain.getTriggerPoint().getEvent()).contains("PayEvent").doesNotContain("CancelEvent");
|
||||
assertThat(cancelChain.getTriggerPoint().getEvent()).contains("CancelEvent").doesNotContain("PayEvent");
|
||||
if (payChain.getTriggerPoint().getPolymorphicEvents() != null) {
|
||||
assertThat(payChain.getTriggerPoint().getPolymorphicEvents()).hasSizeLessThanOrEqualTo(1);
|
||||
}
|
||||
if (cancelChain.getTriggerPoint().getPolymorphicEvents() != null) {
|
||||
assertThat(cancelChain.getTriggerPoint().getPolymorphicEvents()).hasSizeLessThanOrEqualTo(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,41 @@ class GenericEventDetectorFireTest {
|
||||
assertThat(triggers.get(0).getStateMachineId()).isEqualTo("paymentStateMachine");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldInferStateMachineIdFromQualifierOnAutowiredSetter(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
enum OrderEvent { PAY }
|
||||
class OrderService {
|
||||
private StateMachine stateMachine;
|
||||
@org.springframework.beans.factory.annotation.Autowired
|
||||
public void setStateMachine(
|
||||
@org.springframework.beans.factory.annotation.Qualifier("paymentStateMachine")
|
||||
StateMachine stateMachine) {
|
||||
this.stateMachine = stateMachine;
|
||||
}
|
||||
public void pay() {
|
||||
stateMachine.sendEvent(OrderEvent.PAY);
|
||||
}
|
||||
}
|
||||
class StateMachine {
|
||||
public void sendEvent(OrderEvent event) {}
|
||||
}
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("OrderService.java"), source);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
|
||||
GenericEventDetector detector = new GenericEventDetector(context, context.getConstantResolver(), List.of());
|
||||
org.eclipse.jdt.core.dom.TypeDeclaration td = context.getTypeDeclaration("com.example.OrderService");
|
||||
List<TriggerPoint> triggers = detector.detect((org.eclipse.jdt.core.dom.CompilationUnit) td.getRoot());
|
||||
|
||||
assertThat(triggers).hasSize(1);
|
||||
assertThat(triggers.get(0).getStateMachineId()).isEqualTo("paymentStateMachine");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotInferSourceStateFromVariableOnlyIfCondition(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
digraph statemachine {
|
||||
rankdir=LR;
|
||||
node [shape=rounded, style=filled, fillcolor=white, fontname="Arial"];
|
||||
edge [fontname="Arial", fontsize=10];
|
||||
|
||||
_start [shape=circle, label="", fillcolor=black, width=0.1];
|
||||
_start -> NEW;
|
||||
DECLINED [fillcolor=lightgray];
|
||||
QUIRK2 [fillcolor=lightgray];
|
||||
QUIRK1 [fillcolor=lightgray];
|
||||
QUIRK4 [fillcolor=lightgray];
|
||||
QUIRK3 [fillcolor=lightgray];
|
||||
CAPTURED [fillcolor=lightgray];
|
||||
NEW -> AUTHORIZED [label="String.AUTHORIZE", style="solid", color="black"];
|
||||
AUTHORIZED -> CAPTURED [label="String.CAPTURE", style="solid", color="black"];
|
||||
NEW -> DECLINED [label="String.DECLINE", style="solid", color="black"];
|
||||
NEW -> QUIRK1 [label="String.PRIMARY_EVENT", style="solid", color="black"];
|
||||
NEW -> QUIRK2 [label="String.NAMED_EVENT", style="solid", color="black"];
|
||||
NEW -> QUIRK3 [label="String.QUALIFIER_EVENT", style="solid", color="black"];
|
||||
NEW -> QUIRK4 [label="String.FALLBACK_EVENT", style="solid", color="black"];
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1,42 @@
|
||||
@startuml
|
||||
!pragma layout smetana
|
||||
set separator none
|
||||
hide empty description
|
||||
hide stereotype
|
||||
skinparam state {
|
||||
BackgroundColor white
|
||||
BorderColor #94a3b8
|
||||
BorderThickness 1
|
||||
FontName Inter
|
||||
FontSize 9
|
||||
FontStyle bold
|
||||
RoundCorner 20
|
||||
Padding 1
|
||||
}
|
||||
skinparam shadowing false
|
||||
skinparam ArrowFontName JetBrains Mono
|
||||
skinparam ArrowFontSize 8
|
||||
skinparam ArrowColor #cbd5e1
|
||||
skinparam ArrowThickness 1
|
||||
skinparam dpi 110
|
||||
skinparam svgLinkTarget _self
|
||||
|
||||
[*] --> String.NEW
|
||||
|
||||
|
||||
String.NEW -[#1E90FF,bold]-> String.AUTHORIZED <<external>> : String.AUTHORIZE
|
||||
String.AUTHORIZED -[#1E90FF,bold]-> String.CAPTURED <<external>> : String.CAPTURE
|
||||
String.NEW -[#1E90FF,bold]-> String.DECLINED <<external>> : String.DECLINE
|
||||
String.NEW -[#1E90FF,bold]-> String.QUIRK1 <<external>> : String.PRIMARY_EVENT
|
||||
String.NEW -[#1E90FF,bold]-> String.QUIRK2 <<external>> : String.NAMED_EVENT
|
||||
String.NEW -[#1E90FF,bold]-> String.QUIRK3 <<external>> : String.QUALIFIER_EVENT
|
||||
String.NEW -[#1E90FF,bold]-> String.QUIRK4 <<external>> : String.FALLBACK_EVENT
|
||||
|
||||
String.DECLINED --> [*]
|
||||
String.QUIRK2 --> [*]
|
||||
String.QUIRK1 --> [*]
|
||||
String.QUIRK4 --> [*]
|
||||
String.QUIRK3 --> [*]
|
||||
String.CAPTURED --> [*]
|
||||
@enduml
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" initial="NEW">
|
||||
<state id="NEW">
|
||||
<transition target="AUTHORIZED" event="String.AUTHORIZE"/>
|
||||
<transition target="DECLINED" event="String.DECLINE"/>
|
||||
<transition target="QUIRK1" event="String.PRIMARY_EVENT"/>
|
||||
<transition target="QUIRK2" event="String.NAMED_EVENT"/>
|
||||
<transition target="QUIRK3" event="String.QUALIFIER_EVENT"/>
|
||||
<transition target="QUIRK4" event="String.FALLBACK_EVENT"/>
|
||||
</state>
|
||||
<state id="AUTHORIZED">
|
||||
<transition target="CAPTURED" event="String.CAPTURE"/>
|
||||
</state>
|
||||
<state id="CAPTURED">
|
||||
</state>
|
||||
<state id="DECLINED">
|
||||
</state>
|
||||
<state id="QUIRK1">
|
||||
</state>
|
||||
<state id="QUIRK2">
|
||||
</state>
|
||||
<state id="QUIRK3">
|
||||
</state>
|
||||
<state id="QUIRK4">
|
||||
</state>
|
||||
</scxml>
|
||||
|
||||
@@ -197,12 +197,27 @@
|
||||
"parameters" : [ ]
|
||||
}, {
|
||||
"type" : "REST",
|
||||
"name" : "POST /pay-ternary",
|
||||
"name" : "POST /pay-ternary?isPay=true",
|
||||
"className" : "click.kamil.examples.statemachine.polymorphic.PolymorphicController",
|
||||
"methodName" : "payTernary",
|
||||
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/PolymorphicController.java",
|
||||
"metadata" : {
|
||||
"path" : "/pay-ternary",
|
||||
"path" : "/pay-ternary?isPay=true",
|
||||
"verb" : "POST"
|
||||
},
|
||||
"parameters" : [ {
|
||||
"name" : "isPay",
|
||||
"type" : "boolean",
|
||||
"annotations" : [ ]
|
||||
} ]
|
||||
}, {
|
||||
"type" : "REST",
|
||||
"name" : "POST /pay-ternary?isPay=false",
|
||||
"className" : "click.kamil.examples.statemachine.polymorphic.PolymorphicController",
|
||||
"methodName" : "payTernary",
|
||||
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/PolymorphicController.java",
|
||||
"metadata" : {
|
||||
"path" : "/pay-ternary?isPay=false",
|
||||
"verb" : "POST"
|
||||
},
|
||||
"parameters" : [ {
|
||||
@@ -479,12 +494,12 @@
|
||||
}, {
|
||||
"entryPoint" : {
|
||||
"type" : "REST",
|
||||
"name" : "POST /pay-ternary",
|
||||
"name" : "POST /pay-ternary?isPay=true",
|
||||
"className" : "click.kamil.examples.statemachine.polymorphic.PolymorphicController",
|
||||
"methodName" : "payTernary",
|
||||
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/PolymorphicController.java",
|
||||
"metadata" : {
|
||||
"path" : "/pay-ternary",
|
||||
"path" : "/pay-ternary?isPay=true",
|
||||
"verb" : "POST"
|
||||
},
|
||||
"parameters" : [ {
|
||||
@@ -495,22 +510,65 @@
|
||||
},
|
||||
"methodChain" : [ "click.kamil.examples.statemachine.polymorphic.PolymorphicController.payTernary", "click.kamil.examples.statemachine.polymorphic.OrderService.processEvent" ],
|
||||
"triggerPoint" : {
|
||||
"event" : "isPay ? new PayEvent() : new CancelEvent()",
|
||||
"event" : "new PayEvent()",
|
||||
"className" : "click.kamil.examples.statemachine.polymorphic.OrderService",
|
||||
"methodName" : "processEvent",
|
||||
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/OrderService.java",
|
||||
"sourceModule" : null,
|
||||
"stateMachineId" : null,
|
||||
"sourceState" : null,
|
||||
"sourceState" : "click.kamil.examples.statemachine.polymorphic.OrderStates.SUBMITTED",
|
||||
"lineNumber" : 13,
|
||||
"polymorphicEvents" : [ "click.kamil.examples.statemachine.polymorphic.OrderEvents.PAY", "click.kamil.examples.statemachine.polymorphic.OrderEvents.CANCEL" ],
|
||||
"polymorphicEvents" : [ "click.kamil.examples.statemachine.polymorphic.OrderEvents.PAY" ],
|
||||
"external" : false,
|
||||
"constraint" : null,
|
||||
"ambiguous" : true
|
||||
"ambiguous" : false
|
||||
},
|
||||
"contextMachineId" : null,
|
||||
"matchedTransitions" : null,
|
||||
"linkResolution" : "AMBIGUOUS_WIDEN"
|
||||
"matchedTransitions" : [ {
|
||||
"sourceState" : "click.kamil.examples.statemachine.polymorphic.OrderStates.SUBMITTED",
|
||||
"targetState" : "click.kamil.examples.statemachine.polymorphic.OrderStates.PAID",
|
||||
"event" : "click.kamil.examples.statemachine.polymorphic.OrderEvents.PAY"
|
||||
} ],
|
||||
"linkResolution" : "RESOLVED"
|
||||
}, {
|
||||
"entryPoint" : {
|
||||
"type" : "REST",
|
||||
"name" : "POST /pay-ternary?isPay=false",
|
||||
"className" : "click.kamil.examples.statemachine.polymorphic.PolymorphicController",
|
||||
"methodName" : "payTernary",
|
||||
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/PolymorphicController.java",
|
||||
"metadata" : {
|
||||
"path" : "/pay-ternary?isPay=false",
|
||||
"verb" : "POST"
|
||||
},
|
||||
"parameters" : [ {
|
||||
"name" : "isPay",
|
||||
"type" : "boolean",
|
||||
"annotations" : [ ]
|
||||
} ]
|
||||
},
|
||||
"methodChain" : [ "click.kamil.examples.statemachine.polymorphic.PolymorphicController.payTernary", "click.kamil.examples.statemachine.polymorphic.OrderService.processEvent" ],
|
||||
"triggerPoint" : {
|
||||
"event" : "new CancelEvent()",
|
||||
"className" : "click.kamil.examples.statemachine.polymorphic.OrderService",
|
||||
"methodName" : "processEvent",
|
||||
"sourceFile" : "src/main/java/click/kamil/examples/statemachine/polymorphic/OrderService.java",
|
||||
"sourceModule" : null,
|
||||
"stateMachineId" : null,
|
||||
"sourceState" : "click.kamil.examples.statemachine.polymorphic.OrderStates.SUBMITTED",
|
||||
"lineNumber" : 13,
|
||||
"polymorphicEvents" : [ "click.kamil.examples.statemachine.polymorphic.OrderEvents.CANCEL" ],
|
||||
"external" : false,
|
||||
"constraint" : null,
|
||||
"ambiguous" : false
|
||||
},
|
||||
"contextMachineId" : null,
|
||||
"matchedTransitions" : [ {
|
||||
"sourceState" : "click.kamil.examples.statemachine.polymorphic.OrderStates.SUBMITTED",
|
||||
"targetState" : "click.kamil.examples.statemachine.polymorphic.OrderStates.CANCELED",
|
||||
"event" : "click.kamil.examples.statemachine.polymorphic.OrderEvents.CANCEL"
|
||||
} ],
|
||||
"linkResolution" : "RESOLVED"
|
||||
}, {
|
||||
"entryPoint" : {
|
||||
"type" : "REST",
|
||||
|
||||
Reference in New Issue
Block a user