From 0558fd2bb7edb12494b289e77c9b30a003658d6f Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Wed, 15 Jul 2026 07:41:09 +0200 Subject: [PATCH] Add layered regression tests for functional-interface dataflow linking. Lock Supplier.get(), Runnable.run(), and call-site enum resolution to the dataflow pipeline so a parallel resolver cannot creep back in unnoticed. Co-authored-by: Cursor --- ...tionalInterfaceDataFlowRegressionTest.java | 507 ++++++++++++++++++ 1 file changed, 507 insertions(+) create mode 100644 state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/FunctionalInterfaceDataFlowRegressionTest.java diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/FunctionalInterfaceDataFlowRegressionTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/FunctionalInterfaceDataFlowRegressionTest.java new file mode 100644 index 0000000..c425507 --- /dev/null +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/service/FunctionalInterfaceDataFlowRegressionTest.java @@ -0,0 +1,507 @@ +package click.kamil.springstatemachineexporter.analysis.service; + +import click.kamil.springstatemachineexporter.analysis.model.CallChain; +import click.kamil.springstatemachineexporter.analysis.model.CallEdge; +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 click.kamil.springstatemachineexporter.ast.common.DataFlowModel; +import click.kamil.springstatemachineexporter.ast.common.JdtDataFlowModel; +import org.eclipse.jdt.core.dom.Expression; +import org.eclipse.jdt.core.dom.ExpressionStatement; +import org.eclipse.jdt.core.dom.LambdaExpression; +import org.eclipse.jdt.core.dom.MethodDeclaration; +import org.eclipse.jdt.core.dom.MethodInvocation; +import org.eclipse.jdt.core.dom.TypeDeclaration; +import org.eclipse.jdt.core.dom.VariableDeclarationFragment; +import org.eclipse.jdt.core.dom.VariableDeclarationStatement; +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 java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Regression suite for functional-interface event resolution ({@code Supplier.get()}, + * {@code Runnable.run()}, side-effect lambdas). Must stay on the dataflow pipeline: + * {@link JdtDataFlowModel} → {@link VariableTracer} → {@link PathBindingEvaluator} → call graph. + * Do not reintroduce a parallel {@code CallSiteArgumentResolver} bridge. + */ +class FunctionalInterfaceDataFlowRegressionTest { + + @Nested + class JdtDataFlowModelLayer { + + @Test + void shouldResolveEnumFromSupplierGetOnLocalLambda(@TempDir Path tempDir) throws IOException { + CodebaseContext context = scan(tempDir, """ + package com.example; + import java.util.function.Supplier; + public enum OrderCommand { PAY, SHIP } + public class Hooks { + OrderCommand read() { + Supplier supplier = () -> OrderCommand.PAY; + return supplier.get(); + } + } + """); + + MethodInvocation getCall = findReturnMethodInvocation(context, "com.example.Hooks", "read"); + assertThat(getCall.getName().getIdentifier()).isEqualTo("get"); + + DataFlowModel model = new JdtDataFlowModel(context); + List constants = new ArrayList<>(); + for (Expression def : model.getReachingDefinitions(getCall)) { + new ConstantExtractor(context, context.getConstantResolver(), null) + .extractConstantsFromExpression(def, constants); + } + assertThat(constants).anyMatch(c -> c.contains("PAY")); + } + + @Test + void shouldResolveEnumLiteralThroughDataflow(@TempDir Path tempDir) throws IOException { + CodebaseContext context = scan(tempDir, """ + package com.example; + public enum OrderCommand { PAY, SHIP } + public class Service { + public void fireEvent() { + sendEvent(OrderCommand.PAY); + } + protected void sendEvent(OrderCommand event) {} + } + """); + + MethodInvocation sendEventCall = findSideEffectCall(context, "com.example.Service", "fireEvent"); + Expression eventArg = (Expression) sendEventCall.arguments().get(0); + + DataFlowModel model = new JdtDataFlowModel(context); + List fromDataflow = new ArrayList<>(); + for (Expression def : model.getReachingDefinitions(eventArg)) { + new ConstantExtractor(context, context.getConstantResolver(), null) + .extractConstantsFromExpression(def, fromDataflow); + } + assertThat(fromDataflow).anyMatch(c -> c.contains("PAY")); + } + + @Test + void shouldNotTreatPlainObjectGetAsFunctionalSupplier(@TempDir Path tempDir) throws IOException { + CodebaseContext context = scan(tempDir, """ + package com.example; + public class Runner { + static class Box { + public String get() { return "X"; } + } + public void run() { + Box box = new Box(); + String value = box.get(); + } + } + """); + + DataFlowModel model = new JdtDataFlowModel(context); + Expression initializer = findInitializer(context, "com.example.Runner", "run", "value"); + assertThat(model.resolveValue(initializer, context)).isEqualTo("X"); + } + } + + @Nested + class VariableTracerLayer { + + @Test + void shouldResolveSupplierLambdaAtCallSite(@TempDir Path tempDir) throws IOException { + RunnableProject project = RunnableProject.supplierOnly(tempDir); + VariableTracer tracer = tracer(project.context()); + + List constants = tracer.resolveConstantsFromCallSiteArgument( + "com.example.web.OrderController.process", + "com.example.service.OrderService.fireEvent", + 0); + assertThat(constants).containsExactly("OrderCommand.PAY"); + } + + @Test + void shouldResolveRunnableSideEffectLambdaAtCallSite(@TempDir Path tempDir) throws IOException { + RunnableProject project = RunnableProject.withExecutor(tempDir); + VariableTracer tracer = tracer(project.context()); + + assertThat(tracer.findFunctionalParameterIndex("com.example.executor.TaskExecutor.execute")) + .isZero(); + assertThat(tracer.findCallSiteArgumentExpression( + "com.example.web.OrderController.process", + "com.example.executor.TaskExecutor.execute", + 0)).isInstanceOf(LambdaExpression.class); + + List constants = tracer.resolveConstantsFromCallSiteArgument( + "com.example.service.AbstractOrderService.fireEvent", + "com.example.service.AbstractOrderService.sendEvent", + 0); + assertThat(constants).containsExactly("OrderCommand.PAY"); + } + } + + @Nested + class PathBindingEvaluatorLayer { + + @Test + void shouldPropagateRunnableLambdaEnumThroughPathBindings(@TempDir Path tempDir) throws IOException { + RunnableProject project = RunnableProject.withExecutor(tempDir); + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(project.context()); + Map> graph = engine.buildCallGraph(); + CallGraphPathFinder pathFinder = new CallGraphPathFinder(project.context()); + PathBindingEvaluator evaluator = new PathBindingEvaluator( + project.context(), engine.variableTracer, project.context().getConstantResolver(), engine.typeResolver); + + List path = List.of( + "com.example.web.OrderController.process", + "com.example.executor.TaskExecutor.execute", + runnableRunHop(graph), + "com.example.service.AbstractOrderService.fireEvent", + "com.example.service.AbstractOrderService.sendEvent"); + + Map bindings = evaluator.traceBindings(path, graph, pathFinder); + assertThat(bindings.values()).anyMatch(v -> v.contains("PAY")); + } + + @Test + void shouldPropagateSupplierLambdaThroughParameterMap(@TempDir Path tempDir) throws IOException { + RunnableProject project = RunnableProject.supplierOnly(tempDir); + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(project.context()); + Map> graph = engine.buildCallGraph(); + + Map paramValues = engine.variableTracer.buildParameterValuesMap( + "com.example.web.OrderController.process", + "com.example.service.OrderService.fireEvent", + graph, + List.of( + "com.example.web.OrderController.process", + "com.example.service.OrderService.fireEvent"), + 1); + + assertThat(paramValues.get("provider")).contains("PAY"); + } + } + + @Nested + class CallGraphEndToEndLayer { + + @Test + void shouldLinkSupplierLambdaToSingleEnum(@TempDir Path tempDir) throws IOException { + CodebaseContext context = scan(tempDir, """ + package com.example; + import java.util.function.Supplier; + public class MyController { + private StateMachine machine; + public void process() { + fireEvent(() -> TransitionEnum.STATE_L); + } + private void fireEvent(Supplier eventSupplier) { + machine.fire(eventSupplier.get()); + } + } + class StateMachine { + public void fire(TransitionEnum event) {} + } + enum TransitionEnum { STATE_L, STATE_M } + """); + + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + List chains = engine.findChains( + List.of(EntryPoint.builder() + .className("com.example.MyController") + .methodName("process") + .build()), + List.of(TriggerPoint.builder() + .className("com.example.StateMachine") + .methodName("fire") + .event("event") + .build())); + assertThat(chains).hasSize(1); + assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()) + .containsExactly("TransitionEnum.STATE_L"); + } + + @Test + void shouldLinkCrossClassSupplierLambda(@TempDir Path tempDir) throws IOException { + Path root = tempDir.resolve("cross"); + Files.createDirectories(root); + Files.writeString(root.resolve("JmsListener.java"), """ + package com.example.web; + import com.example.service.StateMachineService; + import com.example.domain.OrderEvent; + public class JmsOrderListener { + private final StateMachineService service; + public JmsOrderListener(StateMachineService service) { this.service = service; } + public void receive() { service.sendWithProvider(() -> OrderEvent.CANCEL); } + } + """); + Files.writeString(root.resolve("Service.java"), """ + package com.example.service; + import com.example.domain.OrderEvent; + import java.util.function.Supplier; + public class StateMachineService { + public void sendWithProvider(Supplier provider) { + OrderEvent event = provider.get(); + fire(event); + } + private void fire(OrderEvent event) {} + } + """); + Files.writeString(root.resolve("OrderEvent.java"), """ + package com.example.domain; + public enum OrderEvent { CANCEL, PROCESS } + """); + + CodebaseContext context = new CodebaseContext(); + context.scan(root); + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context); + List chains = engine.findChains( + List.of(EntryPoint.builder() + .className("com.example.web.JmsOrderListener") + .methodName("receive") + .build()), + List.of(TriggerPoint.builder() + .className("com.example.service.StateMachineService") + .methodName("fire") + .event("event") + .build())); + assertThat(chains).hasSize(1); + assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).containsExactly("OrderEvent.CANCEL"); + } + + @Test + void shouldLinkRunnableExecuteSideEffectLambda(@TempDir Path tempDir) throws IOException { + RunnableProject project = RunnableProject.withExecutor(tempDir); + assertSinglePayEvent(chains(project, "com.example.service.AbstractOrderService", "sendEvent")); + } + + @Test + void shouldReachFireSiteThroughRunnableRunHop(@TempDir Path tempDir) throws IOException { + RunnableProject project = RunnableProject.withExecutor(tempDir); + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(project.context()); + Map> graph = engine.buildCallGraph(); + + assertThat(graph.get("com.example.executor.TaskExecutor.execute")) + .extracting(CallEdge::getTargetMethod) + .anyMatch(target -> target.contains("Runnable.run")); + + List chains = engine.findChains( + List.of(EntryPoint.builder() + .className("com.example.web.OrderController") + .methodName("process") + .build()), + List.of(TriggerPoint.builder() + .className("com.example.service.AbstractOrderService") + .methodName("sendEvent") + .event("event") + .build())); + assertThat(chains).hasSize(1); + assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()) + .containsExactly("OrderCommand.PAY"); + } + } + + private static void assertSinglePayEvent(List chains) { + assertThat(chains).hasSize(1); + assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).containsExactly("OrderCommand.PAY"); + } + + private static List chains(RunnableProject project, String triggerClass, String triggerMethod) { + HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(project.context()); + return engine.findChains( + List.of(EntryPoint.builder() + .className("com.example.web.OrderController") + .methodName("process") + .build()), + List.of(TriggerPoint.builder() + .className(triggerClass) + .methodName(triggerMethod) + .event("event") + .build())); + } + + private static String runnableRunHop(Map> graph) { + List edges = graph.get("com.example.executor.TaskExecutor.execute"); + assertThat(edges).isNotNull(); + return edges.stream() + .map(CallEdge::getTargetMethod) + .filter(hop -> hop.endsWith("Runnable.run")) + .findFirst() + .orElseThrow(); + } + + private static VariableTracer tracer(CodebaseContext context) { + VariableTracer tracer = new VariableTracer(context, context.getConstantResolver()); + TypeResolver typeResolver = new TypeResolver(context); + ConstantExtractor constantExtractor = new ConstantExtractor(context, context.getConstantResolver(), null); + tracer.setTypeResolver(typeResolver); + tracer.setConstantExtractor(constantExtractor); + return tracer; + } + + private static CodebaseContext scan(Path tempDir, String source) throws IOException { + Files.writeString(tempDir.resolve("App.java"), source); + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + return context; + } + + private static Expression findInitializer( + CodebaseContext context, String typeFqn, String methodName, String variableName) { + TypeDeclaration type = context.getTypeDeclaration(typeFqn); + MethodDeclaration method = context.findMethodDeclaration(type, methodName, true); + for (Object statementObj : method.getBody().statements()) { + if (statementObj instanceof VariableDeclarationStatement statement) { + for (Object fragmentObj : statement.fragments()) { + if (fragmentObj instanceof VariableDeclarationFragment fragment + && variableName.equals(fragment.getName().getIdentifier())) { + return fragment.getInitializer(); + } + } + } + } + throw new AssertionError("Initializer not found: " + variableName); + } + + private static MethodInvocation findMethodInvocationInitializer( + CodebaseContext context, String typeFqn, String methodName, String variableName) { + Expression initializer = findInitializer(context, typeFqn, methodName, variableName); + assertThat(initializer).isInstanceOf(MethodInvocation.class); + return (MethodInvocation) initializer; + } + + private static MethodInvocation findSideEffectCall(CodebaseContext context, String typeFqn, String methodName) { + TypeDeclaration type = context.getTypeDeclaration(typeFqn); + MethodDeclaration method = context.findMethodDeclaration(type, methodName, true); + for (Object statementObj : method.getBody().statements()) { + if (statementObj instanceof ExpressionStatement statement + && statement.getExpression() instanceof MethodInvocation invocation) { + return invocation; + } + } + throw new AssertionError("Side-effect call not found in " + methodName); + } + + private static MethodInvocation findReturnMethodInvocation(CodebaseContext context, String typeFqn, String methodName) { + TypeDeclaration type = context.getTypeDeclaration(typeFqn); + MethodDeclaration method = context.findMethodDeclaration(type, methodName, true); + for (Object statementObj : method.getBody().statements()) { + if (statementObj instanceof org.eclipse.jdt.core.dom.ReturnStatement returnStatement + && returnStatement.getExpression() instanceof MethodInvocation invocation) { + return invocation; + } + } + throw new AssertionError("Return MethodInvocation not found in " + methodName); + } + + private record RunnableProject(CodebaseContext context) { + + static RunnableProject supplierOnly(Path tempDir) throws IOException { + Path root = tempDir.resolve("supplier"); + Files.createDirectories(root); + writeSharedSources(root, false); + return new RunnableProject(scanRoot(root)); + } + + static RunnableProject withExecutor(Path tempDir) throws IOException { + Path root = tempDir.resolve("runnable"); + Files.createDirectories(root); + writeSharedSources(root, true); + return new RunnableProject(scanRoot(root)); + } + + private static CodebaseContext scanRoot(Path root) throws IOException { + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(root); + return context; + } + + private static void writeSharedSources(Path root, boolean withExecutor) throws IOException { + Files.writeString(root.resolve("OrderCommand.java"), """ + package com.example.domain; + public enum OrderCommand { PAY, SHIP, META } + """); + if (withExecutor) { + Files.writeString(root.resolve("TaskExecutor.java"), """ + package com.example.executor; + public class TaskExecutor { + public void execute(Runnable task) { task.run(); } + } + """); + Files.writeString(root.resolve("OrderController.java"), """ + package com.example.web; + import com.example.executor.TaskExecutor; + import com.example.service.AbstractOrderService; + public class OrderController { + private final TaskExecutor executor; + private final AbstractOrderService service; + public OrderController(TaskExecutor executor, AbstractOrderService service) { + this.executor = executor; + this.service = service; + } + public void process() { + executor.execute(() -> service.fireEvent()); + } + } + """); + Files.writeString(root.resolve("AbstractOrderService.java"), """ + package com.example.service; + import com.example.domain.OrderCommand; + public abstract class AbstractOrderService { + protected com.example.machine.StateMachine machine; + public void fireEvent() { sendEvent(OrderCommand.PAY); } + protected void sendEvent(OrderCommand event) { machine.fire(event); } + } + """); + Files.writeString(root.resolve("StateMachine.java"), """ + package com.example.machine; + import com.example.domain.OrderCommand; + public class StateMachine { + public void fire(OrderCommand event) {} + } + """); + } else { + Files.writeString(root.resolve("OrderController.java"), """ + package com.example.web; + import com.example.service.OrderService; + import com.example.domain.OrderCommand; + public class OrderController { + private final OrderService service; + public OrderController(OrderService service) { this.service = service; } + public void process() { + service.fireEvent(() -> OrderCommand.PAY); + } + } + """); + Files.writeString(root.resolve("OrderService.java"), """ + package com.example.service; + import com.example.domain.OrderCommand; + import com.example.machine.StateMachine; + import java.util.function.Supplier; + public class OrderService { + private StateMachine machine; + public void fireEvent(Supplier provider) { + machine.fire(provider.get()); + } + } + """); + Files.writeString(root.resolve("StateMachine.java"), """ + package com.example.machine; + import com.example.domain.OrderCommand; + public class StateMachine { + public void fire(OrderCommand event) {} + } + """); + } + } + } +}