diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java index 82f47d0..5e22140 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpander.java @@ -650,6 +650,12 @@ public final class EntryPointBindingExpander { declaringType = context.getTypeDeclaration(declaringClass.getQualifiedName()); } } + if (declaringType == null && ownerMethod != null) { + ASTNode root = ownerMethod.getRoot(); + if (root instanceof CompilationUnit compilationUnit) { + declaringType = context.resolveStaticImport(fieldName, compilationUnit); + } + } if (declaringType == null) { declaringType = AstUtils.findEnclosingType(receiver); } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpanderTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpanderTest.java index 9f5c6fa..e8774fb 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpanderTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/EntryPointBindingExpanderTest.java @@ -1986,4 +1986,389 @@ class EntryPointBindingExpanderTest { assertThat(variants).extracting(map -> map.get("commandKey")) .containsExactlyInAnyOrder("order.pay", "order.ship"); } + + @Test + void shouldExpandCommandKeyFromSwitchExpression(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class GenericCommandController { + OrderGateway gateway; + public void execute(String commandKey) { + gateway.trigger(commandKey); + } + } + class OrderGateway { + void trigger(String commandKey) { + DomainCommand command = switch (commandKey) { + case "order.pay" -> DomainCommand.ORDER_PAY; + case "order.ship" -> DomainCommand.ORDER_SHIP; + default -> throw new IllegalArgumentException("Unknown command: " + commandKey); + }; + StateMachine sm = new StateMachine(); + sm.sendEvent(command); + } + } + enum DomainCommand { ORDER_PAY, ORDER_SHIP } + class StateMachine { void sendEvent(DomainCommand command) {} } + """; + Files.writeString(tempDir.resolve("App.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + Map> graph = engine.buildCallGraph(); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.GenericCommandController") + .methodName("execute") + .name("POST /api/commands/{commandKey}") + .parameters(List.of(EntryPoint.Parameter.builder() + .name("commandKey") + .type("String") + .annotations(List.of("PathVariable")) + .build())) + .build(); + + List> variants = + EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph); + + assertThat(variants).extracting(map -> map.get("commandKey")) + .containsExactlyInAnyOrder("order.pay", "order.ship"); + } + + @Test + void shouldExpandMachineTypeFromEqualsIgnoreCaseIfChain(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class MachineController { + StateMachineDispatcher dispatcher; + public void transition(String machineType, String event) { + dispatcher.dispatch(machineType, event); + } + } + class StateMachineDispatcher { + void dispatch(String machineType, String eventString) { + if ("ORDER".equalsIgnoreCase(machineType)) { + OrderEvent.valueOf(eventString.toUpperCase()); + } else if ("DOCUMENT".equalsIgnoreCase(machineType)) { + DocumentEvent.valueOf(eventString.toUpperCase()); + } + } + } + enum OrderEvent { PAY, SHIP } + enum DocumentEvent { SUBMIT, APPROVE } + """; + Files.writeString(tempDir.resolve("App.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + Map> graph = engine.buildCallGraph(); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.MachineController") + .methodName("transition") + .name("POST /api/machine/{machineType}/transition/{event}") + .parameters(List.of( + EntryPoint.Parameter.builder() + .name("machineType") + .type("String") + .annotations(List.of("PathVariable")) + .build(), + EntryPoint.Parameter.builder() + .name("event") + .type("String") + .annotations(List.of("PathVariable")) + .build())) + .build(); + + List> variants = + EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph); + + assertThat(variants.stream().map(v -> v.get("machineType")).distinct()) + .containsExactlyInAnyOrder("ORDER", "DOCUMENT"); + } + + @Test + void shouldExpandCommandKeyFromStaticMapGetOrDefault(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + import java.util.Map; + public class GenericCommandController { + OrderGateway gateway; + public void execute(String commandKey) { + gateway.trigger(commandKey); + } + } + class OrderGateway { + private static final Map ROUTES = Map.of( + "order.pay", OrderEvent.PAY, + "order.ship", OrderEvent.SHIP); + void trigger(String commandKey) { + StateMachine sm = new StateMachine(); + sm.sendEvent(ROUTES.getOrDefault(commandKey, OrderEvent.PAY)); + } + } + class StateMachine { void sendEvent(OrderEvent event) {} } + enum OrderEvent { PAY, SHIP } + """; + Files.writeString(tempDir.resolve("App.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + Map> graph = engine.buildCallGraph(); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.GenericCommandController") + .methodName("execute") + .name("POST /api/commands/{commandKey}") + .parameters(List.of(EntryPoint.Parameter.builder() + .name("commandKey") + .type("String") + .annotations(List.of("PathVariable")) + .build())) + .build(); + + List> variants = + EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph); + + assertThat(variants).extracting(map -> map.get("commandKey")) + .containsExactlyInAnyOrder("order.pay", "order.ship"); + } + + @Test + void shouldExpandMachineTypeFromBooleanPredicateUsingEqualsIgnoreCaseOnHelper(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + public class MachineController { + StateMachineDispatcher dispatcher; + public void transition(String machineType, String event) { + dispatcher.dispatch(machineType, event); + } + } + class StateMachineDispatcher { + void dispatch(String machineType, String eventString) { + if (matchesOrder(machineType)) { + OrderEvent.valueOf(eventString.toUpperCase()); + } else if (matchesDocument(machineType)) { + DocumentEvent.valueOf(eventString.toUpperCase()); + } + } + boolean matchesOrder(String raw) { + return "ORDER".equalsIgnoreCase(normalize(raw)); + } + boolean matchesDocument(String raw) { + return "DOCUMENT".equalsIgnoreCase(normalize(raw)); + } + String normalize(String raw) { + return raw.trim(); + } + } + enum OrderEvent { PAY, SHIP } + enum DocumentEvent { SUBMIT, APPROVE } + """; + Files.writeString(tempDir.resolve("App.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + Map> graph = engine.buildCallGraph(); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.MachineController") + .methodName("transition") + .name("POST /api/machine/{machineType}/transition/{event}") + .parameters(List.of( + EntryPoint.Parameter.builder() + .name("machineType") + .type("String") + .annotations(List.of("PathVariable")) + .build(), + EntryPoint.Parameter.builder() + .name("event") + .type("String") + .annotations(List.of("PathVariable")) + .build())) + .build(); + + List> variants = + EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph); + + assertThat(variants.stream().map(v -> v.get("machineType")).distinct()) + .containsExactlyInAnyOrder("ORDER", "DOCUMENT"); + } + + @Test + void shouldExpandCommandKeyFromFullyQualifiedCrossPackageMapLookup(@TempDir Path tempDir) throws Exception { + Files.createDirectories(tempDir.resolve("com/example/routes")); + Files.writeString(tempDir.resolve("com/example/routes/CommandRoutes.java"), """ + package com.example.routes; + import java.util.Map; + public class CommandRoutes { + public static final Map ROUTES = Map.of( + "order.pay", "OrderEvent.PAY", + "order.ship", "OrderEvent.SHIP"); + } + """); + Files.writeString(tempDir.resolve("com/example/App.java"), """ + package com.example; + public class GenericCommandController { + OrderGateway gateway; + public void execute(String commandKey) { + gateway.trigger(commandKey); + } + } + class OrderGateway { + void trigger(String commandKey) { + OrderEvent event = OrderEvent.valueOf( + com.example.routes.CommandRoutes.ROUTES.get(commandKey)); + StateMachine sm = new StateMachine(); + sm.sendEvent(event); + } + } + enum OrderEvent { PAY, SHIP } + class StateMachine { void sendEvent(OrderEvent event) {} } + """); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + Map> graph = engine.buildCallGraph(); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.GenericCommandController") + .methodName("execute") + .name("POST /api/commands/{commandKey}") + .parameters(List.of(EntryPoint.Parameter.builder() + .name("commandKey") + .type("String") + .annotations(List.of("PathVariable")) + .build())) + .build(); + + List> variants = + EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph); + + assertThat(variants).extracting(map -> map.get("commandKey")) + .containsExactlyInAnyOrder("order.pay", "order.ship"); + } + + @Test + void shouldExpandCommandKeyFromStaticImportMapLookup(@TempDir Path tempDir) throws Exception { + Files.createDirectories(tempDir.resolve("com/example/routes")); + Files.writeString(tempDir.resolve("com/example/routes/CommandRoutes.java"), """ + package com.example.routes; + import java.util.Map; + public class CommandRoutes { + public static final Map ROUTES = Map.of( + "order.pay", "OrderEvent.PAY", + "order.ship", "OrderEvent.SHIP"); + } + """); + Files.writeString(tempDir.resolve("com/example/App.java"), """ + package com.example; + import static com.example.routes.CommandRoutes.ROUTES; + public class GenericCommandController { + OrderGateway gateway; + public void execute(String commandKey) { + gateway.trigger(commandKey); + } + } + class OrderGateway { + void trigger(String commandKey) { + OrderEvent event = OrderEvent.valueOf(ROUTES.get(commandKey)); + StateMachine sm = new StateMachine(); + sm.sendEvent(event); + } + } + enum OrderEvent { PAY, SHIP } + class StateMachine { void sendEvent(OrderEvent event) {} } + """); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + Map> graph = engine.buildCallGraph(); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.GenericCommandController") + .methodName("execute") + .name("POST /api/commands/{commandKey}") + .parameters(List.of(EntryPoint.Parameter.builder() + .name("commandKey") + .type("String") + .annotations(List.of("PathVariable")) + .build())) + .build(); + + List> variants = + EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph); + + assertThat(variants).extracting(map -> map.get("commandKey")) + .containsExactlyInAnyOrder("order.pay", "order.ship"); + } + + @Test + void shouldNotExpandCommandKeyWhenMapKeysAreNonLiteralConstants(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + import java.util.Map; + public class GenericCommandController { + OrderGateway gateway; + public void execute(String commandKey) { + gateway.trigger(commandKey); + } + } + class OrderGateway { + private static final String PAY_KEY = "order.pay"; + private static final String SHIP_KEY = "order.ship"; + private static final Map ROUTES = Map.of( + PAY_KEY, OrderEvent.PAY, + SHIP_KEY, OrderEvent.SHIP); + void trigger(String commandKey) { + StateMachine sm = new StateMachine(); + sm.sendEvent(ROUTES.get(commandKey)); + } + } + class StateMachine { void sendEvent(OrderEvent event) {} } + enum OrderEvent { PAY, SHIP } + """; + Files.writeString(tempDir.resolve("App.java"), source); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + Map> graph = engine.buildCallGraph(); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.GenericCommandController") + .methodName("execute") + .name("POST /api/commands/{commandKey}") + .parameters(List.of(EntryPoint.Parameter.builder() + .name("commandKey") + .type("String") + .annotations(List.of("PathVariable")) + .build())) + .build(); + + List> variants = + EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, graph); + + assertThat(variants).isEmpty(); + } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/MultiModulePathBindingExpanderTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/MultiModulePathBindingExpanderTest.java index 00f3f7b..b296984 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/MultiModulePathBindingExpanderTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/MultiModulePathBindingExpanderTest.java @@ -348,4 +348,77 @@ class MultiModulePathBindingExpanderTest { assertThat(variants.stream().map(v -> v.get("machineType")).distinct()) .containsExactlyInAnyOrder("ORDER", "DOCUMENT"); } + + @Test + void shouldExpandCommandKeyFromCrossClassStaticMapInSiblingModule(@TempDir Path tempDir) throws Exception { + Path root = tempDir.resolve("order-system"); + Files.createDirectories(root); + Files.writeString(root.resolve("settings.gradle"), "include 'api-module', 'core-module'"); + + Path apiModule = root.resolve("api-module"); + Files.createDirectories(apiModule.resolve("src/main/java/com/example/routes")); + Files.writeString(apiModule.resolve("build.gradle"), ""); + Files.writeString(apiModule.resolve("src/main/java/com/example/routes/CommandRoutes.java"), """ + package com.example.routes; + import java.util.Map; + public class CommandRoutes { + public static final Map ROUTES = Map.of( + "order.pay", "OrderEvent.PAY", + "order.ship", "OrderEvent.SHIP"); + } + """); + + Path coreModule = root.resolve("core-module"); + Files.createDirectories(coreModule.resolve("src/main/java/com/example")); + Files.writeString(coreModule.resolve("build.gradle"), + "dependencies { implementation project(':api-module') }"); + Files.writeString(coreModule.resolve("src/main/java/com/example/GenericCommandController.java"), """ + package com.example; + import com.example.routes.CommandRoutes; + public class GenericCommandController { + OrderGateway gateway; + public void execute(String commandKey) { + gateway.trigger(commandKey); + } + } + class OrderGateway { + void trigger(String commandKey) { + OrderEvent event = OrderEvent.valueOf(CommandRoutes.ROUTES.get(commandKey)); + StateMachine sm = new StateMachine(); + sm.sendEvent(event); + } + } + enum OrderEvent { PAY, SHIP } + class StateMachine { void sendEvent(OrderEvent event) {} } + """); + + SiblingDependencyResolver siblingResolver = new SiblingDependencyResolver(); + ProjectModuleGraph graph = siblingResolver.analyzeProject(coreModule); + Set scanPaths = graph.resolveScanPaths(coreModule, false); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(scanPaths, Collections.emptySet()); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + Map> callGraph = + engine.buildCallGraph(); + + EntryPoint entryPoint = EntryPoint.builder() + .className("com.example.GenericCommandController") + .methodName("execute") + .name("POST /api/commands/{commandKey}") + .parameters(List.of(EntryPoint.Parameter.builder() + .name("commandKey") + .type("String") + .annotations(List.of("PathVariable")) + .build())) + .build(); + + List> variants = + EntryPointBindingExpander.expandPathVariableBindings(entryPoint, context, callGraph); + + assertThat(variants).extracting(map -> map.get("commandKey")) + .containsExactlyInAnyOrder("order.pay", "order.ship"); + } }