From 85af06c89daef8bb356225789940fd5f51d23cbb Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Tue, 14 Jul 2026 18:41:37 +0200 Subject: [PATCH] Add regression coverage for constant-based path binding patterns. Lock in cross-class, static-import, reversed-equals, sibling-module, and fail-closed static-initializer map behavior for commandKey and machineType expansion. Co-authored-by: Cursor --- .../EntryPointBindingExpanderTest.java | 348 ++++++++++++++++++ .../MultiModulePathBindingExpanderTest.java | 228 ++++++++++++ 2 files changed, 576 insertions(+) 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 07d448f..bb3b701 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 @@ -2697,4 +2697,352 @@ class EntryPointBindingExpanderTest { assertThat(variants).extracting(map -> map.get("commandKey")) .containsExactlyInAnyOrder("order.pay", "order.ship"); } + + @Test + void shouldExpandMachineTypeFromCrossClassStaticFinalConstantInIfEquals(@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 (MachineTypes.ORDER.equalsIgnoreCase(machineType)) { + OrderEvent.valueOf(eventString.toUpperCase()); + } else if (MachineTypes.DOCUMENT.equalsIgnoreCase(machineType)) { + DocumentEvent.valueOf(eventString.toUpperCase()); + } + } + } + class MachineTypes { + static final String ORDER = "ORDER"; + static final String DOCUMENT = "DOCUMENT"; + } + 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 shouldExpandMachineTypeFromStaticImportConstantInIfEquals(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + import static com.example.MachineTypes.ORDER; + import static com.example.MachineTypes.DOCUMENT; + 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()); + } + } + } + class MachineTypes { + static final String ORDER = "ORDER"; + static final String DOCUMENT = "DOCUMENT"; + } + 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 shouldExpandMachineTypeFromReversedCrossClassConstantEquals(@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 (machineType.equalsIgnoreCase(MachineTypes.ORDER)) { + OrderEvent.valueOf(eventString.toUpperCase()); + } else if (machineType.equals(MachineTypes.DOCUMENT)) { + DocumentEvent.valueOf(eventString.toUpperCase()); + } + } + } + class MachineTypes { + static final String ORDER = "ORDER"; + static final String DOCUMENT = "DOCUMENT"; + } + 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 shouldExpandCommandKeyFromSwitchExpressionWithExternalConstantsClass(@TempDir Path tempDir) throws Exception { + Files.createDirectories(tempDir.resolve("com/example/constants")); + Files.writeString(tempDir.resolve("com/example/constants/CommandKeys.java"), """ + package com.example.constants; + public class CommandKeys { + public static final String PAY_KEY = "order.pay"; + public static final String SHIP_KEY = "order.ship"; + } + """); + Files.writeString(tempDir.resolve("com/example/App.java"), """ + package com.example; + import com.example.constants.CommandKeys; + public class GenericCommandController { + OrderGateway gateway; + public void execute(String commandKey) { + gateway.trigger(commandKey); + } + } + class OrderGateway { + void trigger(String commandKey) { + DomainCommand command = switch (commandKey) { + case CommandKeys.PAY_KEY -> DomainCommand.ORDER_PAY; + case CommandKeys.SHIP_KEY -> 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) {} } + """); + + 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 shouldExpandCommandKeyFromSwitchExpressionWithStaticImportCaseConstants(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + import static com.example.CommandKeys.PAY_KEY; + import static com.example.CommandKeys.SHIP_KEY; + public class GenericCommandController { + OrderGateway gateway; + public void execute(String commandKey) { + gateway.trigger(commandKey); + } + } + class OrderGateway { + void trigger(String commandKey) { + DomainCommand command = switch (commandKey) { + case PAY_KEY -> DomainCommand.ORDER_PAY; + case SHIP_KEY -> DomainCommand.ORDER_SHIP; + default -> throw new IllegalArgumentException("Unknown command: " + commandKey); + }; + StateMachine sm = new StateMachine(); + sm.sendEvent(command); + } + } + class CommandKeys { + static final String PAY_KEY = "order.pay"; + static final String SHIP_KEY = "order.ship"; + } + 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 shouldNotExpandCommandKeyFromStaticInitializerMap(@TempDir Path tempDir) throws Exception { + String source = """ + package com.example; + import java.util.HashMap; + import java.util.Map; + public class GenericCommandController { + OrderGateway gateway; + public void execute(String commandKey) { + gateway.trigger(commandKey); + } + } + class OrderGateway { + private static final Map ROUTES; + static { + ROUTES = new HashMap<>(); + ROUTES.put("order.pay", OrderEvent.PAY); + ROUTES.put("order.ship", 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 b296984..a1e6436 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 @@ -421,4 +421,232 @@ class MultiModulePathBindingExpanderTest { assertThat(variants).extracting(map -> map.get("commandKey")) .containsExactlyInAnyOrder("order.pay", "order.ship"); } + + @Test + void shouldExpandMachineTypeFromCrossClassConstantInSiblingModuleIfEquals(@TempDir Path tempDir) throws Exception { + Path root = tempDir.resolve("enterprise-style"); + 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/constants")); + Files.writeString(apiModule.resolve("build.gradle"), ""); + Files.writeString(apiModule.resolve("src/main/java/com/example/constants/MachineTypes.java"), """ + package com.example.constants; + public class MachineTypes { + public static final String ORDER = "ORDER"; + public static final String DOCUMENT = "DOCUMENT"; + } + """); + + 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/MachineController.java"), """ + package com.example; + import com.example.constants.MachineTypes; + 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 (MachineTypes.ORDER.equalsIgnoreCase(machineType)) { + OrderEvent.valueOf(eventString.toUpperCase()); + } else if (MachineTypes.DOCUMENT.equalsIgnoreCase(machineType)) { + DocumentEvent.valueOf(eventString.toUpperCase()); + } + } + } + enum OrderEvent { PAY, SHIP } + enum DocumentEvent { SUBMIT, APPROVE } + """); + + 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.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, callGraph); + + assertThat(variants.stream().map(v -> v.get("machineType")).distinct()) + .containsExactlyInAnyOrder("ORDER", "DOCUMENT"); + } + + @Test + void shouldExpandCommandKeyFromSwitchExpressionWithSiblingModuleCaseConstants(@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/constants")); + Files.writeString(apiModule.resolve("build.gradle"), ""); + Files.writeString(apiModule.resolve("src/main/java/com/example/constants/CommandKeys.java"), """ + package com.example.constants; + public class CommandKeys { + public static final String PAY_KEY = "order.pay"; + public static final String SHIP_KEY = "order.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.constants.CommandKeys; + public class GenericCommandController { + OrderGateway gateway; + public void execute(String commandKey) { + gateway.trigger(commandKey); + } + } + class OrderGateway { + void trigger(String commandKey) { + DomainCommand command = switch (commandKey) { + case CommandKeys.PAY_KEY -> DomainCommand.ORDER_PAY; + case CommandKeys.SHIP_KEY -> 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) {} } + """); + + 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"); + } + + @Test + void shouldExpandCommandKeyFromSiblingModuleConstantMapKeys(@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/constants")); + Files.writeString(apiModule.resolve("build.gradle"), ""); + Files.writeString(apiModule.resolve("src/main/java/com/example/constants/CommandKeys.java"), """ + package com.example.constants; + public class CommandKeys { + public static final String PAY_KEY = "order.pay"; + public static final String SHIP_KEY = "order.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.constants.CommandKeys; + 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( + CommandKeys.PAY_KEY, OrderEvent.PAY, + CommandKeys.SHIP_KEY, OrderEvent.SHIP); + void trigger(String commandKey) { + StateMachine sm = new StateMachine(); + sm.sendEvent(ROUTES.get(commandKey)); + } + } + 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"); + } }