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:
2026-07-11 10:14:23 +02:00
parent 12183693aa
commit c447641800
48 changed files with 4174 additions and 184 deletions

View File

@@ -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();
}
}

View File

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

View File

@@ -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();
}
}

View File

@@ -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");
}
}

View File

@@ -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;
}
}
}

View File

@@ -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()");
}
}

View File

@@ -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");
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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();

View File

@@ -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);
}
}

View File

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

View File

@@ -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);
}
}

View File

@@ -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");
}
}
}

View File

@@ -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));
}
}