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 <cursoragent@cursor.com>
This commit is contained in:
2026-07-15 07:41:09 +02:00
parent d5a6adafcc
commit 0558fd2bb7

View File

@@ -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<OrderCommand> 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<String> 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<String> 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<String> 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<String> 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<String, List<CallEdge>> graph = engine.buildCallGraph();
CallGraphPathFinder pathFinder = new CallGraphPathFinder(project.context());
PathBindingEvaluator evaluator = new PathBindingEvaluator(
project.context(), engine.variableTracer, project.context().getConstantResolver(), engine.typeResolver);
List<String> 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<String, String> 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<String, List<CallEdge>> graph = engine.buildCallGraph();
Map<String, String> 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<TransitionEnum> eventSupplier) {
machine.fire(eventSupplier.get());
}
}
class StateMachine {
public void fire(TransitionEnum event) {}
}
enum TransitionEnum { STATE_L, STATE_M }
""");
HeuristicCallGraphEngine engine = new HeuristicCallGraphEngine(context);
List<CallChain> 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 <T extends OrderEvent> void sendWithProvider(Supplier<T> 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<CallChain> 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<String, List<CallEdge>> graph = engine.buildCallGraph();
assertThat(graph.get("com.example.executor.TaskExecutor.execute"))
.extracting(CallEdge::getTargetMethod)
.anyMatch(target -> target.contains("Runnable.run"));
List<CallChain> 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<CallChain> chains) {
assertThat(chains).hasSize(1);
assertThat(chains.get(0).getTriggerPoint().getPolymorphicEvents()).containsExactly("OrderCommand.PAY");
}
private static List<CallChain> 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<String, List<CallEdge>> graph) {
List<CallEdge> 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<OrderCommand> 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) {}
}
""");
}
}
}
}