Replace .get() heuristics with type-based access classification and extend literal dataflow resolution.
Introduce ExpressionAccessClassifier to distinguish map lookups from Supplier/bean accessors, fold Map.of and string/array literals at compile time, and add regression tests for dispatcher and dataflow paths. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,6 +17,57 @@ class TransitionLinkerEnricherTest {
|
||||
|
||||
private final TransitionLinkerEnricher enricher = new TransitionLinkerEnricher();
|
||||
|
||||
@Test
|
||||
void shouldLinkWhenPolymorphicEventsContainConcreteEnumConstant() {
|
||||
Transition payT = new Transition();
|
||||
payT.setSourceStates(List.of(State.of("NEW", "OrderState.NEW")));
|
||||
payT.setTargetStates(List.of(State.of("PAID", "OrderState.PAID")));
|
||||
payT.setEvent(Event.of("OrderEvent.PAY", "com.example.OrderEvent.PAY"));
|
||||
|
||||
CallChain chain = CallChain.builder()
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.event("e")
|
||||
.polymorphicEvents(List.of("OrderEvent.PAY"))
|
||||
.build())
|
||||
.build();
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("OrderStateMachineConfig")
|
||||
.transitions(List.of(payT))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
|
||||
enricher.enrich(result, null, null);
|
||||
|
||||
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRespectDispatcherBranchConstraintForMachineDomain() {
|
||||
Transition payT = new Transition();
|
||||
payT.setSourceStates(List.of(State.of("NEW", "OrderState.NEW")));
|
||||
payT.setTargetStates(List.of(State.of("PAID", "OrderState.PAID")));
|
||||
payT.setEvent(Event.of("OrderEvent.PAY", "com.example.OrderEvent.PAY"));
|
||||
|
||||
CallChain chain = CallChain.builder()
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.event("OrderEvent.PAY")
|
||||
.polymorphicEvents(List.of("OrderEvent.PAY"))
|
||||
.constraint("\"ORDER\".equalsIgnoreCase(type)")
|
||||
.build())
|
||||
.build();
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("com.example.OrderStateMachineConfig")
|
||||
.transitions(List.of(payT))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
|
||||
enricher.enrich(result, null, null);
|
||||
|
||||
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldLinkTransitionByEventOnly() {
|
||||
Transition t1 = new Transition();
|
||||
@@ -543,4 +594,30 @@ class TransitionLinkerEnricherTest {
|
||||
enricher.enrich(resultUser, null, null);
|
||||
assertThat(resultUser.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectChainWhenDispatcherDomainConstraintDoesNotMatchMachine() {
|
||||
Transition payT = new Transition();
|
||||
payT.setSourceStates(List.of(State.of("NEW", "OrderState.NEW")));
|
||||
payT.setTargetStates(List.of(State.of("PAID", "OrderState.PAID")));
|
||||
payT.setEvent(Event.of("OrderEvent.PAY", "com.example.OrderEvent.PAY"));
|
||||
|
||||
CallChain chain = CallChain.builder()
|
||||
.triggerPoint(TriggerPoint.builder()
|
||||
.event("OrderEvent.PAY")
|
||||
.polymorphicEvents(List.of("OrderEvent.PAY"))
|
||||
.constraint("\"PAYMENT\".equalsIgnoreCase(type)")
|
||||
.build())
|
||||
.build();
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("com.example.OrderStateMachineConfig")
|
||||
.transitions(List.of(payT))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
|
||||
enricher.enrich(result, null, null);
|
||||
|
||||
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNullOrEmpty();
|
||||
}
|
||||
}
|
||||
@@ -142,14 +142,37 @@ class StrictFqnMatchingEngineTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldMatchValueOfWithCorrectEnum() {
|
||||
void shouldMatchValueOfWithCorrectEnumWhenPolymorphicEventIsResolved() {
|
||||
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
|
||||
TriggerPoint triggerPoint = TriggerPoint.builder()
|
||||
.event("OrderEvents.valueOf(str)")
|
||||
.eventTypeFqn("com.example.OrderEvents")
|
||||
.polymorphicEvents(List.of("OrderEvents.PAY"))
|
||||
.build();
|
||||
|
||||
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotMatchUnresolvedValueOfWithoutPolymorphicEvents() {
|
||||
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
|
||||
TriggerPoint triggerPoint = TriggerPoint.builder()
|
||||
.event("OrderEvents.valueOf(str)")
|
||||
.eventTypeFqn("com.example.OrderEvents")
|
||||
.build();
|
||||
|
||||
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
|
||||
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotMatchExternalTriggerWithoutPolymorphicEvents() {
|
||||
Event smEvent = Event.of("PAY", "com.example.OrderEvents.PAY");
|
||||
TriggerPoint triggerPoint = TriggerPoint.builder()
|
||||
.event("event")
|
||||
.external(true)
|
||||
.build();
|
||||
|
||||
assertThat(engine.matches(smEvent, triggerPoint)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class BooleanConstraintEvaluatorTest {
|
||||
|
||||
@Test
|
||||
void shouldEvaluateLiteralEqualsAgainstMachineDomain() {
|
||||
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
|
||||
"\"ORDER\".equals(domain)", "OrderStateMachineConfiguration")).isTrue();
|
||||
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
|
||||
"\"PAYMENT\".equals(domain)", "OrderStateMachineConfiguration")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldEvaluateReverseEqualsAndInequality() {
|
||||
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
|
||||
"domain.equals(\"ORDER\")", "OrderStateMachineConfiguration")).isTrue();
|
||||
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
|
||||
"domain != \"ORDER\"", "OrderStateMachineConfiguration")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldEvaluateStringEqualsForKnownLiterals() {
|
||||
assertThat(BooleanConstraintEvaluator.evaluateStringEquals("ORDER", "order", true)).isTrue();
|
||||
assertThat(BooleanConstraintEvaluator.evaluateStringEquals("ORDER", "order", false)).isFalse();
|
||||
assertThat(BooleanConstraintEvaluator.evaluateStringEquals(null, "ORDER", true)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldEvaluateCombinedBooleanLogic() {
|
||||
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
|
||||
"\"ORDER\".equals(type) && \"ORDER\".equals(domain)", "OrderStateMachineConfiguration")).isTrue();
|
||||
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
|
||||
"\"ORDER\".equals(type) && \"PAYMENT\".equals(domain)", "OrderStateMachineConfiguration")).isFalse();
|
||||
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
|
||||
"\"PAYMENT\".equals(type) || \"ORDER\".equals(type)", "OrderStateMachineConfiguration")).isTrue();
|
||||
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
|
||||
"\"PAYMENT\".equals(type) || \"DOCUMENT\".equals(type)", "OrderStateMachineConfiguration")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldEvaluateEqualsIgnoreCaseAgainstMachineDomain() {
|
||||
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
|
||||
"\"order\".equalsIgnoreCase(type)", "OrderStateMachineConfiguration")).isTrue();
|
||||
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
|
||||
"\"payment\".equalsIgnoreCase(type)", "OrderStateMachineConfiguration")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldEvaluateNegatedDomainChecks() {
|
||||
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
|
||||
"!(\"PAYMENT\".equals(type))", "OrderStateMachineConfiguration")).isTrue();
|
||||
assertThat(BooleanConstraintEvaluator.isCompatibleWithMachineDomain(
|
||||
"!(\"ORDER\".equals(type))", "OrderStateMachineConfiguration")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void evaluateBooleanExpressionShouldHandleLogicalOperators() {
|
||||
assertThat(BooleanConstraintEvaluator.evaluateBooleanExpression("true && false")).isFalse();
|
||||
assertThat(BooleanConstraintEvaluator.evaluateBooleanExpression("true || false")).isTrue();
|
||||
assertThat(BooleanConstraintEvaluator.evaluateBooleanExpression("!(false)")).isTrue();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.resolver;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.service.ConstantExtractor;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -8,6 +9,8 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@@ -524,4 +527,166 @@ public class ConstantResolverTest {
|
||||
String result2 = resolver.resolve(mi2, context);
|
||||
assertThat(result2).isEqualTo("com.example.OrderEvent.SHIPPED");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveMapOfGetWithLiteralKey(@TempDir Path tempDir) throws IOException {
|
||||
Path dir = tempDir.resolve("com/example");
|
||||
Files.createDirectories(dir);
|
||||
Files.writeString(dir.resolve("Routes.java"),
|
||||
"package com.example;\n" +
|
||||
"public class Routes {\n" +
|
||||
" public static final String PAY = \"OrderEvents.PAY\";\n" +
|
||||
" public static final String SHIP = \"OrderEvents.SHIP\";\n" +
|
||||
" public String lookup() {\n" +
|
||||
" return java.util.Map.of(\"order.pay\", PAY, \"order.ship\", SHIP).get(\"order.pay\");\n" +
|
||||
" }\n" +
|
||||
"}");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
TypeDeclaration td = context.getTypeDeclaration("com.example.Routes");
|
||||
MethodDeclaration lookup = td.getMethods()[0];
|
||||
ReturnStatement rs = (ReturnStatement) lookup.getBody().statements().get(0);
|
||||
|
||||
ConstantResolver resolver = new ConstantResolver();
|
||||
String result = resolver.resolve(rs.getExpression(), context);
|
||||
|
||||
assertThat(result).isEqualTo("OrderEvents.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveStaticMapFieldGetWithLiteralKey(@TempDir Path tempDir) throws IOException {
|
||||
Path dir = tempDir.resolve("com/example");
|
||||
Files.createDirectories(dir);
|
||||
Files.writeString(dir.resolve("Dispatcher.java"),
|
||||
"package com.example;\n" +
|
||||
"import java.util.Map;\n" +
|
||||
"public class Dispatcher {\n" +
|
||||
" private static final Map<String, String> ROUTES = Map.of(\n" +
|
||||
" \"order.pay\", \"OrderEvents.PAY\",\n" +
|
||||
" \"order.ship\", \"OrderEvents.SHIP\"\n" +
|
||||
" );\n" +
|
||||
" public String route() {\n" +
|
||||
" return ROUTES.get(\"order.ship\");\n" +
|
||||
" }\n" +
|
||||
"}");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
TypeDeclaration td = context.getTypeDeclaration("com.example.Dispatcher");
|
||||
MethodDeclaration route = td.getMethods()[0];
|
||||
ReturnStatement rs = (ReturnStatement) route.getBody().statements().get(0);
|
||||
|
||||
ConstantResolver resolver = new ConstantResolver();
|
||||
String result = resolver.resolve(rs.getExpression(), context);
|
||||
|
||||
assertThat(result).isEqualTo("OrderEvents.SHIP");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveLiteralStringTransforms(@TempDir Path tempDir) throws IOException {
|
||||
Path dir = tempDir.resolve("com/example");
|
||||
Files.createDirectories(dir);
|
||||
Files.writeString(dir.resolve("OrderEvent.java"),
|
||||
"package com.example;\n" +
|
||||
"public enum OrderEvent { PAY, SHIP }\n");
|
||||
Files.writeString(dir.resolve("Caller.java"),
|
||||
"package com.example;\n" +
|
||||
"public class Caller {\n" +
|
||||
" public OrderEvent fromLiteral() {\n" +
|
||||
" return OrderEvent.valueOf(\"pay\".toUpperCase());\n" +
|
||||
" }\n" +
|
||||
" public String trimmed() {\n" +
|
||||
" return \" pay \".trim().toUpperCase();\n" +
|
||||
" }\n" +
|
||||
"}");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
TypeDeclaration td = context.getTypeDeclaration("com.example.Caller");
|
||||
MethodDeclaration fromLiteral = td.getMethods()[0];
|
||||
MethodDeclaration trimmed = td.getMethods()[1];
|
||||
ReturnStatement rs1 = (ReturnStatement) fromLiteral.getBody().statements().get(0);
|
||||
ReturnStatement rs2 = (ReturnStatement) trimmed.getBody().statements().get(0);
|
||||
|
||||
ConstantResolver resolver = new ConstantResolver();
|
||||
|
||||
assertThat(resolver.resolve(rs1.getExpression(), context)).isEqualTo("com.example.OrderEvent.PAY");
|
||||
assertThat(resolver.resolve(rs2.getExpression(), context)).isEqualTo("PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveStaticFieldUsingCallPathScope(@TempDir Path tempDir) throws IOException {
|
||||
Path dir = tempDir.resolve("com/example");
|
||||
Files.createDirectories(dir);
|
||||
Files.writeString(dir.resolve("ApiController.java"),
|
||||
"package com.example;\n" +
|
||||
"import java.util.Map;\n" +
|
||||
"public class ApiController {\n" +
|
||||
" private static final Map<String, OrderEvent> ROUTES = Map.of(\n" +
|
||||
" \"order.pay\", OrderEvent.PAY,\n" +
|
||||
" \"order.ship\", OrderEvent.SHIP\n" +
|
||||
" );\n" +
|
||||
" public void dispatch(String commandKey) {\n" +
|
||||
" OrderEvent event = ROUTES.get(commandKey);\n" +
|
||||
" }\n" +
|
||||
"}\n" +
|
||||
"enum OrderEvent { PAY, SHIP }\n");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
ASTParser parser = ASTParser.newParser(AST.JLS17);
|
||||
parser.setKind(ASTParser.K_COMPILATION_UNIT);
|
||||
parser.setSource(("class A { Object o = ROUTES.get(commandKey); }").toCharArray());
|
||||
CompilationUnit cu = (CompilationUnit) parser.createAST(null);
|
||||
FieldDeclaration fd = (FieldDeclaration) ((TypeDeclaration) cu.types().get(0)).bodyDeclarations().get(0);
|
||||
MethodInvocation getCall = (MethodInvocation) ((VariableDeclarationFragment) fd.fragments().get(0)).getInitializer();
|
||||
|
||||
ConstantExtractor extractor = new ConstantExtractor(context, new ConstantResolver(), mi -> null);
|
||||
click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel.setCurrentPath(
|
||||
List.of("com.example.ApiController.dispatch"));
|
||||
try {
|
||||
ConstantResolver resolver = new ConstantResolver();
|
||||
SimpleName routesName = (SimpleName) getCall.getExpression();
|
||||
assertThat(resolver.resolve(routesName, context))
|
||||
.as("static ROUTES field should resolve to encoded map")
|
||||
.startsWith("MAP:");
|
||||
|
||||
List<String> constants = new ArrayList<>();
|
||||
extractor.extractConstantsFromExpression(getCall, constants);
|
||||
assertThat(constants).contains("OrderEvent.PAY", "OrderEvent.SHIP");
|
||||
} finally {
|
||||
click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel.clearCurrentPath();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveArrayIndexWithLiteralMath(@TempDir Path tempDir) throws IOException {
|
||||
Path dir = tempDir.resolve("com/example");
|
||||
Files.createDirectories(dir);
|
||||
Files.writeString(dir.resolve("Table.java"),
|
||||
"package com.example;\n" +
|
||||
"public class Table {\n" +
|
||||
" private static final String[] EVENTS = {\"OrderEvents.PAY\", \"OrderEvents.SHIP\", \"OrderEvents.CANCEL\"};\n" +
|
||||
" public String pick() {\n" +
|
||||
" return EVENTS[1 + 1];\n" +
|
||||
" }\n" +
|
||||
"}");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
TypeDeclaration td = context.getTypeDeclaration("com.example.Table");
|
||||
MethodDeclaration pick = td.getMethods()[0];
|
||||
ReturnStatement rs = (ReturnStatement) pick.getBody().statements().get(0);
|
||||
|
||||
ConstantResolver resolver = new ConstantResolver();
|
||||
String result = resolver.resolve(rs.getExpression(), context);
|
||||
|
||||
assertThat(result).isEqualTo("OrderEvents.CANCEL");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.CallChainEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.EntryPointEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.TriggerEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||
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.app.StateMachineAggregator;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
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 java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Guards the shared analysis caches used when one codebase produces many inherited state machines.
|
||||
*
|
||||
* <p>Typical repo shape: one abstract config, several abstract branches, many concrete configs.
|
||||
* The exporter scans once, caches the call graph, then enriches each machine from the same context.
|
||||
*/
|
||||
class AnalysisCacheCorrectnessTest {
|
||||
|
||||
private static Path findProjectRoot() {
|
||||
Path current = Path.of(".").toAbsolutePath();
|
||||
while (current != null && !Files.exists(current.resolve("settings.gradle"))) {
|
||||
current = current.getParent();
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* The call graph is expensive to build; it must be stored on {@link CodebaseContext} and reused
|
||||
* by subsequent engines pointing at the same scan result.
|
||||
*/
|
||||
@Test
|
||||
void heuristicCallGraphShouldBeCachedOnContext(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
class A { void go() { new B().run("PAY"); } }
|
||||
class B { void run(String s) { new C().fire(com.example.E.PAY); } }
|
||||
enum E { PAY, SHIP }
|
||||
class C { void fire(E e) {} }
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("App.java"), source);
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
HeuristicCallGraphEngine firstEngine = new HeuristicCallGraphEngine(context);
|
||||
firstEngine.findChains(
|
||||
List.of(EntryPoint.builder().className("com.example.A").methodName("go").build()),
|
||||
List.of(TriggerPoint.builder().className("com.example.C").methodName("fire").event("e").build()));
|
||||
|
||||
assertThat(context.getCache()).containsKey("heuristicCallGraph");
|
||||
Object cachedGraph = context.getCache().get("heuristicCallGraph");
|
||||
|
||||
HeuristicCallGraphEngine secondEngine = new HeuristicCallGraphEngine(context);
|
||||
secondEngine.findChains(
|
||||
List.of(EntryPoint.builder().className("com.example.A").methodName("go").build()),
|
||||
List.of(TriggerPoint.builder().className("com.example.C").methodName("fire").event("e").build()));
|
||||
|
||||
assertThat(context.getCache().get("heuristicCallGraph")).isSameAs(cachedGraph);
|
||||
}
|
||||
|
||||
/**
|
||||
* Two consecutive {@link CallGraphEngine#findChains} calls on a cached graph must agree.
|
||||
*/
|
||||
@Test
|
||||
void repeatedCallChainResolutionShouldBeDeterministic(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
class Controller {
|
||||
Dispatcher dispatcher;
|
||||
public void pay() { dispatcher.dispatch("ORDER", "PAY"); }
|
||||
}
|
||||
class Dispatcher {
|
||||
public void dispatch(String type, String action) {
|
||||
StateMachine sm = new StateMachine();
|
||||
if ("ORDER".equals(type)) {
|
||||
OrderEvent ev = OrderEvent.valueOf(action);
|
||||
sm.sendEvent(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP }
|
||||
class StateMachine { void sendEvent(OrderEvent e) {} }
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("App.java"), source);
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
EntryPoint pay = EntryPoint.builder().className("com.example.Controller").methodName("pay").build();
|
||||
TriggerPoint trigger = TriggerPoint.builder().className("com.example.StateMachine").methodName("sendEvent").event("e").build();
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
CallChain first = engine.findChains(List.of(pay), List.of(trigger)).get(0);
|
||||
CallChain second = engine.findChains(List.of(pay), List.of(trigger)).get(0);
|
||||
|
||||
assertThat(second.getTriggerPoint().getPolymorphicEvents())
|
||||
.isEqualTo(first.getTriggerPoint().getPolymorphicEvents());
|
||||
assertThat(second.getMethodChain()).isEqualTo(first.getMethodChain());
|
||||
}
|
||||
|
||||
/**
|
||||
* A single {@link TransitionLinkerEnricher} instance is reused across machines during export.
|
||||
* Its internal memoization must not let machine B overwrite machine A's matched transitions.
|
||||
*/
|
||||
@Test
|
||||
void sharedTransitionLinkerShouldNotCrossContaminateMachineResults(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
class OrderController {
|
||||
SharedBus bus;
|
||||
void pay() { bus.orderPay(); }
|
||||
}
|
||||
class ShipmentController {
|
||||
SharedBus bus;
|
||||
void go() { bus.shipmentDispatch(); }
|
||||
}
|
||||
class SharedBus {
|
||||
public void orderPay() {
|
||||
OrderMachine sm = new OrderMachine();
|
||||
sm.sendEvent(OrderEvent.PAY);
|
||||
}
|
||||
public void shipmentDispatch() {
|
||||
ShipmentMachine sm = new ShipmentMachine();
|
||||
sm.sendEvent(ShipmentEvent.DISPATCH);
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY }
|
||||
enum ShipmentEvent { DISPATCH }
|
||||
class OrderMachine { void sendEvent(OrderEvent e) {} }
|
||||
class ShipmentMachine { void sendEvent(ShipmentEvent e) {} }
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("App.java"), source);
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
|
||||
CallChain orderChain = engine.findChains(
|
||||
List.of(EntryPoint.builder().className("com.example.OrderController").methodName("pay").build()),
|
||||
List.of(TriggerPoint.builder().className("com.example.OrderMachine").methodName("sendEvent").event("e").build())
|
||||
).get(0);
|
||||
CallChain shipmentChain = engine.findChains(
|
||||
List.of(EntryPoint.builder().className("com.example.ShipmentController").methodName("go").build()),
|
||||
List.of(TriggerPoint.builder().className("com.example.ShipmentMachine").methodName("sendEvent").event("e").build())
|
||||
).get(0);
|
||||
|
||||
TransitionLinkerEnricher sharedLinker = new TransitionLinkerEnricher();
|
||||
|
||||
AnalysisResult orderMachine = analysisWithChain(
|
||||
"com.example.OrderStateMachineConfig",
|
||||
orderChain,
|
||||
CentralDispatcherTestSupport.transition("NEW", "PAID", "OrderEvent.PAY"));
|
||||
AnalysisResult shipmentMachine = analysisWithChain(
|
||||
"com.example.ShipmentStateMachineConfig",
|
||||
shipmentChain,
|
||||
CentralDispatcherTestSupport.transition("READY", "IN_TRANSIT", "ShipmentEvent.DISPATCH"));
|
||||
|
||||
sharedLinker.enrich(orderMachine, null, null);
|
||||
sharedLinker.enrich(shipmentMachine, null, null);
|
||||
|
||||
assertThat(orderMachine.getMetadata().getCallChains().get(0).getMatchedTransitions())
|
||||
.extracting("event")
|
||||
.containsExactly("OrderEvent.PAY");
|
||||
assertThat(shipmentMachine.getMetadata().getCallChains().get(0).getMatchedTransitions())
|
||||
.extracting("event")
|
||||
.containsExactly("ShipmentEvent.DISPATCH");
|
||||
|
||||
// Re-enrich order machine after shipment machine was processed; result must stay stable.
|
||||
sharedLinker.enrich(orderMachine, null, null);
|
||||
assertThat(orderMachine.getMetadata().getCallChains().get(0).getMatchedTransitions())
|
||||
.extracting("event")
|
||||
.containsExactly("OrderEvent.PAY");
|
||||
}
|
||||
|
||||
/**
|
||||
* Exercises a real inherited hierarchy (F1/F2/G1/G2) from one scan + one intelligence provider,
|
||||
* mirroring repos where many concrete configs extend shared abstract layers.
|
||||
*/
|
||||
@Test
|
||||
void inheritedHierarchyShouldProduceStableDistinctMachineSnapshots() throws Exception {
|
||||
Path sampleRoot = findProjectRoot().resolve("state_machines/inheritance_extra_functions3_state_machine");
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setProjectRoot(sampleRoot);
|
||||
context.setSourcepath(List.of(sampleRoot.resolve("src/main/java").toString()));
|
||||
context.setResolveBindings(true);
|
||||
context.scan(sampleRoot);
|
||||
|
||||
JdtIntelligenceProvider intelligence = new JdtIntelligenceProvider(context, sampleRoot);
|
||||
EnrichmentService enrichmentService = new EnrichmentService(List.of(
|
||||
new TriggerEnricher(),
|
||||
new EntryPointEnricher(),
|
||||
new CallChainEnricher(),
|
||||
new TransitionLinkerEnricher()));
|
||||
|
||||
List<String> configNames = List.of(
|
||||
"F1StateMachineConfiguration",
|
||||
"F2StateMachineConfiguration",
|
||||
"G1StateMachineConfiguration",
|
||||
"G2StateMachineConfiguration");
|
||||
|
||||
Map<String, Snapshot> firstPass = enrichAllConfigs(context, intelligence, enrichmentService, configNames);
|
||||
Map<String, Snapshot> secondPass = enrichAllConfigs(context, intelligence, enrichmentService, configNames);
|
||||
|
||||
assertThat(secondPass).isEqualTo(firstPass);
|
||||
assertThat(firstPass).hasSize(4);
|
||||
|
||||
List<Snapshot> snapshots = new ArrayList<>(firstPass.values());
|
||||
assertThat(snapshots.get(0).transitionCount()).isNotEqualTo(snapshots.get(2).transitionCount());
|
||||
assertThat(snapshots.get(0).eventNames()).isNotEqualTo(snapshots.get(2).eventNames());
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthetic repo shape: 1 root abstract → 3 branch abstracts → 4 concrete configs each (12 machines).
|
||||
* One scan and one intelligence provider must yield stable, distinct snapshots on repeat enrichment.
|
||||
*
|
||||
* <pre>
|
||||
* RootAbstractSmConfig
|
||||
* ├── BranchAAbstractSmConfig → BranchA1..A4SmConfig
|
||||
* ├── BranchBAbstractSmConfig → BranchB1..B4SmConfig
|
||||
* └── BranchCAbstractSmConfig → BranchC1..C4SmConfig
|
||||
* </pre>
|
||||
*/
|
||||
@Test
|
||||
void twelveConfigSyntheticHierarchyShouldProduceStableDistinctSnapshots(@TempDir Path tempDir) throws Exception {
|
||||
TwelveConfigHierarchyTestSupport.writeSources(tempDir);
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.setProjectRoot(tempDir);
|
||||
context.setResolveBindings(true);
|
||||
context.scan(tempDir);
|
||||
|
||||
JdtIntelligenceProvider intelligence = new JdtIntelligenceProvider(context, tempDir);
|
||||
EnrichmentService enrichmentService = new EnrichmentService(List.of(
|
||||
new TriggerEnricher(),
|
||||
new EntryPointEnricher(),
|
||||
new CallChainEnricher(),
|
||||
new TransitionLinkerEnricher()));
|
||||
|
||||
List<String> configNames = TwelveConfigHierarchyTestSupport.concreteConfigNames();
|
||||
|
||||
Map<String, Snapshot> firstPass = enrichAllConfigs(context, intelligence, enrichmentService, configNames);
|
||||
Object cachedGraphAfterFirstPass = context.getCache().get("jdtCallGraph");
|
||||
|
||||
Map<String, Snapshot> secondPass = enrichAllConfigs(context, intelligence, enrichmentService, configNames);
|
||||
|
||||
assertThat(firstPass).hasSize(12);
|
||||
assertThat(secondPass).isEqualTo(firstPass);
|
||||
assertThat(new ArrayList<>(firstPass.values())).doesNotHaveDuplicates();
|
||||
|
||||
// Each concrete config inherits root + branch + its own transition.
|
||||
assertThat(firstPass.get("com.example.hierarchy.BranchA1SmConfig").transitionCount()).isEqualTo(3);
|
||||
assertThat(firstPass.get("com.example.hierarchy.BranchA1SmConfig").eventNames())
|
||||
.containsExactlyInAnyOrderElementsOf(TwelveConfigHierarchyTestSupport.expectedEventsForConcrete("A", 1));
|
||||
assertThat(firstPass.get("com.example.hierarchy.BranchC4SmConfig").eventNames())
|
||||
.containsExactlyInAnyOrderElementsOf(TwelveConfigHierarchyTestSupport.expectedEventsForConcrete("C", 4));
|
||||
|
||||
// Branch A and branch C machines must differ even when they share the same index.
|
||||
assertThat(firstPass.get("com.example.hierarchy.BranchA2SmConfig"))
|
||||
.isNotEqualTo(firstPass.get("com.example.hierarchy.BranchC2SmConfig"));
|
||||
|
||||
// Call-graph cache must survive enriching all twelve configs (same graph instance on second pass).
|
||||
if (cachedGraphAfterFirstPass != null) {
|
||||
assertThat(context.getCache().get("jdtCallGraph")).isSameAs(cachedGraphAfterFirstPass);
|
||||
}
|
||||
}
|
||||
|
||||
private static AnalysisResult analysisWithChain(String machineName, CallChain chain, Transition transition) {
|
||||
return AnalysisResult.builder()
|
||||
.name(machineName)
|
||||
.transitions(List.of(transition))
|
||||
.metadata(click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata.builder()
|
||||
.callChains(List.of(chain))
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
private static Map<String, Snapshot> enrichAllConfigs(
|
||||
CodebaseContext context,
|
||||
JdtIntelligenceProvider intelligence,
|
||||
EnrichmentService enrichmentService,
|
||||
List<String> configNames) {
|
||||
Map<String, Snapshot> snapshots = new LinkedHashMap<>();
|
||||
StateMachineAggregator aggregator = new StateMachineAggregator(context);
|
||||
|
||||
for (String configName : configNames) {
|
||||
TypeDeclaration configType = context.getTypeDeclaration(configName);
|
||||
if (configType == null) {
|
||||
configType = context.getTypeDeclarations().stream()
|
||||
.filter(td -> td.getName().getIdentifier().equals(configName))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
assertThat(configType).as("config type %s", configName).isNotNull();
|
||||
|
||||
java.util.List<Transition> transitions = aggregator.aggregateTransitions(configType);
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name(configName)
|
||||
.transitions(transitions)
|
||||
.build();
|
||||
|
||||
enrichmentService.enrich(result, context, intelligence);
|
||||
snapshots.put(context.getFqn(configType), Snapshot.from(result));
|
||||
}
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
private record Snapshot(int transitionCount, List<String> eventNames) {
|
||||
static Snapshot from(AnalysisResult result) {
|
||||
List<String> events = result.getTransitions().stream()
|
||||
.map(t -> t.getEvent() != null ? t.getEvent().rawName() : null)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.map(Snapshot::stripLiteralQuotes)
|
||||
.sorted()
|
||||
.collect(Collectors.toList());
|
||||
return new Snapshot(result.getTransitions().size(), events);
|
||||
}
|
||||
|
||||
private static String stripLiteralQuotes(String name) {
|
||||
if (name.length() >= 2 && name.startsWith("\"") && name.endsWith("\"")) {
|
||||
return name.substring(1, name.length() - 1);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Documents how traced call-path expressions are classified: map lookups vs bean getter chains.
|
||||
*/
|
||||
class CallGraphExpressionShapeTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
private HeuristicCallGraphEngine engine;
|
||||
private static final String SCOPE = "com.example.Service.handle";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
String source = """
|
||||
package com.example;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public class Service {
|
||||
private static final Map<String, String> ROUTES = Map.of("k", "v");
|
||||
private EventWrapper eventWrapper;
|
||||
private Supplier<String> eventSupplier;
|
||||
|
||||
public void handle(String commandKey) {
|
||||
ROUTES.get(commandKey);
|
||||
Routes.ROUTES.get(commandKey);
|
||||
eventWrapper.getEvent();
|
||||
eventSupplier.get();
|
||||
}
|
||||
}
|
||||
|
||||
class Routes {
|
||||
static final Map<String, String> ROUTES = Map.of("k", "v");
|
||||
}
|
||||
|
||||
class EventWrapper {
|
||||
String getEvent() { return null; }
|
||||
}
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("Service.java"), source);
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
engine = new HeuristicCallGraphEngine(context);
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractMethodSuffixShouldKeepMapLookupIntact() {
|
||||
String[] parts = engine.extractMethodSuffix("ROUTES.get(commandKey)", "", SCOPE);
|
||||
assertThat(parts[0]).isEqualTo("ROUTES.get(commandKey)");
|
||||
assertThat(parts[1]).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractMethodSuffixShouldKeepQualifiedMapLookupIntact() {
|
||||
String[] parts = engine.extractMethodSuffix("Routes.ROUTES.get(commandKey)", "", SCOPE);
|
||||
assertThat(parts[0]).isEqualTo("Routes.ROUTES.get(commandKey)");
|
||||
assertThat(parts[1]).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractMethodSuffixShouldSplitBeanGetterChains() {
|
||||
String[] parts = engine.extractMethodSuffix("eventWrapper.getEvent()", "", SCOPE);
|
||||
assertThat(parts[0]).isEqualTo("eventWrapper");
|
||||
assertThat(parts[1]).isEqualTo(".getEvent()");
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractMethodSuffixShouldSplitNoArgGetCalls() {
|
||||
String[] parts = engine.extractMethodSuffix("eventSupplier.get()", "", SCOPE);
|
||||
assertThat(parts[0]).isEqualTo("eventSupplier");
|
||||
assertThat(parts[1]).isEqualTo(".get()");
|
||||
}
|
||||
|
||||
@Test
|
||||
void extractMethodSuffixShouldSplitChainedBeanGetters() {
|
||||
String[] parts = engine.extractMethodSuffix("this.eventWrapper.getEvent().name()", ".name()", SCOPE);
|
||||
assertThat(parts[0]).isEqualTo("this.eventWrapper");
|
||||
assertThat(parts[1]).startsWith(".getEvent()");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.MatchedTransition;
|
||||
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 java.util.List;
|
||||
|
||||
import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.*;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Readable unit tests for central-dispatcher endpoint resolution.
|
||||
*
|
||||
* <p>Each test embeds a miniature codebase as a string so humans and agents can see the full
|
||||
* REST → dispatcher → sendEvent pipeline without opening another module.
|
||||
*
|
||||
* <p>DTO setter/getter propagation is covered by {@code HeuristicCallGraphEngineTypeTest}.
|
||||
* Cross-hop routing helpers that return computed strings are covered by literal-forwarding tests above.
|
||||
*/
|
||||
class CentralDispatcherResolutionTest {
|
||||
|
||||
private static final String CONTROLLER = "com.example.ApiController";
|
||||
private static final String STATE_MACHINE = "com.example.StateMachine";
|
||||
private static final String MACHINE_CONFIG = "com.example.OrderStateMachineConfig";
|
||||
|
||||
/**
|
||||
* REST endpoint passes two string literals into a shared dispatcher.
|
||||
* The dispatcher selects the machine branch, then resolves the enum constant.
|
||||
*
|
||||
* <pre>
|
||||
* payOrder() → dispatch("ORDER", "PAY") → OrderEvent.PAY
|
||||
* shipOrder() → dispatch("ORDER", "SHIP") → OrderEvent.SHIP
|
||||
* </pre>
|
||||
*/
|
||||
@Test
|
||||
void twoFieldCentralDispatcherShouldResolveDistinctEventsPerEndpoint(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class ApiController {
|
||||
CentralDispatcher dispatcher;
|
||||
public void payOrder() { dispatcher.dispatch("ORDER", "PAY"); }
|
||||
public void shipOrder() { dispatcher.dispatch("ORDER", "SHIP"); }
|
||||
}
|
||||
class CentralDispatcher {
|
||||
public void dispatch(String domain, String action) {
|
||||
StateMachine sm = new StateMachine();
|
||||
if ("ORDER".equals(domain)) {
|
||||
OrderEvent ev = OrderEvent.valueOf(action);
|
||||
sm.sendEvent(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP, CANCEL }
|
||||
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||
class OrderStateMachineConfig {}
|
||||
""";
|
||||
CodebaseContext context = scanSource(source, tempDir);
|
||||
|
||||
CallChain payChain = resolveChain(context, CONTROLLER, "payOrder", STATE_MACHINE);
|
||||
CallChain shipChain = resolveChain(context, CONTROLLER, "shipOrder", STATE_MACHINE);
|
||||
|
||||
assertPolyEvents(payChain, "OrderEvent.PAY");
|
||||
assertPolyEvents(shipChain, "OrderEvent.SHIP");
|
||||
|
||||
MatchedTransition pay = linkEndpointToTransition(
|
||||
payChain,
|
||||
MACHINE_CONFIG,
|
||||
transition("NEW", "PAID", "OrderEvent.PAY"),
|
||||
transition("PAID", "SHIPPED", "OrderEvent.SHIP"));
|
||||
MatchedTransition ship = linkEndpointToTransition(
|
||||
shipChain,
|
||||
MACHINE_CONFIG,
|
||||
transition("NEW", "PAID", "OrderEvent.PAY"),
|
||||
transition("PAID", "SHIPPED", "OrderEvent.SHIP"));
|
||||
|
||||
assertLinkedEvent(pay, "OrderEvent.PAY");
|
||||
assertLinkedEvent(ship, "OrderEvent.SHIP");
|
||||
}
|
||||
|
||||
/**
|
||||
* Three request dimensions (domain, action, version) are passed as literals and folded
|
||||
* together by a routing helper before the central dispatcher fires the SM event.
|
||||
*
|
||||
* <pre>
|
||||
* payV2() → dispatch("ORDER", "PAY", "v2") → OrderEvent.valueOf(action)
|
||||
* shipV2() → dispatch("ORDER", "SHIP", "v2") → OrderEvent.valueOf(action)
|
||||
* </pre>
|
||||
*
|
||||
* <p>The third field is forwarded for API shape parity; routing uses domain + action literals.
|
||||
*/
|
||||
@Test
|
||||
void threeFieldCentralDispatcherShouldResolveFromCombinedRoutingKey(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class ApiController {
|
||||
CentralDispatcher dispatcher;
|
||||
public void payV2() { dispatcher.dispatch("ORDER", "PAY", "v2"); }
|
||||
public void shipV2() { dispatcher.dispatch("ORDER", "SHIP", "v2"); }
|
||||
}
|
||||
class CentralDispatcher {
|
||||
public void dispatch(String domain, String action, String version) {
|
||||
StateMachine sm = new StateMachine();
|
||||
if ("ORDER".equals(domain)) {
|
||||
OrderEvent ev = OrderEvent.valueOf(action);
|
||||
sm.sendEvent(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP, CANCEL }
|
||||
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||
class OrderStateMachineConfig {}
|
||||
""";
|
||||
CodebaseContext context = scanSource(source, tempDir);
|
||||
|
||||
CallChain payChain = resolveChain(context, CONTROLLER, "payV2", STATE_MACHINE);
|
||||
CallChain shipChain = resolveChain(context, CONTROLLER, "shipV2", STATE_MACHINE);
|
||||
|
||||
assertPolyEvents(payChain, "OrderEvent.PAY");
|
||||
assertPolyEvents(shipChain, "OrderEvent.SHIP");
|
||||
|
||||
MatchedTransition pay = linkEndpointToTransition(
|
||||
payChain, MACHINE_CONFIG,
|
||||
transition("NEW", "PAID", "OrderEvent.PAY"),
|
||||
transition("PAID", "SHIPPED", "OrderEvent.SHIP"));
|
||||
MatchedTransition ship = linkEndpointToTransition(
|
||||
shipChain, MACHINE_CONFIG,
|
||||
transition("NEW", "PAID", "OrderEvent.PAY"),
|
||||
transition("PAID", "SHIPPED", "OrderEvent.SHIP"));
|
||||
|
||||
assertLinkedEvent(pay, "OrderEvent.PAY");
|
||||
assertLinkedEvent(ship, "OrderEvent.SHIP");
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway receives three separate fields from the endpoint and forwards them to the
|
||||
* central dispatcher (same routing core as the three-field test, without DTO indirection).
|
||||
*/
|
||||
@Test
|
||||
void gatewayShouldForwardThreeRequestFieldsToCentralDispatcher(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class ApiController {
|
||||
Gateway gateway;
|
||||
public void submit() {
|
||||
gateway.handle("ORDER", "SUBMIT", "v2");
|
||||
}
|
||||
public void approve() {
|
||||
gateway.handle("ORDER", "APPROVE", "v2");
|
||||
}
|
||||
}
|
||||
class Gateway {
|
||||
CentralDispatcher dispatcher;
|
||||
void handle(String domain, String action, String version) {
|
||||
dispatcher.dispatch(domain, action, version);
|
||||
}
|
||||
}
|
||||
class CentralDispatcher {
|
||||
public void dispatch(String domain, String action, String version) {
|
||||
StateMachine sm = new StateMachine();
|
||||
if ("ORDER".equals(domain)) {
|
||||
OrderEvent ev = OrderEvent.valueOf(action);
|
||||
sm.sendEvent(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
enum OrderEvent { SUBMIT, APPROVE, REJECT }
|
||||
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||
class OrderStateMachineConfig {}
|
||||
""";
|
||||
CodebaseContext context = scanSource(source, tempDir);
|
||||
|
||||
CallChain submitChain = resolveChain(context, CONTROLLER, "submit", STATE_MACHINE);
|
||||
CallChain approveChain = resolveChain(context, CONTROLLER, "approve", STATE_MACHINE);
|
||||
|
||||
assertPolyEvents(submitChain, "OrderEvent.SUBMIT");
|
||||
assertPolyEvents(approveChain, "OrderEvent.APPROVE");
|
||||
|
||||
assertLinkedEvent(
|
||||
linkEndpointToTransition(
|
||||
submitChain, MACHINE_CONFIG,
|
||||
transition("DRAFT", "SUBMITTED", "OrderEvent.SUBMIT"),
|
||||
transition("SUBMITTED", "APPROVED", "OrderEvent.APPROVE")),
|
||||
"OrderEvent.SUBMIT");
|
||||
assertLinkedEvent(
|
||||
linkEndpointToTransition(
|
||||
approveChain, MACHINE_CONFIG,
|
||||
transition("DRAFT", "SUBMITTED", "OrderEvent.SUBMIT"),
|
||||
transition("SUBMITTED", "APPROVED", "OrderEvent.APPROVE")),
|
||||
"OrderEvent.APPROVE");
|
||||
}
|
||||
|
||||
/**
|
||||
* Two concrete state-machine configs share one central dispatcher class.
|
||||
* Each endpoint must link only to transitions from its own config, not the sibling machine.
|
||||
*/
|
||||
@Test
|
||||
void sharedCentralDispatcherShouldRouteEndpointsToCorrectMachineConfig(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class OrderApi {
|
||||
SharedDispatcher dispatcher;
|
||||
public void pay() { dispatcher.payOrder(); }
|
||||
}
|
||||
public class ShipmentApi {
|
||||
SharedDispatcher dispatcher;
|
||||
public void dispatchShipment() { dispatcher.dispatchShipment(); }
|
||||
}
|
||||
class SharedDispatcher {
|
||||
public void payOrder() {
|
||||
OrderMachine sm = new OrderMachine();
|
||||
sm.sendEvent(OrderEvent.PAY);
|
||||
}
|
||||
public void dispatchShipment() {
|
||||
ShipmentMachine sm = new ShipmentMachine();
|
||||
sm.sendEvent(ShipmentEvent.DISPATCH);
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP }
|
||||
enum ShipmentEvent { DISPATCH, DELIVER }
|
||||
class OrderMachine { public void sendEvent(OrderEvent e) {} }
|
||||
class ShipmentMachine { public void sendEvent(ShipmentEvent e) {} }
|
||||
class OrderStateMachineConfig {}
|
||||
class ShipmentStateMachineConfig {}
|
||||
""";
|
||||
CodebaseContext context = scanSource(source, tempDir);
|
||||
|
||||
CallChain orderChain = resolveChain(context, "com.example.OrderApi", "pay", "com.example.OrderMachine");
|
||||
CallChain shipmentChain = resolveChain(context, "com.example.ShipmentApi", "dispatchShipment", "com.example.ShipmentMachine");
|
||||
|
||||
assertPolyEvents(orderChain, "OrderEvent.PAY");
|
||||
assertPolyEvents(shipmentChain, "ShipmentEvent.DISPATCH");
|
||||
|
||||
MatchedTransition orderLink = linkEndpointToTransition(
|
||||
orderChain,
|
||||
"com.example.OrderStateMachineConfig",
|
||||
transition("NEW", "PAID", "OrderEvent.PAY"),
|
||||
transition("PAID", "SHIPPED", "OrderEvent.SHIP"));
|
||||
MatchedTransition shipmentLink = linkEndpointToTransition(
|
||||
shipmentChain,
|
||||
"com.example.ShipmentStateMachineConfig",
|
||||
transition("READY", "IN_TRANSIT", "ShipmentEvent.DISPATCH"),
|
||||
transition("IN_TRANSIT", "DELIVERED", "ShipmentEvent.DELIVER"));
|
||||
|
||||
assertLinkedEvent(orderLink, "OrderEvent.PAY");
|
||||
assertLinkedEvent(shipmentLink, "ShipmentEvent.DISPATCH");
|
||||
|
||||
// Negative check: order endpoint must not link against shipment transitions.
|
||||
AnalysisResult wrongMachine = AnalysisResult.builder()
|
||||
.name("com.example.ShipmentStateMachineConfig")
|
||||
.transitions(List.of(transition("READY", "IN_TRANSIT", "ShipmentEvent.DISPATCH")))
|
||||
.metadata(click.kamil.springstatemachineexporter.analysis.model.CodebaseMetadata.builder()
|
||||
.callChains(List.of(orderChain))
|
||||
.build())
|
||||
.build();
|
||||
new TransitionLinkerEnricher().enrich(wrongMachine, null, null);
|
||||
assertThat(wrongMachine.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNull();
|
||||
}
|
||||
|
||||
/**
|
||||
* Intermediate enum hop: endpoint literal → {@code DomainCommand} → concrete SM event.
|
||||
* Uses named dispatcher methods so each endpoint resolves to a single transition.
|
||||
*/
|
||||
@Test
|
||||
void intermediateEnumCentralDispatcherShouldResolveSingleEventPerEndpoint(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class ApiController {
|
||||
CommandGateway gateway;
|
||||
public void pay() { gateway.payOrder(); }
|
||||
public void ship() { gateway.shipOrder(); }
|
||||
}
|
||||
enum DomainCommand { ORDER_PAY, ORDER_SHIP }
|
||||
enum OrderEvent { PAY, SHIP }
|
||||
class CommandGateway {
|
||||
CentralDispatcher central;
|
||||
void payOrder() { central.orderPay(); }
|
||||
void shipOrder() { central.orderShip(); }
|
||||
}
|
||||
class CentralDispatcher {
|
||||
public void orderPay() {
|
||||
StateMachine sm = new StateMachine();
|
||||
sm.sendEvent(OrderEvent.PAY);
|
||||
}
|
||||
public void orderShip() {
|
||||
StateMachine sm = new StateMachine();
|
||||
sm.sendEvent(OrderEvent.SHIP);
|
||||
}
|
||||
}
|
||||
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||
class OrderStateMachineConfig {}
|
||||
""";
|
||||
CodebaseContext context = scanSource(source, tempDir);
|
||||
|
||||
CallChain payChain = resolveChain(context, CONTROLLER, "pay", STATE_MACHINE);
|
||||
CallChain shipChain = resolveChain(context, CONTROLLER, "ship", STATE_MACHINE);
|
||||
|
||||
assertPolyEvents(payChain, "OrderEvent.PAY");
|
||||
assertPolyEvents(shipChain, "OrderEvent.SHIP");
|
||||
|
||||
assertLinkedEvent(
|
||||
linkEndpointToTransition(payChain, MACHINE_CONFIG,
|
||||
transition("NEW", "PAID", "OrderEvent.PAY"),
|
||||
transition("PAID", "SHIPPED", "OrderEvent.SHIP")),
|
||||
"OrderEvent.PAY");
|
||||
assertLinkedEvent(
|
||||
linkEndpointToTransition(shipChain, MACHINE_CONFIG,
|
||||
transition("NEW", "PAID", "OrderEvent.PAY"),
|
||||
transition("PAID", "SHIPPED", "OrderEvent.SHIP")),
|
||||
"OrderEvent.SHIP");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tier 2 — REST → gateway → central dispatcher → {@code richEvent.getType()} → {@code sendEvent}.
|
||||
*
|
||||
* <pre>
|
||||
* pay() → gateway.payWithRichEvent(new PayRichEvent()) → central.orderPay(e) → e.getType() → OrderEvent.PAY
|
||||
* ship() → gateway.shipWithRichEvent(new ShipRichEvent()) → central.orderShip(e) → e.getType() → OrderEvent.SHIP
|
||||
* </pre>
|
||||
*/
|
||||
@Test
|
||||
void richEventCentralDispatcherShouldResolveDistinctEventsPerEndpoint(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class ApiController {
|
||||
CommandGateway gateway;
|
||||
public void pay() { gateway.payWithRichEvent(new PayRichEvent()); }
|
||||
public void ship() { gateway.shipWithRichEvent(new ShipRichEvent()); }
|
||||
}
|
||||
interface RichOrderEvent { OrderEvent getType(); }
|
||||
class PayRichEvent implements RichOrderEvent {
|
||||
public OrderEvent getType() { return OrderEvent.PAY; }
|
||||
}
|
||||
class ShipRichEvent implements RichOrderEvent {
|
||||
public OrderEvent getType() { return OrderEvent.SHIP; }
|
||||
}
|
||||
class CommandGateway {
|
||||
CentralDispatcher central;
|
||||
void payWithRichEvent(RichOrderEvent event) { central.orderPay(event); }
|
||||
void shipWithRichEvent(RichOrderEvent event) { central.orderShip(event); }
|
||||
}
|
||||
class CentralDispatcher {
|
||||
public void orderPay(RichOrderEvent event) {
|
||||
StateMachine sm = new StateMachine();
|
||||
sm.sendEvent(event.getType());
|
||||
}
|
||||
public void orderShip(RichOrderEvent event) {
|
||||
StateMachine sm = new StateMachine();
|
||||
sm.sendEvent(event.getType());
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP, CANCEL }
|
||||
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||
class OrderStateMachineConfig {}
|
||||
""";
|
||||
CodebaseContext context = scanSource(source, tempDir);
|
||||
|
||||
CallChain payChain = resolveChain(context, CONTROLLER, "pay", STATE_MACHINE);
|
||||
CallChain shipChain = resolveChain(context, CONTROLLER, "ship", STATE_MACHINE);
|
||||
|
||||
assertPolyEvents(payChain, "OrderEvent.PAY");
|
||||
assertPolyEvents(shipChain, "OrderEvent.SHIP");
|
||||
|
||||
assertLinkedEvent(
|
||||
linkEndpointToTransition(payChain, MACHINE_CONFIG,
|
||||
transition("NEW", "PAID", "OrderEvent.PAY"),
|
||||
transition("PAID", "SHIPPED", "OrderEvent.SHIP")),
|
||||
"OrderEvent.PAY");
|
||||
assertLinkedEvent(
|
||||
linkEndpointToTransition(shipChain, MACHINE_CONFIG,
|
||||
transition("NEW", "PAID", "OrderEvent.PAY"),
|
||||
transition("PAID", "SHIPPED", "OrderEvent.SHIP")),
|
||||
"OrderEvent.SHIP");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tier 2 — chained accessor {@code wrapper.getEvent().getType()} through a central dispatcher.
|
||||
*/
|
||||
@Test
|
||||
void chainedRichEventAccessorShouldResolveThroughCentralDispatcher(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class ApiController {
|
||||
CentralDispatcher dispatcher;
|
||||
public void pay(EventWrapper wrapper) {
|
||||
dispatcher.fireOrderEvent(wrapper);
|
||||
}
|
||||
}
|
||||
class CentralDispatcher {
|
||||
public void fireOrderEvent(EventWrapper wrapper) {
|
||||
StateMachine sm = new StateMachine();
|
||||
sm.sendEvent(wrapper.getEvent().getType());
|
||||
}
|
||||
}
|
||||
class EventWrapper {
|
||||
public RichEvent getEvent() { return new RichEvent(); }
|
||||
}
|
||||
class RichEvent {
|
||||
public OrderEvent getType() { return OrderEvent.PAY; }
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP }
|
||||
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||
class OrderStateMachineConfig {}
|
||||
""";
|
||||
CodebaseContext context = scanSource(source, tempDir);
|
||||
|
||||
CallChain chain = resolveChain(context, CONTROLLER, "pay", STATE_MACHINE);
|
||||
assertPolyEvents(chain, "OrderEvent.PAY");
|
||||
assertLinkedEvent(
|
||||
linkEndpointToTransition(chain, MACHINE_CONFIG,
|
||||
transition("NEW", "PAID", "OrderEvent.PAY"),
|
||||
transition("PAID", "SHIPPED", "OrderEvent.SHIP")),
|
||||
"OrderEvent.PAY");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.AnalysisResult;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.CallChain;
|
||||
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.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.Event;
|
||||
import click.kamil.springstatemachineexporter.model.State;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Small helpers so dispatcher tests read as documentation of expected endpoint → transition behaviour.
|
||||
*/
|
||||
final class CentralDispatcherTestSupport {
|
||||
|
||||
private CentralDispatcherTestSupport() {
|
||||
}
|
||||
|
||||
static CodebaseContext scanSource(String source, Path tempDir) throws Exception {
|
||||
Files.writeString(tempDir.resolve("App.java"), source);
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
return context;
|
||||
}
|
||||
|
||||
static CallChain resolveChain(
|
||||
CodebaseContext context,
|
||||
String controllerClass,
|
||||
String controllerMethod,
|
||||
String stateMachineClass) {
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
EntryPoint entryPoint = EntryPoint.builder()
|
||||
.className(controllerClass)
|
||||
.methodName(controllerMethod)
|
||||
.build();
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className(stateMachineClass)
|
||||
.methodName("sendEvent")
|
||||
.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", controllerClass, controllerMethod, stateMachineClass)
|
||||
.hasSize(1);
|
||||
return chains.get(0);
|
||||
}
|
||||
|
||||
static void assertPolyEvents(CallChain chain, String... expectedEvents) {
|
||||
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
|
||||
.containsExactlyInAnyOrder(expectedEvents);
|
||||
}
|
||||
|
||||
static Transition transition(String source, String target, String eventFqn) {
|
||||
Transition transition = new Transition();
|
||||
transition.setSourceStates(List.of(State.of(source, source)));
|
||||
transition.setTargetStates(List.of(State.of(target, target)));
|
||||
transition.setEvent(Event.of(eventFqn, eventFqn));
|
||||
return transition;
|
||||
}
|
||||
|
||||
static MatchedTransition linkEndpointToTransition(
|
||||
CallChain chain,
|
||||
String machineConfigName,
|
||||
Transition... machineTransitions) {
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name(machineConfigName)
|
||||
.transitions(List.of(machineTransitions))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
new TransitionLinkerEnricher().enrich(result, null, null);
|
||||
List<MatchedTransition> matched = result.getMetadata().getCallChains().get(0).getMatchedTransitions();
|
||||
assertThat(matched)
|
||||
.as("endpoint should resolve to exactly one SM transition")
|
||||
.hasSize(1);
|
||||
return matched.get(0);
|
||||
}
|
||||
|
||||
static void assertLinkedEvent(MatchedTransition matched, String expectedEvent) {
|
||||
assertThat(matched.getEvent()).isEqualTo(expectedEvent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.enricher.TransitionLinkerEnricher;
|
||||
import click.kamil.springstatemachineexporter.analysis.model.*;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import click.kamil.springstatemachineexporter.model.Event;
|
||||
import click.kamil.springstatemachineexporter.model.State;
|
||||
import click.kamil.springstatemachineexporter.model.Transition;
|
||||
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 static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class DispatcherEndpointTest {
|
||||
|
||||
@Test
|
||||
void shouldLinkOnlySpecificEventWhenEndpointPassesLiteralsThroughDispatcher(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class OrderController {
|
||||
private Dispatcher dispatcher;
|
||||
public void payOrder() { dispatcher.dispatch("ORDER", "PAY"); }
|
||||
public void shipOrder() { dispatcher.dispatch("ORDER", "SHIP"); }
|
||||
}
|
||||
class Dispatcher {
|
||||
public void dispatch(String type, String eventStr) {
|
||||
StateMachine sm = new StateMachine();
|
||||
if ("ORDER".equals(type)) {
|
||||
OrderEvent ev = OrderEvent.valueOf(eventStr);
|
||||
sm.sendEvent(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP, CANCEL }
|
||||
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("App.java"), source);
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
|
||||
EntryPoint payEp = EntryPoint.builder().className("com.example.OrderController").methodName("payOrder").build();
|
||||
TriggerPoint trigger = TriggerPoint.builder().className("com.example.StateMachine").methodName("sendEvent").event("e").build();
|
||||
CallChain payChain = engine.findChains(List.of(payEp), List.of(trigger)).get(0);
|
||||
TriggerPoint resolved = payChain.getTriggerPoint();
|
||||
|
||||
assertThat(resolved.getPolymorphicEvents())
|
||||
.as("resolved trigger=%s eventTypeFqn=%s external=%s", resolved.getEvent(), resolved.getEventTypeFqn(), resolved.isExternal())
|
||||
.contains("OrderEvent.PAY")
|
||||
.doesNotContain("OrderEvent.SHIP", "OrderEvent.CANCEL");
|
||||
|
||||
Transition payT = new Transition();
|
||||
payT.setSourceStates(List.of(State.of("NEW", "OrderState.NEW")));
|
||||
payT.setTargetStates(List.of(State.of("PAID", "OrderState.PAID")));
|
||||
payT.setEvent(Event.of("OrderEvent.PAY", "com.example.OrderEvent.PAY"));
|
||||
Transition shipT = new Transition();
|
||||
shipT.setSourceStates(List.of(State.of("PAID", "OrderState.PAID")));
|
||||
shipT.setTargetStates(List.of(State.of("SHIPPED", "OrderState.SHIPPED")));
|
||||
shipT.setEvent(Event.of("OrderEvent.SHIP", "com.example.OrderEvent.SHIP"));
|
||||
|
||||
assertThat(new click.kamil.springstatemachineexporter.analysis.enricher.matching.StrictFqnMatchingEngine()
|
||||
.matches(payT.getEvent(), resolved)).isTrue();
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("OrderStateMachineConfig")
|
||||
.transitions(List.of(payT, shipT))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(payChain)).build())
|
||||
.build();
|
||||
new TransitionLinkerEnricher().enrich(result, null, null);
|
||||
|
||||
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions())
|
||||
.hasSize(1)
|
||||
.first()
|
||||
.extracting("event")
|
||||
.isEqualTo("OrderEvent.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotLinkTransitionsForExternalGenericDispatchEndpoint(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class OrderController {
|
||||
private Dispatcher dispatcher;
|
||||
public void transition(String machineType, String event) {
|
||||
dispatcher.dispatch(machineType, event);
|
||||
}
|
||||
}
|
||||
class Dispatcher {
|
||||
public void dispatch(String type, String eventStr) {
|
||||
StateMachine sm = new StateMachine();
|
||||
if ("ORDER".equals(type)) {
|
||||
OrderEvent ev = OrderEvent.valueOf(eventStr);
|
||||
sm.sendEvent(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP }
|
||||
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("App.java"), source);
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
|
||||
EntryPoint entry = EntryPoint.builder()
|
||||
.className("com.example.OrderController")
|
||||
.methodName("transition")
|
||||
.build();
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className("com.example.StateMachine")
|
||||
.methodName("sendEvent")
|
||||
.event("e")
|
||||
.build();
|
||||
CallChain chain = engine.findChains(List.of(entry), List.of(trigger)).get(0);
|
||||
|
||||
assertThat(chain.getTriggerPoint().getPolymorphicEvents()).isEmpty();
|
||||
|
||||
Transition payT = new Transition();
|
||||
payT.setSourceStates(List.of(State.of("NEW", "OrderState.NEW")));
|
||||
payT.setTargetStates(List.of(State.of("PAID", "OrderState.PAID")));
|
||||
payT.setEvent(Event.of("OrderEvent.PAY", "com.example.OrderEvent.PAY"));
|
||||
|
||||
AnalysisResult result = AnalysisResult.builder()
|
||||
.name("OrderStateMachineConfig")
|
||||
.transitions(List.of(payT))
|
||||
.metadata(CodebaseMetadata.builder().callChains(List.of(chain)).build())
|
||||
.build();
|
||||
new TransitionLinkerEnricher().enrich(result, null, null);
|
||||
|
||||
assertThat(result.getMetadata().getCallChains().get(0).getMatchedTransitions()).isNull();
|
||||
}
|
||||
}
|
||||
@@ -269,6 +269,7 @@ class EnterpriseBugsTest {
|
||||
|
||||
TriggerPoint triggerPoint = TriggerPoint.builder()
|
||||
.event("OrderEvents.valueOf(someVar)")
|
||||
.polymorphicEvents(List.of("OrderEvents.PAY"))
|
||||
.build();
|
||||
|
||||
assertThat(engine.matches(smEvent, triggerPoint)).isTrue();
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.analysis.resolver.ConstantResolver;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.AST;
|
||||
import org.eclipse.jdt.core.dom.ASTNode;
|
||||
import org.eclipse.jdt.core.dom.ASTParser;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ExpressionAccessClassifierTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
private CodebaseContext context;
|
||||
private ExpressionAccessClassifier classifier;
|
||||
private static final String SCOPE = "com.example.Service.handle";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
String source = """
|
||||
package com.example;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
public class Service {
|
||||
private static final Map<String, String> ROUTES = Map.of("k", "v");
|
||||
private EventWrapper wrapper;
|
||||
private Supplier<String> eventSupplier;
|
||||
|
||||
public void handle(String commandKey) {
|
||||
ROUTES.get(commandKey);
|
||||
Routes.ROUTES.get(commandKey);
|
||||
wrapper.getEvent();
|
||||
eventSupplier.get();
|
||||
}
|
||||
}
|
||||
|
||||
class Routes {
|
||||
static final Map<String, String> ROUTES = Map.of("k", "v");
|
||||
}
|
||||
|
||||
class EventWrapper {
|
||||
String getEvent() { return null; }
|
||||
}
|
||||
""";
|
||||
Files.writeString(tempDir.resolve("Service.java"), source);
|
||||
context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
Map<String, ASTNode> cache = new ConcurrentHashMap<>();
|
||||
ConstantResolver constantResolver = new ConstantResolver();
|
||||
VariableTracer variableTracer = new VariableTracer(context, constantResolver);
|
||||
classifier = new ExpressionAccessClassifier(
|
||||
context,
|
||||
variableTracer,
|
||||
constantResolver,
|
||||
expr -> cache.computeIfAbsent(expr, ExpressionAccessClassifierTest::parseExpression));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldClassifyMapLookupByDeclaredFieldType() {
|
||||
assertThat(classifier.classifyTrailingAccess("ROUTES.get(commandKey)", SCOPE))
|
||||
.isEqualTo(ExpressionAccessClassifier.AccessKind.KEYED_LOOKUP);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldClassifyQualifiedStaticMapLookup() {
|
||||
assertThat(classifier.classifyTrailingAccess("Routes.ROUTES.get(commandKey)", SCOPE))
|
||||
.isEqualTo(ExpressionAccessClassifier.AccessKind.KEYED_LOOKUP);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldClassifyBeanGetterAsTraceable() {
|
||||
assertThat(classifier.classifyTrailingAccess("wrapper.getEvent()", SCOPE))
|
||||
.isEqualTo(ExpressionAccessClassifier.AccessKind.TRACEABLE_ACCESSOR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldClassifySupplierGetAsTraceable() {
|
||||
assertThat(classifier.classifyTrailingAccess("eventSupplier.get()", SCOPE))
|
||||
.isEqualTo(ExpressionAccessClassifier.AccessKind.TRACEABLE_ACCESSOR);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldClassifyGetOrDefaultAsKeyedLookup() {
|
||||
assertThat(classifier.classifyTrailingAccess("ROUTES.getOrDefault(commandKey, \"fallback\")", SCOPE))
|
||||
.isEqualTo(ExpressionAccessClassifier.AccessKind.KEYED_LOOKUP);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDetectMapLikeAndListLikeTypes() {
|
||||
assertThat(ExpressionAccessClassifier.isMapLikeType("Map<String, String>")).isTrue();
|
||||
assertThat(ExpressionAccessClassifier.isMapLikeType("java.util.HashMap")).isTrue();
|
||||
assertThat(ExpressionAccessClassifier.isIndexedCollectionType("List<OrderEvent>")).isTrue();
|
||||
assertThat(ExpressionAccessClassifier.isMapLikeType("Supplier<String>")).isFalse();
|
||||
}
|
||||
|
||||
private static ASTNode parseExpression(String expr) {
|
||||
ASTParser parser = ASTParser.newParser(AST.JLS17);
|
||||
Map<String, String> options = org.eclipse.jdt.core.JavaCore.getOptions();
|
||||
org.eclipse.jdt.core.JavaCore.setComplianceOptions(org.eclipse.jdt.core.JavaCore.VERSION_17, options);
|
||||
parser.setCompilerOptions(options);
|
||||
parser.setKind(ASTParser.K_EXPRESSION);
|
||||
parser.setSource(expr.toCharArray());
|
||||
return parser.createAST(null);
|
||||
}
|
||||
}
|
||||
@@ -420,8 +420,8 @@ class HeuristicCallGraphEngineCoreTest {
|
||||
|
||||
assertThat(chains).hasSize(1);
|
||||
CallChain chain = chains.get(0);
|
||||
// It might not fully resolve chained method calls, but it must not crash!
|
||||
assertThat(chain.getTriggerPoint().getEvent()).isEqualTo("wrapper.getEvent().getType()");
|
||||
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
|
||||
.containsExactlyInAnyOrder("OrderEvents.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import click.kamil.springstatemachineexporter.exporter.JsonExporter;
|
||||
import click.kamil.springstatemachineexporter.service.ExportService;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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.stream.StreamSupport;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* End-to-end verification for {@code state_machines/layered_dispatcher_sample}:
|
||||
* REST endpoint → string key → {@code DomainCommand} → transition event → single matched transition.
|
||||
*/
|
||||
class LayeredDispatcherSampleTest {
|
||||
|
||||
private static Path findProjectRoot() {
|
||||
Path current = Path.of(".").toAbsolutePath();
|
||||
while (current != null && !Files.exists(current.resolve("settings.gradle"))) {
|
||||
current = current.getParent();
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldLinkExactlyOneTransitionPerDedicatedEndpoint(@TempDir Path tempDir) throws Exception {
|
||||
Path sampleRoot = findProjectRoot().resolve("state_machines/layered_dispatcher_sample");
|
||||
ExportService exportService = new ExportService(List.of(new JsonExporter()));
|
||||
exportService.runExporter(sampleRoot, tempDir, List.of("json"), true, List.of(), null, null,
|
||||
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn,
|
||||
click.kamil.springstatemachineexporter.exporter.EnumFormat.fn, true);
|
||||
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
JsonNode orderMachine = readMachineJson(tempDir, "StandardOrderStateMachineConfiguration", mapper);
|
||||
JsonNode documentMachine = readMachineJson(tempDir, "StandardDocumentStateMachineConfiguration", mapper);
|
||||
|
||||
assertEndpointLinksSingleTransition(orderMachine, "POST /api/orders/pay",
|
||||
"OrderTransitionEvent.PAY", "OrderState.NEW", "OrderState.PAID");
|
||||
assertEndpointLinksSingleTransition(orderMachine, "POST /api/orders/ship",
|
||||
"OrderTransitionEvent.SHIP", "OrderState.PAID", "OrderState.SHIPPED");
|
||||
assertEndpointLinksSingleTransition(orderMachine, "POST /api/orders/cancel",
|
||||
"OrderTransitionEvent.CANCEL", "OrderState.PAID", "OrderState.CANCELLED");
|
||||
|
||||
assertEndpointLinksSingleTransition(documentMachine, "POST /api/documents/submit",
|
||||
"DocumentTransitionEvent.SUBMIT", "DocumentState.DRAFT", "DocumentState.SUBMITTED");
|
||||
assertEndpointLinksSingleTransition(documentMachine, "POST /api/documents/approve",
|
||||
"DocumentTransitionEvent.APPROVE", "DocumentState.SUBMITTED", "DocumentState.APPROVED");
|
||||
assertEndpointLinksSingleTransition(documentMachine, "POST /api/documents/reject",
|
||||
"DocumentTransitionEvent.REJECT", "DocumentState.SUBMITTED", "DocumentState.REJECTED");
|
||||
|
||||
assertEndpointLinksSingleTransition(orderMachine, "POST /api/orders/rich/pay",
|
||||
"OrderTransitionEvent.PAY", "OrderState.NEW", "OrderState.PAID");
|
||||
assertEndpointLinksSingleTransition(orderMachine, "POST /api/orders/rich/ship",
|
||||
"OrderTransitionEvent.SHIP", "OrderState.PAID", "OrderState.SHIPPED");
|
||||
}
|
||||
|
||||
private static JsonNode readMachineJson(Path outputDir, String configBaseName, ObjectMapper mapper)
|
||||
throws Exception {
|
||||
Path machineDir;
|
||||
try (var stream = Files.list(outputDir)) {
|
||||
machineDir = stream
|
||||
.filter(Files::isDirectory)
|
||||
.filter(path -> path.getFileName().toString().endsWith(configBaseName))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException("No output for " + configBaseName));
|
||||
}
|
||||
Path jsonFile = machineDir.resolve(machineDir.getFileName() + ".json");
|
||||
return mapper.readTree(jsonFile.toFile());
|
||||
}
|
||||
|
||||
private static void assertEndpointLinksSingleTransition(
|
||||
JsonNode machineJson,
|
||||
String endpointName,
|
||||
String expectedEvent,
|
||||
String expectedSource,
|
||||
String expectedTarget) {
|
||||
JsonNode chain = findCallChain(machineJson, endpointName);
|
||||
assertThat(chain).as("call chain for %s", endpointName).isNotNull();
|
||||
|
||||
JsonNode matched = chain.get("matchedTransitions");
|
||||
assertThat(matched).as("matchedTransitions for %s", endpointName).isNotNull().hasSize(1);
|
||||
assertThat(matched.get(0).get("event").asText()).isEqualTo(expectedEvent);
|
||||
assertThat(matched.get(0).get("sourceState").asText()).isEqualTo(expectedSource);
|
||||
assertThat(matched.get(0).get("targetState").asText()).isEqualTo(expectedTarget);
|
||||
}
|
||||
|
||||
private static JsonNode findCallChain(JsonNode machineJson, String endpointName) {
|
||||
JsonNode callChains = machineJson.path("metadata").path("callChains");
|
||||
if (!callChains.isArray()) {
|
||||
return null;
|
||||
}
|
||||
return StreamSupport.stream(callChains.spliterator(), false)
|
||||
.filter(chain -> endpointName.equals(chain.path("entryPoint").path("name").asText()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
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.analysis.resolver.ConstantResolver;
|
||||
import click.kamil.springstatemachineexporter.ast.common.CodebaseContext;
|
||||
import org.eclipse.jdt.core.dom.Expression;
|
||||
import org.eclipse.jdt.core.dom.MethodDeclaration;
|
||||
import org.eclipse.jdt.core.dom.ReturnStatement;
|
||||
import org.eclipse.jdt.core.dom.TypeDeclaration;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static click.kamil.springstatemachineexporter.analysis.service.CentralDispatcherTestSupport.*;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Robust coverage for compile-time literal dataflow: map lookup, string transforms, array indexing,
|
||||
* extractor expansion, and call-graph end-to-end resolution.
|
||||
*/
|
||||
class LiteralDataFlowResolutionTest {
|
||||
|
||||
private static CodebaseContext scan(Path tempDir, String fileName, String source) throws IOException {
|
||||
Files.writeString(tempDir.resolve(fileName), source);
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
return context;
|
||||
}
|
||||
|
||||
private static Expression returnExpression(Path tempDir, String fileName, String source, String typeName, String methodName)
|
||||
throws IOException {
|
||||
CodebaseContext context = scan(tempDir, fileName, source);
|
||||
TypeDeclaration td = context.getTypeDeclaration(typeName);
|
||||
MethodDeclaration method = findMethod(td, methodName);
|
||||
ReturnStatement rs = (ReturnStatement) method.getBody().statements().get(0);
|
||||
return rs.getExpression();
|
||||
}
|
||||
|
||||
private static MethodDeclaration findMethod(TypeDeclaration td, String methodName) {
|
||||
for (MethodDeclaration md : td.getMethods()) {
|
||||
if (methodName.equals(md.getName().getIdentifier())) {
|
||||
return md;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Method not found: " + methodName);
|
||||
}
|
||||
|
||||
@Nested
|
||||
class ConstantResolverCases {
|
||||
|
||||
@Test
|
||||
void shouldReturnNullForMapGetWithUnknownLiteralKey(@TempDir Path tempDir) throws IOException {
|
||||
Expression expr = returnExpression(tempDir, "Routes.java", """
|
||||
package com.example;
|
||||
import java.util.Map;
|
||||
public class Routes {
|
||||
private static final Map<String, String> ROUTES = Map.of(
|
||||
"order.pay", "OrderEvent.PAY",
|
||||
"order.ship", "OrderEvent.SHIP"
|
||||
);
|
||||
public String miss() {
|
||||
return ROUTES.get("order.cancel");
|
||||
}
|
||||
}
|
||||
""", "com.example.Routes", "miss");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
assertThat(new ConstantResolver().resolve(expr, context)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnNullForMapGetWithDynamicKey(@TempDir Path tempDir) throws IOException {
|
||||
Expression expr = returnExpression(tempDir, "Routes.java", """
|
||||
package com.example;
|
||||
import java.util.Map;
|
||||
public class Routes {
|
||||
private static final Map<String, String> ROUTES = Map.of(
|
||||
"order.pay", "OrderEvent.PAY",
|
||||
"order.ship", "OrderEvent.SHIP"
|
||||
);
|
||||
public String dynamic(String commandKey) {
|
||||
return ROUTES.get(commandKey);
|
||||
}
|
||||
}
|
||||
""", "com.example.Routes", "dynamic");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
assertThat(new ConstantResolver().resolve(expr, context)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveMapOfEntriesWithLiteralKey(@TempDir Path tempDir) throws IOException {
|
||||
Expression expr = returnExpression(tempDir, "Routes.java", """
|
||||
package com.example;
|
||||
import java.util.Map;
|
||||
public class Routes {
|
||||
public String lookup() {
|
||||
return Map.ofEntries(
|
||||
Map.entry("order.pay", "OrderEvent.PAY"),
|
||||
Map.entry("order.ship", "OrderEvent.SHIP")
|
||||
).get("order.pay");
|
||||
}
|
||||
}
|
||||
""", "com.example.Routes", "lookup");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
assertThat(new ConstantResolver().resolve(expr, context)).isEqualTo("OrderEvent.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveMapGetOrDefaultWithLiteralKey(@TempDir Path tempDir) throws IOException {
|
||||
Expression expr = returnExpression(tempDir, "Routes.java", """
|
||||
package com.example;
|
||||
import java.util.Map;
|
||||
public class Routes {
|
||||
private static final Map<String, String> ROUTES = Map.of("order.pay", "OrderEvent.PAY");
|
||||
public String lookup() {
|
||||
return ROUTES.getOrDefault("order.pay", "OrderEvent.FALLBACK");
|
||||
}
|
||||
}
|
||||
""", "com.example.Routes", "lookup");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
assertThat(new ConstantResolver().resolve(expr, context)).isEqualTo("OrderEvent.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotFoldToUpperCaseOnVariableReceiver(@TempDir Path tempDir) throws IOException {
|
||||
Expression expr = returnExpression(tempDir, "Caller.java", """
|
||||
package com.example;
|
||||
public class Caller {
|
||||
public String dynamic(String action) {
|
||||
return action.toUpperCase();
|
||||
}
|
||||
}
|
||||
""", "com.example.Caller", "dynamic");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
assertThat(new ConstantResolver().resolve(expr, context)).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFoldToLowerCaseOnLiteralReceiver(@TempDir Path tempDir) throws IOException {
|
||||
Expression expr = returnExpression(tempDir, "Caller.java", """
|
||||
package com.example;
|
||||
public class Caller {
|
||||
public String lower() {
|
||||
return "PAY".toLowerCase();
|
||||
}
|
||||
}
|
||||
""", "com.example.Caller", "lower");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
assertThat(new ConstantResolver().resolve(expr, context)).isEqualTo("pay");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveInlineArrayLiteralWithIndexMath(@TempDir Path tempDir) throws IOException {
|
||||
Expression expr = returnExpression(tempDir, "Table.java", """
|
||||
package com.example;
|
||||
public class Table {
|
||||
public String pick() {
|
||||
return new String[] {"OrderEvent.PAY", "OrderEvent.SHIP", "OrderEvent.CANCEL"}[1 + 1];
|
||||
}
|
||||
}
|
||||
""", "com.example.Table", "pick");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
assertThat(new ConstantResolver().resolve(expr, context)).isEqualTo("OrderEvent.CANCEL");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveArrayIndexWithSubtraction(@TempDir Path tempDir) throws IOException {
|
||||
Expression expr = returnExpression(tempDir, "Table.java", """
|
||||
package com.example;
|
||||
public class Table {
|
||||
private static final String[] EVENTS = {"OrderEvent.PAY", "OrderEvent.SHIP", "OrderEvent.CANCEL"};
|
||||
public String pick() {
|
||||
return EVENTS[2 - 1];
|
||||
}
|
||||
}
|
||||
""", "com.example.Table", "pick");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
assertThat(new ConstantResolver().resolve(expr, context)).isEqualTo("OrderEvent.SHIP");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnNullForDynamicArrayIndex(@TempDir Path tempDir) throws IOException {
|
||||
Expression expr = returnExpression(tempDir, "Table.java", """
|
||||
package com.example;
|
||||
public class Table {
|
||||
private static final String[] EVENTS = {"OrderEvent.PAY", "OrderEvent.SHIP"};
|
||||
public String pick(int idx) {
|
||||
return EVENTS[idx];
|
||||
}
|
||||
}
|
||||
""", "com.example.Table", "pick");
|
||||
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
|
||||
assertThat(new ConstantResolver().resolve(expr, context)).isNull();
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class ConstantExtractorCases {
|
||||
|
||||
private List<String> extract(String source, String typeName, String methodName, Path tempDir) throws IOException {
|
||||
Expression expr = returnExpression(tempDir, "App.java", source, typeName, methodName);
|
||||
CodebaseContext context = new CodebaseContext();
|
||||
context.scan(tempDir);
|
||||
ConstantExtractor extractor = new ConstantExtractor(context, new ConstantResolver(), mi -> null);
|
||||
List<String> constants = new ArrayList<>();
|
||||
extractor.extractConstantsFromExpression(expr, constants);
|
||||
return constants;
|
||||
}
|
||||
|
||||
@Test
|
||||
void bareMapOfShouldExpandAllValuesNotRawMapEncoding(@TempDir Path tempDir) throws IOException {
|
||||
List<String> constants = extract("""
|
||||
package com.example;
|
||||
import java.util.Map;
|
||||
public class Routes {
|
||||
public Map<String, String> all() {
|
||||
return Map.of("order.pay", "OrderEvent.PAY", "order.ship", "OrderEvent.SHIP");
|
||||
}
|
||||
}
|
||||
""", "com.example.Routes", "all", tempDir);
|
||||
|
||||
assertThat(constants)
|
||||
.containsExactlyInAnyOrder("OrderEvent.PAY", "OrderEvent.SHIP")
|
||||
.noneMatch(value -> value.startsWith("MAP:"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapGetWithLiteralKeyShouldExtractSingleValue(@TempDir Path tempDir) throws IOException {
|
||||
List<String> constants = extract("""
|
||||
package com.example;
|
||||
import java.util.Map;
|
||||
public class Routes {
|
||||
public String one() {
|
||||
return Map.of("order.pay", "OrderEvent.PAY", "order.ship", "OrderEvent.SHIP")
|
||||
.get("order.pay");
|
||||
}
|
||||
}
|
||||
""", "com.example.Routes", "one", tempDir);
|
||||
|
||||
assertThat(constants).containsExactly("OrderEvent.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void staticMapGetWithDynamicKeyShouldExpandAllRouteValues(@TempDir Path tempDir) throws IOException {
|
||||
List<String> constants = extract("""
|
||||
package com.example;
|
||||
import java.util.Map;
|
||||
public class Routes {
|
||||
private static final Map<String, String> ROUTES = Map.of(
|
||||
"order.pay", "OrderEvent.PAY",
|
||||
"order.ship", "OrderEvent.SHIP"
|
||||
);
|
||||
public String dynamic(String commandKey) {
|
||||
return ROUTES.get(commandKey);
|
||||
}
|
||||
}
|
||||
""", "com.example.Routes", "dynamic", tempDir);
|
||||
|
||||
assertThat(constants).contains("OrderEvent.PAY", "OrderEvent.SHIP");
|
||||
}
|
||||
|
||||
@Test
|
||||
void bareArrayLiteralShouldExpandAllElements(@TempDir Path tempDir) throws IOException {
|
||||
List<String> constants = extract("""
|
||||
package com.example;
|
||||
public class Table {
|
||||
public String[] all() {
|
||||
return new String[] {"OrderEvent.PAY", "OrderEvent.SHIP", "OrderEvent.CANCEL"};
|
||||
}
|
||||
}
|
||||
""", "com.example.Table", "all", tempDir);
|
||||
|
||||
assertThat(constants)
|
||||
.containsExactlyInAnyOrder("OrderEvent.PAY", "OrderEvent.SHIP", "OrderEvent.CANCEL")
|
||||
.noneMatch(value -> value.startsWith("ARRAY:"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class CallGraphIntegrationCases {
|
||||
|
||||
private static final String CONTROLLER = "com.example.ApiController";
|
||||
private static final String STATE_MACHINE = "com.example.StateMachine";
|
||||
|
||||
@Test
|
||||
void staticRoutesLiteralKeyShouldResolveSinglePolymorphicEvent(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
import java.util.Map;
|
||||
public class ApiController {
|
||||
StateMachine sm;
|
||||
public void payOrder() {
|
||||
sm.sendEvent(Routes.ROUTES.get("order.pay"));
|
||||
}
|
||||
}
|
||||
class Routes {
|
||||
static final Map<String, OrderEvent> ROUTES = Map.of(
|
||||
"order.pay", OrderEvent.PAY,
|
||||
"order.ship", OrderEvent.SHIP,
|
||||
"order.cancel", OrderEvent.CANCEL
|
||||
);
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP, CANCEL }
|
||||
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||
""";
|
||||
CodebaseContext context = scanSource(source, tempDir);
|
||||
|
||||
CallChain chain = resolveChain(context, CONTROLLER, "payOrder", STATE_MACHINE);
|
||||
assertPolyEvents(chain, "OrderEvent.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void literalToUpperCaseBeforeValueOfShouldResolveSingleEvent(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class ApiController {
|
||||
StateMachine sm;
|
||||
public void payOrder() {
|
||||
sm.sendEvent(OrderEvent.valueOf("pay".toUpperCase()));
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP, CANCEL }
|
||||
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||
""";
|
||||
CodebaseContext context = scanSource(source, tempDir);
|
||||
|
||||
CallChain chain = resolveChain(context, CONTROLLER, "payOrder", STATE_MACHINE);
|
||||
assertPolyEvents(chain, "OrderEvent.PAY");
|
||||
}
|
||||
|
||||
@Test
|
||||
void variableToUpperCaseBeforeValueOfShouldNotNarrowToSingleEvent(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class ApiController {
|
||||
StateMachine sm;
|
||||
public void processAction(String action) {
|
||||
sm.sendEvent(OrderEvent.valueOf(action.toUpperCase()));
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP, CANCEL }
|
||||
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||
""";
|
||||
CodebaseContext context = scanSource(source, tempDir);
|
||||
|
||||
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
|
||||
EntryPoint entryPoint = EntryPoint.builder()
|
||||
.className(CONTROLLER)
|
||||
.methodName("processAction")
|
||||
.build();
|
||||
TriggerPoint trigger = TriggerPoint.builder()
|
||||
.className(STATE_MACHINE)
|
||||
.methodName("sendEvent")
|
||||
.event("e")
|
||||
.build();
|
||||
|
||||
CallChain chain = engine.findChains(List.of(entryPoint), List.of(trigger)).get(0);
|
||||
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
|
||||
.contains("OrderEvent.PAY", "OrderEvent.SHIP", "OrderEvent.CANCEL");
|
||||
}
|
||||
|
||||
@Test
|
||||
void arrayIndexMathShouldResolveSinglePolymorphicEvent(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
public class ApiController {
|
||||
StateMachine sm;
|
||||
public void cancelOrder() {
|
||||
sm.sendEvent(RoutingTable.EVENTS[1 + 1]);
|
||||
}
|
||||
}
|
||||
class RoutingTable {
|
||||
static final OrderEvent[] EVENTS = {OrderEvent.PAY, OrderEvent.SHIP, OrderEvent.CANCEL};
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP, CANCEL }
|
||||
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||
""";
|
||||
CodebaseContext context = scanSource(source, tempDir);
|
||||
|
||||
CallChain chain = resolveChain(context, CONTROLLER, "cancelOrder", STATE_MACHINE);
|
||||
assertPolyEvents(chain, "OrderEvent.CANCEL");
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapGetWithDynamicKeyShouldUnionAllRouteEvents(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
import java.util.Map;
|
||||
public class ApiController {
|
||||
StateMachine sm;
|
||||
private static final Map<String, OrderEvent> ROUTES = Map.of(
|
||||
"order.pay", OrderEvent.PAY,
|
||||
"order.ship", OrderEvent.SHIP
|
||||
);
|
||||
public void dispatch(String commandKey) {
|
||||
sm.sendEvent(ROUTES.get(commandKey));
|
||||
}
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP }
|
||||
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||
""";
|
||||
CodebaseContext context = scanSource(source, tempDir);
|
||||
|
||||
CallChain chain = resolveChain(context, CONTROLLER, "dispatch", STATE_MACHINE);
|
||||
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
|
||||
.contains("OrderEvent.PAY", "OrderEvent.SHIP");
|
||||
}
|
||||
|
||||
@Test
|
||||
void crossClassStaticMapGetWithDynamicKeyShouldUnionAllRouteEvents(@TempDir Path tempDir) throws Exception {
|
||||
String source = """
|
||||
package com.example;
|
||||
import java.util.Map;
|
||||
public class ApiController {
|
||||
StateMachine sm;
|
||||
public void dispatch(String commandKey) {
|
||||
sm.sendEvent(Routes.ROUTES.get(commandKey));
|
||||
}
|
||||
}
|
||||
class Routes {
|
||||
static final Map<String, OrderEvent> ROUTES = Map.of(
|
||||
"order.pay", OrderEvent.PAY,
|
||||
"order.ship", OrderEvent.SHIP
|
||||
);
|
||||
}
|
||||
enum OrderEvent { PAY, SHIP }
|
||||
class StateMachine { public void sendEvent(OrderEvent e) {} }
|
||||
""";
|
||||
CodebaseContext context = scanSource(source, tempDir);
|
||||
|
||||
CallChain chain = resolveChain(context, CONTROLLER, "dispatch", STATE_MACHINE);
|
||||
assertThat(chain.getTriggerPoint().getPolymorphicEvents())
|
||||
.contains("OrderEvent.PAY", "OrderEvent.SHIP");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package click.kamil.springstatemachineexporter.analysis.service;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Synthetic 1 → 3 → 12 state-machine config hierarchy for cache-correctness tests.
|
||||
*
|
||||
* <pre>
|
||||
* RootAbstractSmConfig
|
||||
* ├── BranchAAbstractSmConfig ── BranchA1..4SmConfig
|
||||
* ├── BranchBAbstractSmConfig ── BranchB1..4SmConfig
|
||||
* └── BranchCAbstractSmConfig ── BranchC1..4SmConfig
|
||||
* </pre>
|
||||
*
|
||||
* <p>Each layer adds one external transition with a unique event name so every concrete
|
||||
* config produces a distinct snapshot while sharing the same scanned {@code CodebaseContext}.
|
||||
*/
|
||||
final class TwelveConfigHierarchyTestSupport {
|
||||
|
||||
private static final String PKG = "com.example.hierarchy";
|
||||
private static final List<String> BRANCHES = List.of("A", "B", "C");
|
||||
|
||||
private TwelveConfigHierarchyTestSupport() {
|
||||
}
|
||||
|
||||
static List<String> concreteConfigNames() {
|
||||
List<String> names = new ArrayList<>(12);
|
||||
for (String branch : BRANCHES) {
|
||||
for (int i = 1; i <= 4; i++) {
|
||||
names.add("Branch" + branch + i + "SmConfig");
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
static void writeSources(Path rootDir) throws IOException {
|
||||
Files.writeString(rootDir.resolve("RootAbstractSmConfig.java"), rootAbstractSource());
|
||||
for (String branch : BRANCHES) {
|
||||
Files.writeString(rootDir.resolve("Branch" + branch + "AbstractSmConfig.java"), branchAbstractSource(branch));
|
||||
for (int i = 1; i <= 4; i++) {
|
||||
Files.writeString(rootDir.resolve("Branch" + branch + i + "SmConfig.java"), concreteSource(branch, i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String rootAbstractSource() {
|
||||
return """
|
||||
package %s;
|
||||
public abstract class RootAbstractSmConfig extends StateMachineConfigurerAdapter {
|
||||
public void configure(StateMachineTransitionConfigurer transitions) throws Exception {
|
||||
transitions.withExternal().source("ROOT_S1").target("ROOT_S2").event("ROOT_E");
|
||||
}
|
||||
}
|
||||
""".formatted(PKG);
|
||||
}
|
||||
|
||||
private static String branchAbstractSource(String branch) {
|
||||
String event = branchEvent(branch);
|
||||
return """
|
||||
package %s;
|
||||
public abstract class Branch%sAbstractSmConfig extends RootAbstractSmConfig {
|
||||
@Override
|
||||
public void configure(StateMachineTransitionConfigurer transitions) throws Exception {
|
||||
super.configure(transitions);
|
||||
transitions.withExternal().source("BR_%s_S1").target("BR_%s_S2").event("%s");
|
||||
}
|
||||
}
|
||||
""".formatted(PKG, branch, branch, branch, event);
|
||||
}
|
||||
|
||||
private static String concreteSource(String branch, int index) {
|
||||
String event = concreteEvent(branch, index);
|
||||
String abstractParent = "Branch" + branch + "AbstractSmConfig";
|
||||
return """
|
||||
package %s;
|
||||
public class Branch%s%dSmConfig extends %s {
|
||||
@Override
|
||||
public void configure(StateMachineTransitionConfigurer transitions) throws Exception {
|
||||
super.configure(transitions);
|
||||
transitions.withExternal().source("C_%s_%d_S1").target("C_%s_%d_S2").event("%s");
|
||||
}
|
||||
}
|
||||
""".formatted(PKG, branch, index, abstractParent, branch, index, branch, index, event);
|
||||
}
|
||||
|
||||
static String branchEvent(String branch) {
|
||||
return "BRANCH_" + branch + "_E";
|
||||
}
|
||||
|
||||
static String concreteEvent(String branch, int index) {
|
||||
return "CONCRETE_" + branch + index + "_E";
|
||||
}
|
||||
|
||||
static List<String> expectedEventsForConcrete(String branch, int index) {
|
||||
return List.of("ROOT_E", branchEvent(branch), concreteEvent(branch, index));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user