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:
@@ -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) {}
|
||||
}
|
||||
""");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user